Root Locus Plot for Control Systems — Matplotlib

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 Matplotlib

Python source (Matplotlib)

""" anyplot.ai
root-locus-basic: Root Locus Plot for Control Systems
Library: matplotlib 3.11.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-06-17
"""

import os

import matplotlib.patches as mpatches
import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np


# Theme
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"

# Imprint palette — branches follow canonical order, first always #009E73
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]
AMBER = "#DDCC77"  # semantic anchor: stability-boundary warning markers

# Data — G(s) = (s+2) / [s(s+1)(s+3)(s+5)], DC servo position control loop
open_loop_poles = np.array([0.0, -1.0, -3.0, -5.0])
open_loop_zeros = np.array([-2.0])

# Denominator: s(s+1)(s+3)(s+5) = s^4 + 9s^3 + 23s^2 + 15s
den_coeffs = np.array([1.0, 9.0, 23.0, 15.0, 0.0])
num_coeffs = np.array([1.0, 2.0])  # (s+2)

# Vary gain K from 0 → 1000, dense sampling near origin for smooth branches
gains = np.concatenate(
    [
        np.linspace(0, 1, 200),
        np.linspace(1, 10, 300),
        np.linspace(10, 50, 300),
        np.linspace(50, 200, 300),
        np.linspace(200, 1000, 400),
    ]
)

n_poles = len(den_coeffs) - 1
locus = np.full((len(gains), n_poles), np.nan + 1j * np.nan)

for i, K in enumerate(gains):
    num_padded = np.zeros(len(den_coeffs))
    num_padded[-len(num_coeffs) :] = num_coeffs
    char_poly = den_coeffs + K * num_padded
    roots = np.roots(char_poly)
    roots = np.sort_complex(roots)
    locus[i, :] = roots

# Sort branches by continuity (greedy nearest-neighbour tracking)
for i in range(1, len(gains)):
    prev = locus[i - 1, :]
    curr = locus[i, :].copy()
    used = np.zeros(n_poles, dtype=bool)
    order = np.zeros(n_poles, dtype=int)
    for j in range(n_poles):
        dists = np.abs(curr - prev[j])
        dists[used] = np.inf
        best = np.argmin(dists)
        order[j] = best
        used[best] = True
    locus[i, :] = curr[order]

# Find imaginary-axis crossings (stability boundary)
crossings = []
for branch in range(n_poles):
    real_parts = locus[:, branch].real
    for i in range(1, len(real_parts)):
        if real_parts[i - 1] * real_parts[i] < 0:
            t = abs(real_parts[i - 1]) / (abs(real_parts[i - 1]) + abs(real_parts[i]))
            cross_point = locus[i - 1, branch] + t * (locus[i, branch] - locus[i - 1, branch])
            crossings.append(cross_point)

