Problem: Transfer Function Root Locus and Stability Analysis

The problem aims to develop a Python pipeline that loads transfer function coefficients from strings, constructs valid transfer functions, computes closed-loop roots for varying gains, plots enhanced root loci with analytical features, and analyzes system stability, supporting control system design and evaluation.

Background: ### Industry Background: Control Systems Root Locus Analysis
#### Context
These functions implement a complete pipeline for root locus analysis, a core technique in **classical control engineering** used to evaluate the stability and dynamic performance of linear time-invariant (LTI) systems as a gain parameter \( K \) varies. Root locus plots show how the closed-loop poles of a system move in the complex plane as \( K \) changes from 0 to infinity, enabling engineers to tune gains for desired stability and transient response.

#### Key Mathematical & Industry Concepts by Function

##### 1. `load_coefficients`
- **Transfer Function Representation**: LTI systems are described by transfer functions \( H(s) = \frac{\text{Num}(s)}{\text{Den}(s)} \), where \( \text{Num}(s) = a_n s^n + ... + a_0 \) and \( \text{Den}(s) = b_m s^m + ... + b_0 \) are polynomials in the Laplace variable \( s \). This function parses string-formatted coefficients into complex-valued lists.
- **Numerical Stability**: Tiny coefficient values (<\( 10^{-12} \)) are clipped to zero to avoid numerical errors (e.g., division by near-zero values) in subsequent computations, a standard practice in engineering numerical methods.
- **Normalization**: Leading coefficients of numerator and denominator are normalized to 1 to simplify polynomial analysis, ensuring consistency across different transfer function representations.
- **Fractional-Order Smoothing**: Uses fractional calculus (with a fractional order of 0.5) to smooth coefficients. Fractional-order control is an emerging subfield dealing with systems of non-integer order, where this smoothing reduces noise in coefficient data while preserving critical dynamics.

##### 2. `transfer_function`
- **Polynomial Alignment**: Numerator and denominator coefficients are padded to the same length to enable element-wise operations required for forming the characteristic equation \( \text{Den}(s) + K \cdot \text{Num}(s) = 0 \), the foundation of root locus analysis.
- **Complex Data Type Unification**: Converts coefficients to complex128 to support systems with complex poles/zeros (common in oscillatory or underdamped systems) and frequency-domain analysis.
- **Characteristic Equation Preparation**: Normalizes the transfer function by the leading denominator term, simplifying the characteristic equation to \( 1 + K \cdot \frac{\text{Num}(s)}{\text{Den}(s)} = 0 \), which is the standard form for root locus calculations.

##### 3. `compute_roots`
- **Characteristic Equation Roots**: For each gain \( K \), solves \( \text{Den}(s) + K \cdot \text{Num}(s) = 0 \) to find closed-loop poles. Roots of this equation determine system stability (stable if all roots have negative real parts).
- **Root-Finding Methods**:
  - **Companion Matrix**: Converts the polynomial into a matrix whose eigenvalues are the polynomial roots, a stable method for low-degree polynomials (≤50 degrees).
  - **Iterative Deflation with Newton's Method**: For high-degree polynomials (>50), uses a Jenkins-Traub inspired approach with root deflation (removing found roots to simplify remaining root-finding) and Newton-Raphson refinement to improve accuracy.
- **Parallel Processing**: Leverages `ProcessPoolExecutor` to compute roots for multiple gains in parallel, reducing computation time for large gain ranges, a critical optimization for industrial control system analysis where hundreds of gain points may be evaluated.

##### 4. `plot_root_locus`
- **Root Locus Features**:
  - **Initial/Final Poles**: At \( K=0 \), poles match open-loop poles (roots of \( \text{Den}(s) \)); as \( K \to \infty \), poles approach open-loop zeros (roots of \( \text{Num}(s) \)) or asymptotes.
  - **Asymptotes**: Calculated using \( \sigma_a = \frac{\sum \text{poles} - \sum \text{zeros}}{n-m} \) (center) and \( \theta = \frac{(2k+1)\pi}{n-m} \) (angles, for \( n>m \)), where \( n \) is the number of poles and \( m \) is the number of zeros. Asymptotes predict pole behavior at very high gains.
  - **Separation/Conjunction Points**: Points on the real axis where poles merge or split, found by solving \( \frac{ds}{dK} = 0 \) (using cubic spline interpolation and root-finding), which indicate transitions between real and complex pole behavior.
