Summary
Add linear.py to gds_analysis/ providing numerical linear systems analysis: eigenvalue stability, frequency response computation, stability margins, discretization (c2d), LQR synthesis, Kalman filter design, and gain scheduling. All behind the existing [continuous] extra (scipy + numpy already available).
Parent issue: #198 (classical control theory stack)
Depends on: #199 (transfer functions — for margin computation from TF coefficients)
Motivation
gds-domains/symbolic computes LinearizedSystem (A, B, C, D) and transfer functions symbolically. But users need fast numerical answers: "is this stable?" (eigenvalues), "what are the margins?" (frequency response), "what gains should I use?" (LQR), "how do I implement this digitally?" (discretization). These are scipy one-liners on the matrices we already have — the analysis package is the natural home.
Proposed API
New file: gds_analysis/linear.py (behind [continuous] extra)
All functions accept list[list[float]] matrices — no LinearizedSystem import, no coupling to gds-domains.
Stability Analysis
def eigenvalues(A: list[list[float]]) -> list[complex]:
"""Eigenvalues of state matrix A. Wraps numpy.linalg.eigvals."""
def is_stable(A: list[list[float]], continuous: bool = True) -> bool:
"""Check asymptotic stability.
Continuous: all Re(λ) < 0
Discrete: all |λ| < 1
"""
def is_marginally_stable(A: list[list[float]], continuous: bool = True) -> bool:
"""Check marginal stability (eigenvalues on boundary, none in RHP/outside unit circle)."""
Frequency Response
def frequency_response(
A: list[list[float]],
B: list[list[float]],
C: list[list[float]],
D: list[list[float]],
omega: list[float] | None = None,
) -> tuple[list[float], list[float], list[float]]:
"""Compute frequency response H(jω) numerically.
Returns (omega, magnitude_db, phase_deg) as plain lists.
If omega is None, auto-selects frequency range from eigenvalues.
Wraps scipy.signal.bode or scipy.signal.freqresp.
"""
def gain_margin(num: list[float], den: list[float]) -> tuple[float, float]:
"""Gain margin (dB) and frequency (rad/s) where phase = -180°.
Wraps scipy.signal.margin or custom phase-crossing detection.
"""
def phase_margin(num: list[float], den: list[float]) -> tuple[float, float]:
"""Phase margin (deg) and gain crossover frequency (rad/s).
Wraps scipy.signal.margin.
"""
Discretization
def discretize(
A: list[list[float]],
B: list[list[float]],
C: list[list[float]],
D: list[list[float]],
dt: float,
method: str = "tustin",
) -> tuple[list[list[float]], list[list[float]], list[list[float]], list[list[float]]]:
"""Convert continuous state-space to discrete: (A, B, C, D) → (Ad, Bd, Cd, Dd).
Methods: "tustin" (bilinear), "zoh" (zero-order hold),
"euler" (forward Euler), "backward_euler"
Wraps scipy.signal.cont2discrete.
Returns (Ad, Bd, Cd, Dd) as list[list[float]].
"""
Controller Synthesis
def lqr(
A: list[list[float]],
B: list[list[float]],
Q: list[list[float]],
R: list[list[float]],
N: list[list[float]] | None = None,
) -> tuple[list[list[float]], list[list[float]], list[complex]]:
"""Linear Quadratic Regulator.
Solves the continuous algebraic Riccati equation:
A'P + PA - PBR^{-1}B'P + Q = 0
Returns (K, P, E):
K — optimal gain matrix (u = -Kx)
P — Riccati solution
E — closed-loop eigenvalues
Wraps scipy.linalg.solve_continuous_are.
"""
def dlqr(
Ad: list[list[float]],
Bd: list[list[float]],
Q: list[list[float]],
R: list[list[float]],
) -> tuple[list[list[float]], list[list[float]], list[complex]]:
"""Discrete-time LQR. Wraps scipy.linalg.solve_discrete_are."""
def kalman(
A: list[list[float]],
C: list[list[float]],
Q_process: list[list[float]],
R_measurement: list[list[float]],
) -> tuple[list[list[float]], list[list[float]]]:
"""Steady-state Kalman filter (observer) gain.
Dual of LQR: solves ARE for the observer.
Returns (L, P): observer gain and error covariance.
"""
def gain_schedule(
linearize_fn: "Callable[[dict], tuple[list[list[float]], ...]]",
operating_points: list[dict],
Q: list[list[float]],
R: list[list[float]],
) -> list[tuple[dict, list[list[float]], list[complex]]]:
"""Compute LQR gains at multiple operating points.
linearize_fn: takes operating point dict, returns (A, B, C, D)
Returns list of (point, K, closed_loop_eigenvalues) tuples.
"""
Implementation Notes
scipy functions used
| GDS function |
scipy wrapper |
Module |
eigenvalues |
numpy.linalg.eigvals |
numpy |
frequency_response |
scipy.signal.bode / scipy.signal.freqresp |
scipy.signal |
gain_margin / phase_margin |
scipy.signal.margin or custom |
scipy.signal |
discretize |
scipy.signal.cont2discrete |
scipy.signal |
lqr |
scipy.linalg.solve_continuous_are |
scipy.linalg |
dlqr |
scipy.linalg.solve_discrete_are |
scipy.linalg |
kalman |
dual of solve_continuous_are |
scipy.linalg |
Interface contract
- Input:
list[list[float]] matrices, matching LinearizedSystem field types
- Output:
list[list[float]], list[float], list[complex] — never numpy arrays
- Internal: convert to numpy at entry, compute, convert back at exit
- No gds-domains import: functions accept raw matrices, not typed objects
Dependency
All behind the existing [continuous] extra which already pulls gds-continuous[scipy]>=0.1.0 + numpy>=1.26. Consider adding a [control] extra alias for discoverability.
Error handling
is_stable() on an empty matrix returns True (vacuously stable)
lqr() raises ValueError if (A, B) is not stabilizable
kalman() raises ValueError if (A, C) is not detectable
discretize() raises ValueError for unknown method strings
Key Files
packages/gds-analysis/gds_analysis/backward_reachability.py — reference for scipy usage patterns in this package
packages/gds-analysis/gds_analysis/adapter.py — the discrete-time bridge (this is the continuous-time analog)
packages/gds-domains/gds_domains/symbolic/linearize.py — LinearizedSystem definition (data source)
packages/gds-continuous/gds_continuous/engine.py — scipy usage patterns
Testing
test_linear.py:
- Eigenvalue: 2×2 stable system
A = [[-1, 0], [0, -2]] → eigenvalues {-1, -2}, is_stable=True
- Eigenvalue: unstable system with positive eigenvalue →
is_stable=False
- Frequency response:
1/(s+1) → verify -3dB at ω=1, -45° at ω=1
- Margins: known system with 6dB gain margin, 45° phase margin
- Discretize:
A=[-1], B=[1], C=[1], D=[0] with Tustin at dt=0.1 → verify against scipy.signal.cont2discrete directly
- LQR: double integrator with identity Q, R → verify closed-loop eigenvalues in LHP
- Kalman: dual of LQR test case
- Gain scheduling: linearize spring-mass at 3 operating points → verify K varies
Concepts Addressed (MATLAB Tech Talks)
- Video 1: Stability analysis, LQR, Kalman filter, state estimation
- Video 2: Poles (eigenvalues), system stability
- Video 4: Frequency response, gain/phase margin
- Video 5: Discretization (Tustin, ZOH, Euler), state-space conversion
- Video 7: Stability margins for loop shaping
- Video 9: Gain scheduling via multi-point linearization
- Video 11: Stability margin effects of time delay
- Video 14: Z-transform (discretization bridge to discrete-time)
Summary
Add
linear.pytogds_analysis/providing numerical linear systems analysis: eigenvalue stability, frequency response computation, stability margins, discretization (c2d), LQR synthesis, Kalman filter design, and gain scheduling. All behind the existing[continuous]extra (scipy + numpy already available).Parent issue: #198 (classical control theory stack)
Depends on: #199 (transfer functions — for margin computation from TF coefficients)
Motivation
gds-domains/symboliccomputesLinearizedSystem(A, B, C, D) and transfer functions symbolically. But users need fast numerical answers: "is this stable?" (eigenvalues), "what are the margins?" (frequency response), "what gains should I use?" (LQR), "how do I implement this digitally?" (discretization). These are scipy one-liners on the matrices we already have — the analysis package is the natural home.Proposed API
New file:
gds_analysis/linear.py(behind[continuous]extra)All functions accept
list[list[float]]matrices — noLinearizedSystemimport, no coupling togds-domains.Stability Analysis
Frequency Response
Discretization
Controller Synthesis
Implementation Notes
scipy functions used
eigenvaluesnumpy.linalg.eigvalsfrequency_responsescipy.signal.bode/scipy.signal.freqrespgain_margin/phase_marginscipy.signal.marginor customdiscretizescipy.signal.cont2discretelqrscipy.linalg.solve_continuous_aredlqrscipy.linalg.solve_discrete_arekalmansolve_continuous_areInterface contract
list[list[float]]matrices, matchingLinearizedSystemfield typeslist[list[float]],list[float],list[complex]— never numpy arraysDependency
All behind the existing
[continuous]extra which already pullsgds-continuous[scipy]>=0.1.0+numpy>=1.26. Consider adding a[control]extra alias for discoverability.Error handling
is_stable()on an empty matrix returnsTrue(vacuously stable)lqr()raisesValueErrorif (A, B) is not stabilizablekalman()raisesValueErrorif (A, C) is not detectablediscretize()raisesValueErrorfor unknown method stringsKey Files
packages/gds-analysis/gds_analysis/backward_reachability.py— reference for scipy usage patterns in this packagepackages/gds-analysis/gds_analysis/adapter.py— the discrete-time bridge (this is the continuous-time analog)packages/gds-domains/gds_domains/symbolic/linearize.py—LinearizedSystemdefinition (data source)packages/gds-continuous/gds_continuous/engine.py— scipy usage patternsTesting
test_linear.py:A = [[-1, 0], [0, -2]]→ eigenvalues{-1, -2},is_stable=Trueis_stable=False1/(s+1)→ verify -3dB at ω=1, -45° at ω=1A=[-1], B=[1], C=[1], D=[0]with Tustin at dt=0.1 → verify againstscipy.signal.cont2discretedirectlyConcepts Addressed (MATLAB Tech Talks)