# Canvas — 2400×2400 px (square: equal-aspect root locus, symmetric about real axis)
fig, ax = plt.subplots(figsize=(6, 6), dpi=400, facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

# Path-effect stroke uses PAGE_BG so it works in both light and dark
stroke = pe.withStroke(linewidth=2, foreground=PAGE_BG)

# Stable / unstable region shading — Imprint teal + ochre (colorblind-safe, no red/green conflict)
ax.axvspan(-10, 0, color=IMPRINT[0], alpha=0.04, zorder=0)
ax.axvspan(0, 5, color=IMPRINT[3], alpha=0.04, zorder=0)
ax.text(-6.2, -4.4, "Stable", fontsize=8, color=IMPRINT[0], alpha=0.85, path_effects=[stroke])
ax.text(0.3, -4.4, "Unstable", fontsize=8, color=IMPRINT[3], alpha=0.85, path_effects=[stroke])

# Constant damping-ratio lines (ζ = 0.2, 0.4, 0.6, 0.8)
max_r = 7
for zeta in [0.2, 0.4, 0.6, 0.8]:
    angle = np.arccos(zeta)
    r = np.linspace(0, max_r, 100)
    lx = -r * np.cos(np.pi - angle)
    ly = r * np.sin(np.pi - angle)
    ax.plot(lx, ly, "--", color=INK_MUTED, linewidth=0.5, alpha=0.45)
    ax.plot(lx, -ly, "--", color=INK_MUTED, linewidth=0.5, alpha=0.45)
    lr = 3.0 if zeta >= 0.8 else 3.8
    tx = -lr * np.cos(np.pi - angle) - 0.15
    ty = lr * np.sin(np.pi - angle) + 0.18
    ax.text(tx, ty, f"ζ={zeta}", fontsize=7, color=INK_MUTED, alpha=0.9, path_effects=[stroke])

# Constant natural-frequency arcs (ωn = 1 … 6), left half-plane only
for wn in range(1, 7):
    theta = np.linspace(np.pi / 2, 3 * np.pi / 2, 200)
    ax.plot(wn * np.cos(theta), wn * np.sin(theta), "--", color=INK_MUTED, linewidth=0.5, alpha=0.45)

# Branch colors: Imprint canonical order
branch_colors = [IMPRINT[i % len(IMPRINT)] for i in range(n_poles)]

# Draw locus branches
for branch in range(n_poles):
    ax.plot(
        locus[:, branch].real, locus[:, branch].imag, color=branch_colors[branch], linewidth=2.0, alpha=0.9, zorder=3
    )

# Directional arrows (increasing gain)
for branch in range(n_poles):
    n_pts = len(gains)
    idx = n_pts // 3
    if idx + 5 < n_pts:
        p1 = locus[idx, branch]
        p2 = locus[idx + 5, branch]
        dx, dy = p2.real - p1.real, p2.imag - p1.imag
        if np.hypot(dx, dy) > 0.005:
            ax.add_patch(
                mpatches.FancyArrowPatch(
                    (p1.real, p1.imag),
                    (p2.real, p2.imag),
                    arrowstyle="-|>",
                    color=branch_colors[branch],
                    linewidth=1.5,
                    mutation_scale=12,
                    zorder=4,
                    path_effects=[pe.withStroke(linewidth=3, foreground=PAGE_BG, alpha=0.5)],
                )
            )

# Open-loop poles (×) and zeros (○) — INK colour so they flip with theme
ax.scatter(
    open_loop_poles.real,
    np.zeros_like(open_loop_poles),
    marker="x",
    s=120,
    color=INK,
    linewidths=2.0,
    zorder=5,
    label="Open-loop poles",
)
ax.scatter(
    open_loop_zeros.real,
    np.zeros_like(open_loop_zeros),
    marker="o",
    s=100,
    facecolors="none",
    edgecolors=INK,
    linewidths=2.0,
    zorder=5,
    label="Open-loop zeros",
)

# Imaginary-axis crossings: amber diamonds (semantic: stability-boundary warning)
for cp in crossings:
    ax.scatter(cp.real, cp.imag, marker="D", s=80, color=AMBER, edgecolors=INK_SOFT, linewidths=0.8, zorder=6)
    ax.annotate(
        f"jω≈{cp.imag:+.1f}",
        xy=(cp.real, cp.imag),
        xytext=(10, 5),
        textcoords="offset points",
        fontsize=7,
        color=AMBER,
        fontweight="bold",
        path_effects=[stroke],
        zorder=7,
    )

# Real-axis segments (left of an odd count of real poles+zeros)
real_pts = np.sort(np.concatenate([open_loop_poles.real, open_loop_zeros.real]))
x_range = np.linspace(-8, 1, 5000)
on_locus = np.array([np.sum(real_pts >= x) % 2 == 1 for x in x_range])

segments = []
in_seg = False
for i, val in enumerate(on_locus):
    if val and not in_seg:
        seg_start = x_range[i]
        in_seg = True
    elif not val and in_seg:
        segments.append((seg_start, x_range[i - 1]))
        in_seg = False
if in_seg:
    segments.append((seg_start, x_range[-1]))

for s0, s1 in segments:
    ax.plot([s0, s1], [0, 0], color=IMPRINT[0], linewidth=3.5, alpha=0.22, zorder=2)

# Origin cross-hair — INK_SOFT so it adapts to theme
ax.axhline(y=0, color=INK_SOFT, linewidth=0.6, zorder=1)
ax.axvline(x=0, color=INK_SOFT, linewidth=0.6, zorder=1)

# Axis chrome — all tokens theme-adaptive
title = "root-locus-basic · python · matplotlib · anyplot.ai"
title_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12
ax.set_title(title, fontsize=title_fontsize, fontweight="medium", color=INK)
ax.set_xlabel("Real Axis (σ)", fontsize=10, color=INK)
ax.set_ylabel("Imaginary Axis (jω)", fontsize=10, color=INK)
ax.tick_params(axis="both", labelsize=8, colors=INK_SOFT, labelcolor=INK_SOFT)
for spine in ("left", "bottom"):
    ax.spines[spine].set_color(INK_SOFT)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)

ax.set_aspect("equal")
ax.set_xlim(-7, 2.5)
ax.set_ylim(-5, 5)

# Legend
leg = ax.legend(fontsize=8, loc="upper left", framealpha=0.9)
if leg:
    leg.get_frame().set_facecolor(ELEVATED_BG)
    leg.get_frame().set_edgecolor(INK_SOFT)
    plt.setp(leg.get_texts(), color=INK_SOFT)

fig.subplots_adjust(left=0.12, right=0.96, top=0.93, bottom=0.09)
plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=PAGE_BG)

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

Other implementations