civil-and-structural-engineering
How to Automate Routh-hurwitz Stability Checks with Python Scripts
Table of Contents
The Routh-Hurwitz Criterion: A Cornerstone of Control Systems Analysis
The Routh-Hurwitz criterion is a fundamental method in control engineering used to determine the stability of a system based on its characteristic equation. Traditionally, performing these checks manually can be time-consuming and prone to errors, especially as the order of the system increases. Automating the process with Python scripts significantly improves efficiency and accuracy, enabling engineers to analyze high-order polynomials rapidly, incorporate stability checks into automated design loops, and reduce the risk of calculation mistakes.
This article provides a comprehensive guide to automating Routh-Hurwitz stability checks using Python. It covers the mathematical background, implementation details, handling of special cases, and practical integration into engineering workflows. By the end, you will be equipped to write robust scripts that can process multiple system configurations, handle symbolic coefficients, and produce clear stability assessments.
What Is the Routh-Hurwitz Criterion?
Named after Edward John Routh and Adolf Hurwitz, the criterion provides necessary and sufficient conditions for the stability of a linear time-invariant (LTI) system. For a given characteristic polynomial:
Δ(s) = a₀sⁿ + a₁sⁿ⁻¹ + ... + aₙ (with a₀ > 0)
the method constructs the Routh array from the coefficients. The system is stable if, and only if, all the elements in the first column of the array are positive. Any sign changes indicate unstable poles, and a zero in the first column or an entire row of zeros points to marginal stability or the presence of symmetrically located roots.
The criterion is widely used because it avoids solving the polynomial itself and works directly with coefficients. However, manual construction for polynomials of degree 5 or higher becomes tedious and error-prone, making automation highly valuable.
Why Automate Stability Checks?
Automating the Routh-Hurwitz procedure offers substantial advantages in both academic and industrial settings:
- Speed and efficiency: A script can evaluate dozens of polynomials per second, enabling rapid iteration during controller tuning or system identification.
- Accuracy: Eliminates arithmetic mistakes that commonly occur when building the array by hand, especially when dealing with fractional or symbolic coefficients.
- Parameter sweeps: Engineers can systematically vary gains, time constants, or other design parameters and immediately see the effect on stability boundaries.
- Integration with larger simulations: The stability check can be embedded in optimization loops, Monte Carlo analyses, or automated design scripts.
- Reproducibility: Scripts provide a documented, version-controlled analysis that can be easily shared and audited.
By offloading the routine calculation to Python, engineers can focus on higher-level design decisions and interpretation of results.
Foundations of the Routh Array Construction
Before writing code, it is essential to understand the algorithm the script must follow. Given a polynomial of degree n:
- Arrange the coefficients in two rows: the first row contains coefficients of even powers (starting from the highest power), and the second row contains coefficients of odd powers. For example, for a fifth-order polynomial a₀s⁵ + a₁s⁴ + a₂s³ + a₃s² + a₄s + a₅, the first row is [a₀, a₂, a₄] and the second row is [a₁, a₃, a₅].
- Pad rows with zeros to ensure equal length if necessary.
- Compute subsequent rows using the formula:
bᵢ = (a₁·aᵢ₊₂ - a₀·aᵢ₊₃) / a₁ (where a₁ is the first element of the previous row)
Repeat until the array has n+1 rows.
A crucial nuance: if a zero appears in the first column of a row, the standard formula fails. The Routh array construction must handle special cases:
- Zero in first column (but not all zeros in the row): Replace the zero with a small positive number ε, continue construction, then examine the signs as ε → 0.
- Entire row of zeros: This indicates the presence of symmetrically placed roots (e.g., complex conjugate on the imaginary axis, or pairs of roots with opposite signs). The auxiliary polynomial formed from the row above the zero row must be used to continue the array.
A robust automation script must detect and appropriately handle both cases.
Implementing the Routh-Hurwitz Algorithm in Python
Libraries and Setup
We will use SymPy for symbolic mathematics and NumPy for numeric computations. SymPy is especially valuable when coefficients involve parameters (e.g., unknown gains) that are not numeric. For purely numeric polynomials, NumPy alone suffices, but SymPy provides a cleaner handling of rational arithmetic and ε-limit logic.
Basic Numeric Implementation
The simplest script accepts a list of numeric coefficients (float or integer) and constructs the Routh array using floating-point arithmetic. Below is an updated and expanded version of the basic code:
import numpy as np
def routh_hurwitz_numeric(coeffs):
"""
Construct the Routh array for a polynomial with numeric coefficients.
coeffs: list of coefficients from highest power down (a0, a1, ..., an)
Returns a tuple (array, stability: str) or raises ValueError if first element is zero.
"""
if coeffs[0] <= 0:
raise ValueError("Coefficient a0 must be positive for standard Routh-Hurwitz.")
n = len(coeffs) - 1
# Build first two rows
row1 = np.array([coeffs[i] for i in range(0, n+1, 2)], dtype=float)
row2 = np.array([coeffs[i] for i in range(1, n+1, 2)], dtype=float)
# Pad to same length
max_len = max(len(row1), len(row2))
row1 = np.pad(row1, (0, max_len - len(row1)))
row2 = np.pad(row2, (0, max_len - len(row2)))
routh = [row1.tolist(), row2.tolist()]
for i in range(2, n+1):
if routh[-1][0] == 0:
# Special case: zero in first column
# Replace with small epsilon (here we modify the row)
routh[-1][0] = 1e-10 # Use a tiny positive number
# Mark that epsilon was used (for sign analysis)
# For simplicity, we assume epsilon > 0; later we can refine
row = []
for j in range(len(routh[0]) - 1):
a = routh[i-2][0]
b = routh[i-2][j+1] if j+1 < len(routh[i-2]) else 0
c = routh[i-1][0]
d = routh[i-1][j+1] if j+1 < len(routh[i-1]) else 0
if c == 0:
row.append(0) # Not reached because we handled epsilon above
else:
row.append((c * b - a * d) / c)
if all(abs(x) < 1e-12 for x in row):
# Entire row of zeros -> handle auxiliary polynomial
return handle_auxiliary_row(routh, row, coeffs)
routh.append(row)
# Check stability
first_col = [routh[i][0] for i in range(n+1)]
if all(x > 0 for x in first_col):
return routh, "Stable"
else:
return routh, "Unstable"
def handle_auxiliary_row(routh, zero_row, coeffs):
# Extract row above zero row (the one that generated the auxiliary polynomial)
prev_row = routh[-1]
# Build auxiliary polynomial from prev_row: s^? (using degrees)
# This is a simplified placeholder; full implementation requires polynomial differentiation
# For detail, see reference.
# For now, we raise an error indicating advanced handling needed.
raise NotImplementedError("Entire row of zeros: auxiliary polynomial method required. Consider using sympy implementation.")
While this numeric implementation works for many cases, it becomes fragile near singularities. The ε-replacement approach requires careful tracking of sign changes. A more robust method uses symbolic epsilon with SymPy.
Symbolic Implementation Using SymPy
SymPy’s rational arithmetic and limit capabilities allow a clean, exact handling of zero and auxiliary row cases. Here is a complete symbolic version:
import sympy as sp
def routh_hurwitz_symbolic(coeffs):
"""
coeffs: list of symbolic or numeric coefficients (a0, a1, ..., an), a0 > 0.
Returns the Routh array (list of lists) and a stability message.
"""
coeffs = [sp.sympify(c) for c in coeffs]
n = len(coeffs) - 1
# First two rows
row1 = [coeffs[i] for i in range(0, n+1, 2)]
row2 = [coeffs[i] for i in range(1, n+1, 2)]
# Pad
max_len = max(len(row1), len(row2))
row1 += [0] * (max_len - len(row1))
row2 += [0] * (max_len - len(row2))
routh = [row1, row2]
epsilon = sp.symbols('epsilon', positive=True)
for i in range(2, n+1):
prev_row1 = routh[i-2]
prev_row2 = routh[i-1]
first_prev = prev_row2[0]
# Check for zero in first column
if first_prev == 0:
if all(x == 0 for x in prev_row2):
# Entire row of zeros
# Build auxiliary polynomial from previous row
# Ex: if row above zero row is [a, b, c, ...] -> auxiliary poly: a*s^? + b*s^? + ...
# Need to reconstruct degrees. Function robuster needed.
# For brevity, we refer to the extended implementation.
return routh, "Marginal stability detected; auxiliary polynomial needed"
else:
# Replace zero with epsilon
prev_row2 = [epsilon if j == 0 else prev_row2[j] for j in range(len(prev_row2))]
first_prev = epsilon
new_row = []
for j in range(len(prev_row1) - 1):
a = prev_row1[0]
b = prev_row1[j+1] if j+1 < len(prev_row1) else 0
c = first_prev
d = prev_row2[j+1] if j+1 < len(prev_row2) else 0
if c == 0:
value = 0
else:
value = (c * b - a * d) / c
new_row.append(sp.simplify(value))
# Simplify the row
new_row = [sp.simplify(x) for x in new_row]
routh.append(new_row)
# After building row, if epsilon was used, take limit epsilon -> 0+
# This simplifies the row to a numeric result if possible.
if epsilon in sp.flatten([sp.preorder_traversal(x) for x in new_row]):
new_row = [sp.limit(x, epsilon, 0) for x in new_row]
routh[-1] = new_row
# Check first column signs
first_col = [routh[i][0] for i in range(n+1)]
# If any symbol still present, cannot decide numerically; user must substitute.
if any(sp.sympify(x).has(sp.Symbol) for x in first_col):
return routh, "Indeterminate due to symbolic parameters; substitute numeric values."
signs = [sp.sign(x) for x in first_col]
if all(s == 1 for s in signs):
return routh, "Stable"
elif any(s == -1 for s in signs):
return routh, "Unstable"
else:
return routh, "Marginal stability (zeros in first column)"
This symbolic version correctly handles zeros and, with additional logic, can manage entire rows of zeros. It is ideal for parameterized analyses where coefficients contain symbolic variables like K.
Handling Entire Rows of Zeros
When a row of zeros appears, the standard construction must switch to the auxiliary polynomial method. The auxiliary polynomial is formed from the row directly above the zero row. The coefficients of that row correspond to even powers of s if the zero row is at an odd index, etc. The derivatives of the auxiliary polynomial provide the replacement row. Implementing full handling requires several extra lines; the SymPy polys module can help. For space reasons, we reference the comprehensive example in the SymPy source code or the Python Control Systems Library which includes built-in Routh-Hurwitz functions.
Testing and Validation
Any automated script must be tested against known cases. Create a test suite covering:
- Stable polynomials (e.g.,
s² + 3s + 2-> stable) - Unstable polynomials (e.g.,
s² - s + 1-> unstable due to sign change) - Polynomials with zero in first column (e.g.,
s³ + 2s² + s + 2-> marginal or unstable depending on roots) - Polynomials with an entire row of zeros (e.g.,
s⁴ + 2s² + 1-> marginal) - High-order polynomials (e.g., degree 10) to verify performance.
Compare results with manual calculation or known output from the control library's stability_margin or dedicated Routh functions.
Integration into Engineering Workflows
Once the script is reliable, integrate it into a broader Python environment:
Parameter Sweeping and Plotting
Use NumPy or pandas to generate a grid of parameter values (e.g., gain K from 0 to 100). For each value, compute the characteristic polynomial coefficients (via system transfer function), run the stability check, and store the result. Visualize stability regions with matplotlib:
import numpy as np
import matplotlib.pyplot as plt
from control import tf, feedback
def check_stability_for_gain(K):
# Example: unity feedback with plant G(s) = K/(s^3 + 3s^2 + 2s)
G = tf([K], [1, 3, 2, 0])
T = feedback(G, 1)
poly = T.den[0][0] # denominator coefficients
stable = routh_hurwitz_numeric(poly) # call your function
return stable
Ks = np.linspace(0, 20, 100)
stable_list = [check_stability_for_gain(K) for K in Ks]
plt.plot(Ks, stable_list)
plt.xlabel('Gain K')
plt.ylabel('Stable (1) / Unstable (0)')
plt.show()
Such plots quickly reveal stability margins (e.g., the gain margin where stability changes).
Automated Design Optimization
Embed the stability check as a constraint in optimization. For instance, use scipy.optimize to minimize a cost function while requiring the Routh-Hurwitz criterion to return "Stable". The symbolic version allows gradient-free evaluation.
Integration with Jupyter Notebooks
Jupyter notebooks are ideal for interactive analysis. Combine the Routh-Hurwitz function with sympy's pretty printing to display the array step-by-step, making it educational and debuggable.
Advanced Considerations
Numerical Precision
For floating-point coefficients, the algorithm may suffer from cancellation errors. Use SymPy with rational numbers when possible, or use high-precision floats via mpmath. Alternatively, the numpy.polynomial module can compute roots directly, which is simpler for numeric polynomials. However, the Routh-Hurwitz method gives insight into stability without solving for roots and is often preferred in control textbooks.
Handling Symbolic Parameters with Assumptions
When coefficients involve symbols (e.g., K, ω), the script may produce expressions requiring manual sign analysis. Use SymPy’s refine() with assumptions to simplify based on known ranges (e.g., K > 0). This can partially automate the stability decision.
Parallelization
For large parameter sweeps, parallelize using multiprocessing or joblib. Each stability check is independent.
Conclusion and Best Practices
Automating Routh-Hurwitz stability checks with Python scripts transforms a tedious manual process into a fast, reliable, and extensible tool. Key takeaways:
- Start with a clear understanding of the algorithm, including special cases.
- Use SymPy for symbolic coefficients and exact handling of the zero-row case.
- Use NumPy for pure numeric polynomials when speed is paramount.
- Thoroughly test your implementation with a variety of polynomials.
- Integrate the function into broader analysis pipelines for design and optimization.
By following the examples and guidelines in this article, you can create robust automation scripts that serve as a cornerstone of your control systems work. The time saved will allow you to explore more design alternatives and achieve better system performance.
Note: The code provided in this article is for educational purposes. For production use, consider contributing to or adopting established packages such as the Python Control Systems Library, which includes well-tested Routh-Hurwitz and other stability analysis functions.
With the scripts in hand, you are now ready to automate stability checks for systems of any order, freeing your mental energy for the creative challenges of control system design.