alkahest
Python API · Submodule

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 — SISO G(s) = num(s)/den(s): poles, zeros, DC gain, series / parallel / feedback, minimal realisation, step and impulse responses (exact sums over RootOf for high-order denominators), Tustin / ZOH discretisation and its inverse d2c, 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 and d2c, 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 needs numpy, which is not installed.

Example

Python
>>> 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]
class
class controls.Margins(tuple)

(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.

Properties
gain_margin_db
class
class controls.StateSpace(A, B, C, D=None, *, dt=None)

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].

Properties
nstates: int
ninputs: int
noutputs: int
Methods
is_siso() → bool
characteristic_polynomial(var)

det(var·I − A), expanded.

to_tf(var)

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.

poles(var=None) → list

Eigenvalues of A — roots of det(sI − A) with multiplicity.

zeros(var=None) → list

Zeros of a SISO system (roots of the numerator of to_tf()).

dc_gain()

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.

controllability_matrix()

[B, AB, …, A^{n−1}B] (n × n·m).

observability_matrix()

[C; CA; …; CA^{n−1}] (n·p × n).

controllability_rank() → int

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.

observability_rank() → int

Generic rank of the observability matrix (see controllability_rank()).

is_controllable() → bool

Generic controllability (rank n), with its side conditions stated.

is_observable() → bool

Generic observability (rank n), with its side conditions stated.

series(other)

other ∘ self: the output of self drives other.

parallel(other)

Sum of the outputs of two systems driven by the same input.

feedback(other=None, sign=-1)

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.

impulse_response(t)

Impulse response (SISO: an expression; MIMO: a matrix), via to_tf().

step_response(t)

Unit-step response (SISO: an expression; MIMO: a matrix), via to_tf().

discretize(T, method='zoh')

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.

d2c(method='zoh')

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.

minreal(var=None, method='auto')

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.

subs(mapping)

Substitute parameter values in all four matrices.

class
class controls.TransferFunction(num, den=None, var=None, *, dt=None)

SISO transfer function G(var) = num(var) / den(var).

Parameters
numExpr | int

Numerator polynomial in var — or, when den is omitted, any rational function of var (it is then split by cancel()).

denExpr | int, optional

Denominator polynomial in var.

varExpr

The transform variable (s, or z for a discrete system).

dtExpr | float, optional

Sampling period of a discrete-time system; None for 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.

Examples
Python
>>> 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]]
Properties

Numerator polynomial (an expression in var).

Denominator polynomial (an expression in var).

num_coeffs: list

Numerator coefficients, highest power first (scipy order).

den_coeffs: list

Denominator coefficients, highest power first (scipy order).

num / den as one expression.

is_discrete: bool
Methods
from_coeffs(num, den, var, *, dt=None)
classmethod

Build from coefficient lists, highest power first (scipy order).

degree() → tuple

(deg num, deg den) for generic parameter values.

is_proper() → bool
is_strictly_proper() → bool
__call__(value)

G(value) as an expression (exact).

subs(mapping)

Substitute parameter values; returns a new TransferFunction.

freqresp(w) → list

Complex frequency response G(jω) (or G(e^{jωT})) at each ω.

poles() → list

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().

zeros() → list

Roots of the numerator (see poles()).

dc_gain()

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.

series(other)

other ∘ self — the product G₁·G₂ (no cancellation).

parallel(other)

G₁ + G₂ over the common denominator d₁·d₂ (no cancellation).

feedback(other=1, sign=-1)

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.

equals(other) → bool

Whether self and other are the same rational function.

minreal()

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.

to_ss()

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.

impulse_response(t)

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.

step_response(t)

Unit-step response y(t) = L⁻¹{G(s)/s}(t) for t > 0.

discretize(T, method='tustin', var=None)

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).

d2c(method='tustin', var=None)

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.

bode(w)

(magnitude_dB, phase_deg) at the frequencies w; see bode().

margins()

Gain/phase margins; see margins().

exception
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.

function
controls.bode(sys, w)

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.

function
controls.care(A, B, Q, R, method='auto', sample=None)

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.

Examples
Python
>>> 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)
function
controls.controls_side_conditions() → list

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.

function
controls.dare(A, B, Q, R, method='auto')

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()).

function
controls.dlqr(A, B, Q, R, method='auto')

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).

function
controls.dlyap(A, Q, method='auto')

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.

Examples
Python
>>> dlyap(Matrix([[1, 0], [0, 1]]) / 2, Matrix([[1, 0], [0, 1]]))   # (4/3)·I
function
controls.kronecker(A, B)

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.

Examples
Python
>>> kronecker(Matrix([[a, 0], [0, b]]), Matrix([[1, 1]]))
[[a, a, 0, 0], [0, 0, b, b]]
function
controls.lqr(A, B, Q, R, method='auto', sample=None)

Continuous LQR: (K, X) with K = R⁻¹ Bᵀ X and X from care().

u = −K x minimises ∫ xᵀQx + uᵀRu dt. Exact or numeric as care(); side conditions as care() states them.

function
controls.lyapunov(A, Q, method='auto')

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.

Returns
Matrix | numpy.ndarray
Examples
Python
>>> lyapunov(Matrix([[0, 1], [-k, -b]]), Matrix([[1, 0], [0, 1]]))  # X for AX+XAᵀ+I=0
function
controls.margins(sys) → Margins

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).