Skip to content

gds-analysis: numerical linear systems analysis, discretization, and controller synthesis #201

@rororowyourboat

Description

@rororowyourboat

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    control-theoryClassical control theory capabilitiesenhancementNew feature or requesttier-1Tier 1: High Priority

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions