Skip to content

Commit 0c216b3

Browse files
feat(plotnine): implement root-locus-basic (#5123)
## Implementation: `root-locus-basic` - plotnine Implements the **plotnine** version of `root-locus-basic`. **File:** `plots/root-locus-basic/implementations/plotnine.py` **Parent Issue:** #4414 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/23362982110)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 1d47a18 commit 0c216b3

2 files changed

Lines changed: 516 additions & 0 deletions

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
""" pyplots.ai
2+
root-locus-basic: Root Locus Plot for Control Systems
3+
Library: plotnine 0.15.3 | Python 3.14.3
4+
Quality: 89/100 | Created: 2026-03-20
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from mizani.formatters import custom_format
10+
from plotnine import (
11+
aes,
12+
annotate,
13+
arrow,
14+
coord_fixed,
15+
element_blank,
16+
element_line,
17+
element_rect,
18+
element_text,
19+
geom_hline,
20+
geom_path,
21+
geom_point,
22+
geom_segment,
23+
geom_text,
24+
geom_vline,
25+
ggplot,
26+
guide_legend,
27+
guides,
28+
labs,
29+
scale_color_manual,
30+
scale_shape_manual,
31+
scale_x_continuous,
32+
scale_y_continuous,
33+
theme,
34+
theme_minimal,
35+
)
36+
37+
38+
# Data - Transfer function G(s) = 1 / [s(s+1)(s+3)]
39+
# Open-loop poles at s = 0, -1, -3; no zeros
40+
# Characteristic equation: s^3 + 4s^2 + 3s + K = 0
41+
open_loop_poles = np.array([0.0, -1.0, -3.0])
42+
open_loop_zeros = np.array([])
43+
44+
num_coeffs = np.array([1.0])
45+
den_coeffs = np.poly(open_loop_poles)
46+
47+
gains = np.concatenate(
48+
[
49+
np.linspace(0, 0.5, 200),
50+
np.linspace(0.5, 2, 300),
51+
np.linspace(2, 6, 400),
52+
np.linspace(6, 20, 400),
53+
np.linspace(20, 80, 300),
54+
]
55+
)
56+
57+
branch_data = []
58+
for K in gains:
59+
char_eq = den_coeffs.copy()
60+
char_eq[-1] += K * num_coeffs[-1]
61+
roots = np.roots(char_eq)
62+
roots = np.sort_complex(roots)
63+
for branch_idx, root in enumerate(roots):
64+
branch_data.append({"real": root.real, "imaginary": root.imag, "gain": K, "branch": f"Branch {branch_idx + 1}"})
65+
66+
df = pd.DataFrame(branch_data)
67+
68+
# Find imaginary axis crossings (stability boundary)
69+
crossings = []
70+
for branch in df["branch"].unique():
71+
branch_df = df[df["branch"] == branch].reset_index(drop=True)
72+
for i in range(1, len(branch_df)):
73+
r0 = branch_df.loc[i - 1, "real"]
74+
r1 = branch_df.loc[i, "real"]
75+
if r0 * r1 < 0:
76+
frac = abs(r0) / (abs(r0) + abs(r1))
77+
cross_imag = branch_df.loc[i - 1, "imaginary"] + frac * (
78+
branch_df.loc[i, "imaginary"] - branch_df.loc[i - 1, "imaginary"]
79+
)
80+
cross_gain = branch_df.loc[i - 1, "gain"] + frac * (branch_df.loc[i, "gain"] - branch_df.loc[i - 1, "gain"])
81+
crossings.append({"real": 0.0, "imaginary": cross_imag, "gain": cross_gain})
82+
83+
# Find breakaway point on the real axis (where branches depart from real axis)
84+
# dK/ds = 0 => derivative of -(s^3 + 4s^2 + 3s) = -(3s^2 + 8s + 3) = 0
85+
breakaway_roots = np.roots([3, 8, 3])
86+
breakaway_s = breakaway_roots[(breakaway_roots > -1) & (breakaway_roots < 0)][0]
87+
breakaway_K = -(breakaway_s**3 + 4 * breakaway_s**2 + 3 * breakaway_s)
88+
89+
# Real axis segments: to the left of an odd number of real poles+zeros
90+
real_features = np.sort(np.concatenate([open_loop_poles, open_loop_zeros]))
91+
real_axis_segments = []
92+
x_min_axis = -5.0
93+
test_points = np.linspace(x_min_axis, 1.0, 2000)
94+
for x in test_points:
95+
count = np.sum(real_features >= x)
96+
if count % 2 == 1:
97+
real_axis_segments.append(x)
98+
99+
# Build segment intervals
100+
seg_intervals = []
101+
if len(real_axis_segments) > 0:
102+
seg_start = real_axis_segments[0]
103+
for i in range(1, len(real_axis_segments)):
104+
if real_axis_segments[i] - real_axis_segments[i - 1] > 0.01:
105+
seg_intervals.append((seg_start, real_axis_segments[i - 1]))
106+
seg_start = real_axis_segments[i]
107+
seg_intervals.append((seg_start, real_axis_segments[-1]))
108+
109+
seg_df = pd.DataFrame(seg_intervals, columns=["x_start", "x_end"])
110+
seg_df["y"] = 0.0
111+
112+
# Arrow indicators for direction of increasing gain
113+
arrows = []
114+
for branch in df["branch"].unique():
115+
branch_df = df[df["branch"] == branch].reset_index(drop=True)
116+
mid_idx = len(branch_df) * 2 // 5
117+
if mid_idx > 0:
118+
arrows.append(
119+
{
120+
"x": branch_df.loc[mid_idx - 1, "real"],
121+
"y": branch_df.loc[mid_idx - 1, "imaginary"],
122+
"xend": branch_df.loc[mid_idx, "real"],
123+
"yend": branch_df.loc[mid_idx, "imaginary"],
124+
}
125+
)
126+
127+
arrow_df = pd.DataFrame(arrows)
128+
129+
# Poles markers
130+
pole_df = pd.DataFrame({"real": open_loop_poles, "imaginary": np.zeros(len(open_loop_poles)), "type": "Open-loop Pole"})
131+
132+
crossing_df = pd.DataFrame(crossings)
133+
134+
# Breakaway point marker
135+
breakaway_df = pd.DataFrame([{"real": breakaway_s, "imaginary": 0.0, "type": "Breakaway Point"}])
136+
137+
# Combined markers for legend via scale_shape_manual
138+
marker_df = pd.concat([pole_df, breakaway_df], ignore_index=True)
139+
140+
# Damping ratio lines — radiate from origin into the LEFT half-plane (stable region)
141+
damping_ratios = [0.2, 0.4, 0.6, 0.8]
142+
damp_lines = []
143+
radius = 4.8
144+
for zeta in damping_ratios:
145+
theta = np.arccos(zeta)
146+
x_end = -radius * zeta
147+
y_end_pos = radius * np.sin(theta)
148+
damp_lines.append({"x": 0, "y": 0, "xend": x_end, "yend": y_end_pos, "label": f"ζ={zeta}"})
149+
damp_lines.append({"x": 0, "y": 0, "xend": x_end, "yend": -y_end_pos, "label": f"ζ={zeta}"})
150+
151+
damp_df = pd.DataFrame(damp_lines)
152+
153+
# Damping ratio labels (placed along upper lines, offset from endpoints)
154+
damp_label_df = damp_df[damp_df["yend"] > 0].copy()
155+
damp_label_df["lx"] = damp_label_df["xend"] * 0.75
156+
damp_label_df["ly"] = damp_label_df["yend"] * 0.75
157+
158+
# Natural frequency circles
159+
wn_values = [1.0, 2.0, 3.0, 4.0]
160+
wn_data = []
161+
for wn in wn_values:
162+
theta = np.linspace(0, 2 * np.pi, 100)
163+
for t in theta:
164+
wn_data.append({"real": wn * np.cos(t), "imaginary": wn * np.sin(t), "wn": f"ωn={wn}"})
165+
166+
wn_df = pd.DataFrame(wn_data)
167+
168+
# Natural frequency labels (placed at top-left of each circle)
169+
wn_label_df = pd.DataFrame([{"real": -0.5, "imaginary": wn + 0.2, "label": f"ωn={int(wn)}"} for wn in wn_values])
170+
171+
# Crossing annotations
172+
crossing_label_df = crossing_df.copy()
173+
crossing_label_df["label"] = crossing_label_df["gain"].apply(lambda g: f"K={g:.1f}")
174+
175+
# Branch colors — cohesive palette starting with Python Blue
176+
branch_colors = ["#306998", "#E8833A", "#5BA65B"]
177+
178+
# Mizani custom formatters for axis labels (distinctive plotnine feature)
179+
sigma_fmt = custom_format("{:.0f}")
180+
181+
182+
# Custom label function for imaginary axis — displays ±Nj with special "0" at origin
183+
def jw_label_fn(values):
184+
labels = []
185+
for v in values:
186+
v_int = int(round(v))
187+
if v_int == 0:
188+
labels.append("0")
189+
else:
190+
labels.append(f"{v_int}j")
191+
return labels
192+
193+
194+
# Plot — square format (3600x3600) for coord_fixed root locus
195+
plot = (
196+
ggplot()
197+
# Subtle stability region shading
198+
+ annotate("rect", xmin=-5.5, xmax=0, ymin=-5, ymax=5, fill="#E8F5E9", alpha=0.25)
199+
+ annotate("rect", xmin=0, xmax=2.5, ymin=-5, ymax=5, fill="#FFEBEE", alpha=0.2)
200+
+ annotate("text", x=-4.6, y=4.2, label="Stable", color="#2E7D32", size=9, fontstyle="italic", alpha=0.6)
201+
+ annotate("text", x=1.3, y=4.2, label="Unstable", color="#C62828", size=9, fontstyle="italic", alpha=0.6)
202+
# Damping ratio guide lines — increased visibility
203+
+ geom_segment(
204+
damp_df, aes(x="x", y="y", xend="xend", yend="yend"), color="#AAAAAA", linetype="dashed", size=0.6, alpha=0.7
205+
)
206+
# Damping ratio labels directly on plot
207+
+ geom_text(
208+
damp_label_df, aes(x="lx", y="ly", label="label"), color="#777777", size=9, fontstyle="italic", ha="center"
209+
)
210+
# Natural frequency circles — increased visibility
211+
+ geom_path(
212+
wn_df, aes(x="real", y="imaginary", group="wn"), color="#BBBBBB", linetype="dotted", size=0.5, alpha=0.55
213+
)
214+
# Natural frequency labels
215+
+ geom_text(wn_label_df, aes(x="real", y="imaginary", label="label"), color="#888888", size=9, fontstyle="italic")
216+
# Real axis segments of root locus
217+
+ geom_segment(
218+
seg_df, aes(x="x_start", y="y", xend="x_end", yend="y"), color="#8B5E3C", size=2.5, alpha=0.55, linetype="solid"
219+
)
220+
# Root locus branches
221+
+ geom_path(df, aes(x="real", y="imaginary", color="branch", group="branch"), size=1.5, alpha=0.9)
222+
# Direction arrows
223+
+ geom_segment(
224+
arrow_df, aes(x="x", y="y", xend="xend", yend="yend"), color="#222222", size=1.2, arrow=arrow(length=0.15)
225+
)
226+
# Open-loop poles and breakaway point via shape mapping
227+
+ geom_point(
228+
marker_df, aes(x="real", y="imaginary", shape="type"), size=5, color="#222222", stroke=2, fill="#222222"
229+
)
230+
+ scale_shape_manual(values={"Open-loop Pole": "x", "Breakaway Point": "s"}, name="Markers")
231+
# Imaginary axis crossings (stability boundary)
232+
+ geom_point(crossing_df, aes(x="real", y="imaginary"), shape="D", size=4.5, color="#D62728", stroke=1.5)
233+
# Crossing gain annotations — offset to avoid overlap
234+
+ geom_text(
235+
crossing_label_df,
236+
aes(x="real", y="imaginary", label="label"),
237+
color="#D62728",
238+
size=9,
239+
ha="left",
240+
nudge_x=0.4,
241+
nudge_y=0.3,
242+
fontweight="bold",
243+
)
244+
# Breakaway annotation — moved further from origin to reduce clutter
245+
+ annotate(
246+
"text",
247+
x=breakaway_s - 0.8,
248+
y=-0.7,
249+
label=f"Breakaway\nK={breakaway_K:.2f}",
250+
color="#555555",
251+
size=9,
252+
ha="center",
253+
fontweight="bold",
254+
)
255+
# Axes
256+
+ geom_hline(yintercept=0, color="#888888", size=0.5)
257+
+ geom_vline(xintercept=0, color="#888888", size=0.5, linetype="solid")
258+
+ scale_color_manual(values=branch_colors)
259+
# Mizani formatters for axis tick labels (distinctive plotnine/mizani feature)
260+
+ scale_x_continuous(labels=sigma_fmt, breaks=[-5, -4, -3, -2, -1, 0, 1, 2])
261+
+ scale_y_continuous(labels=jw_label_fn, breaks=[-4, -3, -2, -1, 0, 1, 2, 3, 4])
262+
+ coord_fixed(ratio=1, xlim=(-5.2, 2.2), ylim=(-4.8, 4.8))
263+
+ labs(title="root-locus-basic · plotnine · pyplots.ai", x="Real Axis (σ)", y="Imaginary Axis (jω)", color="Branch")
264+
# Plotnine guides() for legend customization (distinctive feature)
265+
+ guides(
266+
shape=guide_legend(order=1, override_aes={"size": 4}), color=guide_legend(order=2, override_aes={"size": 2})
267+
)
268+
+ theme_minimal()
269+
+ theme(
270+
figure_size=(12, 12),
271+
plot_title=element_text(size=24, weight="bold", ha="center"),
272+
axis_title=element_text(size=20),
273+
axis_text=element_text(size=16),
274+
legend_title=element_text(size=16, weight="bold"),
275+
legend_text=element_text(size=14),
276+
legend_position="right",
277+
legend_background=element_rect(fill="#FAFAFA", color="#DDDDDD", size=0.5),
278+
legend_key_size=20,
279+
panel_grid_major=element_line(color="#F5F5F5", size=0.2),
280+
panel_grid_minor=element_blank(),
281+
plot_background=element_rect(fill="white", color="white"),
282+
)
283+
)
284+
285+
# Save as square format for coord_fixed root locus
286+
plot.save("plot.png", dpi=300, verbose=False)

0 commit comments

Comments
 (0)