- **Damping Ratio Contours**: Damping ratio \( \zeta = \cos(\theta) \) (where \( \theta \) is the angle between the pole and the real axis) is a key metric for transient response. Contours of constant \( \zeta \) help engineers select gains to achieve desired response characteristics (e.g., \( \zeta=0.707 \) for critically damped response).

##### 5. `analyze_stability`
- **Lyapunov Stability**: A linear system is asymptotically stable if all closed-loop poles have negative real parts. This function checks this criterion using a numerical tolerance of \( -10^{-9} \) to account for numerical noise.
- **Stability Margins**: Measures the minimum distance of the closest pole to the imaginary axis, a quantifier of robust stability (how much parameter variation the system can tolerate before becoming unstable).
- **Stable Gain Intervals**: Identifies ranges of \( K \) where the system remains stable, critical for tuning industrial control systems (e.g., PID controllers) to operate within safe, stable parameter ranges.
- **Numerical Condition Checking**: Evaluates the condition number of the root matrix to detect ill-conditioned systems, where small coefficient changes lead to large root variations, warning engineers of potential numerical unreliability.

---
from typing import Tuple, List, Union
import numpy as np
from concurrent.futures import ProcessPoolExecutor, as_completed
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline
from scipy import optimize
import scipy.sparse as sp

Input/Output: The problem receives two string inputs (num_line for numerator coefficients, denum_line for denominator coefficients) and a 1D strictly increasing non-negative numpy array of gains. It parses, clips, normalizes, and smooths the coefficient strings to generate numerator/denominator coefficient lists. These lists are converted into a 2D transfer function array via padding, type unification, and normalization. The transfer function and gains are used to compute a 2D complex root array. The gains and roots produce a matplotlib Figure and Axes for the root locus plot (with asymptotes, separation points, damping contours). Finally, a printed stability analysis report details stable gain intervals, margins, and robustness metrics.

Required dependencies:
from typing import Tuple, List, Union
import numpy as np
from concurrent.futures import ProcessPoolExecutor, as_completed
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline
from scipy import optimize
import scipy.sparse as sp

Sub-steps:
step_number: 1
step_description_prompt: Please write a Python function named load_coefficients that takes two string arguments (num_line, denum_line). It parses the strings into complex coefficients, clips tiny values, normalizes leading coefficient to 1, applies fractional-order smoothing with adaptive window, converts real-only to float, returns tuples of float/complex lists.
function_header: def load_coefficients(num_line, denum_line):
test_cases: for num_line, denum_line, exp_num, exp_denum in [
    ("1 2", "3 4 5", [1.5, 1.5], [1.2973, 1.3333, 1.3694]),  # Updated: Fractional-order smoothing changes values
    ("1", "1 0 0", [1.0], [0.3694, 0.3333, 0.2612]),  # Updated: Fractional-order smoothing
    ("", "1", [], [1.0])
]:
    num, denum = load_coefficients(num_line, denum_line)
    # Round to 4 decimals for comparison due to fractional-order smoothing
    num_rounded = [round(float(n), 4) for n in num]
    denum_rounded = [round(float(d), 4) for d in denum]
    exp_num_rounded = [round(float(n), 4) for n in exp_num]
    exp_denum_rounded = [round(float(d), 4) for d in exp_denum]
    assert num_rounded == exp_num_rounded and denum_rounded == exp_denum_rounded, f"Mismatch: num={num_rounded} vs {exp_num_rounded}, denum={denum_rounded} vs {exp_denum_rounded}"

step_number: 2
step_description_prompt: Please write a Python function named transfer_function that takes two Sequence[Any] parameters num and denum and returns a numpy.ndarray. The function should validate inputs (1D, non-empty denum), unify data type (complex if either input is complex), pad to max length, zero values smaller than 1e-12, normalize by leading non-zero denominator term, then stack normalized num and denum.
function_header: def transfer_function(num, denum):
test_cases: for num, denum, exp_padded in [
    ([1], [1, 0, 0], np.array([0.0, 0.0, 1.0])),
    ([1, 1], [1, 2, 1], np.array([0.0,1.0, 1.0])),
    ([], [1], np.array([0.0]))
]:
    tf = transfer_function(num, denum)
    assert np.array_equal(tf[0], exp_padded) and np.array_equal(tf[1], denum)

