Compiled from 100+ GitHub issues, 40+ PRs, discussions, test suites, and 14 Jupyter notebook examples from python-control/python-control.
Focus: numerical discrepancies vs MATLAB, edge cases, and regression patterns that apply to our Go implementation.
- Riccati Solvers (CARE/DARE)
- LQR / LQE / Kalman
- Lyapunov Solvers
- Pole Placement
- Controllability & Observability
- Gramians & Hankel Singular Values
- Canonical Forms & Modal Decomposition
- Transfer Function / State Space Conversion
- System Interconnection (Feedback, Series, Parallel)
- Stability Margins
- Frequency Response & Bode Plots
- Time-Domain Simulation
- Discretization (c2d / d2c)
- Model Reduction (Balanced Realization)
- System Norms (H2, H-infinity)
- DC Gain & Zeros
- Nyquist Plots
- Numerical Primitives & General Patterns
Source: Issue #1174, PR #348
The elementwise check (M - M.T) < eps is broken in two ways:
- Missing
abs(): small negative roundoff passes the check incorrectly - Fixed
epsdoesn't scale with matrix magnitude
Correct approach (from scipy):
norm(M - M.T, 1) > spacing(norm(M, 1)) * 100
Test case:
// Matrix that is symmetric up to roundoff
Q := mat.NewDense(2, 2, []float64{1.0, 0.5 + 1e-16, 0.5 - 1e-16, 1.0})
// Should pass symmetry checkSource: Issue #8, #157
scipy's solve_discrete_are uses van Dooren's generalized eigenvalue method;
MATLAB uses Arnold-Laub (SLICOT sb02od) which is more robust for
ill-conditioned A matrices.
Test case (ill-conditioned):
// System where scipy fails but MATLAB succeeds
// A with condition number > 1e8
A := mat.NewDense(2, 2, []float64{1e4, 1, 0, 1e-4})
B := mat.NewDense(2, 1, []float64{1, 1})
Q := eye(2)
R := eye(1)
// Should produce stabilizing solution, not errorSource: Issue #36 (open 8+ years), python-control test suite
scipy only handles S=0, E=I. MATLAB handles the full generalized form.
Test matrices (from python-control mateqn_test.py):
A := []float64{-2, -1, -1, -1} // 2x2
Q := []float64{0, 0, 0, 1}
B := []float64{1, 0, 0, 4}
R := []float64{2, 0, 0, 1}
S := []float64{0, 0, 0, 0}
E := []float64{2, 1, 1, 2}
// Verify: A'XE + E'XA - (E'XB+S)*inv(R)*(B'XE+S') + Q = 0Source: Issue #252, #274
LQR/DARE should validate that the combined cost [Q N; N' R] is positive
semi-definite before solving. Non-PD costs produce eigenvalues with real parts
~1e-16 that flip sign randomly.
Test case:
// Non-PD cost should error, not silently produce garbage
Q := mat.NewDense(2, 2, []float64{1, 0, 0, 0})
R := mat.NewDense(1, 1, []float64{-1}) // negative R
// Lqr(A, B, Q, R) should return errorAlways verify the residual equation:
- CARE:
A'X + XA - XBR^{-1}B'X + Q = 0 - DARE:
X = A'XA - A'XB(B'XB+R)^{-1}B'XA + Q - Always check closed-loop stability: eigenvalues in LHP (continuous) or inside unit circle (discrete)
Source: Issue #1173 (open)
python-control's dlqe returns the predictor gain:
L_pred = A*P*C'*(C*P*C'+R)^{-1}
MATLAB returns the filter gain:
L_filt = P*C'*(C*P*C'+R)^{-1}
When A is singular, you cannot recover filter gain from predictor gain.
Test case:
// Singular A -- filter vs predictor gain differs fundamentally
A := mat.NewDense(2, 2, []float64{0, 1, 0, 0}) // double integrator
B := mat.NewDense(2, 1, []float64{0, 1})
C := mat.NewDense(1, 2, []float64{1, 0})
Qn := eye(2)
Rn := eye(1)
// L_filt = P*C'*inv(C*P*C'+Rn)
// L_pred = A*L_filt -- but A is singular!Source: Issue #1159 (open)
When calling lqe(sys, QN, RN) without explicit G (disturbance input matrix),
the default differs between MATLAB/Octave and python-control. Dimension
mismatch errors result.
Source: Issue #439, PR #477
lqr returned eigenvalues as 1D; lqe returned 2D matrix. Both should
return consistent shapes.
Source: python-control statefbk_test.py
A := []float64{1, -2, 3, -4} // 2x2 NON-SYMMETRIC (critical!)
B := []float64{5, 6, 7, 8} // 2x2
C := []float64{4, 5, 6, 7} // 2x2
D := []float64{13, 14, 15, 16} // 2x2
// Continuous controllability Gramian (from MATLAB):
Wc := []float64{18.5, 24.5, 24.5, 32.5}
// Continuous observability Gramian (from MATLAB):
Wo := []float64{257.5, -94.5, -94.5, 56.5}
// Also test discrete versions after c2d with dt=0.2Source: Issue #342
For A where eigenvalues of A and -A are close (e.g., orthogonal/skew-symmetric matrices), the continuous Lyapunov solver (Bartels-Stewart) fails. The discrete solver works fine for the same system.
Test case:
// Skew-symmetric A from orthogonal group
// A and -A have shared eigenvalues
A := mat.NewDense(3, 3, []float64{
0, -1, 0,
1, 0, -1,
0, 1, 0,
})
Q := eye(3)
// Lyap(A, Q) should detect this and return meaningful error
// DLyap(sqrt(0.9)*A, Q) should work finePre-allocate Lyapunov workspace for repeated solves (e.g., inside iterative Riccati or H-infinity synthesis loops).
Source: Issue #1190, PR #1195
place_acker returned 1D array instead of 2D. The bug was
K[-1, :] (collapses dimension) vs K[-1:, :] (preserves 2D).
Test case:
A := mat.NewDense(2, 2, []float64{0, 1, 0, 0})
B := mat.NewDense(2, 1, []float64{0, 1})
poles := []complex128{-1 + 1i, -1 - 1i}
K := Acker(A, B, poles)
// K should be (1, 2) not (2,)Source: Issue #1190, #1100
Ackermann's formula is SISO-only. Must error on MIMO input, not produce garbage.
Source: Issue #1100
The python-control acker function validated inputs via _ssmatrix() but then
continued using the raw (unvalidated) A, B variables instead of the
converted a, b.
Source: Issue #117
Old Ackermann-based multi-input placement returned wrong eigenvalues.
MATLAB's place uses Tits-Yang robust algorithm.
Test case (from scipy example):
A := mat.NewDense(4, 4, []float64{
1.380, -0.2077, 6.715, -5.676,
-0.5814, -4.290, 0, 0.6750,
1.067, 4.273, -6.654, 5.893,
0.0480, 4.273, 1.343, -2.104,
})
B := mat.NewDense(4, 2, []float64{
0, 5.679,
1.136, 1.136,
0, 0,
-3.146, 0,
})
P := []complex128{-0.5+1i, -0.5-1i, -5.0566, -8.6659}
K := Place(A, B, P)
// Verify: eig(A - B*K) matches PSource: python-control statefbk_test.py
// 3 repeated poles but rank(B)=2 -- should fail
P := []complex128{-0.5, -0.5, -0.5, -8.6659}
// Place(A, B, P) should return errorSource: Issue #177
A := mat.NewDense(2, 2, []float64{0, 1, 100, 0}) // unstable
B := mat.NewDense(2, 1, []float64{0, 1})
P := []complex128{-20+10i, -20-10i}
K := Place(A, B, P)
// Must not error with "complex pair on real eigenvalue"Source: Issue #1097, PR #1099
Passing 1D B (e.g., [1, 1] instead of column [[1], [1]]) produces wrong
dimensions silently.
Test case:
A := mat.NewDense(2, 2, []float64{1, 2, 3, 4})
B := mat.NewDense(2, 1, []float64{5, 7})
Wc := Ctrb(A, B)
// Wc should be 2x2: [B, AB] = [[5, 19], [7, 43]]// Ctrb(A, B) == Obsv(A', B')'
// This catches transposition bugsSource: PR #941
Don't call matrix_power(A, i) each iteration. Reuse: A^i = A * A^{i-1}.
Source: Issue #967
python-control silently used continuous Lyapunov for discrete systems.
Must use A*X*A' - X + B*B' = 0 (discrete), not A*X + X*A' + B*B' = 0.
Source: Issue #1123 (open)
When computing Cholesky-factored Gramians, transposing A before passing to the
SLICOT routine destroys Schur form triangularity, forcing re-QR which
introduces epsilon errors. Use tra='T' flag instead of explicit transpose.
Source: python-control modelsimp_test.py
A := []float64{1, -2, 3, -4} // 2x2
B := []float64{5, 7} // 2x1
C := []float64{6, 8} // 1x2
D := []float64{9} // 1x1
// Hankel singular values (from MATLAB):
hsv := []float64{24.42686, 0.5731395}Source: Issue #318, PR #495
Eigenvector matrix T becomes singular (entries ~4.5e15) for repeated eigenvalues. Our codebase already handles this with Schur fallback (commit 982472e).
Source: python-control canonical_test.py
// Real distinct eigenvalues
eigvals := []complex128{-1,-2,-3,-4,-5}
// condmax=nil -> blksizes=[1,1,1,1,1]
// Force all into one block
// condmax=1.01 -> blksizes=[5]
// Repeated eigenvalues
eigvals = []complex128{-1,-1,-2,-2,-2}
// condmax=nil -> blksizes=[2,3]
// Complex pairs
eigvals = []complex128{-1+1i,-1-1i,-2+2i,-2-2i,-2}
// condmax=nil -> blksizes=[2,2,1]A 2x2 block [[a, b], [-b, a]] with near-zero b is defective (repeated
real eigenvalue), not a complex pair.
Source: python-control canonical_test.py
// Start with known companion form, apply random transform, convert back
coeffs := []float64{1.0, 2.0, 3.0, 4.0, 1.0}
A_true := companion(coeffs)
T_true := randomOrthogonal(n)
A := inv(T_true) * A_true * T_true
// Canon(ss(A,B,C,D), "reachable") should recover A_trueEdge cases:
- Unreachable systems -> error
- Unobservable systems -> error
- MIMO systems -> error for reachable/observable forms
Source: Issue #1068
// DC motor with widely separated poles
// Poles at 0, -10, -990 -> coefficient range spans many orders
A := mat.NewDense(3, 3, []float64{
0, 1, 0,
0, -10, 1,
0, 0, -990,
})
B := mat.NewDense(3, 1, []float64{0, 0, 1})
C := mat.NewDense(1, 3, []float64{1, 0, 0})
// ss2tf should NOT produce spurious zeros at +/-6.6e9jSource: Issue #240
// 2x2 MIMO with 1e-10 perturbation in denominator
// H11 = 0.25/(s+1), H12 = 0/(1), H21 = 0/(1), H22 = 0.25/(s+1+1e-10)
// Step response should give ~0.25 for both channels, not 0Source: Issue #244, PR #129
// tf2ss of static gain must produce 0-state system, not crash
tf := NewTransferFunc([]float64{23}, []float64{46})
sys := tf.StateSpace()
// sys.A should be 0x0, sys.D should be [[0.5]]Source: Issue #935
16th-order TF with coefficients spanning 1e-31 to 1e-8 produced unstable state-space via SLICOT (correct via scipy). Guard against high polynomial order + wide dynamic range.
Source: Issue #156
Simple 1/(s+1) (D=0) crashed python-control's ss2tf. Ensure strictly proper
systems are handled.
Source: Issue #235
Single-input, multiple-output TF conversion crashed with broadcast error in common denominator computation.
Source: Issue #1067, #1102
// This blows up in polynomial domain (overshoot ~1e52)
sys := NewZPK([]complex128{}, []complex128{-2, -2.0/3.0}, 10.314)
cl := Feedback(sys, 1.0, -1)
// Step response should be bounded, not 1e52
// Same system via state-space works perfectly
// LESSON: Always compute feedback in state-space, not polynomial formSource: PR #1078
2 * mimo_sys should scale all channels, not fill all entries with 2.
Source: Issue #421, PR #474
Negative feedback output indexing had boundary check bug:
outp < 0 and -outp >= -sys.outputs should be -outp <= sys.outputs.
Source: Issue #1172
MIMO feedback with numpy array controller path incorrectly converted to TransferFunction, triggering "MIMO not implemented" error.
Source: Issue #725 (closed, wontfix)
python-control's series(sys2, sys1) produces state vector (x2, x1) while
MATLAB produces (x1, x2). Documented difference, not a bug.
Source: Issue #465, #523, PR #469, #566
DT systems need z = exp(j*w*dt) substitution, not s = jw. Polynomial
methods for finding |H(z)|=1 crossings are numerically fragile when
num/den coefficient magnitudes differ greatly.
Fallback strategy (PR #566): detect numerical inaccuracy via
||num(z)*num(1/z)|| < 1e-3 * ||den(z)*den(1/z)|| and fall back to FRD
interpolation.
Source: Issue #947
Gain margin was returned as magnitude (0.1) instead of margin (10 = 1/0.1).
Source: Issue #462
margin(tf(50000, den)) (integer) gave wrong results;
margin(tf(50000., den)) (float) was correct.
Source: Issue #61 (open)
Phase crossover frequency finder seeded with lowest frequency, converging to wrong solution. Should seed with median frequency.
Source: Issue #784 (open)
Only finding one gain margin when phase crosses -180 multiple times. All crossings should be reported.
// Type 0 system with clear margins
num := []float64{1}
den := []float64{1, 3, 3, 1} // (s+1)^3
// MATLAB: GM = 8 (18.06 dB) at wg = 1.732 rad/s
// PM = 41.4 deg at wp = 0.767 rad/sSource: Issue #467, #1179
python-control wraps phase to [-180, 180], producing discontinuous jumps. MATLAB does continuous unwrapping.
// 1/s^3 should show -270 deg (MATLAB), not +90 deg
sys := NewTransferFunc([]float64{1}, []float64{1, 0, 0, 0})
// Phase at any frequency should be -270, not +90Source: Issue #1179 (open)
Third-order system with undamped zeros: phase jumps go in wrong direction. MATLAB shows -90 -> +90 -> -90; python-control shows -90 -> -270 -> -450.
Source: Issue #860
24x18 MIMO system: frequency_response() on derived TFs (H/(1+P)) returns
noisy/incorrect results due to polynomial coefficient cancellation in
high-order TF algebra. Manual complex arithmetic on state-space is correct.
Lesson: Evaluate frequency response in state-space domain
(C*(jwI-A)^{-1}*B + D), not via polynomial evaluation.
Source: Issue #1105, PR #1106
phase_crossover_frequencies crashes on 200/(s^3 + 21s^2 + 20s).
Source: Issue #862
When plotting multiple systems, phase wrapping state leaked between systems, producing different phases for identical systems.
Source: Issue #384
Closed-loop system with purely imaginary poles produced unstable diverging response instead of sustained oscillation. Octave gave correct sinusoidal.
Source: Issue #470
(0.8s+1)/(s^2+s) should give exponential step response starting at 0.8->1.0
but gave a ramp y=t. Pole at origin mishandled.
Source: Issue #974
DT impulse response was scaled by extra 1/T. Accumulator H(z)=Tz/(z-1)
with T=0.1: python-control gave 1.0, MATLAB/Octave gave 0.1 (correct).
Source: Issue #1062
Same system defined as zpk vs tf gave unstable vs stable impulse responses. Internal representation mismatch.
Source: Issue #1152
Dirac impulse applied after index 5 completely ignored. ODE solver step size determination skipped over the impulse.
Source: python-control timeresp_test.py
A := []float64{1, -2, 3, -4} // 2x2
B := []float64{5, 7} // 2x1
C := []float64{6, 8} // 1x2
D := []float64{9} // 1x1
t := linspace(0, 1, 10)
// Step response (from MATLAB):
y := []float64{9., 17.6457, 24.7072, 30.4855, 35.2234,
39.1165, 42.3227, 44.9694, 47.1599, 48.9776}Source: python-control timeresp_test.py
// G(s) = (s^2 + 5s + 5) / (s^4 + 1.65s^3 + 5s^2 + 6.5s + 2)
// From MATLAB online help:
// RiseTime: 3.8456
// SettlingTime: 27.9762
// Overshoot: 7.4915%
// SteadyStateValue: 2.5// G(s) = (-s+1)/(s^2+s+1)
// Undershoot: 28%
// Overshoot: 20.84%Source: PR #555
Overshoot relative to dcgain(), not last simulation sample. Non-strictly
proper TFs give wrong overshoot with final value approach.
Source: Issue #950, PR #951
Matched pole-zero method used polynomial leading coefficient (sgain) instead
of dcgain() for gain normalization, producing ~10x error.
Correct approach: Normalize so that sys_ct(0) == sys_dt(1).
Test case:
// For all discretization methods, verify:
// dcgain(c2d(sys, dt)) == dcgain(sys)
sys := NewSS(A, B, C, D)
for _, method := range []string{"zoh", "foh", "tustin", "matched"} {
sysd := Discretize(sys, dt, method)
assert(abs(DCGain(sysd) - DCGain(sys)) < tol)
}Source: Issue #23
Never discretize by converting to TF first. Use matrix exponential
Ad = expm(A*T) directly.
Source: PR #1064
zpk() defaulted timebase to nil (discrete) instead of 0 (continuous).
All factory functions should use consistent default.
Source: Issue #1166, PR #1074
Double integrator A=[[0,1],[0,0]] (poles at origin) was rejected as
"unstable" by balanced reduction. Should warn, not error. Eigenvalues
at exactly 0 or on imaginary axis need tolerance-aware stability check.
Source: python-control modelsimp_test.py
// TF: num=[1,11,45,32], den=[1,15,60,200,60]
// Controllable canonical form (4 states)
// Reduced to order 2 (truncate):
Ar := []float64{-1.958, -1.194, -1.194, -0.8344} // from MATLAB
Br := []float64{0.9057, 0.4068}
// Note: state ordering may differ -- test via similarity transformTest that warn_unstable=false suppresses warnings for intentionally unstable
systems.
Source: Issue #1177 (open)
Combining similarity transforms + balanced reduction corrupts state data. Test sequential operations for data integrity.
Source: python-control sysnorm_test.py
// 1st order stable: G = 1/(s+1)
// Hinf = 1.0, H2 = 0.707106781186547
// 1st order unstable: G = 1/(1-s)
// Hinf = 1.0, H2 = inf (with warning)
// 2nd order with imaginary poles: G = 1/(s^2+1)
// Hinf = inf (marginally stable), H2 = inf
// 3rd order MIMO (from MATLAB):
A := []float64{
-1.017, -0.224, 0.043,
-0.310, -0.516, -0.119,
-1.453, 1.800, -1.492,
} // 3x3
B := []float64{0.313, -0.165, -0.865, 0.628, -0.030, 1.093} // 3x2
C := []float64{1.109, 0.077, -1.114, -0.864, -1.214, -0.007} // 2x3
// Hinf = 4.276759162964244 (from MATLAB)
// H2 = 2.237461821810309 (from MATLAB)
// Also test after discretization with dt=0.1Source: Issue #367 (open)
Same plant: MATLAB finds stabilizing H-infinity controller, python-control says "cannot be found". Root cause: slightly different tf2ss realizations produce numerically different state-space matrices.
Lesson: hinfsyn is extremely sensitive to the state-space realization. Consider pre-balancing or using balanced realization before synthesis.
Source: python-control robust_test.py
// P = ss(-1, [1,1], [[1],[1]], [[0,1],[1,0]])
// k, cl, gam, rcond = hinfsyn(P, 1, 1)
// From Octave (SB10AD):
// k.A = [[-3]], k.B = [[1]], k.C = [[-1]], k.D = [[0]]Source: Issue #128 (open)
If any eigenvalue of A is 0, dcgain() returns NaN for ALL outputs, even for
channels that have finite DC gain. Should compute per-channel via
C*(s*I-A)^{-1}*B + D evaluated at s=0 for each I/O pair.
Source: Issue #127 (open)
Both num and den have roots at s=0: should compute the limit (ratio of lowest non-zero coefficients), not return NaN.
Source: Issue #532
tf([1],[1,0])(0) returns inf+0j; SS version raises LinAlgError.
dcgain() returns inf vs nan depending on representation. Must be consistent.
Source: Issue #859
Transmission zeros for non-square MIMO (e.g., 3-in 6-out) require generalized eigenvalue approach that handles rectangular matrices.
Source: Issue #768
Zeros reported as -4.72e-16 +/- 1j instead of clean +/- 1j. Inherent
floating-point limitation -- consider optional cleanup threshold.
Source: PR #722
Default indentation radius (0.1) around poles near imaginary axis could miss right-half-plane closed-loop poles. Reduced to 1e-4.
Source: PR #885
Nyquist plot failed for DT TFs with poles at z=0 and z=1. Need special handling for these discrete-time singularities.
Source: Issue #1191 (open)
Indentation curves around imaginary-axis poles go counter-clockwise instead of standard clockwise.
Source: PR #91, #101, #200
Replace ALL inv(M) * X with solve(M, X). This applies to:
- Riccati gain computation
- Canonical form transforms
- Feedback computation
- DC gain (C * inv(-A) * B)
Source: PR #91, Issue #84
Computing poles via det(sI-A) -> roots is O(n!) worse numerically than
direct eigenvalue decomposition.
Source: PR #345
realTol := math.Sqrt(eps * float64(nInputs*nOutputs))
imagTol := 2 * realTol
// Poles with |imag| < imagTol are forced to realSource: PR #1086
After reconstructing polynomials from complex poles/zeros, cast coefficients to real to avoid spurious complex warnings.
Source: CLAUDE.md, python-control test suite
Nearly every test suite uses A = [[1, -2], [3, -4]] or similar
non-symmetric matrices. Diagonal or symmetric A hides transposition bugs.
Source: PR #91, #101
abs(det(F)) < threshold is unreliable. Use matrix_rank() (SVD-based with
proper numerical threshold) instead.
Source: Issue #646
Discrete-time systems with negative poles: log(poles) fails for real
negative values. Must cast to complex first: log(complex(pole)).
Source: Issue #244
Don't call _remove_useless_states() in the constructor. ss(0,0,1,0) must
preserve the single state, not collapse to empty.
Source: PR #555
For non-strictly proper systems, the final simulated value != DC gain.
Use dcgain() as the reference for percent overshoot calculation.
Standard test systems used across python-control (with MATLAB reference values):
| Name | A | B | C | D | Notes |
|---|---|---|---|---|---|
| Basic 2x2 | [[1,-2],[3,-4]] |
[[5],[7]] |
[[6,8]] |
[[9]] |
Non-symmetric A, primary test system |
| Basic 2x2 MIMO | [[1,-2],[3,-4]] |
[[5,6],[7,8]] |
[[4,5],[6,7]] |
[[13,14],[15,16]] |
MIMO variant |
| Double integrator | [[0,1],[0,0]] |
[[0],[1]] |
[[1,0]] |
[[0]] |
Marginally stable, poles at 0 |
| Unstable | [[0,1],[100,0]] |
[[0],[1]] |
[[1,0]] |
[[0]] |
Unstable open-loop |
| 1/(s+1) | [[-1]] |
[[1]] |
[[1]] |
[[0]] |
Hinf=1, H2=0.7071 |
| 1/(s^2+1) | [[0,1],[-1,0]] |
[[0],[1]] |
[[1,0]] |
[[0]] |
Marginally stable, imaginary poles |
| DC motor | 3x3, poles at 0,-10,-990 | Wide pole spread | |||
| 4-state (scipy) | 4x4 dense | 4x2 | Pole placement reference |
| Priority | Area | Issue | Our Status |
|---|---|---|---|
| DONE | Modal form | Schur fallback for repeated eigenvalues | Commit 982472e |
| DONE | Input validation | Improper TF, descriptor Riccati, Q PSD | Commit 44d7971 |
| DONE | Numerical hardening | Python-control PR audit fixes | Commit 2392746 |
| HIGH | Riccati | Scale-aware symmetry check | Verify current impl |
| HIGH | DARE | Ill-conditioned A robustness | Test coverage |
| HIGH | Margins | DT margin computation | Test coverage |
| HIGH | Feedback | ZPK/polynomial blow-up | Test with pathological gains |
| HIGH | ss2tf | Spurious zeros for ill-conditioned systems | Test coverage |
| MED | dlqe | Filter vs predictor gain convention | Document + test |
| MED | Gramians | DT Gramian correctness | Test coverage |
| MED | Model reduction | Marginally stable handling | Test coverage |
| MED | DC gain | Poles at origin, per-channel | Test + fix |
| MED | Phase | Unwrapping like MATLAB | Test coverage |
| LOW | Acker | Return shape, MIMO rejection | Verify |
| LOW | c2d matched | DC gain normalization | Test coverage |
| LOW | Norms | Discrete-time H2/Hinf | MATLAB-validated tests |
| PR | Title | Key Pattern |
|---|---|---|
| #91 | eigvals for poles, solve for feedback | Never use roots(poly(A)) |
| #101 | Replace inv() with solve() | Numerical stability |
| #200 | Fix observable canonical form | solve() + rank check |
| #206 | _common_den rewrite | Pole matching algorithm |
| #345 | Root precision tolerance | Separate real/imag tolerances |
| #348 | Machine-epsilon symmetry check | Scale-aware tolerance |
| #469 | DT stability margins | z-domain treatment |
| #495 | bdschur for modal form | Condition number control |
| #566 | Margin fallback to FRD | Auto-detect numerical issues |
| #683 | LQR using scipy | Dual-method approach |
| #951 | Matched c2d DC gain | Use dcgain() not leading coeff |
| #1078 | MIMO scalar multiply | Element-wise, not filled matrix |
| #1195 | place_acker return shape | Preserve 2D with [-1:, :] |
Extracted from 14 Caltech CDS 110/112 course notebooks. These are physically meaningful systems that stress the library differently than toy 2x2 examples.
The most exercised system across notebooks. Parameters: m=4, J=0.0475, r=0.25, g=9.8, c=0.05.
Linearized at hover (xe=0, ue=[0, m*g]):
A := []float64{
0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 1, 0,
0, 0, 0, 0, 0, 1,
0, 0, -9.8, -0.0125, 0, 0,
0, 0, 0, 0, -0.0125, 0,
0, 0, 0, 0, 0, 0,
} // 6x6
B := []float64{
0, 0,
0, 0,
0, 0,
0.25, 0,
0, 0.25,
5.2632, 0,
} // 6x2, r/J=5.2632
C_pos := eye(3, 6) // measures x, y, theta onlyLQR weights tested (3 cases):
- Identity: Qx=I(6), Qu=I(2)
- Physical: Qx=diag(100, 10, 180/pi/5, 0, 0, 0), Qu=diag(10, 1)
- High-drag (c=20): Qx=diag(10, 100, 180/pi/5, 0, 0, 0), Qu=diag(10, 1)
Kalman with non-diagonal Qw:
Qv := []float64{1e-2, 0, 0, 1e-2} // 2x2 disturbance
Qw := []float64{
2e-4, 0, 1e-5,
0, 2e-4, 1e-5,
1e-5, 1e-5, 1e-4,
} // 3x3 measurement noise, cross-terms!Why valuable: Open-loop unstable (double integrator in theta), MIMO, real aircraft parameters, exercises LQR + Kalman + margins simultaneously.
m=1, c=0.1, k=2.
A := []float64{
0, 0, 1, 0,
0, 0, 0, 1,
-4, 2, -0.1, 0,
2, -4, 0, -0.1,
} // 4x4
B := []float64{0, 0, 0, 2} // 4x1
C := []float64{1, 0, 0, 0, 0, 1, 0, 0} // 2x4Why valuable: Has transmission zero (dip in frequency response of q2 at ~2 rad/s). Lightly damped. Good for SIMO transfer function and frequency response testing.
// P(s) = 1/(s^2 + 0.1s - 1), one RHP pole
A := []float64{0, 1, 1, -0.1} // 2x2
B := []float64{0, 1}
C := []float64{1, 0}
D := []float64{0}Why valuable: Open-loop unstable. Requires Nyquist encirclement
N=-1 for closed-loop stability. With PD controller kp=10, kd=2:
L(s) = (2s+10)/(s^2+0.1s-1).
Caltech maglev experiment. Parameters: m=0.2, g=9.81, km=3.13e-4.
2-state, 1-input, 1-output. Open-loop unstable (one RHP pole).
Analog controller (RC circuit):
// C(s) = -0.5 * (0.024s + 1)/(0.002s + 1)
// Lead compensator from actual hardwareWhy valuable: Real hardware parameters. Tests Bode sensitivity
integral: integral(ln|S(jw)|, 0, inf) = pi * sum(Re(unstable_poles)).
Demonstrates fundamental performance limits from RHP poles.
Bicycle model at v0=15 m/s, normalized to double integrator:
A := []float64{0, 1, 0, 0} // 2x2
B := []float64{0, 1}
C := []float64{1, 0}Placement tested:
- Feedback: wc=0.7, zeta=0.707 (standard) and wc=10 (high bandwidth)
- Observer: wo=1, zeta=0.7
- Observer gain via duality:
L = Place(A', C', obs_poles)'
Why valuable: Tests observer+controller design, duality for observer gain. Also tested in reverse (v=-2 m/s) which creates non-minimum phase dynamics.
Lotka-Volterra: r=1.6, d=0.56, b=0.6, k=125, a=3.2, c=50. 2-state, 1-input. Linearized at interior equilibrium (~[21.05, 24.61]).
Why valuable: Unstable complex eigenvalues from biological system. Demonstrates sensitivity: changing r from 1.6 to 1.65 (<4%) destabilizes proportional controller, requiring integral action (Ki=0.0001).
m=1600 kg, Tm=190 N-m. Single velocity state. Linearized: first-order P(s)=b/(s+a) at v=20 m/s.
// State feedback K=0.5, feedforward kf = -1/(C*inv(A-BK)*B)
// Integral gain ki=0.1
// Robustness test: m varies from 1200 to 2000 kgWhy valuable: Simplest nonlinear system. Tests robustness to parameter uncertainty (25% mass variation).
J=100, b=10, k=1. Third-order with actuator.
// PI that creates UNSTABLE closed loop (Nyquist encirclement):
// C(s) = (s+1)/s, with plant P(s) = 0.02/(s^2 + 0.1s + 0.00966)
// PI with reduced integral gain (stable):
// C(s) = (s+0.005)/s
// PID (loop-shaped):
// kp=150, ki=30, kd=150Why valuable: Same plant produces unstable CL with kp=ki=1 but stable CL with ki=0.005. Tests that margin computation correctly identifies instability. Ziegler-Nichols tuning also demonstrated.
Extracted from Python scripts in examples/ folder. These provide
published/textbook numerical values for verification.
Reference: Scherer, Gahinet & Chilali, "Multiobjective Output-Feedback Control via LMI Optimization", IEEE TAC, Vol. 42, No. 7, July 1997, Example 7.
A := []float64{
0, 10, 2,
-1, 1, 0,
0, 2, -5,
} // 3x3, open-loop UNSTABLE (eigs ≈ -5.37, 1.69±2.50j)
B1 := []float64{1, 0, 1} // 3x1 disturbance
B2 := []float64{0, 1, 0} // 3x1 control
// H2 performance output
C1_h2 := []float64{0, 1, 0, 0, 0, 1, 0, 0, 0} // 3x3
D12_h2 := []float64{0, 0, 1} // 3x1
// Measurement
C2 := []float64{0, 1, 0} // 1x3
D21 := []float64{2} // 1x1H2 synthesis: h2syn(P, nmeas=1, ncon=1)
Published H2 norm = 7.748350599360575
H-infinity synthesis: Same plant, different C1/D12:
C1_hinf := []float64{1, 0, 0, 0, 0, 0} // 2x3
D12_hinf := []float64{0, 1} // 2x1Reference: Skogestad & Postlethwaite, Example 3.8, 1st Ed.
// 2x2 MIMO plant with RHP zero at s=0.5
// den = 0.2s^2 + 1.2s + 1
// G = [[1/den, 1/den], [(2s+1)/den, 2/den]]
// Sensitivity weight: W(s) = (s/M + wb) / (s + wb*A)
// M=1.5, A=1e-4Published gammas from textbook:
- wb1=0.25, wb2=0.25 → gamma ≈ 2.80
- wb1=0.25, wb2=25 → gamma ≈ 2.92
- wb1=25, wb2=0.25 → gamma ≈ 6.73
SISO system 1:
// L(s) = 25 / (s^3 + 10s^2 + 10s + 10)
A := []float64{0, 1, 0, 0, 0, 1, -10, -10, -10} // 3x3 companion
B := []float64{0, 0, 1}
C := []float64{25, 0, 0}Test with skew = {-1, 0, 1} (T-based, balanced, S-based).
SISO system 2 (5th order, more complex):
// L(s) = 6.25(s+3)(s+5) / (s(s+1)^2(s^2+0.18s+100))
// num = [6.25, 50, 93.75]
// den = [1, 2.18, 100.36, 200.18, 100, 0]MIMO disk margins:
// Plant: purely oscillatory
A := []float64{0, 10, -10, 0} // 2x2, eigs at ±10j
B := eye(2)
C := []float64{1, 10, -10, 1} // 2x2
D := zeros(2, 2)
// Static controller K = [[1,-2],[0,1]]Reference: Mechanical Vibrations textbook, Ex 6.7.
A := []float64{
0, 0, 1, 0,
0, 0, 0, 1,
-6, 2, -2, 1,
1, -4, 0.5, -1.5,
} // 4x4
B := []float64{0, 0, 0, 0, 1, 0, 0, 0.5} // 4x2
C := []float64{1, 0, 0, 0, 0, 1, 0, 0} // 2x4
D := zeros(2, 2)
dt := 0.1Recover 4-state system from impulse response via ERA(markov, r=4, dt=0.1).
Verify: recovered poles match original, frequency response matches.
A := []float64{
-15, -7.5, -6.25, -1.875,
8, 0, 0, 0,
0, 4, 0, 0,
0, 0, 1, 0,
} // 4x4 controllable canonical form
B := []float64{2, 0, 0, 0} // 4x1
C := []float64{0.5, 0.6875, 0.7031, 0.5} // 1x4
D := []float64{0}
// TF: (s^3+11s^2+45s+32) / (s^4+15s^3+60s^2+200s+60)
// DC gain = 32/60 = 0.5333Reduce order 4 → 3 (truncate). Verify DC gain preserved. Reduce order 4 → 2 (truncate). From MATLAB:
Ar := []float64{-1.958, -1.194, -1.194, -0.8344} // 2x2
Br := []float64{0.9057, 0.4068} // 2x1Multiple integrators in the loop — stress test for margin computation.
// Plant: pure integrator P(s) = 1/s with friction b=10
// Inner plant: Peff(s) = (1/s) / (1 + 10/s) = 1/(s+10)
// Type 2 controller: C(s) = 165*(s+55)/s
// Loop has 1 integrator from plant → type 1; +1 from controller → type 2
// Type 3 controller: C(s) = 110*((s+55)/s)^2
// Loop has 1 integrator from plant → type 1; +2 from controller → type 3
// Phase at DC = -270° (3 integrators), margin computation must handle this// System 1 (state-space):
A1 := []float64{0, 1, -4, -1} // underdamped, wn=2, zeta=0.25
B1 := []float64{0, 1}
C1 := []float64{1, 0}
// TF: G1(s) = 1/(s^2+s+4)
// System 2 (transfer function):
// G2(s) = (s+0.5)/(s+5)
// Verify: Series, Parallel, Feedback produce correct poles// Mass-spring-damper: m=250, k=40, b=60
A := []float64{0, 1, -0.16, -0.24} // 2x2
B := []float64{0, 0.004}
C := []float64{1, 0}
// wn = sqrt(0.16) = 0.4 rad/s
// zeta = 0.24/(2*0.4) = 0.3
// Verify Damp() returns these valuesA := []float64{1, -1, 1, 1, -0.16, -0.24, 1, 1, 1} // 3x3
B := []float64{0, 0.004, 1} // 3x1
C := []float64{1, 0, 1} // 1x3
// Ctrb = [B, AB, A^2*B] → 3x3, check rank
// Obsv = [C; CA; CA^2] → 3x3, check rank