Root Locus Plot for Control Systems — Plotly

A root locus plot traces how the closed-loop poles of a transfer function migrate through the complex plane as a system parameter (typically gain K) varies from 0 to infinity. It is a fundamental tool in classical control theory for analyzing system stability and designing controllers. The plot reveals critical information about pole trajectories, stability boundaries, and gain margins.

Root Locus Plot for Control Systems rendered with Plotly

Python source (Plotly)

""" anyplot.ai
root-locus-basic: Root Locus Plot for Control Systems
Library: plotly 6.8.0 | Python 3.13.14
Quality: 86/100 | Updated: 2026-06-18
"""

import os

import numpy as np
import plotly.graph_objects as go


# Theme tokens — Imprint palette, theme-adaptive chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"
REF_LINE = "rgba(107,106,99,0.28)" if THEME == "light" else "rgba(168,167,159,0.28)"

# Imprint categorical palette — positions 1–3 for the three root locus branches
BRANCH_COLORS = ["#009E73", "#C475FD", "#4467A3"]

# Data: G(s) = 1 / (s(s+1)(s+3))
# Open-loop poles at s = 0, -1, -3; no finite zeros
# Characteristic equation: s^3 + 4s^2 + 3s + K = 0
open_loop_poles = np.array([0.0, -1.0, -3.0])

gains = np.concatenate(
    [
        np.linspace(0, 0.5, 200),
        np.linspace(0.5, 4, 400),
        np.linspace(4, 12, 400),
        np.linspace(12, 50, 300),
        np.linspace(50, 200, 200),
    ]
)

branches = {i: {"real": [], "imag": [], "gain": []} for i in range(3)}
prev_roots = open_loop_poles.copy().astype(complex)

for K in gains:
    roots = np.roots([1, 4, 3, K])
    roots = np.sort_complex(roots)
    used = [False] * 3
    assignment = [0] * 3
    for i in range(3):
        best_j, best_dist = -1, np.inf
        for j in range(3):
            if not used[j]:
                d = abs(prev_roots[i] - roots[j])
                if d < best_dist:
                    best_dist = d
                    best_j = j
        used[best_j] = True
        assignment[i] = best_j
    for i in range(3):
        r = roots[assignment[i]]
        branches[i]["real"].append(r.real)
        branches[i]["imag"].append(r.imag)
        branches[i]["gain"].append(K)
    prev_roots = np.array([roots[assignment[i]] for i in range(3)])

branch_names = ["Branch 1 (from s=0)", "Branch 2 (from s=−1)", "Branch 3 (from s=−3)"]

# Plot
fig = go.Figure()

# Real axis root locus segments: [−1, 0] and (−∞, −3]
for seg in [[-1, 0], [-5.5, -3]]:
    fig.add_trace(
        go.Scatter(
            x=seg,
            y=[0, 0],
            mode="lines",
            line={"width": 7, "color": "rgba(0,158,115,0.18)"},
            showlegend=False,
            hoverinfo="skip",
        )
    )

# Constant damping ratio lines (ζ = 0.2, 0.4, 0.6, 0.8)
r_max = 5.3
for zeta in [0.2, 0.4, 0.6, 0.8]:
    r_line = np.linspace(0, r_max, 2)
    x_vals = -r_line * zeta
    y_vals = r_line * np.sqrt(1 - zeta**2)
    for sign in [1, -1]:
        fig.add_trace(
            go.Scatter(
                x=x_vals,
                y=sign * y_vals,
                mode="lines",
                line={"width": 1, "color": REF_LINE, "dash": "dash"},
                showlegend=False,
                hoverinfo="skip",
            )
        )
    fig.add_annotation(
        x=x_vals[-1], y=y_vals[-1] + 0.12, text=f"ζ={zeta}", showarrow=False, font={"size": 10, "color": INK_MUTED}
    )