step_number: 3
step_description_prompt: Please write a Python function named compute_roots. It takes a transfer function (tuple/list of two numpy arrays or a 2D array) and gains, validates inputs, trims leading zeros from numerator/denominator, computes roots for unique gains (companion matrix for low-degree, iterative method for high), uses parallel processing, returns roots in original gain order.
function_header: def compute_roots(tf, gains):
test_cases: for num, denum, expected_num in [
    ([1], [1, 2, 1], np.array([0.0, 0.0, 1.0])),
    ([1, 1], [1, 0, 0, 0], np.array([0.0,0.0, 1.0, 1.0])),
    ([], [1, 0], np.array([0.0, 0.0]))
]:
    tf = transfer_function(num, denum)
    # print(tf[0],expected_num)
    assert np.array_equal(tf[0], expected_num)
    assert np.array_equal(tf[1], denum)

step_number: 4
step_description_prompt: Please write a Python function named plot_root_locus. It takes gains (1D strictly increasing non-negative numpy array) and roots (2D complex array). Validate inputs, plot root locus with initial/final poles, asymptotes, separation points, damping ratio contours, then return matplotlib Figure and Axes objects.
function_header: def plot_root_locus(gains, roots):
test_cases: import math
if __name__ == '__main__':
    test_cases = [
        (np.array([0, 0, 1.0]), np.array([1.0, 2.0, 1.0]), [0, 1, 10]),
        (np.array([1.0]), np.array([1.0, 1.0]), [0, 2]),
        (np.array([1.0, 0.5]), np.array([1.0, 2.0, 1.0]), [1])
    ]
    expected_results = []
    for num, denum, gains in test_cases:
        try:
            exp = compute_roots((num, denum), gains)
            expected_results.append(exp)
        except Exception:
            expected_results.append(None)
    for (num, denum, gains), expected in zip(test_cases, expected_results):
        if expected is None:
            roots = compute_roots((num, denum), gains)
            assert roots.ndim == 2, f"Roots should be 2D array, got shape {roots.shape}"
            assert roots.shape[0] == len(gains), f"Number of rows should match number of gains: got {roots.shape[0]}, expected {len(gains)}"
        else:
            roots = compute_roots((num, denum), gains)
            assert roots.shape == expected.shape, f"Shape mismatch: got {roots.shape}, expected {expected.shape}"
            for i in range(roots.shape[0]):
                for j in range(roots.shape[1]):
                    if np.isnan(expected[i, j]):
                        assert np.isnan(roots[i, j]) or np.abs(roots[i, j]) < 1e-10, f"Root[{i},{j}] should be NaN or near zero, got {roots[i, j]}"
                    else:
                        assert np.isclose(roots[i, j], expected[i, j], rtol=1e-5), f"Root[{i},{j}] mismatch: got {roots[i, j]}, expected {expected[i, j]}"

step_number: 5
step_description_prompt: Please write a Python function named analyze_stability(gains: np.ndarray, roots: Union[np.ndarray, 'sp.spmatrix']) -> None. It validates inputs, computes max root real parts for stability checks, calculates margins, finds stable gain intervals, performs robustness/numerical checks, and prints a detailed analysis report.
function_header: def analyze_stability(gains, roots):
test_cases: gains = np.array([0, 5, 10])
roots = np.array([[-1, -2], [-0.5+1j, -0.5-1j], [0.1, -3]], dtype=complex)
fig, ax = plot_root_locus(gains, roots)

# Assertions: Structure and Integrity
assert hasattr(fig, 'savefig'), "Output 'fig' is not a valid matplotlib Figure"
assert hasattr(ax, 'get_lines'), "Output 'ax' is not a valid matplotlib Axes"
assert ax.figure is fig, "Axes does not belong to the figure"

# Assertions: Numerical and Content Validation
scatters = ax.collections
assert len(scatters) >= 2, "Missing pole/zero markers"
# Validate that the plotted initial poles (K=0) match input data real parts
init_coords = scatters[0].get_offsets()
assert np.isclose(np.mean(init_coords[:, 0]), np.mean(roots[0].real), atol=0.1), "Initial pole coordinates mismatch"

# Validate that the number of plotted branches matches the number of roots
branch_count = roots.shape[1]
# Filtering lines that match the intermediate data length
locus_lines = [l for l in ax.get_lines() if len(l.get_xdata()) == (len(gains) - 2)]
assert len(locus_lines) == branch_count, f"Expected {branch_count} branches, found {len(locus_lines)}"

# Assertions: Metadata
assert 'Re' in ax.get_xlabel() and 'Im' in ax.get_ylabel(), "Axes labels mismatch"
assert len(ax.get_title()) > 0, "Plot title is missing"

# Memory Cleanup Check
f_num = getattr(fig, 'number', None)
plt.close(fig)
if f_num is not None:
    assert not plt.fignum_exists(f_num), "Figure was not properly closed"

General solution:

General tests: