Controls: LTI systems, Lyapunov and Riccati
alkahest.controls is a controls layer built on the exact kernel — cancel,
solve (with its RootOf roots), Matrix (inverse, det, matrix_exp) and the
inverse Laplace transform — so a plant may carry symbolic parameters (m, b, k,
a gain K) and every exact answer is exact. The main names are re-exported at the
top level: TransferFunction, StateSpace, kronecker, lyapunov, care, lqr,
bode, margins, dlyap, dare, dlqr, controls_side_conditions, ControlsError.
import alkahest as ak
from alkahest import TransferFunction, StateSpace, controls_side_conditions
p = ak.ExprPool()
s, t, m, b, k = (p.symbol(n) for n in "s t m b k".split())
G = TransferFunction(1, m*s**2 + b*s + k, s)
G.poles() # two roots of m s² + b s + k (radicals)
controls_side_conditions() # ['m ≠ 0']
sys = G.to_ss() # controllable canonical form (scipy.signal.tf2ss layout)
sys.to_tf(s).equals(G) # True
G.step_response(t) # L⁻¹{G(s)/s}Transfer functions
TransferFunction(num, den, var, dt=None) is a SISO rational function of var.
Numerator and denominator are kept as given: common factors are not cancelled
behind the caller's back, because a pole–zero cancellation can hide an unstable mode.
(A single rational expression passed without den is brought to lowest terms.)
from_coeffs(num, den, var) takes coefficient lists highest power first, as scipy does.
| Method | Result |
|---|---|
poles(), zeros() |
Exact roots. Rational coefficients: square-free decomposition over ℚ, each factor solved by solve (radicals or RootOf), with multiplicity. Parametric coefficients: the distinct generic roots, with the solver's side conditions forwarded. |
dc_gain() |
G(0) (G(1) in discrete time) after cancelling common factors; E-CTRL-004 for a remaining pole there |
series, parallel, feedback(H=1, sign=-1), *, +, -, / |
Interconnection without cancellation: feedback is n₁d₂ / (d₁d₂ − sign·n₁n₂) |
minreal() |
Cancels common factors over ℚ(parameters); the cancelled factor is stated ("(s + 1) ≠ 0") |
to_ss() |
Controllable canonical form, entry-for-entry scipy.signal.tf2ss; E-CTRL-002 if improper |
step_response(t), impulse_response(t) |
Inverse Laplace transform. A denominator with an irreducible factor of degree > 2 (rational coefficients) is expanded in partial fractions over ℚ and inverted by residues: an exact finite sum Σₖ e^{rₖt}·Σᵢ cᵢ(rₖ)tⁱ over rₖ = RootOf(f, k) (repeated factors included), evaluable numerically. Parametric high-order denominators still refuse (E-TRANSFORM-002) |
discretize(T, "tustin" | "zoh") |
Tustin substitutes s = (2/T)(z−1)/(z+1); ZOH goes through StateSpace.discretize |
d2c("tustin" | "zoh") |
Inverse of discretize: Tustin is exact (discretize(...).d2c(...) equals the original); ZOH is exact when Ad − I is nilpotent (integrator chains) and numeric (float coefficients) otherwise |
subs({K: 2}), equals(other), freqresp(w) |
Parameter substitution; proven equality of rational functions; numeric G(jω) |
State space
StateSpace(A, B, C, D=None, dt=None) is MIMO; entries may be symbolic.
to_tf(s)isC (sI − A)⁻¹ B + Dover the common denominatordet(sI − A), not cancelled (asscipy.signal.ss2tf); a MIMO system returns rows of transfer functions.poles()are the roots ofdet(sI − A);dc_gain()isD − C A⁻¹ Bwithdet A ≠ 0stated when symbolic.controllability_matrix(),observability_matrix(),controllability_rank(),observability_rank(),is_controllable(),is_observable(). The ranks are generic:controls_side_conditions()lists the pivot hypotheses ("a ≠ 0"for an input gaina). The rank is exact where they hold and an upper bound everywhere.series,parallel,feedback(K=None, sign=-1)(aStateSpaceor a static gain;I − sign·D₁D₂must be invertible).discretize(T, "zoh")is exact:Ad = e^{AT}andBd = ∫₀ᵀ e^{Aτ}dτ Bare read offexp([[A, B], [0, 0]]·T), so no integral is needed."tustin"/"bilinear"followsscipy.signal.cont2discrete's bilinear formulas.d2c("zoh" | "tustin")inverts it.minreal()is the controllable-and-observable realisation. SISO goes through the transfer function (canonical form); MIMO (method="kalman") is the exact Kalman decomposition:range[B, AB, …]is split off by an exact change of basis, thenker[C; CA; …]of what is left. Symbolic data eliminate over ℚ(parameters) with every pivot stated as a side condition. The result is verified —Dand the Markov parametersC Aᵏ Bagree with the original — so the transfer matrix is unchanged.
The same channel as Matrix.inverse applies to ranks generally:
ak.Matrix([[a]]).rank() is 1 and ak.matrix_rank_side_conditions() is ['a ≠ 0'].
Lyapunov, Kronecker, Riccati and LQR
kronecker(A, B)— the Kronecker product, symbolic entries allowed.lyapunov(A, Q)solvesA X + X Aᵀ + Q = 0(python-control'slyapconvention;scipy.linalg.solve_continuous_lyapunov(A, -Q)is the sameX). Exact: the system rational data of any size use a structured solve (Cayley–Hamilton turns the Sylvester operator into a polynomial inAandAᵀ:O(n)matrix products over ℚ, non²×n²system); symbolic data vectorise(I ⊗ A + A ⊗ I) vec X = −vec Q; either way the residual is verified to cancel. The operator is singular iff two eigenvalues satisfyλᵢ + λⱼ = 0— a symbolic determinant is a side condition ("(4·k·b²) ≠ 0"forA = [[0, 1], [−k, −b]]), a proven singular one isE-CTRL-004. Symbolic data are exact up to 3×3; numeric (numpy) withmethod="numeric"or float data.dlyap(A, Q)solves the Stein equationA X Aᵀ − X + Q = 0(python-control'sdlyap): rational data go through the Cayley transformF = (A − I)(A + I)⁻¹to the continuous equation, residual verified;E-CTRL-004when two eigenvalues multiply to 1.care(A, B, Q, R)returns the stabilising solution ofAᵀ X + X A − X B R⁻¹ Bᵀ X + Q = 0;lqrreturns(K, X)withK = R⁻¹ Bᵀ X. Forn ≤ 2with exact data the polynomial system is solved withsolve, each candidate's closed loopA − B Kgets its Routh–Hurwitz conditions, and the branch whose conditions hold at a sample point of the parameters (sample={r: 2}; default every parameter in(1, 2)) is returned with those conditions stated — the result is the stabilising solution exactly where they hold. Otherwise the Hamiltonian's stable invariant subspace is computed numerically (ordered Schur through scipy when installed). For rational data of any size the subspace is computed exactly when the Hamiltonian's characteristic polynomial factors over ℚ into pieces that each lie on one side of the imaginary axis (an exact Routh test per irreducible factor): the kernels off(H)^mfor the stable factors give[U₁; U₂]andX = U₂U₁⁻¹is rational and verified. A factor with roots on both sides (the generic case, eigenvalues±√c) has no rational stable subspace:method="exact"refuses (E-CTRL-005) and"auto"returns the numeric Schur solution, stating the fallback incontrols_side_conditions().dare(A, B, Q, R)/dlqrsolve the discrete Riccati equation numerically (symplectic stable subspace; scipy for a singularA).
r, N = p.symbol("r"), p.integer # double integrator, R = r
A = ak.Matrix([[N(0), N(1)], [N(0), N(0)]])
B = ak.Matrix([[N(0)], [N(1)]])
Q = ak.Matrix([[N(1), N(0)], [N(0), N(1)]])
K, X = ak.lqr(A, B, Q, [[r]])
# X12 = √r, X22 = √(r(1 + 2√r)), K = [1/√r, √((1 + 2√r)/r)]
ak.controls_side_conditions() # ['r ≠ 0', '… r … > 0', …]Frequency response
bode(sys, w) returns (magnitude_dB, phase_deg) with the phase unwrapped, as
scipy.signal.bode. margins(sys) returns Margins(gain_margin, phase_margin, phase_crossover, gain_crossover) with control.margin's conventions (absolute gain
margin, inf/nan when there is no crossing). The crossovers are the real roots of
Im[N(jω) D(−jω)] and |N(jω)|² − |D(jω)|²; for rational coefficients they are isolated
exactly (Sturm sequences, exact bisection) — K/(s(s+1)(s+5)) has its phase crossover at
√5 and gain margin 30/K. Both need numeric coefficients (E-CTRL-003).
Discrete-time systems (dt set) are evaluated on the unit circle z = e^{jωT}, ω ≤ π/T:
with c = cos θ both crossover conditions are real polynomials in c (Chebyshev U/T
expansions of Im[N(z)D(z̄)] and |N|² − |D|²) isolated exactly on [−1, 1]. z = ±1 are
always on the real axis and count as phase crossovers, which python-control ignores.
Refusals
| Code | Meaning |
|---|---|
E-CTRL-001 |
Malformed input: not rational in the variable, shapes that do not conform, MIMO where SISO is needed |
E-CTRL-002 |
An improper transfer function where a proper one is needed (realisation, impulse response) |
E-CTRL-003 |
A numeric routine on data with free parameters — substitute values first |
E-CTRL-004 |
The object does not exist: singular Lyapunov operator, no stabilising ARE solution, infinite DC gain, ill-posed feedback |
E-CTRL-005 |
Beyond the exact engine (symbolic size, unsolvable system) — use method="numeric" |
E-CTRL-006 |
numpy is required for the numeric route and is not installed |