# Constant natural frequency arcs (ωn = 1, 2, 3, 4, 5)
theta = np.linspace(np.pi / 2, np.pi, 100)
for wn in [1, 2, 3, 4, 5]:
    for sign in [1, -1]:
        fig.add_trace(
            go.Scatter(
                x=wn * np.cos(theta),
                y=sign * wn * np.sin(theta),
                mode="lines",
                line={"width": 1, "color": REF_LINE, "dash": "dot"},
                showlegend=False,
                hoverinfo="skip",
            )
        )
    fig.add_annotation(
        x=wn * np.cos(np.pi * 0.55),
        y=wn * np.sin(np.pi * 0.55) + 0.12,
        text=f"ωn={wn}",
        showarrow=False,
        font={"size": 10, "color": INK_MUTED},
    )

# Stability boundary — imaginary axis shaded band
fig.add_shape(
    type="line", x0=0, x1=0, y0=-5.5, y1=5.5, line={"color": "rgba(174,48,48,0.15)", "width": 20}, layer="below"
)
fig.add_annotation(
    x=0.4,
    y=4.7,
    text="Stability<br>Boundary",
    showarrow=False,
    font={"size": 10, "color": "rgba(174,48,48,0.6)", "family": "Arial, sans-serif"},
)

# Root locus branches
for i in range(3):
    fig.add_trace(
        go.Scatter(
            x=branches[i]["real"],
            y=branches[i]["imag"],
            mode="lines",
            line={"width": 2.5, "color": BRANCH_COLORS[i]},
            name=branch_names[i],
            legendgroup=f"branch{i}",
            hovertemplate=(
                f"<b>Branch {i + 1}</b><br>σ = %{{x:.3f}}<br>jω = %{{y:.3f}}<br>K = %{{customdata:.2f}}<extra></extra>"
            ),
            customdata=branches[i]["gain"],
        )
    )

# Direction arrows indicating increasing gain
for i in range(3):
    n = len(branches[i]["real"])
    for frac in [0.3, 0.65]:
        idx = int(n * frac)
        if idx < n - 5:
            dx = branches[i]["real"][idx + 5] - branches[i]["real"][idx]
            dy = branches[i]["imag"][idx + 5] - branches[i]["imag"][idx]
            norm = np.sqrt(dx**2 + dy**2)
            if norm > 1e-6:
                fig.add_annotation(
                    x=branches[i]["real"][idx],
                    y=branches[i]["imag"][idx],
                    ax=-dx / norm * 25,
                    ay=dy / norm * 25,
                    xref="x",
                    yref="y",
                    axref="pixel",
                    ayref="pixel",
                    showarrow=True,
                    arrowhead=3,
                    arrowsize=1.8,
                    arrowwidth=2,
                    arrowcolor=BRANCH_COLORS[i],
                    text="",
                )

# Open-loop poles (× markers)
fig.add_trace(
    go.Scatter(
        x=open_loop_poles,
        y=np.zeros(3),
        mode="markers+text",
        marker={"symbol": "x-thin", "size": 16, "color": INK, "line": {"width": 3}},
        text=["s=0", "s=−1", "s=−3"],
        textposition="top center",
        textfont={"size": 10, "color": INK},
        name="Open-loop poles",
        hovertemplate="Pole at s = %{x:.1f}<extra></extra>",
    )
)

# jω-axis crossing (Routh–Hurwitz: K_crit = 12, roots at ±j√3)
K_crit = 12.0
jw_cross = np.sqrt(3)
fig.add_trace(
    go.Scatter(
        x=[0, 0],
        y=[jw_cross, -jw_cross],
        mode="markers",
        marker={"symbol": "diamond", "size": 14, "color": "#AE3030", "line": {"width": 2, "color": PAGE_BG}},
        name=f"jω crossing (K={K_crit:.0f})",
        hovertemplate="s = %{y:+.3f}j<br>K = 12 (critical gain)<extra></extra>",
    )
)
fig.add_annotation(
    x=0,
    y=jw_cross,
    text=f"  K={K_crit:.0f}",
    showarrow=True,
    arrowhead=0,
    arrowwidth=1,
    arrowcolor="#AE3030",
    ax=50,
    ay=-20,
    font={"size": 10, "color": "#AE3030"},
)

# Breakaway point (σ ≈ −0.451)
s_break = -0.451
K_break = -(s_break**3 + 4 * s_break**2 + 3 * s_break)
fig.add_trace(
    go.Scatter(
        x=[s_break],
        y=[0],
        mode="markers",
        marker={"symbol": "star", "size": 18, "color": "#BD8233", "line": {"width": 2, "color": PAGE_BG}},
        name=f"Breakaway (K≈{K_break:.2f})",
        hovertemplate="Breakaway point<br>s ≈ −0.451<br>K ≈ %{customdata:.2f}<extra></extra>",
        customdata=[K_break],
    )
)
fig.add_annotation(
    x=s_break,
    y=0,
    text=f"  K≈{K_break:.2f}",
    showarrow=True,
    arrowhead=0,
    arrowwidth=1,
    arrowcolor="#BD8233",
    ax=-55,
    ay=30,
    font={"size": 10, "color": "#BD8233"},
)

# Layout — square canvas preserves equal axis scaling for the complex plane
axis_range = 5.5
fig.update_layout(
    autosize=False,
    title={
        "text": "root-locus-basic · python · plotly · anyplot.ai",
        "font": {"size": 16, "color": INK, "family": "Arial, sans-serif"},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.975,
    },
    xaxis={
        "title": {
            "text": "Real Axis (σ)",
            "font": {"size": 12, "color": INK, "family": "Arial, sans-serif"},
            "standoff": 12,
        },
        "tickfont": {"size": 10, "color": INK_SOFT},
        "zeroline": True,
        "zerolinewidth": 1.5,
        "zerolinecolor": INK_SOFT,
        "showgrid": True,
        "gridwidth": 1,
        "gridcolor": GRID,
        "range": [-axis_range, axis_range],
        "constrain": "domain",
        "dtick": 1,
        "linecolor": INK_SOFT,
    },
    yaxis={
        "title": {
            "text": "Imaginary Axis (jω)",
            "font": {"size": 12, "color": INK, "family": "Arial, sans-serif"},
            "standoff": 12,
        },
        "tickfont": {"size": 10, "color": INK_SOFT},
        "zeroline": True,
        "zerolinewidth": 1.5,
        "zerolinecolor": INK_SOFT,
        "showgrid": True,
        "gridwidth": 1,
        "gridcolor": GRID,
        "range": [-axis_range, axis_range],
        "scaleanchor": "x",
        "scaleratio": 1,
        "dtick": 1,
        "linecolor": INK_SOFT,
    },
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK},
    legend={
        "font": {"size": 10, "color": INK_SOFT, "family": "Arial, sans-serif"},
        "bgcolor": ELEVATED_BG,
        "bordercolor": INK_SOFT,
        "borderwidth": 1,
        "x": 0.99,
        "y": 0.01,
        "xanchor": "right",
        "yanchor": "bottom",
        "itemsizing": "constant",
    },
    margin={"l": 80, "r": 40, "t": 80, "b": 60},
    hoverlabel={"bgcolor": ELEVATED_BG, "font_size": 12, "bordercolor": INK_SOFT},
)

# Transfer function subtitle — lower-left, clear of legend (lower-right) and ζ labels (upper-left)
fig.add_annotation(
    text="G(s) = 1 / s(s+1)(s+3)",
    xref="paper",
    yref="paper",
    x=0.03,
    y=0.03,
    xanchor="left",
    yanchor="bottom",
    showarrow=False,
    font={"size": 10, "color": INK_MUTED, "family": "Courier New, monospace"},
)

# Save — square canvas (2400×2400)
fig.write_image(f"plot-{THEME}.png", width=600, height=600, scale=4)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Root Locus Plot for Control Systems on anyplot.ai.

Other implementations