Skip to content

Visualization Engine (Matplotlib & Plotly)

Matplotlib Static Plotting

plotting

Visualization and plotting routines for control systems analysis.

plot_step

plot_step(sys, T=None, ax=None)

Plot the time-domain step response of an LTI system.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration. If None, auto-generated.

None
ax Axes | None

Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

None

Returns:

Type Description
tuple[Figure, Axes]

The Matplotlib Figure and Axes.

Source code in src/ctrlpy/plotting.py
def plot_step(
    sys: LinearTimeInvariant,
    T: Sequence[float] | NDArray[np.floating] | float | None = None,
    ax: Axes | None = None,
) -> tuple[Figure, Axes]:
    """Plot the time-domain step response of an LTI system.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration. If None, auto-generated.
    ax : Axes | None, optional
        Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

    Returns
    -------
    tuple[Figure, Axes]
        The Matplotlib Figure and Axes.
    """
    if ax is None:
        fig, ax_out = plt.subplots(figsize=(8, 5))
    else:
        ax_out = ax
        fig = cast(Figure, ax_out.figure)

    res = step_response(sys, T=T)

    ax_out.plot(res.t, res.y, "b-", linewidth=1.5, label="Step Response")

    if res.y.ndim == 1:
        yss = res.steady_state_value()
        ax_out.axhline(
            yss,
            color="r",
            linestyle="--",
            linewidth=1.2,
            alpha=0.7,
            label=f"Steady State ({yss:.3g})",
        )

    ax_out.set_xlabel("Time (s)")
    ax_out.set_ylabel("Amplitude")
    ax_out.set_title("Step Response")
    ax_out.grid(True, linestyle="--", alpha=0.6)
    ax_out.legend(loc="best")

    return fig, ax_out

plot_impulse

plot_impulse(sys, T=None, ax=None)

Plot the time-domain impulse response of an LTI system.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration. If None, auto-generated.

None
ax Axes | None

Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

None

Returns:

Type Description
tuple[Figure, Axes]

The Matplotlib Figure and Axes.

Source code in src/ctrlpy/plotting.py
def plot_impulse(
    sys: LinearTimeInvariant,
    T: Sequence[float] | NDArray[np.floating] | float | None = None,
    ax: Axes | None = None,
) -> tuple[Figure, Axes]:
    """Plot the time-domain impulse response of an LTI system.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration. If None, auto-generated.
    ax : Axes | None, optional
        Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

    Returns
    -------
    tuple[Figure, Axes]
        The Matplotlib Figure and Axes.
    """
    if ax is None:
        fig, ax_out = plt.subplots(figsize=(8, 5))
    else:
        ax_out = ax
        fig = cast(Figure, ax_out.figure)

    res = impulse_response(sys, T=T)

    ax_out.plot(res.t, res.y, "b-", linewidth=1.5, label="Impulse Response")
    ax_out.axhline(0.0, color="gray", linestyle="-", linewidth=0.8, alpha=0.5)

    ax_out.set_xlabel("Time (s)")
    ax_out.set_ylabel("Amplitude")
    ax_out.set_title("Impulse Response")
    ax_out.grid(True, linestyle="--", alpha=0.6)
    ax_out.legend(loc="best")

    return fig, ax_out

plot_bode

plot_bode(sys, omega=None, margins=True, ax=None)

Plot the Bode diagram (magnitude and phase) for a SISO LTI system.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
omega Sequence[float] | NDArray[floating] | None

Frequencies in rad/s. If None, auto-generated based on system poles and zeros.

None
margins bool

Whether to calculate and annotate stability margins (gain and phase margins), by default True.

True
ax Sequence[Axes] | NDArray[Any] | None

Sequence of 2 Matplotlib Axes for magnitude and phase subplots. If None, a new Figure and subplots are created.

None

Returns:

Type Description
tuple[Figure, tuple[Axes, Axes]]

The Matplotlib Figure and a tuple of (magnitude_axis, phase_axis).

Raises:

Type Description
ValueError

If provided ax does not contain exactly 2 Axes.

Source code in src/ctrlpy/plotting.py
def plot_bode(
    sys: LinearTimeInvariant,
    omega: Sequence[float] | NDArray[np.floating] | None = None,
    margins: bool = True,
    ax: Sequence[Axes] | NDArray[Any] | None = None,
) -> tuple[Figure, tuple[Axes, Axes]]:
    """Plot the Bode diagram (magnitude and phase) for a SISO LTI system.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequencies in rad/s. If None, auto-generated based on system poles and zeros.
    margins : bool, optional
        Whether to calculate and annotate stability margins (gain and phase margins),
        by default True.
    ax : Sequence[Axes] | NDArray[Any] | None, optional
        Sequence of 2 Matplotlib Axes for magnitude and phase subplots.
        If None, a new Figure and subplots are created.

    Returns
    -------
    tuple[Figure, tuple[Axes, Axes]]
        The Matplotlib Figure and a tuple of (magnitude_axis, phase_axis).

    Raises
    ------
    ValueError
        If provided ax does not contain exactly 2 Axes.
    """
    if ax is None:
        fig, axes_arr = plt.subplots(2, 1, sharex=True, figsize=(8, 6))
        ax_mag, ax_phase = axes_arr[0], axes_arr[1]
    else:
        if len(ax) != 2:
            raise ValueError(
                f"plot_bode requires exactly 2 Axes (magnitude, phase), got {len(ax)}."
            )
        ax_mag, ax_phase = ax[0], ax[1]
        fig = cast(Figure, ax_mag.figure)

    bdata = bode_data(sys, omega=omega)

    # Plot Magnitude
    ax_mag.semilogx(bdata.w, bdata.mag_db, "b-", linewidth=1.5, label="Magnitude")
    ax_mag.set_ylabel("Magnitude (dB)")
    ax_mag.set_title("Bode Diagram")
    ax_mag.grid(True, which="both", linestyle="--", alpha=0.6)

    # Plot Phase
    ax_phase.semilogx(bdata.w, bdata.phase, "b-", linewidth=1.5, label="Phase")
    ax_phase.set_ylabel("Phase (deg)")
    ax_phase.set_xlabel("Frequency (rad/s)")
    ax_phase.grid(True, which="both", linestyle="--", alpha=0.6)

    if margins:
        sm = margin(sys)

        # Baseline reference lines
        ax_mag.axhline(0.0, color="gray", linestyle="--", linewidth=0.8, alpha=0.5)
        ax_phase.axhline(-180.0, color="gray", linestyle="--", linewidth=0.8, alpha=0.5)

        # Gain Crossover Frequency (Wcg) and Phase Margin (PM)
        if not np.isnan(sm.wcg) and not np.isinf(sm.wcg):
            ax_mag.axvline(sm.wcg, color="r", linestyle=":", linewidth=1.2, alpha=0.8)
            ax_phase.axvline(sm.wcg, color="r", linestyle=":", linewidth=1.2, alpha=0.8)

            if not np.isinf(sm.pm_deg):
                phase_at_wcg = sm.pm_deg - 180.0
                ax_phase.plot(
                    [sm.wcg, sm.wcg],
                    [-180.0, phase_at_wcg],
                    "r-",
                    linewidth=2.0,
                )
                ax_phase.plot(sm.wcg, phase_at_wcg, "ro", markersize=4)
                ax_phase.text(
                    sm.wcg * 1.15,
                    (-180.0 + phase_at_wcg) / 2.0,
                    f"PM = {sm.pm_deg:.1f}°\n({sm.wcg:.2g} rad/s)",
                    color="r",
                    fontsize=8,
                    verticalalignment="center",
                )

        # Phase Crossover Frequency (Wcp) and Gain Margin (GM)
        if not np.isnan(sm.wcp) and not np.isinf(sm.wcp):
            ax_mag.axvline(sm.wcp, color="g", linestyle=":", linewidth=1.2, alpha=0.8)
            ax_phase.axvline(sm.wcp, color="g", linestyle=":", linewidth=1.2, alpha=0.8)

            if not np.isinf(sm.gm_db):
                mag_at_wcp = -sm.gm_db
                ax_mag.plot(
                    [sm.wcp, sm.wcp],
                    [0.0, mag_at_wcp],
                    "g-",
                    linewidth=2.0,
                )
                ax_mag.plot(sm.wcp, mag_at_wcp, "go", markersize=4)
                ax_mag.text(
                    sm.wcp * 1.15,
                    mag_at_wcp / 2.0,
                    f"GM = {sm.gm_db:.1f} dB\n({sm.wcp:.2g} rad/s)",
                    color="g",
                    fontsize=8,
                    verticalalignment="center",
                )

    return fig, (ax_mag, ax_phase)

plot_nyquist

plot_nyquist(sys, omega=None, ax=None)

Plot the Nyquist diagram for a SISO LTI system.

Plots the real and imaginary parts of the frequency response G(jw), indented circular arc around s=0 if poles at the origin exist, marks the critical point (-1, 0j), and overlays the unit circle.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
omega Sequence[float] | NDArray[floating] | None

Frequencies in rad/s. If None, auto-generated based on system poles and zeros.

None
ax Axes | None

Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

None

Returns:

Type Description
tuple[Figure, Axes]

The Matplotlib Figure and Axes.

Source code in src/ctrlpy/plotting.py
def plot_nyquist(
    sys: LinearTimeInvariant,
    omega: Sequence[float] | NDArray[np.floating] | None = None,
    ax: Axes | None = None,
) -> tuple[Figure, Axes]:
    """Plot the Nyquist diagram for a SISO LTI system.

    Plots the real and imaginary parts of the frequency response G(jw),
    indented circular arc around s=0 if poles at the origin exist,
    marks the critical point (-1, 0j), and overlays the unit circle.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequencies in rad/s. If None, auto-generated based on system poles and zeros.
    ax : Axes | None, optional
        Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

    Returns
    -------
    tuple[Figure, Axes]
        The Matplotlib Figure and Axes.
    """
    if ax is None:
        fig, ax_out = plt.subplots(figsize=(7, 6))
    else:
        ax_out = ax
        fig = cast(Figure, ax_out.figure)

    ndata = nyquist_data(sys, omega=omega)

    # Positive frequencies G(jw)
    ax_out.plot(
        ndata.real,
        ndata.imag,
        "b-",
        linewidth=1.5,
        label=r"$G(j\omega), \omega > 0$",
    )
    # Negative frequencies G(-jw)
    ax_out.plot(
        ndata.real,
        -ndata.imag,
        "b--",
        linewidth=1.2,
        alpha=0.7,
        label=r"$G(j\omega), \omega < 0$",
    )

    # Plot indented arc if present
    if ndata.arc_response is not None:
        ax_out.plot(
            np.real(ndata.arc_response),
            np.imag(ndata.arc_response),
            "b:",
            linewidth=1.2,
            alpha=0.7,
            label=r"$\omega \to 0\text{ arc } (s = \epsilon e^{j\theta})$",
        )

    # Critical point (-1, 0)
    ax_out.plot(
        -1.0,
        0.0,
        "r+",
        markersize=12,
        markeredgewidth=2.0,
        label="Critical Point (-1, 0)",
    )

    # Unit circle
    theta = np.linspace(0.0, 2.0 * np.pi, 200)
    ax_out.plot(
        np.cos(theta),
        np.sin(theta),
        "k:",
        linewidth=1.0,
        alpha=0.5,
        label="Unit Circle",
    )

    # Axes lines
    ax_out.axhline(0.0, color="gray", linestyle="-", linewidth=0.8, alpha=0.5)
    ax_out.axvline(0.0, color="gray", linestyle="-", linewidth=0.8, alpha=0.5)

    # Smart auto-scaling around the critical point (-1, 0j)
    all_resps = [ndata.response, np.conj(ndata.response)]
    if ndata.arc_response is not None:
        all_resps.append(ndata.arc_response)
    comb = np.concatenate(all_resps)
    finite_resp = comb[np.isfinite(comb)]

    if finite_resp.size > 0:
        mags = np.abs(finite_resp)
        min_mag = float(np.min(mags))
        r_thresh = max(3.0, min_mag * 3.0)
        interesting = finite_resp[mags <= r_thresh]
        if interesting.size == 0:
            interesting = finite_resp

        x_pts = np.concatenate([[-1.5, 1.5], np.real(interesting)])
        y_pts = np.concatenate([[-1.5, 1.5], np.imag(interesting)])

        x_min, x_max = float(np.min(x_pts)), float(np.max(x_pts))
        y_min, y_max = float(np.min(y_pts)), float(np.max(y_pts))

        # Clamp excessively huge ranges to reasonable bounding box around critical point
        x_min = max(x_min, -10.0)
        x_max = min(x_max, 10.0)
        y_min = max(y_min, -10.0)
        y_max = min(y_max, 10.0)

        pad_x = 0.1 * (x_max - x_min)
        pad_y = 0.1 * (y_max - y_min)
        ax_out.set_xlim(x_min - pad_x, x_max + pad_x)
        ax_out.set_ylim(y_min - pad_y, y_max + pad_y)

    ax_out.set_xlabel(r"$\mathrm{Re}(G(j\omega))$")
    ax_out.set_ylabel(r"$\mathrm{Im}(G(j\omega))$")
    ax_out.set_title("Nyquist Diagram")
    ax_out.grid(True, linestyle="--", alpha=0.6)
    ax_out.legend(loc="best")

    return fig, ax_out

plot_root_locus

plot_root_locus(sys, gains=None, ax=None)

Plot the root locus diagram in the complex s-plane.

