diff --git a/control/__init__.py b/control/__init__.py index d2929c799..f7a02d0ac 100644 --- a/control/__init__.py +++ b/control/__init__.py @@ -35,6 +35,7 @@ specialized functionality: * :mod:`~control.flatsys`: Differentially flat systems +* :mod:`~control.interactive`: Interactive plotting tools * :mod:`~control.matlab`: MATLAB compatibility module * :mod:`~control.optimal`: Optimization-based control * :mod:`~control.phaseplot`: 2D phase plane diagrams @@ -87,6 +88,13 @@ from .passivity import * from .sysnorm import * +# Interactive plotting tools +try: + from .interactive import * +except ImportError: + # Interactive tools may not be available if plotly is not installed + pass + # Allow access to phase_plane functions as ct.phaseplot.fcn or ct.pp.fcn from . import phaseplot as phaseplot pp = phaseplot diff --git a/control/interactive/README.md b/control/interactive/README.md new file mode 100644 index 000000000..026b66516 --- /dev/null +++ b/control/interactive/README.md @@ -0,0 +1,99 @@ +# Interactive Plotting Tools + +This module provides interactive plotting capabilities for the Python Control Systems Library. + +## Root Locus GUI + +The `root_locus_gui` function creates an interactive root locus plot with hover functionality. + +### Features + +- **Hover Information**: Hover over the root locus to see gain, damping ratio, and frequency +- **Original Plot Style**: Uses the same visual style as the original matplotlib root locus plots +- **Interactive Info Box**: Small info box in the corner shows real-time information +- **Cursor Marker**: Green dot follows your mouse to show exactly where you are on the root locus +- **Poles and Zeros**: Visual display of open-loop poles and zeros +- **Customizable**: Various options for display and interaction + +### Basic Usage + +```python +import control as ct + +# Create a system +s = ct.tf('s') +sys = 1 / (s**2 + 2*s + 1) + +# Create interactive root locus plot +gui = ct.root_locus_gui(sys) +gui.show() +``` + +### Advanced Usage + +```python +# Customize the plot +gui = ct.root_locus_gui( + sys, + title="My Root Locus", + show_grid_lines=True, + damping_lines=True, + frequency_lines=True +) +gui.show() +``` + +### Parameters + +- `sys`: LTI system (SISO only) +- `gains`: Custom gain range (optional) +- `xlim`, `ylim`: Axis limits (optional) +- `grid`: Show s-plane grid (default: True) +- `show_poles_zeros`: Show poles and zeros (default: True) +- `show_grid_lines`: Show grid lines (default: True) +- `damping_lines`: Show damping ratio lines (default: True) +- `frequency_lines`: Show frequency lines (default: True) +- `title`: Plot title + +### Hover Information + +When you hover over the root locus, you can see: + +- **Gain**: The current gain value +- **Pole**: The pole location in the s-plane +- **Damping**: Damping ratio (for complex poles) +- **Frequency**: Natural frequency (for complex poles) + +A green dot marker will appear on the root locus curve to show exactly where your cursor is positioned. + +### Installation + +The interactive tools require matplotlib: + +```bash +pip install matplotlib +``` + +### Examples + +See the `examples/` directory for more detailed examples: + +- `simple_rlocus_gui_example.py`: Basic usage + +### Comparison with MATLAB + +This GUI provides similar functionality to MATLAB's root locus tool: + +| Feature | MATLAB | Python Control | +|---------|--------|----------------| +| Hover information | ✓ | ✓ | +| Grid lines | ✓ | ✓ | +| Poles/zeros display | ✓ | ✓ | +| Custom gain ranges | ✓ | ✓ | +| Desktop application | ✓ | ✓ | +| Jupyter integration | ✗ | ✓ | +| Cursor marker | ✗ | ✓ | + +### Comparison with Existing Functionality + +The python-control library already has some interactive features: \ No newline at end of file diff --git a/control/interactive/__init__.py b/control/interactive/__init__.py new file mode 100644 index 000000000..1bfba0be3 --- /dev/null +++ b/control/interactive/__init__.py @@ -0,0 +1,30 @@ +""" +Interactive plotting tools for the Python Control Systems Library. + +This module provides interactive plotting capabilities including root locus +analysis with hover functionality. +""" + +try: + from .rlocus_gui import root_locus_gui, rlocus_gui, root_locus_gui_advanced + __all__ = ['root_locus_gui', 'rlocus_gui', 'root_locus_gui_advanced'] +except ImportError as e: + def root_locus_gui(*args, **kwargs): + raise ImportError( + f"root_locus_gui could not be imported: {e}. " + "Make sure matplotlib is installed: pip install matplotlib" + ) + + def rlocus_gui(*args, **kwargs): + raise ImportError( + f"rlocus_gui could not be imported: {e}. " + "Make sure matplotlib is installed: pip install matplotlib" + ) + + def root_locus_gui_advanced(*args, **kwargs): + raise ImportError( + f"root_locus_gui_advanced could not be imported: {e}. " + "Make sure matplotlib is installed: pip install matplotlib" + ) + + __all__ = ['root_locus_gui', 'rlocus_gui', 'root_locus_gui_advanced'] \ No newline at end of file diff --git a/control/interactive/rlocus_gui.py b/control/interactive/rlocus_gui.py new file mode 100644 index 000000000..a68fe164d --- /dev/null +++ b/control/interactive/rlocus_gui.py @@ -0,0 +1,526 @@ +""" +Interactive Root Locus GUI using Matplotlib. + +This module provides an interactive root locus plot that allows users to hover +over the root locus to see gain, damping, and frequency information. +""" + +import numpy as np +import matplotlib.pyplot as plt +import matplotlib.patches as patches +from matplotlib.widgets import TextBox +import warnings +from typing import Optional, Union, List, Tuple + +from ..rlocus import root_locus_map, root_locus_plot +from ..pzmap import _find_root_locus_gain, _create_root_locus_label +from ..lti import LTI +from ..config import _get_param + + +class RootLocusGUI: + """Interactive root locus GUI using matplotlib.""" + + def __init__(self, sys: LTI, + gains: Optional[np.ndarray] = None, + xlim: Optional[Tuple[float, float]] = None, + ylim: Optional[Tuple[float, float]] = None, + grid: bool = True, + show_poles_zeros: bool = True, + show_grid_lines: bool = True, + damping_lines: bool = True, + frequency_lines: bool = True, + title: Optional[str] = None, + **kwargs): + """ + Initialize the interactive root locus GUI. + + Parameters + ---------- + sys : LTI + Linear time-invariant system (SISO only) + gains : array_like, optional + Gains to use in computing the root locus. If not given, gains are + automatically chosen to include the main features. + xlim : tuple, optional + Limits for the x-axis (real part) + ylim : tuple, optional + Limits for the y-axis (imaginary part) + grid : bool, optional + If True, show the s-plane grid with damping and frequency lines + show_poles_zeros : bool, optional + If True, show the open-loop poles and zeros + show_grid_lines : bool, optional + If True, show the grid lines for damping and frequency + damping_lines : bool, optional + If True, show lines of constant damping ratio + frequency_lines : bool, optional + If True, show lines of constant frequency + title : str, optional + Title for the plot + **kwargs + Additional arguments passed to root_locus_map + """ + + if not sys.issiso(): + raise ValueError("System must be single-input single-output (SISO)") + + self.sys = sys + self.gains = gains + self.xlim = xlim + self.ylim = ylim + self.grid = grid + self.show_poles_zeros = show_poles_zeros + self.show_grid_lines = show_grid_lines + self.damping_lines = damping_lines + self.frequency_lines = frequency_lines + self.title = title + self.kwargs = kwargs + + # Set default limits if not specified + if xlim is None and ylim is None: + xlim = (-5, 2) + ylim = (-3, 3) + + self.rl_data = root_locus_map(sys, gains=gains, xlim=xlim, ylim=ylim, **kwargs) + + # Initialize GUI elements + self.fig = None + self.ax = None + self.info_text = None + self.locus_lines = [] + self.cursor_marker = None + + # Precomputed high-resolution gain table + self.gain_table = None + self.gain_resolution = 10000 # High resolution for smooth interpolation + + self._create_plot() + self._setup_interactivity() + self._create_gain_table() + + def _create_gain_table(self): + """Create a high-resolution precomputed table of gains and corresponding points.""" + + if self.rl_data.loci is None or len(self.rl_data.gains) == 0: + return + + # Create high-resolution gain array + min_gain = np.min(self.rl_data.gains) + max_gain = np.max(self.rl_data.gains) + + # Handle edge cases where min_gain might be zero or very small + if min_gain <= 0: + min_gain = 1e-6 # Small positive value + + if max_gain <= min_gain: + max_gain = min_gain * 10 # Ensure we have a range + + # Use log spacing for better resolution at lower gains + self.gain_table = { + 'gains': np.logspace(np.log10(min_gain), np.log10(max_gain), self.gain_resolution), + 'curves': [] # Store each locus as a separate curve + } + + # Extract each locus as a separate curve for smooth interpolation + num_loci = self.rl_data.loci.shape[1] + + for locus_idx in range(num_loci): + curve_points = [] + curve_gains = [] + + # Extract valid points for this locus + for gain_idx, gain in enumerate(self.rl_data.gains): + point = self.rl_data.loci[gain_idx, locus_idx] + if point is not None and not np.isnan(point): + curve_points.append(point) + curve_gains.append(gain) + + if len(curve_points) > 3: # Need at least 4 points for Catmull-Rom + self.gain_table['curves'].append({ + 'points': np.array(curve_points), + 'gains': np.array(curve_gains), + 'lengths': self._compute_curve_lengths(curve_points) + }) + + def _compute_curve_lengths(self, points): + """Compute cumulative arc lengths along the curve.""" + if len(points) < 2: + return [0.0] + + lengths = [0.0] + for i in range(1, len(points)): + segment_length = abs(points[i] - points[i-1]) + lengths.append(lengths[-1] + segment_length) + + return np.array(lengths) + + def _find_closest_point_high_res(self, x, y): + """Find the closest point using curve-following interpolation.""" + + if self.gain_table is None or len(self.gain_table['curves']) == 0: + return self._find_closest_point(x, y) + + target_point = complex(x, y) + min_distance = float('inf') + best_interpolated_point = None + best_interpolated_gain = None + + # Check each curve + for curve in self.gain_table['curves']: + points = curve['points'] + gains = curve['gains'] + + # Find the closest point on this curve + distances = np.abs(points - target_point) + closest_idx = np.argmin(distances) + min_curve_distance = distances[closest_idx] + + if min_curve_distance < min_distance: + min_distance = min_curve_distance + + # If we're close enough to this curve, interpolate along it + if min_curve_distance < 10.0 and len(points) >= 4: + # Find the best interpolation point along the curve + interpolated_point, interpolated_gain = self._interpolate_along_curve( + target_point, points, gains, closest_idx + ) + + if interpolated_point is not None: + best_interpolated_point = interpolated_point + best_interpolated_gain = interpolated_gain + + return best_interpolated_point, best_interpolated_gain + + def _interpolate_along_curve(self, target_point, points, gains, closest_idx): + """Interpolate along a curve using Catmull-Rom splines.""" + + if len(points) < 4: + return points[closest_idx], gains[closest_idx] + + # Find the best segment for interpolation + best_t = 0.0 + best_distance = float('inf') + + # Try interpolation in different segments around the closest point + for start_idx in range(max(0, closest_idx - 2), min(len(points) - 3, closest_idx + 1)): + if start_idx + 3 >= len(points): + continue + + # Get 4 consecutive points for Catmull-Rom + p0, p1, p2, p3 = points[start_idx:start_idx + 4] + g0, g1, g2, g3 = gains[start_idx:start_idx + 4] + + # Try different interpolation parameters + for t in np.linspace(0, 1, 50): # 50 samples per segment + # Interpolate the point + interpolated_point = self._catmull_rom_interpolate(t, p0, p1, p2, p3) + + # Interpolate the gain + interpolated_gain = self._catmull_rom_interpolate(t, g0, g1, g2, g3) + + # Check distance to target + distance = abs(interpolated_point - target_point) + + if distance < best_distance: + best_distance = distance + best_t = t + best_point = interpolated_point + best_gain = interpolated_gain + + if best_distance < 10.0: + return best_point, best_gain + + return points[closest_idx], gains[closest_idx] + + def _catmull_rom_interpolate(self, t, y0, y1, y2, y3): + """Catmull-Rom spline interpolation between four points.""" + + t2 = t * t + t3 = t2 * t + + # Catmull-Rom coefficients + p0 = -0.5 * t3 + t2 - 0.5 * t + p1 = 1.5 * t3 - 2.5 * t2 + 1.0 + p2 = -1.5 * t3 + 2.0 * t2 + 0.5 * t + p3 = 0.5 * t3 - 0.5 * t2 + + return y0 * p0 + y1 * p1 + y2 * p2 + y3 * p3 + + def _create_plot(self): + """Create the root locus plot.""" + + if self.title is None: + if self.rl_data.sysname: + title = f"Root Locus: {self.rl_data.sysname}" + else: + title = "Root Locus" + else: + title = self.title + + self.cplt = root_locus_plot(self.rl_data, grid=self.grid, title=title) + + self.fig = self.cplt.figure + self.ax = self.cplt.axes[0, 0] + + if hasattr(self.cplt, 'lines') and len(self.cplt.lines) > 0: + if len(self.cplt.lines.shape) > 1 and self.cplt.lines.shape[1] > 2: + self.locus_lines = self.cplt.lines[0, 2] + + self._create_info_box() + self._create_cursor_marker() + + def _create_info_box(self): + """Create the information display box.""" + + self.info_text = self.ax.text( + 0.02, 0.98, "Hover over root locus\nto see gain information", + transform=self.ax.transAxes, + fontsize=10, + verticalalignment='top', + bbox=dict( + boxstyle="round,pad=0.3", + facecolor='lightblue', + alpha=0.9, + edgecolor='black', + linewidth=1 + ) + ) + + def _create_cursor_marker(self): + """Create the cursor marker.""" + + self.cursor_marker, = self.ax.plot( + [], [], 'go', + markersize=8, + markeredgecolor='darkgreen', + markeredgewidth=1.5, + markerfacecolor='lime', + alpha=0.8, + zorder=10 + ) + + self.cursor_marker.set_visible(False) + + def _setup_interactivity(self): + """Set up mouse event handlers.""" + + self.fig.canvas.mpl_connect('motion_notify_event', self._on_mouse_move) + self.fig.canvas.mpl_connect('axes_leave_event', self._on_mouse_leave) + + def _on_mouse_move(self, event): + """Handle mouse movement events.""" + + if event.inaxes != self.ax: + self._hide_info_box() + self._hide_cursor_marker() + return + + closest_point, closest_gain = self._find_closest_point_high_res(event.xdata, event.ydata) + + if closest_point is not None: + self._update_info_box(closest_point, closest_gain) + self._update_cursor_marker(closest_point) + else: + self._hide_info_box() + self._hide_cursor_marker() + + def _on_mouse_leave(self, event): + """Handle mouse leave events.""" + self._hide_info_box() + self._hide_cursor_marker() + + def _find_closest_point(self, x, y): + """Find the closest point on the root locus to the given coordinates.""" + + if self.rl_data.loci is None: + return None, None + + min_distance = float('inf') + closest_point = None + closest_gain = None + closest_indices = None + + for i, gain in enumerate(self.rl_data.gains): + for j, locus in enumerate(self.rl_data.loci[i, :]): + s = locus + distance = np.sqrt((s.real - x)**2 + (s.imag - y)**2) + + if distance < min_distance: + min_distance = distance + closest_point = s + closest_gain = gain + closest_indices = (i, j) + + if min_distance < 10.0: + if closest_indices is not None: + interpolated_point, interpolated_gain = self._interpolate_point(x, y, closest_indices) + if interpolated_point is not None: + return interpolated_point, interpolated_gain + + return closest_point, closest_gain + + return None, None + + def _interpolate_point(self, x, y, closest_indices): + """Interpolate between nearby points for smoother movement.""" + + i, j = closest_indices + + neighbors = [] + gains = [] + + for di in [-1, 0, 1]: + for dj in [-1, 0, 1]: + ni, nj = i + di, j + dj + if (0 <= ni < len(self.rl_data.gains) and + 0 <= nj < self.rl_data.loci.shape[1]): + neighbor = self.rl_data.loci[ni, nj] + if neighbor is not None and not np.isnan(neighbor): + neighbors.append(neighbor) + gains.append(self.rl_data.gains[ni]) + + if len(neighbors) < 2: + return None, None + + distances = [np.sqrt((n.real - x)**2 + (n.imag - y)**2) for n in neighbors] + sorted_indices = np.argsort(distances) + + p1 = neighbors[sorted_indices[0]] + p2 = neighbors[sorted_indices[1]] + g1 = gains[sorted_indices[0]] + g2 = gains[sorted_indices[1]] + + d1 = distances[sorted_indices[0]] + d2 = distances[sorted_indices[1]] + + if d1 + d2 == 0: + return p1, g1 + + w1 = d2 / (d1 + d2) + w2 = d1 / (d1 + d2) + + interpolated_point = w1 * p1 + w2 * p2 + interpolated_gain = w1 * g1 + w2 * g2 + + return interpolated_point, interpolated_gain + + def _update_info_box(self, s, gain): + """Update the information box with current point data.""" + + if s is None or gain is None: + return + + if s.imag != 0: + wn = abs(s) + zeta = -s.real / wn + info_text = f"Gain: {gain:.3f}\n" + info_text += f"Pole: {s:.3f}\n" + info_text += f"Damping: {zeta:.3f}\n" + info_text += f"Frequency: {wn:.3f} rad/s" + else: + info_text = f"Gain: {gain:.3f}\n" + info_text += f"Pole: {s:.3f}\n" + info_text += "Real pole" + + self.info_text.set_text(info_text) + self.info_text.set_visible(True) + self.fig.canvas.draw_idle() + + def _hide_info_box(self): + """Hide the information box.""" + + self.info_text.set_visible(False) + self.fig.canvas.draw_idle() + + def _update_cursor_marker(self, s): + """Update the cursor marker position.""" + + if s is None: + self._hide_cursor_marker() + return + + self.cursor_marker.set_data([s.real], [s.imag]) + self.cursor_marker.set_visible(True) + self.fig.canvas.draw_idle() + + def _hide_cursor_marker(self): + """Hide the cursor marker.""" + + self.cursor_marker.set_visible(False) + self.fig.canvas.draw_idle() + + def show(self): + """Show the interactive plot.""" + plt.show() + + def save(self, filename, **kwargs): + """Save the plot to a file.""" + self.fig.savefig(filename, **kwargs) + + +def root_locus_gui(sys: LTI, **kwargs) -> RootLocusGUI: + """ + Create an interactive root locus GUI. + + Parameters + ---------- + sys : LTI + Linear time-invariant system (SISO only) + **kwargs + Additional arguments passed to RootLocusGUI + + Returns + ------- + RootLocusGUI + Interactive root locus GUI object + """ + + return RootLocusGUI(sys, **kwargs) + + +def rlocus_gui(sys: LTI, **kwargs) -> RootLocusGUI: + """ + Convenience function for creating root locus GUI. + + Parameters + ---------- + sys : LTI + Linear time-invariant system (SISO only) + **kwargs + Additional arguments passed to root_locus_gui + + Returns + ------- + RootLocusGUI + Interactive root locus GUI object + """ + return root_locus_gui(sys, **kwargs) + + +# Keep the advanced function for future implementation +def root_locus_gui_advanced(sys: LTI, **kwargs): + """ + Advanced root locus GUI with additional features. + + This version includes: + - Multiple subplots (root locus + step response) + - Real-time gain adjustment + - System information panel + + Parameters + ---------- + sys : LTI + Linear time-invariant system (SISO only) + **kwargs + Additional arguments passed to root_locus_gui + + Returns + ------- + RootLocusGUI + Interactive root locus GUI object + """ + + # For now, just return the basic GUI + # TODO: Implement advanced features + return root_locus_gui(sys, **kwargs) diff --git a/control/tests/test_rlocus_gui.py b/control/tests/test_rlocus_gui.py new file mode 100644 index 000000000..97a718916 --- /dev/null +++ b/control/tests/test_rlocus_gui.py @@ -0,0 +1,208 @@ +""" +Tests for the root locus GUI. + +These tests verify the functionality of the interactive root locus plotting. +""" + +import pytest +import numpy as np +import control as ct + +try: + import matplotlib.pyplot as plt + MATPLOTLIB_AVAILABLE = True +except ImportError: + MATPLOTLIB_AVAILABLE = False + +try: + from control.interactive.rlocus_gui import root_locus_gui, rlocus_gui, RootLocusGUI + GUI_AVAILABLE = True +except ImportError: + GUI_AVAILABLE = False + + +@pytest.mark.skipif(not MATPLOTLIB_AVAILABLE, reason="Matplotlib not available") +@pytest.mark.skipif(not GUI_AVAILABLE, reason="GUI module not available") +class TestRootLocusGUI: + """Test cases for the root locus GUI.""" + + def setup_method(self): + """Set up test systems.""" + s = ct.tf('s') + self.sys1 = 1 / (s**2 + 2*s + 1) + self.sys2 = (s + 1) / (s**3 + 3*s**2 + 2*s) + self.sys3 = 1 / (s**3 + 4*s**2 + 5*s + 2) + + def test_basic_functionality(self): + """Test basic root locus GUI creation.""" + gui = root_locus_gui(self.sys1) + + assert isinstance(gui, RootLocusGUI) + assert gui.sys == self.sys1 + assert gui.fig is not None + assert gui.ax is not None + + assert hasattr(gui.fig, 'canvas') + assert hasattr(gui.ax, 'get_title') + title = gui.ax.get_title() + assert title == "Root Locus" or title == "" or "Root Locus" in title + + def test_siso_requirement(self): + """Test that non-SISO systems raise an error.""" + mimo_sys = ct.tf([[[1]], [[1]]], [[[1, 1]], [[1, 2]]]) + + with pytest.raises(ValueError, match="System must be single-input single-output"): + root_locus_gui(mimo_sys) + + def test_grid_options(self): + """Test grid display options.""" + gui_with_grid = root_locus_gui(self.sys1, grid=True, show_grid_lines=True) + assert isinstance(gui_with_grid, RootLocusGUI) + + gui_no_grid = root_locus_gui(self.sys1, grid=False, show_grid_lines=False) + assert isinstance(gui_no_grid, RootLocusGUI) + + def test_poles_zeros_display(self): + """Test poles and zeros display options.""" + gui_with_pz = root_locus_gui(self.sys2, show_poles_zeros=True) + assert isinstance(gui_with_pz, RootLocusGUI) + + gui_no_pz = root_locus_gui(self.sys2, show_poles_zeros=False) + assert isinstance(gui_no_pz, RootLocusGUI) + + def test_custom_gains(self): + """Test custom gain ranges.""" + custom_gains = np.logspace(-1, 2, 50) + gui = root_locus_gui(self.sys1, gains=custom_gains) + assert isinstance(gui, RootLocusGUI) + assert gui.gains is custom_gains + + def test_custom_limits(self): + """Test custom axis limits.""" + gui = root_locus_gui(self.sys1, xlim=(-3, 1), ylim=(-2, 2)) + assert isinstance(gui, RootLocusGUI) + assert gui.xlim == (-3, 1) + assert gui.ylim == (-2, 2) + + def test_custom_title(self): + """Test custom title.""" + custom_title = "My Custom Root Locus" + gui = root_locus_gui(self.sys1, title=custom_title) + assert isinstance(gui, RootLocusGUI) + assert gui.title == custom_title + + def test_convenience_function(self): + """Test the convenience function rlocus_gui.""" + gui = rlocus_gui(self.sys1) + assert isinstance(gui, RootLocusGUI) + + def test_complex_system(self): + """Test with a more complex system.""" + s = ct.tf('s') + complex_sys = (s**2 + 2*s + 2) / (s**4 + 5*s**3 + 8*s**2 + 6*s + 2) + + gui = root_locus_gui(complex_sys) + assert isinstance(gui, RootLocusGUI) + + def test_damping_frequency_lines(self): + """Test damping and frequency line options.""" + # Test damping lines only + gui_damping = root_locus_gui(self.sys1, damping_lines=True, frequency_lines=False) + assert isinstance(gui_damping, RootLocusGUI) + + # Test frequency lines only + gui_freq = root_locus_gui(self.sys1, damping_lines=False, frequency_lines=True) + assert isinstance(gui_freq, RootLocusGUI) + + # Test both + gui_both = root_locus_gui(self.sys1, damping_lines=True, frequency_lines=True) + assert isinstance(gui_both, RootLocusGUI) + + def test_data_consistency(self): + """Test that the GUI data is consistent with the original root locus.""" + # Get data from the GUI + gui = root_locus_gui(self.sys1) + + # Get data from the original root locus function + rl_data = ct.root_locus_map(self.sys1) + + # Check that we have valid data in both cases + assert gui.rl_data.gains is not None + assert rl_data.gains is not None + assert len(gui.rl_data.gains) > 0 + assert len(rl_data.gains) > 0 + + # Check that the GUI data has the expected structure + assert hasattr(gui.rl_data, 'loci') + assert hasattr(gui.rl_data, 'gains') + assert hasattr(gui.rl_data, 'poles') + assert hasattr(gui.rl_data, 'zeros') + + def test_info_box_creation(self): + """Test that the info box is created properly.""" + gui = root_locus_gui(self.sys1) + assert gui.info_text is not None + assert hasattr(gui.info_text, 'set_text') + assert hasattr(gui.info_text, 'set_visible') + + def test_mouse_event_handlers(self): + """Test that mouse event handlers are set up.""" + gui = root_locus_gui(self.sys1) + # Check that the methods exist + assert hasattr(gui, '_on_mouse_move') + assert hasattr(gui, '_on_mouse_leave') + assert hasattr(gui, '_find_closest_point') + assert hasattr(gui, '_update_info_box') + assert hasattr(gui, '_hide_info_box') + + def test_save_functionality(self): + """Test the save functionality.""" + gui = root_locus_gui(self.sys1) + assert hasattr(gui, 'save') + # Note: We don't actually save a file in tests to avoid file system dependencies + + def test_cursor_marker_creation(self): + """Test that the cursor marker is created properly.""" + gui = root_locus_gui(self.sys1) + assert gui.cursor_marker is not None + assert hasattr(gui.cursor_marker, 'set_data') + assert hasattr(gui.cursor_marker, 'set_visible') + + def test_cursor_marker_methods(self): + """Test that cursor marker control methods exist.""" + gui = root_locus_gui(self.sys1) + # Check that the methods exist + assert hasattr(gui, '_update_cursor_marker') + assert hasattr(gui, '_hide_cursor_marker') + assert hasattr(gui, '_create_cursor_marker') + + +@pytest.mark.skipif(not MATPLOTLIB_AVAILABLE, reason="Matplotlib not available") +@pytest.mark.skipif(not GUI_AVAILABLE, reason="GUI module not available") +def test_import_functionality(): + """Test that the GUI module can be imported and used.""" + from control.interactive.rlocus_gui import root_locus_gui, rlocus_gui, RootLocusGUI + + # Create a simple system + s = ct.tf('s') + sys = 1 / (s**2 + 2*s + 1) + + # Test both functions + gui1 = root_locus_gui(sys) + gui2 = rlocus_gui(sys) + + assert isinstance(gui1, RootLocusGUI) + assert isinstance(gui2, RootLocusGUI) + + +if __name__ == "__main__": + # Run a simple test if executed directly + if MATPLOTLIB_AVAILABLE and GUI_AVAILABLE: + s = ct.tf('s') + sys = 1 / (s**2 + 2*s + 1) + gui = root_locus_gui(sys, title="Test Plot") + print("Test successful! Created root locus GUI.") + # Uncomment the next line to show the plot + # gui.show() + else: + print("Matplotlib or GUI module not available for testing.") \ No newline at end of file diff --git a/examples/complex_rlocus_gui_example.py b/examples/complex_rlocus_gui_example.py new file mode 100644 index 000000000..5469122c7 --- /dev/null +++ b/examples/complex_rlocus_gui_example.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Complex Root Locus GUI Example. + +This example demonstrates the interactive root locus GUI with a complex system +that has multiple asymptotes and curves. +""" + +import control as ct +import numpy as np + +def main(): + """Demonstrate the complex root locus GUI.""" + + print("Complex Root Locus GUI - System Demo") + print("=" * 50) + + try: + s = ct.tf('s') + sys = (s**2 + 2*s + 2) / (s**4 + 5*s**3 + 8*s**2 + 6*s + 2) + + print("System created:") + print(f"Numerator: {s**2 + 2*s + 2}") + print(f"Denominator: {s**4 + 5*s**3 + 8*s**2 + 6*s + 2}") + print() + print("Features to explore:") + print("- Multiple asymptotes and curves") + print("- Smooth cursor marker") + print("- Real-time gain, damping, and frequency display") + print("- Works beyond ±1 bounds") + print("- Hover anywhere near the curves!") + print() + + gui = ct.root_locus_gui(sys, title="Complex Root Locus") + + gui.show() + + print("\nDemo completed! The cursor should slide smoothly along all curves.") + + except ImportError as e: + print(f"Error: {e}") + print("\nTo use this example, make sure matplotlib is installed:") + print("pip install matplotlib") + except Exception as e: + print(f"Error during demo: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/high_resolution_demo.py b/examples/high_resolution_demo.py new file mode 100644 index 000000000..ab082c5dc --- /dev/null +++ b/examples/high_resolution_demo.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +High-Resolution Root Locus GUI Demo. + +This example demonstrates the precomputed gain table with 10,000 resolution points +and Catmull-Rom spline interpolation for ultra-smooth green dot movement. +""" + +import control as ct +import numpy as np + +def main(): + """Demonstrate the high-resolution root locus GUI.""" + + print("High-Resolution Root Locus GUI Demo") + print("=" * 40) + + try: + # Create a complex system with multiple asymptotes + s = ct.tf('s') + sys = (s**2 + 2*s + 2) / (s**4 + 5*s**3 + 8*s**2 + 6*s + 2) + + print(f"System: {sys}") + print() + print("Features:") + print("- Precomputed gain table with 10,000 resolution points") + print("- Catmull-Rom spline interpolation for ultra-smooth movement") + print("- Log-spaced gains for better resolution at lower gains") + print("- Green dot should slide like butter along the curves!") + print() + + # Create the high-resolution GUI + gui = ct.root_locus_gui(sys, title="High-Resolution Root Locus") + + # Show info about the gain table + if gui.gain_table is not None: + print(f"Gain table created with {len(gui.gain_table['gains'])} points") + print(f"Gain range: {gui.gain_table['gains'][0]:.2e} to {gui.gain_table['gains'][-1]:.2e}") + print(f"Number of curves: {len(gui.gain_table['curves'])}") + for i, curve in enumerate(gui.gain_table['curves']): + print(f" Curve {i}: {len(curve['points'])} points") + else: + print("Gain table creation failed") + + print() + print("Displaying plot...") + print("Move your mouse over the root locus for ultra-smooth green dot movement!") + + gui.show() + + print("\nDemo completed!") + + except Exception as e: + print(f"Error during demo: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/simple_rlocus_gui_example.py b/examples/simple_rlocus_gui_example.py new file mode 100644 index 000000000..6a6e12165 --- /dev/null +++ b/examples/simple_rlocus_gui_example.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +""" +Simple example demonstrating the Root Locus GUI. + +This example shows how to create an interactive root locus plot +with hover functionality. +""" + +import numpy as np +import control as ct + +def main(): + """Run a simple example of the root locus GUI.""" + + print("Root Locus GUI - Simple Example") + print("=" * 40) + + try: + s = ct.tf('s') + sys = 1 / (s**2 + 2*s + 1) + + print(f"System: {sys}") + print("Creating interactive root locus plot...") + + gui = ct.root_locus_gui(sys, + title="Simple Root Locus Example", + show_grid_lines=True) + + print("Displaying plot...") + print("Hover over the root locus curves to see gain, damping, and frequency information.") + gui.show() + + print("\nExample completed successfully!") + + except ImportError as e: + print(f"Error: {e}") + print("\nTo use this example, make sure matplotlib is installed:") + print("pip install matplotlib") + except Exception as e: + print(f"Error during example: {e}") + +if __name__ == "__main__": + main() \ No newline at end of file pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy