{"spec_id":"root-locus-basic","library":"makie","language":"julia","code":"# anyplot.ai\n# root-locus-basic: Root Locus Plot for Control Systems\n# Library: makie 0.22.10 | Julia 1.11.9\n# Quality: 88/100 | Created: 2026-06-18\n\nusing CairoMakie\nusing Colors\nusing LinearAlgebra\n\n# Theme tokens — Imprint palette\nconst THEME       = get(ENV, \"ANYPLOT_THEME\", \"light\")\nconst PAGE_BG     = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nconst ELEVATED_BG = THEME == \"light\" ? colorant\"#FFFDF6\" : colorant\"#242420\"\nconst INK         = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nconst INK_SOFT    = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\nconst IMPRINT     = [\n    colorant\"#009E73\",  # 1 — brand green (first categorical series)\n    colorant\"#C475FD\",  # 2 — lavender\n    colorant\"#4467A3\",  # 3 — blue\n    colorant\"#BD8233\",  # 4 — ochre\n    colorant\"#AE3030\",  # 5 — matte red\n    colorant\"#2ABCCD\",  # 6 — cyan\n    colorant\"#954477\",  # 7 — rose\n    colorant\"#99B314\",  # 8 — lime\n]\n\n# Data\n# Open-loop: G(s) = K / (s(s+2)(s+4))\n# Characteristic equation: s³ + 6s² + 8s + K = 0\n# Open-loop poles: s = 0, −2, −4  |  No finite zeros\n# Breakaway point: s ≈ −0.845 at K ≈ 3.08 (two branches merge → complex pair)\n# jω-axis crossings: s = ±j√8 ≈ ±j2.83 at K = 48  (stability boundary)\n\nconst OL_POLES = [0.0, -2.0, -4.0]\nconst N_BR     = 3\n\nfunction companion_roots(K::Float64)\n    A = Float64[0   1   0\n                0   0   1\n               -K  -8  -6]\n    return complex.(eigvals(A))  # always ComplexF64, even when roots are real\nend\n\nK_range = range(0.0002, 200.0; length = 1200)\n\n# Sort descending by real part: branch 1 (green) ← pole at s=0 (closest to jω axis)\ninit = sort(companion_roots(Float64(K_range[1])), by = real, rev = true)\nbranches = [[c] for c in init]\n\nfor k in K_range[2:end]\n    curr      = companion_roots(Float64(k))\n    prev_tips = [b[end] for b in branches]\n    remaining = collect(1:N_BR)\n    assign    = zeros(Int, N_BR)\n    for b in 1:N_BR\n        best_pos, best_d = 1, Inf\n        for (pos, j) in enumerate(remaining)\n            d = abs(curr[j] - prev_tips[b])\n            if d < best_d\n                best_d   = d\n                best_pos = pos\n            end\n        end\n        assign[b] = remaining[best_pos]\n        deleteat!(remaining, best_pos)\n    end\n    for b in 1:N_BR\n        push!(branches[b], curr[assign[b]])\n    end\nend\n\nbx = [real.(branches[b]) for b in 1:N_BR]\nby = [imag.(branches[b]) for b in 1:N_BR]\n\n# Grid color (INK at 12% opacity)\ngc = RGBAf(INK.r, INK.g, INK.b, 0.12f0)\n\n# Figure — square canvas → 2400×2400 output; DataAspect preserves s-plane geometry\nfig = Figure(\n    size            = (1200, 1200),\n    fontsize        = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title              = \"root-locus-basic · julia · makie · anyplot.ai\",\n    titlesize          = 20,\n    titlecolor         = INK,\n    xlabel             = \"Real Axis\",\n    ylabel             = \"Imaginary Axis\",\n    xlabelsize         = 14,\n    ylabelsize         = 14,\n    xlabelcolor        = INK,\n    ylabelcolor        = INK,\n    xticklabelsize     = 12,\n    yticklabelsize     = 12,\n    xticklabelcolor    = INK_SOFT,\n    yticklabelcolor    = INK_SOFT,\n    xtickcolor         = INK_SOFT,\n    ytickcolor         = INK_SOFT,\n    backgroundcolor    = PAGE_BG,\n    topspinevisible    = false,\n    rightspinevisible  = false,\n    leftspinecolor     = INK_SOFT,\n    bottomspinecolor   = INK_SOFT,\n    xgridcolor         = gc,\n    ygridcolor         = gc,\n    xminorgridvisible  = false,\n    yminorgridvisible  = false,\n    aspect             = DataAspect(),\n)\n\n# Imaginary axis (stability boundary): thin vertical reference at x=0\nvlines!(ax, [0.0]; color = RGBAf(INK.r, INK.g, INK.b, 0.15f0), linewidth = 1.0)\n\n# Constant natural-frequency arcs (left half-plane semicircles, dotted)\nfor wn in (2.0, 4.0, 6.0)\n    th = range(π / 2, 3π / 2; length = 120)\n    lines!(ax, wn .* cos.(th), wn .* sin.(th);\n        color     = RGBAf(INK.r, INK.g, INK.b, 0.15f0),\n        linestyle = :dot,\n        linewidth = 1.0,\n    )\nend\n\n# Constant damping-ratio lines (dashed radial lines from origin)\nfor (zeta, lbl) in ((0.3, \"ζ=0.3\"), (0.5, \"ζ=0.5\"), (0.7, \"ζ=0.7\"))\n    phi    = acos(zeta)            # angle from negative real axis\n    r_ext  = 9.0\n    sx, sy = -cos(phi), sin(phi)  # unit direction into upper half-plane\n    col    = RGBAf(INK.r, INK.g, INK.b, 0.22f0)\n    lines!(ax, [0.0, r_ext * sx], [0.0,  r_ext * sy]; color = col, linestyle = :dash, linewidth = 1.2)\n    lines!(ax, [0.0, r_ext * sx], [0.0, -r_ext * sy]; color = col, linestyle = :dash, linewidth = 1.2)\n    text!(ax, 0.52 * r_ext * sx + 0.15, 0.52 * r_ext * sy;\n        text     = lbl,\n        color    = INK_SOFT,\n        fontsize = 13,\n        align    = (:left, :center),\n    )\nend\n\n# Locus branches with direction arrows\nbranch_colors = IMPRINT[1:3]\nbranch_labels = [\"Branch from s=0\", \"Branch from s=−2\", \"Branch from s=−4\"]\n\nfor b in 1:N_BR\n    xs, ys = bx[b], by[b]\n    lines!(ax, xs, ys; color = branch_colors[b], linewidth = 2.5, label = branch_labels[b])\n\n    # Direction-of-increasing-K arrow (rotated triangle at 40% along branch)\n    n  = length(xs)\n    ai = clamp(round(Int, n * 0.40), 2, n - 1)\n    dx = xs[ai + 1] - xs[ai - 1]\n    dy = ys[ai + 1] - ys[ai - 1]\n    scatter!(ax, [xs[ai]], [ys[ai]];\n        marker      = :utriangle,\n        markersize  = 15,\n        color       = branch_colors[b],\n        strokewidth = 0.0,\n        rotation    = atan(dy, dx) - π / 2,\n    )\nend\n\n# Open-loop poles (× markers)\nscatter!(ax, OL_POLES, zeros(N_BR);\n    marker     = :xcross,\n    markersize = 24,\n    color      = INK,\n    label      = \"Open-loop poles (×)\",\n)\n\n# jω-axis crossings at K=48 (stability boundary)\njw_y = sqrt(8.0)   # ≈ 2.83\nscatter!(ax, [0.0, 0.0], [jw_y, -jw_y];\n    marker      = :diamond,\n    markersize  = 16,\n    color       = IMPRINT[5],\n    strokewidth = 1.5,\n    strokecolor = PAGE_BG,\n    label       = \"jω crossings (K=48)\",\n)\ntext!(ax, 0.2, jw_y + 0.38;\n    text     = \"±j$(round(jw_y; digits = 2))\\n(K = 48)\",\n    color    = INK_SOFT,\n    fontsize = 13,\n    align    = (:left, :center),\n)\n\nxlims!(ax, -9.0, 3.0)\nylims!(ax, -6.0, 6.0)\n\naxislegend(ax;\n    position        = :rt,\n    framecolor      = INK_SOFT,\n    framewidth      = 0.5,\n    backgroundcolor = ELEVATED_BG,\n    labelcolor      = INK,\n    labelsize       = 12,\n    rowgap          = 3,\n)\n\nsave(joinpath(@__DIR__, \"plot-$(THEME).png\"), fig; px_per_unit = 2)\n"}