Root Locus Plot for Control Systems — lets-plot

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 lets-plot

Python source (lets-plot)

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

import os

import numpy as np
import pandas as pd
from lets_plot import *
from lets_plot import aes  # explicit import to satisfy F405 for multi-line calls
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme-adaptive chrome — Imprint palette
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_RULE = "rgba(26,26,23,0.18)" if THEME == "light" else "rgba(240,239,232,0.18)"

# Imprint categorical palette — hybrid-v3 canonical order
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data — G(s) = (s + 3) / [s(s+1)(s+2)(s+4)]
# Open-loop poles: 0, −1, −2, −4 | Open-loop zero: −3
den_coeffs = np.polymul(np.polymul([1, 0], [1, 1]), np.polymul([1, 2], [1, 4]))

open_loop_poles = np.array([0.0, -1.0, -2.0, -4.0])
open_loop_zeros = np.array([-3.0])

# Gain sweep: dense near origin to capture breakaway, sparser at high gain
k_values = np.concatenate(
    [np.linspace(0, 0.5, 200), np.linspace(0.5, 5, 400), np.linspace(5, 30, 400), np.linspace(30, 120, 400)]
)

all_real, all_imag, all_gain, all_branch = [], [], [], []
for k in k_values:
    char_poly = np.polyadd(den_coeffs, k * np.array([0, 0, 0, 1, 3]))
    roots = np.sort_complex(np.roots(char_poly))
    for b, root in enumerate(roots):
        all_real.append(root.real)
        all_imag.append(root.imag)
        all_gain.append(k)
        all_branch.append(f"Branch {b + 1}")

df = pd.DataFrame({"real": all_real, "imaginary": all_imag, "gain": all_gain, "branch": all_branch})

# Pole and zero marker data
poles_df = pd.DataFrame({"real": open_loop_poles, "imaginary": 0.0})
zeros_df = pd.DataFrame({"real": open_loop_zeros, "imaginary": 0.0})

# Imaginary-axis stability crossings (real ≈ 0, nonzero imaginary part)
crossing_mask = (np.abs(df["real"]) < 0.08) & (np.abs(df["imaginary"]) > 0.3)
crossings = df[crossing_mask].copy()
if len(crossings) > 0:
    crossings = crossings.sort_values("imaginary")
    crossing_pts = pd.concat(
        [crossings[crossings["imaginary"] > 0].head(1), crossings[crossings["imaginary"] < 0].head(1)]
    )
else:
    crossing_pts = pd.DataFrame(columns=df.columns)

# Direction arrows at selected gain values
arrow_gains = [5, 15, 50]
arrow_rows = []
for ag in arrow_gains:
    idx = np.argmin(np.abs(k_values - ag))
    subset = df[(df["gain"] >= k_values[max(0, idx - 1)]) & (df["gain"] <= k_values[min(len(k_values) - 1, idx + 1)])]
    for _, row in subset.drop_duplicates(subset="branch").iterrows():
        k_next = k_values[min(len(k_values) - 1, idx + 5)]
        next_pts = df[(np.abs(df["gain"] - k_next) < 1.0) & (df["branch"] == row["branch"])]
        if len(next_pts) > 0:
            npt = next_pts.iloc[0]
            dx, dy = npt["real"] - row["real"], npt["imaginary"] - row["imaginary"]
            mag = np.hypot(dx, dy)
            if mag > 0.01:
                s = 0.25 / mag
                arrow_rows.append(
                    {
                        "x": row["real"],
                        "y": row["imaginary"],
                        "xend": row["real"] + dx * s,
                        "yend": row["imaginary"] + dy * s,
                    }
                )
arrows_df = pd.DataFrame(arrow_rows) if arrow_rows else pd.DataFrame(columns=["x", "y", "xend", "yend"])

# Damping ratio guide lines (ζ = constant), radial from origin in LHP
r_max = 5.8
zeta_values = [0.2, 0.4, 0.6, 0.8]
zeta_segs = []
for z in zeta_values:
    theta = np.arccos(z)
    zeta_segs += [
        {"x": 0, "y": 0, "xend": -r_max * np.cos(theta), "yend": r_max * np.sin(theta)},
        {"x": 0, "y": 0, "xend": -r_max * np.cos(theta), "yend": -r_max * np.sin(theta)},
    ]
zeta_df = pd.DataFrame(zeta_segs)

# Zeta labels — staggered radial distances to avoid crowding in upper LHP
zeta_labels = pd.DataFrame(
    [
        {
            "x": -r_max * (0.36 + i * 0.14) * np.cos(np.arccos(z)),
            "y": r_max * (0.36 + i * 0.14) * np.sin(np.arccos(z)),
            "label": f"ζ={z}",
        }
        for i, z in enumerate(zeta_values)
    ]
)

# Natural frequency arcs (ωn = constant semicircles, LHP only)
wn_values = [1, 2, 3, 4]
wn_rows = []
for wn in wn_values:
    theta = np.linspace(np.pi / 2, 3 * np.pi / 2, 120)
    wn_rows += [{"real": wn * np.cos(t), "imaginary": wn * np.sin(t), "wn": str(wn)} for t in theta]
wn_df = pd.DataFrame(wn_rows)

# Critical gain annotation at stability boundary
crit_k = crossing_pts["gain"].iloc[0] if len(crossing_pts) > 0 else None
crit_y = float(crossing_pts["imaginary"].max()) if len(crossing_pts) > 0 else 2.0
annot_df = pd.DataFrame(
    {"x": [0.2], "y": [crit_y + 0.45], "label": [f"K ≈ {crit_k:.1f}" if crit_k is not None else ""]}
)

