alkahest.controls
Linear time-invariant systems: transfer functions, state space, Lyapunov and Riccati.
This is the controls layer (G10). Everything here is built from the exact
kernel — cancel(), solve() (with its RootOf
roots), Matrix (inverse, det, matrix_exp) and
experimental.inverse_laplace_transform() — so a transfer
function may carry symbolic parameters (m, b, k, a gain K)
and every exact answer is exact.
TransferFunction— SISOG(s) = num(s)/den(s): poles, zeros, DC gain, series / parallel / feedback, minimal realisation, step and impulse responses (exact sums overRootOffor high-order denominators), Tustin / ZOH discretisation and its inversed2c, controllable canonical form.StateSpace— MIMO(A, B, C, D): transfer matrix, poles, controllability / observability matrices and their (generic) rank, series / parallel / feedback interconnection, ZOH / Tustin discretisation andd2c, MIMO minimal realisation (exact Kalman decomposition).kronecker(),lyapunov(),care(),lqr()— exact for small symbolic data and rational data of any size (structured Sylvester solve; Hamiltonian stable subspace over ℚ), numeric (numpy) otherwise;dlyap(),dare(),dlqr()— their discrete-time analogues.bode(),margins()— numeric frequency response, gain and phase margins and their crossover frequencies.
Side conditions. A result that holds only for generic parameter values —
a pole–zero cancellation, a leading coefficient that was divided by, the
stabilising branch of a Riccati equation — states its hypotheses. They are
read with controls_side_conditions() right after the call, one string
per condition ("m ≠ 0", "r > 0"), the same out-of-band pattern as
solve_side_conditions().
Refusals raise ControlsError with a stable code:
E-CTRL-001— malformed input: not a rational function of the variable, shapes that do not conform, a MIMO system where a SISO one is needed, an unknown method.E-CTRL-002— an improper transfer function where a proper one is needed (state-space realisation, impulse response, margins).E-CTRL-003— a numeric operation (Bode, margins, numeric Lyapunov / Riccati) on data that still has free parameters; substitute values first.E-CTRL-004— the requested object does not exist: a Lyapunov operator that is singular (λᵢ + λⱼ = 0), a Riccati equation with no stabilising solution, an infinite DC gain.E-CTRL-005— out of the exact engine's reach (symbolic size, an unsolvable polynomial system, a stabilising branch that could not be selected); the remediation names the numeric route.E-CTRL-006— a numeric routine needsnumpy, which is not installed.
Example
>>> import alkahest as ak
>>> from alkahest.controls import TransferFunction
>>> p = ak.ExprPool(); s = p.symbol("s")
>>> G = TransferFunction(p.integer(1), s**2 + 3*s + 2, s)
>>> G.poles()
[-2, -1](gain_margin, phase_margin, phase_crossover, gain_crossover).
Same order and conventions as control.margin: gain_margin is an
absolute ratio (inf when the Nyquist plot never crosses the negative
real axis), phase_margin in degrees (inf when |G| never
crosses 1); phase_crossover is where the gain margin is measured
(∠G = −180°) and gain_crossover where |G(jω)| = 1 (nan when
absent). gain_margin_db is 20·log₁₀ of the ratio.
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.
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.
exception controls.ControlsError(
message: str,
code: str='E-CTRL-001',
remediation: str | None=None,
span: tuple[int, int] | None=None
)
A controls-layer operation refused (E-CTRL-001 … E-CTRL-006).
001 malformed input; 002 an improper transfer function where a
proper one is needed; 003 a numeric routine on data with free
parameters; 004 the requested object does not exist (singular Lyapunov
operator, no stabilising Riccati solution, infinite DC gain); 005 out
of the exact engine's reach — use the numeric route; 006 numpy is
required and missing.
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.
Stabilising 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)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.
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).
Solve 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)·IKronecker 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=0Gain 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).