Controls API
Linear time-invariant systems, Lyapunov and Riccati equations, and frequency
response (controls). Exact for symbolic or rational data,
numeric (numpy) where stated. Hypotheses a result rests on are read with
controls_side_conditions() right after the call. See the Controls
chapter of the user guide.
Transfer functions and state space
SISO transfer function G(var) = num(var) / den(var).
numExpr | intNumerator polynomial in
var— or, whendenis omitted, any rational function ofvar(it is then split bycancel()).denExpr | int, optionalDenominator polynomial in
var.varExprThe transform variable (
s, orzfor a discrete system).dtExpr | float, optionalSampling period of a discrete-time system;
Nonefor continuous.
num and den are kept as given — common factors are not
cancelled behind the caller's back, because a pole–zero cancellation can
hide an unstable mode. minreal() cancels them and states the
hypothesis. Coefficients may carry symbolic parameters.
>>> G = TransferFunction(1, m*s**2 + b*s + k, s)
>>> G.to_ss().A # controllable canonical form (scipy's tf2ss)
[[-b/m, -k/m], [1, 0]]Build from coefficient lists, highest power first (scipy order).
Substitute parameter values; returns a new TransferFunction.
Roots of the denominator, with multiplicity for rational coefficients.
Exact: radicals or RootOf (via solve()), sorted by
real then imaginary part when numeric. Side conditions (e.g. the
leading coefficient ≠ 0) are in controls_side_conditions().
Steady-state gain: G(0) (continuous) or G(1) (discrete).
Computed after minreal(), so a cancelled pole at the origin does
not count. A remaining pole at 0 (1) refuses with
E-CTRL-004 — the gain is infinite. A symbolic den(0) is stated
as a side condition.
Closed loop G / (1 − sign·G·H); sign=-1 is negative feedback.
With G = n₁/d₁ and H = n₂/d₂ this is
n₁·d₂ / (d₁·d₂ − sign·n₁·n₂), not cancelled.
Cancel common factors of numerator and denominator.
Returns the reduced TransferFunction. It equals self away
from the roots of the cancelled factor, which is stated as a side
condition ("(s + 1) ≠ 0") — those are the hidden modes. Cancellation
is over ℚ(parameters), so it is minimal for generic parameter
values; on a special locus (a = b in (s+a)/(s+b)) a further
cancellation can appear.
Controllable canonical form, matching scipy.signal.tf2ss().
With den = s^n + a₁ s^{n-1} + … + aₙ (after dividing by the leading
coefficient — a side condition when it is symbolic) and the numerator
padded to degree n: A has first row [-a₁ … -aₙ] and ones on
the sub-diagonal, B = e₁, C = [b₁ − a₁b₀, …, bₙ − aₙb₀],
D = b₀. Refuses an improper G with E-CTRL-002 and a static
gain (no states) with E-CTRL-001.
y(t) = L⁻¹{G(s)}(t) for t > 0 (continuous, strictly proper).
Uses experimental.inverse_laplace_transform(), whose
refusals (e.g. an irreducible denominator factor of degree > 2,
E-TRANSFORM-002) propagate. Side conditions from the transform are
forwarded.
Discrete-time equivalent with sampling period T.
method="tustin" (alias "bilinear") substitutes
s = (2/T)·(z − 1)/(z + 1); "zoh" goes through
StateSpace.discretize() (exact matrix_exp). var is the
new variable (default: a symbol z in the same pool).
Continuous-time equivalent of a discrete system (inverse of discretize()).
method="tustin" (alias "bilinear") substitutes
z = (1 + sT/2)/(1 − sT/2) — exactly the inverse of the Tustin
discretisation, so G.discretize(T, "tustin").d2c("tustin") equals
G. "zoh" goes through StateSpace.d2c() (matrix
logarithm: exact when Ad − I is nilpotent, numeric otherwise). The
new variable var defaults to a symbol s in the same pool.
LTI system ẋ = A x + B u, y = C x + D u (MIMO).
A (n×n), B (n×m), C (p×n), D (p×m, default zero) are
Matrix or nested lists; entries may be symbolic.
dt marks a discrete-time system x[k+1] = A x[k] + B u[k].
Transfer function C (var·I − A)⁻¹ B + D.
Returned over the common denominator det(var·I − A) (degree n,
not cancelled — as scipy.signal.ss2tf() does; call
TransferFunction.minreal() to cancel). A SISO system gives one
TransferFunction; a MIMO one a list of rows of them.
D − C A⁻¹ B (continuous) or D + C (I − A)⁻¹ B (discrete).
det A ≠ 0 (resp. det(I − A) ≠ 0) is a side condition when it is
symbolic; a proven singular one refuses with E-CTRL-004.
Generic rank of the controllability matrix.
Exact where the pivot hypotheses in controls_side_conditions()
hold (e.g. "a ≠ 0" for an input gain a), an upper bound
everywhere.
Generic rank of the observability matrix (see controllability_rank()).
Closed loop u = r + sign·y₂, y₂ = other(y₁) (sign=-1: negative).
other is a StateSpace or a static gain matrix (default:
unity). Needs I − sign·D₁D₂ invertible; a symbolic determinant is
a side condition, a singular one refuses with E-CTRL-004.
Discrete-time equivalent with sampling period T.
"zoh": exact Ad = e^{AT}, Bd = ∫₀ᵀ e^{Aτ} dτ B, read off
exp([[A, B], [0, 0]]·T) (no integral needed); Cd = C,
Dd = D. "tustin" / "bilinear": scipy.signal.cont2discrete()'s
bilinear formulas with α = ½ — Ad = (I − AT/2)⁻¹(I + AT/2),
Bd = (I − AT/2)⁻¹ B T, Cd = C (I − AT/2)⁻¹, Dd = D + C Bd / 2.
Continuous-time equivalent of a discrete system (inverse of discretize()).
"tustin" / "bilinear": A = (2/T)(Ad − I)(Ad + I)⁻¹,
B = (2/T)(Ad + I)⁻¹Bd, C = 2 Cd (Ad + I)⁻¹,
D = Dd − C Bd/2 — exact, symbolic data allowed (det(Ad + I) ≠ 0
is a side condition). "zoh": A = log(Ad)/T and
B = (Σₖ Aᵏ Tᵏ⁺¹/(k+1)!)⁻¹ Bd; exact when Ad − I is
nilpotent (integrator chains: a finite logarithm series), otherwise
numeric (scipy logm or eigendecomposition) with floating-point
entries and no free parameters (E-CTRL-003); a matrix with no real
logarithm (negative real eigenvalue) refuses with E-CTRL-004.
Minimal realisation (controllable and observable), transfer matrix unchanged.
method="kalman" (the default for MIMO systems) is the Kalman
decomposition computed exactly: the controllable subspace
range[B, AB, …, Aⁿ⁻¹B] is split off by an exact change of basis,
then the unobservable subspace ker[C; CA; …; CAⁿ⁻¹] of the
controllable part; what remains is the minimal (Aₘ, Bₘ, Cₘ, D).
Rational data use Fraction elimination; symbolic data eliminate
over ℚ(parameters) and every pivot is a side condition (the
realisation is minimal for generic parameters). The result is
verified: D and the Markov parameters C Aᵏ B of the
original and the reduction agree for k < n + nₘ, which proves the
two transfer matrices equal. The state basis is a basis of the
original state space (not a canonical form); method="tf" (the
default for SISO) goes through TransferFunction.minreal() and
returns the controllable canonical form instead.
Floating-point data refuses with E-CTRL-005 (rank decisions need a
tolerance); a system with no controllable-and-observable part is the
static gain D and refuses with E-CTRL-001.
Matrix equations
Kronecker product A ⊗ B.
For A of shape (m, n) and B of shape (p, q) returns the
(m·p, n·q) block matrix [[a₁₁ B, …, a₁ₙ B], …]. Entries may be
symbolic. Satisfies vec(A X B) = (Bᵀ ⊗ A) vec(X) with column-major
vec, which is how lyapunov() builds its linear system.
>>> kronecker(Matrix([[a, 0], [0, b]]), Matrix([[1, 1]]))
[[a, a, 0, 0], [0, 0, b, b]]Solve the continuous Lyapunov equation A X + X Aᵀ + Q = 0.
(python-control's lyap(A, Q) convention; scipy.linalg.solve_continuous_lyapunov()
solves A X + X Aᴴ = Q, i.e. pass -Q there.)
method="exact": the operator X ↦ A X + X Aᵀ is invertible iff no
two eigenvalues of A satisfy λᵢ + λⱼ = 0; a proven singular one
refuses with E-CTRL-004, a symbolic determinant is a side condition
(controls_side_conditions). For rational A and Q of any
size the solve is structured: Cayley–Hamilton turns the Sylvester operator
into a polynomial in A and Aᵀ (O(n) matrix products over ℚ, no
n²×n² system), and the result is verified — the residual is exactly
zero. Symbolic A (up to 3×3) vectorises: (I ⊗ A + A ⊗ I) vec X = −vec Q with an exact inverse, then verifies the residual cancels. Other
non-rational data (radicals, RootOf) up to 8×8; E-CTRL-005 beyond.
method="numeric" solves the same system with numpy and returns a
numpy.ndarray. "auto" picks exact unless an entry is a float.
- Matrix | numpy.ndarray
>>> lyapunov(Matrix([[0, 1], [-k, -b]]), Matrix([[1, 0], [0, 1]])) # X for AX+XAᵀ+I=0Solve the discrete Lyapunov (Stein) equation A X Aᵀ − X + Q = 0.
(python-control's dlyap(A, Q) and scipy.linalg.solve_discrete_lyapunov()
convention.) The operator is invertible iff no two eigenvalues satisfy
λᵢ λⱼ = 1; a proven singular one refuses with E-CTRL-004 (in
particular an eigenvalue −1 or a reciprocal pair).
method="exact": for rational data of any size the equation is mapped
by the Cayley transform F = (A − I)(A + I)⁻¹ to the continuous
Lyapunov equation F X + X Fᵀ + ½(I − F) Q (I − F)ᵀ = 0, which
lyapunov() solves with its structured exact solver, and the result
is verified against the Stein equation (residual exactly zero). Symbolic
data (up to 3×3) vectorises (I − A ⊗ A) vec X = vec Q with an exact
inverse; its determinant is a side condition. E-CTRL-005 beyond.
"numeric" solves the vectorised system with numpy; "auto" is
exact unless an entry is a float.
>>> dlyap(Matrix([[1, 0], [0, 1]]) / 2, Matrix([[1, 0], [0, 1]])) # (4/3)·IStabilising solution of the continuous algebraic Riccati equation.
Aᵀ X + X A − X B R⁻¹ Bᵀ X + Q = 0 (scipy's solve_continuous_are
convention). The stabilising solution is the one with
A − B R⁻¹ Bᵀ X Hurwitz; it is unique when it exists, and real.
method="exact" (n ≤ 2, rational or symbolic data): solves the
polynomial system for the entries of a symmetric X with
solve(), computes the Routh–Hurwitz conditions of each
candidate's closed loop, and returns the candidate whose conditions hold
at sample — a {symbol: number} point inside the parameter region
of interest (default: every parameter set to a distinct value in
(1, 2)). The Hurwitz conditions of the returned branch are stated
in controls_side_conditions() (e.g. "… r … > 0"): the result is
the stabilising solution exactly where they hold, whichever sample was
used. No stabilising candidate refuses with E-CTRL-004.
method="exact" for rational data of any size (and as the fallback
when the parametric system above does not solve): the stable invariant
subspace of the Hamiltonian H = [[A, −BR⁻¹Bᵀ], [−Q, −Aᵀ]] is computed
exactly. The characteristic polynomial of H is factored over ℚ; every
irreducible factor is classified by an exact Routh test as having all its
roots in the open left half plane, all in the right one, or neither. The
kernels of f(H)^m for the stable factors span the subspace
[U₁; U₂] and X = U₂U₁⁻¹ is rational. X is verified:
symmetric with an exactly zero Riccati residual. When an irreducible
factor has roots on both sides (the typical generic case: eigenvalues
±√c) the stable subspace is not defined over ℚ; "exact" refuses
with E-CTRL-005 and "auto" falls back to the numeric route and
states that in controls_side_conditions(). Roots on the imaginary
axis, or a singular U₁, mean no stabilising solution (E-CTRL-004).
method="numeric": the Hamiltonian [[A, −BR⁻¹Bᵀ], [−Q, −Aᵀ]]'s
stable invariant subspace (ordered Schur via scipy when available, else
eigenvectors), X = U₂U₁⁻¹; returns a numpy.ndarray. Eigenvalues on
the imaginary axis (no stabilising solution) refuse with E-CTRL-004.
"auto": exact when every entry is exact and n ≤ 2; for n > 2
exact data the exact Hamiltonian route, falling back to numeric (which
needs numeric data — E-CTRL-003 if parameters remain) when the stable
subspace is not rational. Floating-point data is always numeric.
>>> X = care([[0, 1], [0, 0]], [[0], [1]], [[1, 0], [0, 1]], [[r]]) # double integrator
>>> controls_side_conditions() # r ≠ 0 and the closed-loop Hurwitz conditions (r > 0)Stabilising solution of the discrete algebraic Riccati equation (numeric).
Aᵀ X A − X − (Aᵀ X B)(R + Bᵀ X B)⁻¹(Bᵀ X A) + Q = 0
(scipy.linalg.solve_discrete_are()'s convention); the stabilising
solution makes A − B (R + BᵀXB)⁻¹ BᵀXA Schur (all eigenvalues inside
the unit disc). Returns a numpy.ndarray.
The solution is the stable invariant subspace of the symplectic matrix
M = [[A + G A⁻ᵀ Q, −G A⁻ᵀ], [−A⁻ᵀ Q, A⁻ᵀ]] (G = B R⁻¹ Bᵀ,
A invertible): ordered Schur via scipy when available, else
eigenvectors; X = U₂U₁⁻¹. A singular A uses scipy's
generalised-pencil solver (E-CTRL-006 when scipy is missing). The
residual of the returned X is checked; eigenvalues of M on the unit
circle mean no stabilising solution (E-CTRL-004). The data must be
numeric (E-CTRL-003 if parameters remain); method="exact" is
refused with E-CTRL-005 — the exact engine covers the continuous
equation (care()).
Discrete LQR: (K, X) with K = (R + BᵀXB)⁻¹ BᵀXA and X from dare().
u[k] = −K x[k] minimises Σ xᵀQx + uᵀRu. Numeric (numpy.ndarray).
Frequency response
Bode data of a SISO system: (magnitude_dB, phase_deg) at frequencies w.
Matches scipy.signal.bode(): magnitude 20·log₁₀|G(jω)| and the
unwrapped phase in degrees. Numeric: the system must have no free
parameters (E-CTRL-003; use G.subs({...})). Returns numpy arrays
when numpy is available, else lists.
Gain and phase margins of a continuous SISO loop transfer function.
The crossover frequencies are roots of real polynomials in ω:
Im[N(jω)·D(−jω)] = 0 (phase crossover; kept where G(jω) ≤ 0) and
|N(jω)|² − |D(jω)|² = 0 (gain crossover). For rational coefficients
the roots are isolated exactly (real_roots()) and
refined by exact bisection; otherwise numpy's polynomial roots are used.
The reported margin is the one control.margin reports: the gain margin
closest to 1 in log scale and the smallest-magnitude phase margin.
Discrete time (dt set): the loop is evaluated on the unit circle
z = e^{jθ}, θ = ωT ∈ [0, π] (frequencies up to the Nyquist
frequency π/T). With c = cos θ both crossover conditions are real
polynomials in c over ℚ — the phase condition
Im[N(z)·D(z̄)] / sin θ = Σ eₖ Uₖ₋₁(c) (Chebyshev U; θ = 0, π
are always on the real axis) and the gain condition
|N|² − |D|² = Σ ... Tₖ(c) — and their roots in [−1, 1] are isolated
exactly like the continuous ones. phase_crossover / gain_crossover
are reported in rad per time unit (θ / T).
Refuses systems with free parameters (E-CTRL-003).
Side conditions and errors
Hypotheses the most recent controls call on this thread rests on.
One string per condition, e.g. "m ≠ 0" (a leading coefficient that was
divided by) or "(s + a) ≠ 0" (a cancelled pole–zero pair). An empty list
means the last result holds for every value of its parameters. Each public
entry point of controls resets the list.
The hypotheses the most recent Matrix.rank() (or
rref()) on this thread rests on — one string per
pivot that is not a non-zero rational constant, e.g. "a ≠ 0".
For symbolic entries rank() is the generic rank: the rank over the
field of rational functions in the parameters. Matrix([[a]]).rank() is
1 for a ≠ 0; at a = 0 it is 0. The returned rank is exact
wherever every listed condition holds and an upper bound everywhere (rank
can only drop under specialisation). The conditions are sufficient, not
necessary: where one fails, substitute the values and ask again.
An empty list means the elimination divided only by non-zero rational
constants, so the rank holds for every parameter value. Reset by each
rank / rref call.
ControlsError codes E-CTRL-001 … E-CTRL-006 are listed in
errors.