Plots pole trajectories for varying feedback gains k >= 0 with 'x' for open-loop poles and 'o' for open-loop zeros.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Open-loop SISO LTI system.

required
gains Sequence[float] | NDArray[floating] | None

Gain values k >= 0. If None, auto-generated.

None
ax Axes | None

Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

None

Returns:

Type Description
tuple[Figure, Axes]

The Matplotlib Figure and Axes.

Source code in src/ctrlpy/plotting.py
def plot_root_locus(
    sys: LinearTimeInvariant,
    gains: Sequence[float] | NDArray[np.floating] | None = None,
    ax: Axes | None = None,
) -> tuple[Figure, Axes]:
    """Plot the root locus diagram in the complex s-plane.

    Plots pole trajectories for varying feedback gains k >= 0 with 'x' for
    open-loop poles and 'o' for open-loop zeros.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Open-loop SISO LTI system.
    gains : Sequence[float] | NDArray[np.floating] | None, optional
        Gain values k >= 0. If None, auto-generated.
    ax : Axes | None, optional
        Matplotlib Axes to plot into. If None, a new Figure and Axes are created.

    Returns
    -------
    tuple[Figure, Axes]
        The Matplotlib Figure and Axes.
    """
    if ax is None:
        fig, ax_out = plt.subplots(figsize=(8, 6))
    else:
        ax_out = ax
        fig = cast(Figure, ax_out.figure)

    rldata = root_locus_data(sys, gains=gains)

    # Plot pole trajectory branches
    for branch in range(rldata.roots.shape[1]):
        branch_roots = rldata.roots[:, branch]
        ax_out.plot(branch_roots.real, branch_roots.imag, "b-", linewidth=1.5)

    # Mark open-loop poles
    if rldata.poles.size > 0:
        ax_out.plot(
            rldata.poles.real,
            rldata.poles.imag,
            "rx",
            markersize=9,
            markeredgewidth=2.0,
            label="Open-loop Poles",
        )

    # Mark open-loop zeros
    if rldata.zeros.size > 0:
        ax_out.plot(
            rldata.zeros.real,
            rldata.zeros.imag,
            "go",
            markersize=8,
            markeredgewidth=2.0,
            fillstyle="none",
            label="Open-loop Zeros",
        )

    # Axes lines
    ax_out.axhline(0.0, color="gray", linestyle="-", linewidth=0.8, alpha=0.5)
    ax_out.axvline(0.0, color="gray", linestyle="-", linewidth=0.8, alpha=0.5)

    ax_out.set_xlabel(r"$\mathrm{Real}(\sigma)$")
    ax_out.set_ylabel(r"$\mathrm{Imag}(j\omega)$")
    ax_out.set_title("Root Locus")
    ax_out.grid(True, linestyle="--", alpha=0.6)

    if rldata.poles.size > 0 or rldata.zeros.size > 0:
        ax_out.legend(loc="best")

    return fig, ax_out

Plotly Interactive Visualization

plotting_plotly

Interactive visualization engine for control systems using Plotly.

iplot_step

iplot_step(sys, T=None)

Generate an interactive step response plot using Plotly.

Displays the step response with interactive indicators for steady-state value, rise time, and peak overshoot.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration. If None, automatically determined.

None

Returns:

Type Description
Figure

Interactive Plotly Figure containing the step response.

Source code in src/ctrlpy/plotting_plotly.py
def iplot_step(
    sys: LinearTimeInvariant,
    T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
) -> go.Figure:
    """Generate an interactive step response plot using Plotly.

    Displays the step response with interactive indicators for steady-state value,
    rise time, and peak overshoot.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration. If None, automatically determined.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure containing the step response.
    """
    res = step_response(sys, T=T)
    fig = go.Figure()

    y_1d = res.y if res.y.ndim == 1 else res.y[:, 0]

    # Step response curve
    fig.add_trace(
        go.Scatter(
            x=res.t,
            y=y_1d,
            mode="lines",
            name="Step Response",
            line={"color": "#1f77b4", "width": 2.0},
            hovertemplate="<b>Time</b>: %{x:.3f} s<br><b>Amplitude</b>: %{y:.3f}<extra></extra>",
        )
    )

    # Add performance annotations if system is stable
    try:
        yss = res.steady_state_value()
        fig.add_hline(
            y=yss,
            line_dash="dash",
            line_color="red",
            line_width=1.2,
            annotation_text=f"Steady State ({yss:.3g})",
            annotation_position="bottom right",
            annotation_font_color="red",
        )

        tr = res.rise_time()
        if not np.isnan(tr):
            fig.add_annotation(
                text=f"Rise Time (10-90%): {tr:.2f} s",
                xref="paper",
                yref="paper",
                x=0.02,
                y=0.95,
                showarrow=False,
                bgcolor="rgba(255, 255, 255, 0.8)",
                bordercolor="gray",
                borderwidth=1,
            )

        os_val = res.overshoot()
        tp_val = res.peak_time()
        if os_val > 0.0:
            y_peak = float(np.max(y_1d))
            fig.add_trace(
                go.Scatter(
                    x=[tp_val],
                    y=[y_peak],
                    mode="markers",
                    name="Peak Overshoot",
                    marker={"symbol": "star", "size": 10, "color": "darkorange"},
                    hovertemplate=f"<b>Peak</b>: {y_peak:.3f}<br><b>Overshoot</b>: {os_val:.1f}%<br><b>Peak Time</b>: {tp_val:.2f} s<extra></extra>",
                )
            )
    except UnstableSystemError:
        pass

    fig.update_layout(
        title="Step Response",
        xaxis_title="Time (s)",
        yaxis_title="Amplitude",
        template="plotly_white",
        height=500,
        showlegend=True,
    )

    return fig

iplot_impulse

iplot_impulse(sys, T=None)

Generate an interactive impulse response plot using Plotly.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
T Sequence[float] | NDArray[floating] | float | None

Time vector or simulation duration. If None, automatically determined.

None

Returns:

Type Description
Figure

Interactive Plotly Figure containing the impulse response.

Source code in src/ctrlpy/plotting_plotly.py
def iplot_impulse(
    sys: LinearTimeInvariant,
    T: Sequence[float] | NDArray[np.floating[Any]] | float | None = None,
) -> go.Figure:
    """Generate an interactive impulse response plot using Plotly.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    T : Sequence[float] | NDArray[np.floating] | float | None, optional
        Time vector or simulation duration. If None, automatically determined.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure containing the impulse response.
    """
    res = impulse_response(sys, T=T)
    fig = go.Figure()

    y_1d = res.y if res.y.ndim == 1 else res.y[:, 0]

    fig.add_trace(
        go.Scatter(
            x=res.t,
            y=y_1d,
            mode="lines",
            name="Impulse Response",
            line={"color": "#1f77b4", "width": 2.0},
            hovertemplate="<b>Time</b>: %{x:.3f} s<br><b>Amplitude</b>: %{y:.3f}<extra></extra>",
        )
    )

    fig.add_hline(y=0.0, line_dash="solid", line_color="lightgray", line_width=0.8)

    fig.update_layout(
        title="Impulse Response",
        xaxis_title="Time (s)",
        yaxis_title="Amplitude",
        template="plotly_white",
        height=500,
        showlegend=True,
    )

    return fig

iplot_bode

iplot_bode(sys, omega=None, margins=True)

Generate an interactive Bode diagram using Plotly.

Produces stacked subplots with magnitude in dB and phase in degrees versus logarithmic frequency, with stability margin annotations.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s. If None, automatically determined.

None
margins bool

Whether to display Gain Margin (GM) and Phase Margin (PM) indicators, by default True.

True

Returns:

Type Description
Figure

Interactive Plotly Figure containing the Bode diagram.

Source code in src/ctrlpy/plotting_plotly.py
def iplot_bode(
    sys: LinearTimeInvariant,
    omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
    margins: bool = True,
) -> go.Figure:
    """Generate an interactive Bode diagram using Plotly.

    Produces stacked subplots with magnitude in dB and phase in degrees versus
    logarithmic frequency, with stability margin annotations.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s. If None, automatically determined.
    margins : bool, optional
        Whether to display Gain Margin (GM) and Phase Margin (PM) indicators,
        by default True.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure containing the Bode diagram.
    """
    bdata = bode_data(sys, omega=omega)

    fig = make_subplots(
        rows=2,
        cols=1,
        shared_xaxes=True,
        vertical_spacing=0.1,
        subplot_titles=("Magnitude Response", "Phase Response"),
    )

    # Magnitude trace
    fig.add_trace(
        go.Scatter(
            x=bdata.w,
            y=bdata.mag_db,
            mode="lines",
            name="Magnitude",
            line={"color": "#1f77b4", "width": 2.0},
            hovertemplate="<b>Frequency</b>: %{x:.3g} rad/s<br><b>Magnitude</b>: %{y:.2f} dB<extra></extra>",
        ),
        row=1,
        col=1,
    )

    # Phase trace
    fig.add_trace(
        go.Scatter(
            x=bdata.w,
            y=bdata.phase,
            mode="lines",
            name="Phase",
            line={"color": "#1f77b4", "width": 2.0},
            hovertemplate="<b>Frequency</b>: %{x:.3g} rad/s<br><b>Phase</b>: %{y:.2f}°<extra></extra>",
        ),
        row=2,
        col=1,
    )

    # Reference lines
    fig.add_hline(
        y=0.0,
        line_dash="dash",
        line_color="gray",
        line_width=1.0,
        row=1,
        col=1,
    )
    fig.add_hline(
        y=-180.0,
        line_dash="dash",
        line_color="gray",
        line_width=1.0,
        row=2,
        col=1,
    )

    if margins:
        sm = margin(sys)

        # Gain Crossover Frequency (Wcg) -> Phase Margin
        if not np.isnan(sm.wcg) and not np.isinf(sm.wcg):
            wcg_val = float(sm.wcg)
            fig.add_vline(
                x=wcg_val,
                line_dash="dot",
                line_color="red",
                line_width=1.5,
                row=1,
                col=1,
            )
            fig.add_vline(
                x=wcg_val,
                line_dash="dot",
                line_color="red",
                line_width=1.5,
                row=2,
                col=1,
            )

            if not np.isinf(sm.pm_deg):
                phase_at_wcg = float(sm.pm_deg - 180.0)
                fig.add_trace(
                    go.Scatter(
                        x=[wcg_val, wcg_val],
                        y=[-180.0, phase_at_wcg],
                        mode="lines+markers",
                        name="Phase Margin",
                        line={"color": "red", "width": 2.5},
                        marker={"size": 6, "color": "red"},
                        hovertemplate=f"<b>PM</b>: {sm.pm_deg:.1f}° at {wcg_val:.2g} rad/s<extra></extra>",
                    ),
                    row=2,
                    col=1,
                )
                fig.add_annotation(
                    x=np.log10(wcg_val),
                    y=(-180.0 + phase_at_wcg) / 2.0,
                    text=f"PM = {sm.pm_deg:.1f}°<br>({wcg_val:.2g} rad/s)",
                    showarrow=True,
                    arrowhead=2,
                    arrowcolor="red",
                    font={"color": "red", "size": 11},
                    xref="x2",
                    yref="y2",
                )

        # Phase Crossover Frequency (Wcp) -> Gain Margin
        if not np.isnan(sm.wcp) and not np.isinf(sm.wcp):
            wcp_val = float(sm.wcp)
            fig.add_vline(
                x=wcp_val,
                line_dash="dot",
                line_color="green",
                line_width=1.5,
                row=1,
                col=1,
            )
            fig.add_vline(
                x=wcp_val,
                line_dash="dot",
                line_color="green",
                line_width=1.5,
                row=2,
                col=1,
            )

            if not np.isinf(sm.gm_db):
                mag_at_wcp = float(-sm.gm_db)
                fig.add_trace(
                    go.Scatter(
                        x=[wcp_val, wcp_val],
                        y=[0.0, mag_at_wcp],
                        mode="lines+markers",
                        name="Gain Margin",
                        line={"color": "green", "width": 2.5},
                        marker={"size": 6, "color": "green"},
                        hovertemplate=f"<b>GM</b>: {sm.gm_db:.1f} dB at {wcp_val:.2g} rad/s<extra></extra>",
                    ),
                    row=1,
                    col=1,
                )
                fig.add_annotation(
                    x=np.log10(wcp_val),
                    y=mag_at_wcp / 2.0,
                    text=f"GM = {sm.gm_db:.1f} dB<br>({wcp_val:.2g} rad/s)",
                    showarrow=True,
                    arrowhead=2,
                    arrowcolor="green",
                    font={"color": "green", "size": 11},
                    xref="x",
                    yref="y",
                )

    fig.update_xaxes(type="log", row=1, col=1)
    fig.update_xaxes(type="log", title_text="Frequency (rad/s)", row=2, col=1)
    fig.update_yaxes(title_text="Magnitude (dB)", row=1, col=1)
    fig.update_yaxes(title_text="Phase (deg)", row=2, col=1)

    fig.update_layout(
        title="Bode Diagram",
        template="plotly_white",
        height=650,
        showlegend=False,
        hovermode="x unified",
    )

    return fig

iplot_nyquist

iplot_nyquist(sys, omega=None)

Generate an interactive Nyquist diagram using Plotly.

Plots positive and negative frequency responses, indented origin arcs if present, the critical point (-1, 0), and the unit circle.

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Linear Time-Invariant system.

required
omega Sequence[float] | NDArray[floating] | None

Frequency vector in rad/s. If None, automatically determined.

None

Returns:

Type Description
Figure

