Root Locus Plot for Control Systems — ggplot2

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 ggplot2

R source (ggplot2)

#' anyplot.ai
#' root-locus-basic: Root Locus Plot for Control Systems
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 86/100 | Created: 2026-06-18

library(ggplot2)
library(dplyr)
library(ragg)

set.seed(42)

# Theme tokens
THEME       <- Sys.getenv("ANYPLOT_THEME", "light")
PAGE_BG     <- if (THEME == "light") "#FAF8F1" else "#1A1A17"
ELEVATED_BG <- if (THEME == "light") "#FFFDF6" else "#242420"
INK         <- if (THEME == "light") "#1A1A17" else "#F0EFE8"
INK_SOFT    <- if (THEME == "light") "#4A4A44" else "#B8B7B0"
INK_MUTED   <- if (THEME == "light") "#6B6A63" else "#A8A79F"

IMPRINT_PALETTE <- c(
  "#009E73",  # 1 — brand green
  "#C475FD",  # 2 — lavender
  "#4467A3",  # 3 — blue
  "#BD8233",  # 4 — ochre
  "#AE3030"   # 5 — matte red
)

# Data: third-order system  G(s) = K / (s(s+2)(s+4))
# Open-loop poles: 0, -2, -4. No finite zeros.
# Root locus computed numerically by sweeping K.
gain_vals <- c(seq(0, 2, length.out = 300),
               seq(2, 10, length.out = 400),
               seq(10, 40, length.out = 300))
gain_vals <- sort(unique(gain_vals))

# Characteristic polynomial: s^3 + 6s^2 + 8s + K = 0
# Roots via polyroot for each K
root_locus_rows <- lapply(gain_vals, function(K) {
  # coefficients: s^3 + 6s^2 + 8s + K
  r <- polyroot(c(K, 8, 6, 1))
  # Sort by imaginary part to keep branch assignment consistent
  r <- r[order(Im(r))]
  list(
    gain   = rep(K, 3),
    branch = c("Branch 1", "Branch 2", "Branch 3"),
    re     = Re(r),
    im     = Im(r)
  )
})

df <- bind_rows(lapply(root_locus_rows, function(x) {
  data.frame(gain = x$gain, branch = x$branch, re = x$re, im = x$im,
             stringsAsFactors = FALSE)
}))

# Keep only points within a reasonable window
df <- df[abs(df$re) <= 8 & abs(df$im) <= 8, ]

# Open-loop poles (K=0)
poles <- data.frame(re = c(0, -2, -4), im = c(0, 0, 0))

# Imaginary axis crossing: K at which Routh criterion gives jw roots
# For s^3+6s^2+8s+K: Routh row 1=[1,8], row 2=[6,K], pivot=(48-K)/6
# Crossing when K=48, auxiliary eq 6s^2+48=0 -> s=+/-j*sqrt(8)
crossing <- data.frame(re = c(0, 0), im = c(sqrt(8), -sqrt(8)))

# Arrow data: show direction of increasing gain along each branch
arrow_k_vals <- c(6, 18, 6)  # one per branch at a distinctive location
branch_names <- c("Branch 1", "Branch 2", "Branch 3")

arrow_df <- bind_rows(lapply(seq_along(branch_names), function(i) {
  k0 <- arrow_k_vals[i]
  dk <- 0.6
  seg_start <- df[abs(df$gain - k0) < 0.15 & df$branch == branch_names[i], ][1, ]
  seg_end   <- df[abs(df$gain - (k0 + dk)) < 0.15 & df$branch == branch_names[i], ][1, ]
  if (!is.na(seg_start$re) && !is.na(seg_end$re)) {
    data.frame(x = seg_start$re, y = seg_start$im,
               xend = seg_end$re, yend = seg_end$im,
               branch = branch_names[i])
  }
}))

# Damping ratio reference lines: radial rays from origin at angle arccos(zeta)
# from the negative real axis. A pole with damping ratio zeta lies at angle
# (pi - arccos(zeta)) from the positive real axis => direction (-zeta, sqrt(1-zeta^2)).
zeta_vals <- c(0.2, 0.4, 0.6, 0.8)
plot_re_max <- 7
plot_im_max <- 6

zeta_df <- bind_rows(lapply(zeta_vals, function(z) {
  re_end <- -plot_re_max * z
  im_end <- plot_re_max * sqrt(1 - z^2)
  # Clip to plot bounds
  if (abs(im_end) > plot_im_max) {
    sc <- plot_im_max / abs(im_end)
    re_end <- re_end * sc
    im_end <- im_end * sc
  }
  rbind(
    data.frame(x = 0, y = 0, xend = re_end, yend =  im_end),
    data.frame(x = 0, y = 0, xend = re_end, yend = -im_end)
  )
}))

# Natural frequency circles: concentric circles at omega_n = 1..5
omega_df <- bind_rows(lapply(1:5, function(w) {
  theta <- seq(0, 2 * pi, length.out = 200)
  data.frame(re = w * cos(theta), im = w * sin(theta), grp = paste0("wn", w))
}))

# Title
plot_title <- "root-locus-basic · r · ggplot2 · anyplot.ai"
title_size <- 12

# Plot
p <- ggplot(df, aes(x = re, y = im, color = branch, group = branch)) +
  # Natural frequency circles (dashed, muted — behind locus)
  geom_path(
    data = omega_df,
    aes(x = re, y = im, group = grp),
    color = INK_MUTED, linewidth = 0.3, linetype = "dashed",
    alpha = 0.55, inherit.aes = FALSE
  ) +
  # Damping ratio rays (dashed, muted — behind locus)
  geom_segment(
    data = zeta_df,
    aes(x = x, y = y, xend = xend, yend = yend),
    color = INK_MUTED, linewidth = 0.3, linetype = "dashed",
    alpha = 0.55, inherit.aes = FALSE
  ) +
  # Axis reference lines
  geom_hline(yintercept = 0, color = INK_SOFT, linewidth = 0.4) +
  geom_vline(xintercept = 0, color = INK_SOFT, linewidth = 0.4) +
  # Root locus branches
  geom_path(linewidth = 1.0, alpha = 0.85) +
  # Direction arrows
  geom_segment(
    data = arrow_df,
    aes(x = x, y = y, xend = xend, yend = yend, color = branch),
    arrow = arrow(length = unit(0.18, "cm"), type = "closed"),
    linewidth = 1.2,
    inherit.aes = FALSE
  ) +
  # Imaginary axis crossings (stability boundary)
  geom_point(
    data = crossing,
    aes(x = re, y = im),
    shape = 18, size = 4, color = IMPRINT_PALETTE[5],
    inherit.aes = FALSE
  ) +
  # Open-loop poles
  geom_point(
    data = poles,
    aes(x = re, y = im),
    shape = 4, size = 4, stroke = 1.5, color = INK,
    inherit.aes = FALSE
  ) +
  # Color scale (3 branches)
  scale_color_manual(
    values = IMPRINT_PALETTE[1:3],
    name   = "Branch"
  ) +
  # Equal axes to preserve geometry
  coord_equal(xlim = c(-7, 2), ylim = c(-6, 6)) +
  labs(
    title = plot_title,
    x     = "Real Axis",
    y     = "Imaginary Axis"
  ) +
  theme_minimal(base_size = 8) +
  theme(
    plot.background   = element_rect(fill = PAGE_BG,     color = PAGE_BG),
    panel.background  = element_rect(fill = PAGE_BG,     color = NA),
    panel.grid.major  = element_line(color = INK_MUTED,  linewidth = 0.15),
    panel.grid.minor  = element_line(color = INK_MUTED,  linewidth = 0.08),
    panel.border      = element_blank(),
    axis.line         = element_line(color = INK_SOFT,   linewidth = 0.4),
    axis.title        = element_text(color = INK,        size = 10),
    axis.text         = element_text(color = INK_SOFT,   size = 8),
    plot.title        = element_text(color = INK,        size = title_size,
                                     margin = margin(b = 8)),
    legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT,
                                     linewidth = 0.3),
    legend.text       = element_text(color = INK_SOFT,  size = 8),
    legend.title      = element_text(color = INK,       size = 9),
    legend.position   = "right",
    plot.margin       = margin(12, 14, 10, 10)
  )

# Save
ggsave(
  filename = sprintf("plot-%s.png", THEME),
  plot     = p,
  device   = ragg::agg_png,
  width    = 8,
  height   = 4.5,
  units    = "in",
  dpi      = 400
)

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

Other implementations