plot = (
    ggplot()
    # ── Reference overlays (behind data) ───────────────────────────────
    + geom_path(
        aes(x="real", y="imaginary", group="wn"),
        data=wn_df,
        color=GRID_RULE,
        size=0.5,
        linetype="dashed",
        tooltips="none",
    )
    + geom_segment(
        aes(x="x", y="y", xend="xend", yend="yend"),
        data=zeta_df,
        color=GRID_RULE,
        size=0.5,
        linetype="dashed",
        tooltips="none",
    )
    # Stable LHP shading with Imprint brand green tint
    + geom_rect(
        aes(xmin="xmin", ymin="ymin", xmax="xmax", ymax="ymax"),
        data=pd.DataFrame({"xmin": [-6.5], "xmax": [0.0], "ymin": [-5.5], "ymax": [5.5]}),
        fill=IMPRINT_PALETTE[0],
        alpha=0.07,
        inherit_aes=False,
        tooltips="none",
    )
    # ── Axis reference lines ────────────────────────────────────────────
    + geom_vline(xintercept=0, color=INK_SOFT, size=0.7)
    + geom_hline(yintercept=0, color=INK_SOFT, size=0.4)
    # ── Locus branches (Imprint palette, interactive tooltips) ──────────
    + geom_path(
        aes(x="real", y="imaginary", color="branch"),
        data=df,
        size=2.0,
        alpha=0.9,
        tooltips=layer_tooltips()
        .format("gain", ".2f")
        .format("real", ".3f")
        .format("imaginary", ".3f")
        .line("Branch @branch")
        .line("K = @gain")
        .line("Re = @real")
        .line("Im = @imaginary"),
    )
    + scale_color_manual(values=IMPRINT_PALETTE[:4], name="Locus branch")
    # ── Direction arrows (increasing gain) ─────────────────────────────
    + geom_segment(
        aes(x="x", y="y", xend="xend", yend="yend"),
        data=arrows_df,
        color=INK,
        size=1.0,
        inherit_aes=False,
        tooltips="none",
        arrow=arrow(length=10, ends="last", type="open"),
    )
    # ── Open-loop poles (×) and zero (○) ───────────────────────────────
    + geom_point(
        aes(x="real", y="imaginary"),
        data=poles_df,
        shape=4,
        size=6,
        color=INK,
        stroke=2.0,
        inherit_aes=False,
        tooltips=layer_tooltips().line("Pole: s = @real"),
    )
    + geom_point(
        aes(x="real", y="imaginary"),
        data=zeros_df,
        shape=1,
        size=6,
        color=INK,
        stroke=2.0,
        inherit_aes=False,
        tooltips=layer_tooltips().line("Zero: s = @real"),
    )
    # ── Stability-boundary crossings ────────────────────────────────────
    + geom_point(
        aes(x="real", y="imaginary"),
        data=crossing_pts,
        shape=18,
        size=6,
        color=IMPRINT_PALETTE[4],
        inherit_aes=False,
        tooltips=layer_tooltips().line("Stability boundary").line("K ≈ @gain"),
    )
    # ── Text annotations ────────────────────────────────────────────────
    # Zeta labels — staggered for legibility
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=zeta_labels,
        size=3.5,
        color=INK_MUTED,
        inherit_aes=False,
        family="monospace",
    )
    # Complex-plane axis symbols
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=pd.DataFrame({"x": [0.15], "y": [4.55], "label": ["jω"]}),
        size=4.5,
        color=INK_MUTED,
        hjust=0,
        family="serif",
        inherit_aes=False,
    )
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=pd.DataFrame({"x": [3.2], "y": [-0.22], "label": ["σ"]}),
        size=4.5,
        color=INK_MUTED,
        hjust=1,
        family="serif",
        inherit_aes=False,
    )
    # Stability region labels
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=pd.DataFrame({"x": [-4.8], "y": [4.3], "label": ["STABLE"]}),
        size=5.0,
        color=IMPRINT_PALETTE[0],
        alpha=0.55,
        fontface="bold",
        family="monospace",
        inherit_aes=False,
    )
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=pd.DataFrame({"x": [0.6], "y": [4.3], "label": ["UNSTABLE"]}),
        size=4.0,
        color=IMPRINT_PALETTE[4],
        alpha=0.5,
        fontface="bold",
        family="monospace",
        inherit_aes=False,
    )
    # Critical gain annotation
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=annot_df,
        size=4.0,
        color=IMPRINT_PALETTE[4],
        hjust=0,
        family="monospace",
        fontface="bold",
        inherit_aes=False,
    )
    # ── Title, axis labels, layout ──────────────────────────────────────
    + labs(
        x="Real axis (σ)",
        y="Imaginary axis (jω)",
        title="root-locus-basic · python · letsplot · anyplot.ai",
        caption="G(s) = (s+3)/[s(s+1)(s+2)(s+4)]  ·  × = poles  ·  ○ = zero  ·  ◆ = stability crossing",
    )
    # Equal-axis scaling preserves geometry (circles stay circular)
    + coord_fixed(ratio=1, xlim=[-5.5, 3.5], ylim=[-4.5, 4.5])
    + ggsize(600, 600)
    + theme_minimal()
    + theme(
        axis_text=element_text(size=10, color=INK_SOFT),
        axis_title=element_text(size=12, color=INK, face="bold"),
        plot_title=element_text(size=16, color=INK, face="bold"),
        plot_caption=element_text(size=9, color=INK_MUTED, face="italic"),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_title=element_text(size=11, color=INK, face="bold"),
        legend_position="right",
        panel_grid_major=element_blank(),
        panel_grid_minor=element_blank(),
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        axis_line=element_line(color=INK_SOFT, size=0.5),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        plot_margin=[20, 25, 15, 15],
    )
)

# Save PNG (scale=4 → 2400 × 2400) and interactive HTML
ggsave(plot, f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, f"plot-{THEME}.html", path=".")

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

Other implementations