Interactive Plotly Figure containing the Nyquist diagram.

Source code in src/ctrlpy/plotting_plotly.py
def iplot_nyquist(
    sys: LinearTimeInvariant,
    omega: Sequence[float] | NDArray[np.floating[Any]] | None = None,
) -> go.Figure:
    """Generate an interactive Nyquist diagram using Plotly.

    Plots positive and negative frequency responses, indented origin arcs if present,
    the critical point (-1, 0), and the unit circle.

    Parameters
    ----------
    sys : LinearTimeInvariant
        Linear Time-Invariant system.
    omega : Sequence[float] | NDArray[np.floating] | None, optional
        Frequency vector in rad/s. If None, automatically determined.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure containing the Nyquist diagram.
    """
    ndata = nyquist_data(sys, omega=omega)
    fig = go.Figure()

    # Positive frequency branch: G(jw), w > 0
    fig.add_trace(
        go.Scatter(
            x=ndata.real,
            y=ndata.imag,
            mode="lines",
            name="G(jω), ω > 0",
            line={"color": "#1f77b4", "width": 2.0},
            customdata=ndata.w,
            hovertemplate="<b>ω</b>: %{customdata:.3g} rad/s<br><b>Re</b>: %{x:.3f}<br><b>Im</b>: %{y:.3f}<extra>G(jω)</extra>",
        )
    )

    # Negative frequency branch: G(-jw), w < 0
    fig.add_trace(
        go.Scatter(
            x=ndata.real,
            y=-ndata.imag,
            mode="lines",
            name="G(jω), ω < 0",
            line={"color": "#1f77b4", "width": 1.5, "dash": "dash"},
            customdata=ndata.w,
            hovertemplate="<b>ω</b>: -%{customdata:.3g} rad/s<br><b>Re</b>: %{x:.3f}<br><b>Im</b>: %{y:.3f}<extra>G(-jω)</extra>",
        )
    )

    # Indented circular arc if present
    if ndata.arc_response is not None:
        fig.add_trace(
            go.Scatter(
                x=np.real(ndata.arc_response),
                y=np.imag(ndata.arc_response),
                mode="lines",
                name="ω → 0 Arc",
                line={"color": "#1f77b4", "width": 1.2, "dash": "dot"},
                hovertemplate="<b>Origin Arc</b><br><b>Re</b>: %{x:.3f}<br><b>Im</b>: %{y:.3f}<extra></extra>",
            )
        )

    # Critical Point (-1, 0)
    fig.add_trace(
        go.Scatter(
            x=[-1.0],
            y=[0.0],
            mode="markers",
            name="Critical Point (-1, 0)",
            marker={
                "symbol": "cross",
                "size": 12,
                "color": "red",
                "line": {"width": 2},
            },
            hovertemplate="<b>Critical Point</b>: (-1, 0)<extra></extra>",
        )
    )

    # Unit circle
    theta = np.linspace(0.0, 2.0 * np.pi, 200)
    fig.add_trace(
        go.Scatter(
            x=np.cos(theta),
            y=np.sin(theta),
            mode="lines",
            name="Unit Circle",
            line={"color": "gray", "width": 1.0, "dash": "dot"},
            hoverinfo="skip",
        )
    )

    # Axes reference lines
    fig.add_hline(y=0.0, line_dash="solid", line_color="lightgray", line_width=0.8)
    fig.add_vline(x=0.0, line_dash="solid", line_color="lightgray", line_width=0.8)

    # Compute reasonable axis bounds around critical point (-1, 0)
    all_resps = [ndata.response, np.conj(ndata.response)]
    if ndata.arc_response is not None:
        all_resps.append(ndata.arc_response)
    comb = np.concatenate(all_resps)
    finite_resp = comb[np.isfinite(comb)]

    if finite_resp.size > 0:
        mags = np.abs(finite_resp)
        min_mag = float(np.min(mags))
        r_thresh = max(3.0, min_mag * 3.0)
        interesting = finite_resp[mags <= r_thresh]
        if interesting.size == 0:
            interesting = finite_resp

        x_pts = np.concatenate([[-1.5, 1.5], np.real(interesting)])
        y_pts = np.concatenate([[-1.5, 1.5], np.imag(interesting)])

        x_min, x_max = float(np.min(x_pts)), float(np.max(x_pts))
        y_min, y_max = float(np.min(y_pts)), float(np.max(y_pts))

        x_min = max(x_min, -10.0)
        x_max = min(x_max, 10.0)
        y_min = max(y_min, -10.0)
        y_max = min(y_max, 10.0)

        pad_x = 0.1 * (x_max - x_min)
        pad_y = 0.1 * (y_max - y_min)
        fig.update_xaxes(range=[x_min - pad_x, x_max + pad_x])
        fig.update_yaxes(range=[y_min - pad_y, y_max + pad_y])

    fig.update_layout(
        title="Nyquist Diagram",
        xaxis_title="Real Axis (Re)",
        yaxis_title="Imaginary Axis (Im)",
        template="plotly_white",
        height=600,
        showlegend=True,
    )

    return fig

iplot_root_locus

iplot_root_locus(sys, gains=None)

Generate an interactive Root Locus diagram in the complex s-plane using Plotly.

Plots pole trajectories for varying feedback gain k >= 0 with tooltips displaying gain, pole location, damping ratio (zeta), and natural frequency (wn).

Parameters:

Name Type Description Default
sys LinearTimeInvariant

Open-loop SISO LTI system.

required
gains Sequence[float] | NDArray[floating] | None

Gain values k >= 0. If None, automatically determined.

None

Returns:

Type Description
Figure

Interactive Plotly Figure containing the Root Locus diagram.

Source code in src/ctrlpy/plotting_plotly.py
def iplot_root_locus(
    sys: LinearTimeInvariant,
    gains: Sequence[float] | NDArray[np.floating[Any]] | None = None,
) -> go.Figure:
    """Generate an interactive Root Locus diagram in the complex s-plane using Plotly.

    Plots pole trajectories for varying feedback gain k >= 0 with tooltips displaying
    gain, pole location, damping ratio (zeta), and natural frequency (wn).

    Parameters
    ----------
    sys : LinearTimeInvariant
        Open-loop SISO LTI system.
    gains : Sequence[float] | NDArray[np.floating] | None, optional
        Gain values k >= 0. If None, automatically determined.

    Returns
    -------
    go.Figure
        Interactive Plotly Figure containing the Root Locus diagram.
    """
    rldata = root_locus_data(sys, gains=gains)
    fig = go.Figure()

    # Axes reference lines
    fig.add_hline(y=0.0, line_dash="solid", line_color="lightgray", line_width=0.8)
    fig.add_vline(x=0.0, line_dash="solid", line_color="lightgray", line_width=0.8)

    num_branches = rldata.roots.shape[1]
    gains_arr = rldata.gains

    for branch in range(num_branches):
        branch_roots = rldata.roots[:, branch]
        sigma = branch_roots.real
        omega = branch_roots.imag
        wn = np.abs(branch_roots)
        zeta = np.where(wn > 1e-12, -sigma / wn, 0.0)

        customdata = np.column_stack([gains_arr, zeta, wn])

        fig.add_trace(
            go.Scatter(
                x=sigma,
                y=omega,
                mode="lines",
                name=f"Branch {branch + 1}",
                line={"color": "#1f77b4", "width": 2.0},
                customdata=customdata,
                hovertemplate=(
                    "<b>Gain (k)</b>: %{customdata[0]:.3g}<br>"
                    "<b>Pole</b>: %{x:.3f} + %{y:.3f}j<br>"
                    "<b>Damping (ζ)</b>: %{customdata[1]:.3f}<br>"
                    "<b>Nat. Freq (ωn)</b>: %{customdata[2]:.3g} rad/s<extra></extra>"
                ),
            )
        )

    # Open-loop poles
    if rldata.poles.size > 0:
        fig.add_trace(
            go.Scatter(
                x=rldata.poles.real,
                y=rldata.poles.imag,
                mode="markers",
                name="Open-loop Poles",
                marker={
                    "symbol": "x",
                    "size": 10,
                    "color": "red",
                    "line": {"width": 2},
                },
                hovertemplate="<b>Open-loop Pole</b>: %{x:.3f} + %{y:.3f}j<extra></extra>",
            )
        )

    # Open-loop zeros
    if rldata.zeros.size > 0:
        fig.add_trace(
            go.Scatter(
                x=rldata.zeros.real,
                y=rldata.zeros.imag,
                mode="markers",
                name="Open-loop Zeros",
                marker={
                    "symbol": "circle-open",
                    "size": 9,
                    "color": "green",
                    "line": {"width": 2},
                },
                hovertemplate="<b>Open-loop Zero</b>: %{x:.3f} + %{y:.3f}j<extra></extra>",
            )
        )

    fig.update_layout(
        title="Root Locus",
        xaxis_title="Real Axis (σ)",
        yaxis_title="Imaginary Axis (jω)",
        template="plotly_white",
        height=600,
        showlegend=(num_branches > 1 or rldata.poles.size > 0 or rldata.zeros.size > 0),
    )

    return fig