Skip to main content

symplex/domains/
control.rs

1//! Control systems analysis: state-space models, transfer functions,
2//! stability analysis, and controller design utilities.
3//!
4//! # State-Space Models
5//!
6//! A linear time-invariant (LTI) system in state-space form:
7//!     ẋ = Ax + Bu
8//!     y = Cx + Du
9//!
10//! # Transfer Functions
11//!
12//! G(s) = num(s) / den(s) — a ratio of polynomials in the Laplace variable s.
13
14use crate::base::truth::Truth;
15use crate::domains::matrix::Matrix;
16use crate::prelude::*;
17
18/// Ascending coefficients of `expr` viewed as a polynomial in `var`,
19/// allowing symbolic (var-free) coefficients.
20///
21/// [`Ex::coeffs`] only handles rational coefficients; transfer functions
22/// such as `K·ωₙ² / (s² + 2ζωₙ s + ωₙ²)` need the symbolic variant.
23/// Returns `None` if `var` occurs in a non-polynomial position.
24fn poly_coeffs_symbolic(expr: &Ex, var: &Ex) -> Option<Vec<Ex>> {
25    let var_id = expr.checked_id(var);
26    let mut inner = expr.inner.write();
27    let ids =
28        crate::transforms::solve::symbolic_poly_coeffs(&mut inner.arena, expr.raw_id(), var_id)?;
29    drop(inner);
30    Some(ids.into_iter().map(|id| expr.wrap(id)).collect())
31}
32
33/// Roots of `p`, a polynomial in `var`, repeated with multiplicity (SymPy's
34/// `roots(…, multiple=True)`, which `TransferFunction.poles()` uses), or
35/// the error that says why they are not all known.
36///
37/// They are the eigenvalues of the companion matrix of `p`, so they agree
38/// with [`StateSpace::poles`] of the realisation.  When the eigenvalue
39/// solver does not find every root, the roots of the equation solver are
40/// taken if they are as many as the degree (all simple).  `Err` when they
41/// are not, when `p` is not a polynomial in `var` (`1 − e⁻ˢ` has infinitely
42/// many roots, of which a solver lists the principal one), and for the
43/// zero polynomial.  Up to 0.48 the distinct roots of the solver were
44/// returned, or an empty list when it failed: `(s⁵ + a·s + 1)(s − 2)` had
45/// no poles, not even `2`.
46fn try_roots_with_multiplicity(
47    p: &Ex,
48    var: &Ex,
49    operation: &'static str,
50) -> Result<Vec<Ex>, SymplexError> {
51    let Some(mut c) = poly_coeffs_symbolic(p, var) else {
52        return Err(SymplexError::computation_failed(
53            operation,
54            format!("{p} is not a polynomial in {var}: its roots are not a finite list"),
55        ));
56    };
57    while c.len() > 1 && c.last().is_some_and(is_exact_zero) {
58        c.pop();
59    }
60    let n = c.len().saturating_sub(1);
61    if n == 0 {
62        // A nonzero constant has no roots, the zero polynomial every value.
63        if c.first().is_none_or(is_exact_zero) {
64            return Err(SymplexError::InfiniteSolutions {
65                operation,
66                reason: format!("the zero polynomial vanishes at every {var}"),
67            });
68        }
69        return Ok(Vec::new());
70    }
71    let ctx = p.context();
72    let lead = &c[n];
73    let companion = Matrix::from_fn(n, n, |i, j| {
74        if i + 1 < n {
75            if j == i + 1 { ctx.one() } else { ctx.zero() }
76        } else {
77            (-(&c[j] / lead)).eval()
78        }
79    });
80    if let Ok(ev) = companion.and_then(|m| m.eigenvals())
81        && ev.len() == n
82    {
83        return Ok(ev);
84    }
85    // every root, whatever the declared domain of `var`
86    let roots = p.solve_over_all_values(var)?;
87    if roots.len() == n {
88        return Ok(roots);
89    }
90    Err(SymplexError::computation_failed(
91        operation,
92        format!(
93            "found {} distinct roots of the polynomial {p} of degree {n} in {var}: the others, or \
94             the multiplicities, are not known",
95            roots.len()
96        ),
97    ))
98}
99
100// ═══════════════════════════════════════════════════════════════════════════
101// State-Space Model
102// ═══════════════════════════════════════════════════════════════════════════
103
104/// A linear time-invariant (LTI) state-space model.
105///
106/// ẋ = Ax + Bu
107/// y = Cx + Du
108///
109/// where:
110/// - `x` is the state vector (n×1)
111/// - `u` is the input vector (m×1)
112/// - `y` is the output vector (p×1)
113/// - `A` is the state matrix (n×n)
114/// - `B` is the input matrix (n×m)
115/// - `C` is the output matrix (p×n)
116/// - `D` is the feedthrough matrix (p×m)
117///
118/// The fields are private: [`StateSpace::new`] is the only way in, so every
119/// model has conformant shapes.  Read them with [`a`](Self::a),
120/// [`b`](Self::b), [`c`](Self::c) and [`d`](Self::d).
121#[derive(Clone, Debug)]
122pub struct StateSpace {
123    a: Matrix,
124    b: Matrix,
125    c: Matrix,
126    d: Matrix,
127}
128
129impl StateSpace {
130    /// Extract a [`Context`] from this model's state matrix.
131    fn ctx(&self) -> crate::api::context::Context {
132        self.a.get(0, 0).context()
133    }
134
135    /// Create a new state-space model from the four system matrices.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`SymplexError::InvalidArgument`] if the dimensions are
140    /// inconsistent:
141    /// - A must be n×n (square)
142    /// - B must be n×m
143    /// - C must be p×n
144    /// - D must be p×m
145    ///
146    /// # Examples
147    ///
148    /// ```
149    /// use symplex::prelude::*;
150    ///
151    /// let ctx = Context::new();
152    /// let ss = StateSpace::new(
153    ///     matrix![ctx, [0, 1], [-2, -3]],
154    ///     matrix![ctx, [0], [1]],
155    ///     matrix![ctx, [1, 0]],
156    ///     matrix![ctx, [0]],
157    /// )
158    /// .unwrap();
159    /// assert_eq!((ss.num_states(), ss.num_inputs(), ss.num_outputs()), (2, 1, 1));
160    /// // B has 1 row, A has 2.
161    /// assert!(
162    ///     StateSpace::new(
163    ///         matrix![ctx, [0, 1], [-2, -3]],
164    ///         matrix![ctx, [1]],
165    ///         matrix![ctx, [1, 0]],
166    ///         matrix![ctx, [0]],
167    ///     )
168    ///     .is_err()
169    /// );
170    /// ```
171    pub fn new(a: Matrix, b: Matrix, c: Matrix, d: Matrix) -> Result<Self, SymplexError> {
172        let bad = |reason: String| Err(SymplexError::invalid_argument("StateSpace::new", reason));
173        let n = a.nrows();
174        if a.ncols() != n {
175            return bad(format!("A must be square, got {}×{}", n, a.ncols()));
176        }
177        if b.nrows() != n {
178            return bad(format!(
179                "B must have {n} rows (same as A), got {}",
180                b.nrows()
181            ));
182        }
183        if c.ncols() != n {
184            return bad(format!(
185                "C must have {n} columns (same as A), got {}",
186                c.ncols()
187            ));
188        }
189        let (m, p) = (b.ncols(), c.nrows());
190        if d.shape() != (p, m) {
191            return bad(format!(
192                "D must be {p}×{m} (rows of C × columns of B), got {}×{}",
193                d.nrows(),
194                d.ncols()
195            ));
196        }
197        Ok(StateSpace { a, b, c, d })
198    }
199
200    /// State matrix `A` (n×n).
201    pub fn a(&self) -> &Matrix {
202        &self.a
203    }
204
205    /// Input matrix `B` (n×m).
206    pub fn b(&self) -> &Matrix {
207        &self.b
208    }
209
210    /// Output matrix `C` (p×n).
211    pub fn c(&self) -> &Matrix {
212        &self.c
213    }
214
215    /// Feedthrough matrix `D` (p×m).
216    pub fn d(&self) -> &Matrix {
217        &self.d
218    }
219
220    /// Number of states (dimension of the state vector).
221    pub fn num_states(&self) -> usize {
222        self.a.nrows()
223    }
224
225    /// Number of inputs (dimension of the input vector).
226    pub fn num_inputs(&self) -> usize {
227        self.b.ncols()
228    }
229
230    /// Number of outputs (dimension of the output vector).
231    pub fn num_outputs(&self) -> usize {
232        self.c.nrows()
233    }
234
235    /// Poles of the system (eigenvalues of A), repeated with multiplicity.
236    ///
237    /// Returns an empty vector if the eigenvalue solver cannot find any
238    /// root of the characteristic polynomial.
239    pub fn poles(&self) -> Vec<Ex> {
240        self.a.eigenvals().unwrap_or_default()
241    }
242
243    /// Characteristic polynomial: det(sI - A).
244    ///
245    /// Returns a polynomial expression in the given variable `s`, or NaN if
246    /// the determinant cannot be computed — use
247    /// [`try_char_poly`](Self::try_char_poly) for the error.  (`A` is
248    /// square: [`StateSpace::new`] validates it.)
249    pub fn char_poly(&self, s: &Ex) -> Ex {
250        self.try_char_poly(s).unwrap_or_else(|_| self.ctx().nan())
251    }
252
253    /// Characteristic polynomial `det(sI − A)` in the variable `s`.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the determinant cannot be computed.  `A` is
258    /// square by construction, so a shape error cannot occur.
259    pub fn try_char_poly(&self, s: &Ex) -> Result<Ex, SymplexError> {
260        let n = self.num_states();
261        let si = Matrix::identity(&self.ctx(), n)?.scale(s);
262        si.sub(&self.a)?.det()
263    }
264
265    /// Transfer function `G(s) = C (sI − A)⁻¹ B + D` of a single-input,
266    /// single-output system.
267    ///
268    /// The result has denominator `det(sI − A)` and numerator
269    /// `C·adj(sI − A)·B + D·det(sI − A)`, both expanded polynomials in
270    /// `s`; common factors are **not** cancelled.
271    ///
272    /// # Errors
273    ///
274    /// Returns [`SymplexError::InvalidArgument`] if the system is not SISO.
275    ///
276    /// # Examples
277    ///
278    /// ```
279    /// use symplex::prelude::*;
280    ///
281    /// let ctx = Context::new();
282    /// let s = ctx.symbol("s");
283    /// // ẋ = [[0, 1], [-2, -3]] x + [0, 1] u,  y = [1, 0] x
284    /// let ss = StateSpace::new(
285    ///     matrix![ctx, [0, 1], [-2, -3]],
286    ///     matrix![ctx, [0], [1]],
287    ///     matrix![ctx, [1, 0]],
288    ///     matrix![ctx, [0]],
289    /// )
290    /// .unwrap();
291    /// let g = ss.to_transfer_function(&s).unwrap();
292    /// // G(s) = 1 / (s² + 3s + 2)
293    /// assert_eq!(g.num, ctx.int(1));
294    /// assert_eq!(g.den, &s.powi(2) + &s * 3 + 2);
295    /// ```
296    pub fn to_transfer_function(&self, s: &Ex) -> Result<TransferFunction, SymplexError> {
297        if self.num_inputs() != 1 || self.num_outputs() != 1 {
298            return Err(SymplexError::InvalidArgument {
299                operation: "StateSpace::to_transfer_function",
300                reason: format!(
301                    "requires a SISO system, got {} input(s) and {} output(s)",
302                    self.num_inputs(),
303                    self.num_outputs()
304                ),
305            });
306        }
307        let n = self.num_states();
308        let si_minus_a = Matrix::identity(&self.ctx(), n)?.scale(s).sub(&self.a)?;
309        let den = si_minus_a.det()?.expand();
310        let adj = si_minus_a.adjugate()?;
311        let c_adj_b = self.c.matmul(&adj)?.matmul(&self.b)?;
312        let num = (c_adj_b.get(0, 0) + &(self.d.get(0, 0) * &den)).expand();
313        Ok(TransferFunction::new(num, den, s.clone()))
314    }
315
316    /// Controllability matrix: \[B, AB, A²B, ..., Aⁿ⁻¹B\].
317    ///
318    /// For an n-state, m-input system, this is an n×(n·m) matrix.
319    /// The system is controllable if and only if this matrix has rank n.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if a matrix product fails.  The shapes of `A` and
324    /// `B` are validated by [`StateSpace::new`], so a shape error cannot
325    /// occur.
326    pub fn controllability_matrix(&self) -> Result<Matrix, SymplexError> {
327        let n = self.num_states();
328        let mut cols: Vec<Matrix> = vec![self.b.clone()];
329        let mut ab = self.a.matmul(&self.b)?;
330        for _ in 1..n {
331            cols.push(ab.clone());
332            ab = self.a.matmul(&ab)?;
333        }
334        let refs: Vec<&Matrix> = cols.iter().collect();
335        Matrix::hstack(&refs)
336    }
337
338    /// Observability matrix: \[C; CA; CA²; ...; CAⁿ⁻¹\].
339    ///
340    /// For an n-state, p-output system, this is an (n·p)×n matrix.
341    /// The system is observable if and only if this matrix has rank n.
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if a matrix product fails.  The shapes of `A` and
346    /// `C` are validated by [`StateSpace::new`], so a shape error cannot
347    /// occur.
348    pub fn observability_matrix(&self) -> Result<Matrix, SymplexError> {
349        let n = self.num_states();
350        let mut rows: Vec<Matrix> = vec![self.c.clone()];
351        let mut ca = self.c.matmul(&self.a)?;
352        for _ in 1..n {
353            rows.push(ca.clone());
354            ca = ca.matmul(&self.a)?;
355        }
356        let refs: Vec<&Matrix> = rows.iter().collect();
357        Matrix::vstack(&refs)
358    }
359
360    /// Check controllability: rank(controllability_matrix) == n.
361    ///
362    /// Returns `true` if the system is fully state controllable, `false`
363    /// if it is not or the controllability matrix cannot be computed.
364    pub fn is_controllable(&self) -> bool {
365        self.controllability_matrix()
366            .is_ok_and(|m| m.rank() == self.num_states())
367    }
368
369    /// Check observability: rank(observability_matrix) == n.
370    ///
371    /// Returns `true` if the system is fully observable, `false` if it is
372    /// not or the observability matrix cannot be computed.
373    pub fn is_observable(&self) -> bool {
374        self.observability_matrix()
375            .is_ok_and(|m| m.rank() == self.num_states())
376    }
377
378    /// Check stability: all eigenvalues have negative real part.
379    ///
380    /// Returns `Some(true)` if all poles are in the open left half-plane.
381    /// Returns `Some(false)` if any pole can be shown to have non-negative real part.
382    /// Returns `None` if stability cannot be determined symbolically.
383    ///
384    /// Numeric poles are decided by certified evaluation.  When some pole
385    /// is symbolic (or not found), the Routh–Hurwitz test
386    /// [`is_routh_stable`] decides from the coefficients of `det(sI − A)`,
387    /// as SymPy's `get_asymptotic_stability_conditions` does: `A =
388    /// [[−k, 1], [0, −k − 1]]` with `k > 0` is stable (before 0.41 every
389    /// symbolic pole gave `None`).
390    pub fn is_stable(&self) -> Option<bool> {
391        let poles = self.poles();
392        let mut undecided = poles.len() < self.num_states();
393        for pole in &poles {
394            // Try real evaluation first
395            if let Ok(val) = pole.eval_f64() {
396                if val >= 0.0 {
397                    return Some(false);
398                }
399            } else if let Ok(z) = pole.eval_complex64() {
400                if z.re >= 0.0 {
401                    return Some(false);
402                }
403            } else {
404                undecided = true;
405            }
406        }
407        if !undecided {
408            return Some(true);
409        }
410        let s = self.a.fresh_symbol("s");
411        let cp = self.try_char_poly(&s).ok()?.expand();
412        let mut coeffs = poly_coeffs_symbolic(&cp, &s)?;
413        coeffs.reverse();
414        is_routh_stable(&coeffs)
415    }
416
417    /// Discretize using zero-order hold (ZOH).
418    ///
419    /// Aᵈ = eᴬᵈᵗ (matrix exponential)
420    /// Bᵈ = A⁻¹(eᴬᵈᵗ - I)B — computed via series to avoid inverting A.
421    ///
422    /// Uses series approximation for the matrix exponential.
423    /// `dt` is the sampling period (symbolic or numeric).
424    /// `order` is the Taylor series truncation order.
425    ///
426    /// The discrete-time B matrix is computed as:
427    ///   Bᵈ = (I·dt + A·dt²/2! + A²·dt³/3! + ...)B
428    /// which avoids requiring A to be invertible.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if the matrix exponential series or a matrix
433    /// product fails.  The shapes of `A` and `B` are validated by
434    /// [`StateSpace::new`], so a shape error cannot occur.
435    pub fn discretize_zoh(&self, dt: &Ex, order: usize) -> Result<StateSpace, SymplexError> {
436        let n = self.num_states();
437        let a_dt = self.a.scale(dt);
438        let exp_a_dt = a_dt.exp_series(order)?;
439
440        // Bᵈ = (I·dt + A·dt²/2! + A²·dt³/3! + ...)B, the term
441        // Aᵏ⁻¹·dtᵏ/k! built from the previous one (an `i64` k! overflowed
442        // and panicked for order > 20 before 0.41).
443        let ctx = self.ctx();
444        let mut term = Matrix::identity(&ctx, n)?.scale(dt);
445        let mut b_sum = term.clone();
446        for k in 2..=order {
447            let step = dt * &ctx.rational(1, i64::try_from(k).unwrap_or(i64::MAX));
448            term = term.matmul(&self.a)?.scale(&step);
449            b_sum = b_sum.add(&term)?;
450        }
451        let b_d = b_sum.matmul(&self.b)?;
452
453        // Discretisation preserves every shape, so the result is as
454        // well-formed as `self`.
455        Ok(StateSpace {
456            a: exp_a_dt,
457            b: b_d,
458            c: self.c.clone(),
459            d: self.d.clone(),
460        })
461    }
462
463    // ── Advanced control: Riccati & pole placement ─────────────────────
464
465    /// Set up the continuous-time algebraic Riccati equation (CARE).
466    ///
467    /// Returns the residual matrix: AᵀP + PA − PBR⁻¹BᵀP + Q
468    /// which should equal zero when P is the solution.
469    ///
470    /// # Errors
471    ///
472    /// Returns an error if `R` is singular, or if `P` is not n×n, `Q` is not
473    /// n×n or `R` is not m×m.
474    pub fn riccati_residual(
475        &self,
476        p: &Matrix,
477        q: &Matrix,
478        r: &Matrix,
479    ) -> Result<Matrix, SymplexError> {
480        let at = self.a.transpose();
481        let r_inv = r.inv()?;
482        let bt = self.b.transpose();
483
484        let term1 = at.matmul(p)?; // AᵀP
485        let term2 = p.matmul(&self.a)?; // PA
486        let term3 = p.matmul(&self.b)?.matmul(&r_inv)?.matmul(&bt)?.matmul(p)?; // PBR⁻¹BᵀP
487
488        term1.add(&term2)?.sub(&term3)?.add(q)
489    }
490
491    /// Pole placement via Ackermann's formula (single-input systems only).
492    ///
493    /// Given desired pole locations, computes feedback gain K such that
494    /// the eigenvalues of (A − BK) equal the desired poles.
495    ///
496    /// Only works for single-input (m=1) controllable systems.
497    /// The number of desired poles must equal the number of states.
498    ///
499    /// # Errors
500    ///
501    /// Returns [`SymplexError::InvalidArgument`] if the system is not
502    /// single-input or the number of desired poles is not `n`, and
503    /// [`SymplexError::ComputationFailed`] if the system is not controllable.
504    pub fn ackermann(&self, desired_poles: &[Ex]) -> Result<Matrix, SymplexError> {
505        if self.num_inputs() != 1 {
506            return Err(SymplexError::InvalidArgument {
507                operation: "StateSpace::ackermann",
508                reason: format!(
509                    "requires a single-input system, got {} inputs",
510                    self.num_inputs()
511                ),
512            });
513        }
514        let n = self.num_states();
515        if desired_poles.len() != n {
516            return Err(SymplexError::InvalidArgument {
517                operation: "StateSpace::ackermann",
518                reason: format!("need {n} desired poles, got {}", desired_poles.len()),
519            });
520        }
521
522        let ctrb = self.controllability_matrix()?;
523        if ctrb.rank() != n {
524            return Err(SymplexError::ComputationFailed {
525                operation: "StateSpace::ackermann",
526                reason: "system is not controllable".into(),
527            });
528        }
529        let ctrb_inv = ctrb.inv()?;
530
531        // Build the desired characteristic polynomial:
532        // p(s) = (s − p₁)(s − p₂)···(s − pₙ)
533        // poly_coeffs[0] is the leading coefficient (1),
534        // poly_coeffs[k] is the coefficient of s^(n-k).
535        let mut poly_coeffs: Vec<Ex> = vec![self.ctx().int(1)];
536        for pole in desired_poles {
537            let neg_pole = -(pole.clone());
538            let prev = poly_coeffs;
539            poly_coeffs = vec![self.ctx().int(0); prev.len() + 1];
540            for (i, c) in prev.into_iter().enumerate() {
541                let c_neg_pole = &c * &neg_pole;
542                poly_coeffs[i] = poly_coeffs[i].clone() + c;
543                poly_coeffs[i + 1] = poly_coeffs[i + 1].clone() + c_neg_pole;
544            }
545        }
546
547        // Evaluate p(A) = poly_coeffs[0]·Aⁿ + poly_coeffs[1]·Aⁿ⁻¹ + ··· + poly_coeffs[n]·I
548        let mut p_a = Matrix::zeros(&self.ctx(), n, n)?;
549        for (i, coeff) in poly_coeffs.iter().enumerate() {
550            let power = (poly_coeffs.len() - 1 - i) as u32;
551            let a_power = self.a.powi(power)?;
552            p_a = p_a.add(&a_power.scale(coeff))?;
553        }
554
555        // K = eₙᵀ · C⁻¹ · p(A)
556        // where eₙᵀ is the last standard basis row vector,
557        // so eₙᵀ · C⁻¹ is the last row of C⁻¹.
558        let last_row: Vec<Ex> = (0..n).map(|j| ctrb_inv.get(n - 1, j).clone()).collect();
559        let last_row_mat = Matrix::new(vec![last_row])?; // 1×n
560
561        last_row_mat.matmul(&p_a) // 1×n
562    }
563}
564
565impl std::fmt::Display for StateSpace {
566    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
567        write!(
568            f,
569            "StateSpace(n={}, m={}, p={})\nA = {}\nB = {}\nC = {}\nD = {}",
570            self.num_states(),
571            self.num_inputs(),
572            self.num_outputs(),
573            self.a,
574            self.b,
575            self.c,
576            self.d
577        )
578    }
579}
580
581// ═══════════════════════════════════════════════════════════════════════════
582// Transfer Function
583// ═══════════════════════════════════════════════════════════════════════════
584
585/// A transfer function G(s) = num(s) / den(s).
586///
587/// Numerator and denominator are represented as symbolic expressions
588/// in the Laplace variable s. This representation supports symbolic
589/// manipulation: series/parallel connections, feedback loops, pole/zero
590/// analysis, and evaluation.
591#[derive(Clone, Debug)]
592pub struct TransferFunction {
593    /// Numerator polynomial in s.
594    pub num: Ex,
595    /// Denominator polynomial in s.
596    pub den: Ex,
597    /// The Laplace variable (typically "s").
598    pub var: Ex,
599}
600
601impl TransferFunction {
602    /// Extract a [`Context`] from this transfer function's numerator.
603    fn ctx(&self) -> crate::api::context::Context {
604        self.num.context()
605    }
606
607    /// Create a new transfer function G(s) = num / den.
608    ///
609    /// # Arguments
610    ///
611    /// * `num` - Numerator polynomial expression
612    /// * `den` - Denominator polynomial expression
613    /// * `var` - The Laplace variable (e.g., `Context::new().symbol("s")`)
614    pub fn new(num: Ex, den: Ex, var: Ex) -> Self {
615        TransferFunction { num, den, var }
616    }
617
618    /// Create a transfer function from coefficient slices.
619    ///
620    /// Coefficients are in ascending power order:
621    /// `coeffs[0] + coeffs[1]*s + coeffs[2]*s² + ...`
622    ///
623    /// # Example
624    ///
625    /// ```
626    /// use symplex::prelude::*;
627    /// use symplex::control::TransferFunction;
628    /// let ctx = Context::new();
629    /// let s = ctx.symbol("s");
630    /// // G(s) = 1 / (s² + 3s + 2)
631    /// let g = TransferFunction::from_coeffs(&[1], &[2, 3, 1], &s);
632    /// ```
633    pub fn from_coeffs(num_coeffs: &[i64], den_coeffs: &[i64], var: &Ex) -> Self {
634        let ctx = var.context();
635        let build_poly = |coeffs: &[i64]| -> Ex {
636            let mut result = ctx.int(0);
637            for (i, &c) in coeffs.iter().enumerate() {
638                if c != 0 {
639                    let term = if i == 0 {
640                        ctx.int(c)
641                    } else {
642                        &ctx.int(c) * &var.powi(i as i64)
643                    };
644                    result = &result + &term;
645                }
646            }
647            result
648        };
649        Self::new(build_poly(num_coeffs), build_poly(den_coeffs), var.clone())
650    }
651
652    /// Poles: roots of the denominator polynomial, repeated with
653    /// multiplicity (as SymPy's `TransferFunction.poles()`; common factors
654    /// with the numerator are **not** cancelled).
655    ///
656    /// `1/(s + 1)²` has the poles `[−1, −1]`.  (Before 0.41 every
657    /// repeated pole was listed once, unlike [`StateSpace::poles`].)
658    ///
659    /// The list is empty both for a constant denominator and when the poles
660    /// are not all known — a denominator that is not a polynomial in the
661    /// variable, a factor without roots in closed form, multiplicities not
662    /// found; it is never a part of them (up to 0.48 it could be: the
663    /// solver's distinct roots, or principal roots of a transcendental
664    /// denominator).  [`try_poles`](Self::try_poles) tells the cases apart.
665    ///
666    /// # Examples
667    ///
668    /// ```
669    /// use symplex::prelude::*;
670    ///
671    /// let ctx = Context::new();
672    /// let s = ctx.symbol("s");
673    /// let g = TransferFunction::new(ctx.int(1), (&s + 1).powi(2), s.clone());
674    /// assert_eq!(g.poles(), vec![ctx.int(-1), ctx.int(-1)]);
675    /// ```
676    pub fn poles(&self) -> Vec<Ex> {
677        self.try_poles().unwrap_or_default()
678    }
679
680    /// [`poles`](Self::poles), or the error that says why they are not all
681    /// known.
682    ///
683    /// # Errors
684    ///
685    /// [`SymplexError::ComputationFailed`] when the denominator is not a
686    /// polynomial in the variable, or its roots are not all found with
687    /// their multiplicities; [`SymplexError::InfiniteSolutions`] for the
688    /// zero denominator.
689    ///
690    /// # Examples
691    ///
692    /// ```
693    /// use symplex::prelude::*;
694    ///
695    /// let ctx = Context::new();
696    /// let s = ctx.symbol("s");
697    /// let g = TransferFunction::new(ctx.int(1), &s.powi(2) + 1, s.clone());
698    /// assert_eq!(g.try_poles().unwrap().len(), 2);
699    /// // no poles at all
700    /// let k = TransferFunction::new(ctx.int(1), ctx.int(2), s.clone());
701    /// assert!(k.try_poles().unwrap().is_empty());
702    /// // infinitely many poles: refused (`poles` is empty)
703    /// let d = TransferFunction::new(ctx.int(1), &s.exp() + 1, s.clone());
704    /// assert!(d.try_poles().is_err());
705    /// assert!(d.poles().is_empty());
706    /// ```
707    pub fn try_poles(&self) -> Result<Vec<Ex>, SymplexError> {
708        try_roots_with_multiplicity(&self.den, &self.var, "TransferFunction::try_poles")
709    }
710
711    /// Zeros: roots of the numerator polynomial, repeated with
712    /// multiplicity (as SymPy's `TransferFunction.zeros()`).
713    ///
714    /// The list is empty both for a constant numerator and when the zeros
715    /// are not all known, never a part of them, as for
716    /// [`poles`](Self::poles); [`try_zeros`](Self::try_zeros) tells the
717    /// cases apart.
718    pub fn zeros(&self) -> Vec<Ex> {
719        self.try_zeros().unwrap_or_default()
720    }
721
722    /// [`zeros`](Self::zeros), or the error that says why they are not all
723    /// known.
724    ///
725    /// # Errors
726    ///
727    /// As [`try_poles`](Self::try_poles), for the numerator.
728    ///
729    /// # Examples
730    ///
731    /// ```
732    /// use symplex::prelude::*;
733    ///
734    /// let ctx = Context::new();
735    /// let s = ctx.symbol("s");
736    /// let g = TransferFunction::new(&s - 3, (&s + 1).powi(2), s.clone());
737    /// assert_eq!(g.try_zeros().unwrap(), vec![ctx.int(3)]);
738    /// ```
739    pub fn try_zeros(&self) -> Result<Vec<Ex>, SymplexError> {
740        try_roots_with_multiplicity(&self.num, &self.var, "TransferFunction::try_zeros")
741    }
742
743    /// DC gain: `lim_{s→0} G(s)` (SymPy: `limit(G, s, 0)`).
744    ///
745    /// The steady-state gain for a step input: `num(0)/den(0)`, complex
746    /// infinity `zoo` for a pole at the origin, and for a pole-zero
747    /// cancellation at the origin (`num(0) = den(0) = 0`) the ratio of the
748    /// lowest-order coefficients.  (Before 0.41 the cancellation gave
749    /// `num(0)/den(0) = 0/0 = nan`: `s/(s² + s)` had DC gain `nan`, not 1.)
750    ///
751    /// # Examples
752    ///
753    /// ```
754    /// use symplex::prelude::*;
755    ///
756    /// let ctx = Context::new();
757    /// let s = ctx.symbol("s");
758    /// let g = TransferFunction::from_coeffs(&[0, 1], &[0, 1, 1], &s); // s/(s² + s)
759    /// assert_eq!(g.dc_gain(), ctx.int(1));
760    /// ```
761    pub fn dc_gain(&self) -> Ex {
762        let ctx = self.ctx();
763        let zero = ctx.int(0);
764        let num_0 = self.num.subs(&self.var, &zero).eval();
765        let den_0 = self.den.subs(&self.var, &zero).eval();
766        if !(is_exact_zero(&num_0) && is_exact_zero(&den_0)) {
767            return &num_0 / &den_0;
768        }
769        // 0/0: cancel the common power of s through the lowest-order
770        // nonzero coefficients.
771        if let (Some(n), Some(d)) = (
772            poly_coeffs_symbolic(&self.num, &self.var),
773            poly_coeffs_symbolic(&self.den, &self.var),
774        ) {
775            let order = |c: &[Ex]| c.iter().position(|e| !is_exact_zero(e));
776            return match (order(&n), order(&d)) {
777                (None, Some(_)) => zero, // G = 0
778                (Some(i), Some(j)) if i > j => zero,
779                (Some(i), Some(j)) if i < j => ctx.complex_infinity(),
780                (Some(i), Some(j)) => (&n[i] / &d[j]).eval(),
781                (_, None) => ctx.nan(), // zero denominator
782            };
783        }
784        (&self.num / &self.den).limit(&self.var, &zero)
785    }
786
787    /// `other`'s numerator and denominator in `self`'s Laplace variable.
788    ///
789    /// Each transfer function is a function of its own variable, so an
790    /// `other` written in `p` is renamed to `s` before the two are combined
791    /// (before 0.41 the `p` stayed in the result as a constant).
792    fn other_in_var(&self, other: &TransferFunction) -> (Ex, Ex) {
793        if other.var == self.var {
794            (other.num.clone(), other.den.clone())
795        } else {
796            (
797                other.num.subs(&other.var, &self.var),
798                other.den.subs(&other.var, &self.var),
799            )
800        }
801    }
802
803    /// Series connection: G₁(s) · G₂(s).
804    ///
805    /// The series (cascade) connection multiplies the transfer functions:
806    /// G_series = G₁ · G₂ = (num₁·num₂) / (den₁·den₂)
807    ///
808    /// The result is in `self`'s Laplace variable; if `other` uses another
809    /// one it is renamed (as for [`parallel`](Self::parallel) and
810    /// [`feedback_with`](Self::feedback_with)).
811    pub fn series(&self, other: &TransferFunction) -> TransferFunction {
812        let (num2, den2) = self.other_in_var(other);
813        TransferFunction {
814            num: &self.num * &num2,
815            den: &self.den * &den2,
816            var: self.var.clone(),
817        }
818    }
819
820    /// Parallel connection: G₁(s) + G₂(s).
821    ///
822    /// The parallel connection adds the transfer functions:
823    /// G_parallel = (num₁·den₂ + num₂·den₁) / (den₁·den₂)
824    pub fn parallel(&self, other: &TransferFunction) -> TransferFunction {
825        let (num2, den2) = self.other_in_var(other);
826        TransferFunction {
827            num: &(&self.num * &den2) + &(&num2 * &self.den),
828            den: &self.den * &den2,
829            var: self.var.clone(),
830        }
831    }
832
833    /// Negative unity feedback: G(s) / (1 + G(s)).
834    ///
835    /// The closed-loop transfer function with unity negative feedback:
836    /// G_cl = num / (den + num)
837    pub fn feedback(&self) -> TransferFunction {
838        TransferFunction {
839            num: self.num.clone(),
840            den: &self.den + &self.num,
841            var: self.var.clone(),
842        }
843    }
844
845    /// Feedback with controller H(s): G(s) / (1 + G(s)·H(s)).
846    ///
847    /// The closed-loop transfer function with feedback element H(s):
848    /// G_cl = (num_G · den_H) / (den_G · den_H + num_G · num_H)
849    pub fn feedback_with(&self, h: &TransferFunction) -> TransferFunction {
850        let (h_num, h_den) = self.other_in_var(h);
851        TransferFunction {
852            num: &self.num * &h_den,
853            den: &(&self.den * &h_den) + &(&self.num * &h_num),
854            var: self.var.clone(),
855        }
856    }
857
858    /// Evaluate the transfer function at a specific value of s.
859    ///
860    /// Substitutes `s_val` for the Laplace variable and evaluates:
861    /// G(s_val) = num(s_val) / den(s_val)
862    pub fn eval_at(&self, s_val: &Ex) -> Ex {
863        let num = self.num.subs(&self.var, s_val).eval();
864        let den = self.den.subs(&self.var, s_val).eval();
865        &num / &den
866    }
867
868    /// State-space realisation in **controllable canonical form**.
869    ///
870    /// For a proper transfer function with monic-normalised denominator
871    /// `sⁿ + aₙ₋₁sⁿ⁻¹ + … + a₀` and numerator `bₙsⁿ + … + b₀`:
872    ///
873    /// ```text
874    /// A = [ 0    1    0  …  0   ]   B = [0]   C = [b₀−a₀bₙ, …, bₙ₋₁−aₙ₋₁bₙ]   D = [bₙ]
875    ///     [ 0    0    1  …  0   ]       [0]
876    ///     [ …                  ]       […]
877    ///     [−a₀ −a₁ −a₂ … −aₙ₋₁]       [1]
878    /// ```
879    ///
880    /// Coefficients may be symbolic.
881    ///
882    /// # Errors
883    ///
884    /// Returns [`SymplexError::InvalidArgument`] if numerator or denominator
885    /// is not a polynomial in the Laplace variable, the denominator is
886    /// constant, or the transfer function is improper (`deg num > deg den`).
887    ///
888    /// # Examples
889    ///
890    /// ```
891    /// use symplex::prelude::*;
892    ///
893    /// let ctx = Context::new();
894    /// let s = ctx.symbol("s");
895    /// let g = TransferFunction::from_coeffs(&[1], &[2, 3, 1], &s); // 1/(s²+3s+2)
896    /// let ss = g.to_state_space().unwrap();
897    /// assert_eq!(ss.a(), &matrix![ctx, [0, 1], [-2, -3]]);
898    /// assert_eq!(ss.b(), &matrix![ctx, [0], [1]]);
899    /// assert_eq!(ss.c(), &matrix![ctx, [1, 0]]);
900    /// // Round trip
901    /// let back = ss.to_transfer_function(&s).unwrap();
902    /// assert_eq!(back.den, g.den.expand());
903    /// assert_eq!(back.num, g.num);
904    /// ```
905    pub fn to_state_space(&self) -> Result<StateSpace, SymplexError> {
906        let ctx = self.ctx();
907        let inv = |reason: String| SymplexError::InvalidArgument {
908            operation: "TransferFunction::to_state_space",
909            reason,
910        };
911        let den = poly_coeffs_symbolic(&self.den, &self.var)
912            .ok_or_else(|| inv("denominator is not a polynomial in the Laplace variable".into()))?;
913        let num = poly_coeffs_symbolic(&self.num, &self.var)
914            .ok_or_else(|| inv("numerator is not a polynomial in the Laplace variable".into()))?;
915        let n = den.len().saturating_sub(1);
916        if n == 0 {
917            return Err(inv("denominator must have degree ≥ 1".into()));
918        }
919        if num.len() > den.len() {
920            return Err(inv(format!(
921                "improper transfer function: numerator degree {} > denominator degree {}",
922                num.len() - 1,
923                n
924            )));
925        }
926        // Normalise so the denominator is monic.
927        let lead = &den[n];
928        let a_coef: Vec<Ex> = den.iter().take(n).map(|c| (c / lead).eval()).collect();
929        let mut b_coef: Vec<Ex> = num.iter().map(|c| (c / lead).eval()).collect();
930        b_coef.resize(n + 1, ctx.zero());
931        let bn = b_coef[n].clone();
932
933        let a = Matrix::from_fn(n, n, |i, j| {
934            if i + 1 < n {
935                if j == i + 1 { ctx.one() } else { ctx.zero() }
936            } else {
937                -&a_coef[j]
938            }
939        })?;
940        let b = Matrix::from_fn(n, 1, |i, _| if i + 1 == n { ctx.one() } else { ctx.zero() })?;
941        let c = Matrix::from_fn(1, n, |_, j| (&b_coef[j] - &(&a_coef[j] * &bn)).eval())?;
942        let d = Matrix::new(vec![vec![bn]])?;
943        StateSpace::new(a, b, c, d)
944    }
945}
946
947impl std::fmt::Display for TransferFunction {
948    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
949        write!(f, "({}) / ({})", self.num, self.den)
950    }
951}
952
953// ═══════════════════════════════════════════════════════════════════════════
954// Routh-Hurwitz Stability Criterion
955// ═══════════════════════════════════════════════════════════════════════════
956
957/// Compute the Routh array for stability analysis.
958///
959/// Given coefficients of a characteristic polynomial ordered from
960/// highest degree to lowest:
961///
962///   aₙsⁿ + aₙ₋₁sⁿ⁻¹ + ... + a₁s + a₀
963///
964/// passed as `coeffs = [aₙ, aₙ₋₁, ..., a₁, a₀]`,
965/// this function builds the Routh array and returns it as a vector of rows.
966///
967/// The first two rows are formed from the even-indexed and odd-indexed
968/// coefficients respectively. Subsequent rows are computed as:
969///
970/// ```text
971/// routh[i][j] = (routh[i-1][0] * routh[i-2][j+1] - routh[i-2][0] * routh[i-1][j+1]) / routh[i-1][0]
972/// ```
973///
974/// Leading zero coefficients are dropped (they do not change the
975/// polynomial).  Zero tests are exact.  The two special cases follow the
976/// textbook rules:
977///
978/// * a row that vanishes entirely is replaced by the coefficients of the
979///   derivative of the auxiliary polynomial formed from the row above it
980///   (roots symmetric about the origin, e.g. on the imaginary axis);
981/// * a zero first entry in an otherwise nonzero row is replaced by the
982///   symbol `ε` (primed if the coefficients already use that name), a
983///   positive infinitesimal: the entries below are rational functions of
984///   `ε` whose signs are read as `ε → 0⁺`.
985///
986/// (Before 0.30 zeros were detected with an `f64` threshold of `10⁻³⁰`
987/// and replaced by the number `10⁻⁹`, which gave wrong sign patterns when
988/// the coefficients were small, and leading zeros were kept.)
989///
990/// # Errors
991///
992/// Returns [`SymplexError::InvalidArgument`] if `coeffs` is empty or all
993/// coefficients are zero.
994///
995/// # Examples
996///
997/// ```
998/// use symplex::prelude::*;
999/// use symplex::control::routh_array;
1000///
1001/// let ctx = Context::new();
1002/// // s² + 3s + 2
1003/// let table = routh_array(&[ctx.int(1), ctx.int(3), ctx.int(2)]).unwrap();
1004/// assert_eq!(table.len(), 3);
1005/// assert!(routh_array(&[]).is_err());
1006/// ```
1007pub fn routh_array(coeffs: &[Ex]) -> Result<Vec<Vec<Ex>>, SymplexError> {
1008    routh_table(coeffs).map(|t| t.rows)
1009}
1010
1011/// The Routh table and whether a special case occurred in it.
1012struct RouthTable {
1013    rows: Vec<Vec<Ex>>,
1014    /// A zero first entry, or an entirely zero row: the polynomial has a
1015    /// root with non-negative real part.
1016    degenerate: bool,
1017}
1018
1019fn is_exact_zero(e: &Ex) -> bool {
1020    crate::domains::matrix::ex_is_zero(e).is_true()
1021}
1022
1023fn routh_table(coeffs: &[Ex]) -> Result<RouthTable, SymplexError> {
1024    if coeffs.is_empty() {
1025        return Err(SymplexError::invalid_argument(
1026            "routh_array",
1027            "coefficients must not be empty",
1028        ));
1029    }
1030    // Leading zeros do not change the polynomial.
1031    let first_nonzero = coeffs.iter().position(|c| !is_exact_zero(c));
1032    let Some(start) = first_nonzero else {
1033        return Err(SymplexError::invalid_argument(
1034            "routh_array",
1035            "the zero polynomial has no Routh array",
1036        ));
1037    };
1038    let coeffs = &coeffs[start..];
1039    let lead = &coeffs[0];
1040    let ctx = lead.context();
1041
1042    let n = coeffs.len();
1043    if n == 1 {
1044        return Ok(RouthTable {
1045            rows: vec![vec![lead.clone()]],
1046            degenerate: false,
1047        });
1048    }
1049
1050    // Number of columns in the Routh table
1051    let num_cols = n.div_ceil(2);
1052
1053    // Build first row: even-indexed coefficients (a_n, a_{n-2}, a_{n-4}, ...)
1054    let mut row0: Vec<Ex> = Vec::with_capacity(num_cols);
1055    for i in (0..n).step_by(2) {
1056        row0.push(coeffs[i].clone());
1057    }
1058    // Pad with zeros if needed
1059    while row0.len() < num_cols {
1060        row0.push(coeffs[0].context().int(0));
1061    }
1062
1063    // Build second row: odd-indexed coefficients (a_{n-1}, a_{n-3}, ...)
1064    let mut row1: Vec<Ex> = Vec::with_capacity(num_cols);
1065    for i in (1..n).step_by(2) {
1066        row1.push(coeffs[i].clone());
1067    }
1068    // Pad with zeros if needed
1069    while row1.len() < num_cols {
1070        row1.push(coeffs[0].context().int(0));
1071    }
1072
1073    let mut table: Vec<Vec<Ex>> = vec![row0, row1];
1074    let mut degenerate = false;
1075    // The infinitesimal of the ε method, named apart from the input's symbols.
1076    let epsilon = {
1077        let used: Vec<String> = coeffs
1078            .iter()
1079            .flat_map(|c| c.free_symbols())
1080            .map(|s| s.to_string())
1081            .collect();
1082        let mut name = String::from("ε");
1083        while used.contains(&name) {
1084            name.push('\'');
1085        }
1086        ctx.symbol(&name)
1087    };
1088
1089    // Build subsequent rows (row i belongs to the power s^(n-1-i)).
1090    let total_rows = n;
1091    for i in 2..=total_rows {
1092        // Special cases in row i − 1 (the row about to become the pivot).
1093        let last = i - 1;
1094        if table[last].iter().all(is_exact_zero) {
1095            // Auxiliary polynomial A(s) = Σₖ row[k]·s^(d−2k) of the row
1096            // above, d = n − 1 − (last − 1); replace the zero row by A′.
1097            degenerate = true;
1098            let d = (n - 1 - (last - 1)) as i64;
1099            let above = table[last - 1].clone();
1100            let deriv: Vec<Ex> = above
1101                .iter()
1102                .enumerate()
1103                .map(|(k, c)| (c * (d - 2 * k as i64)).eval())
1104                .collect();
1105            table[last] = deriv;
1106        }
1107        if is_exact_zero(&table[last][0]) {
1108            degenerate = true;
1109            table[last][0] = epsilon.clone();
1110        }
1111        if i == total_rows {
1112            break;
1113        }
1114        let prev = &table[i - 1];
1115        let prev2 = &table[i - 2];
1116        let pivot = prev[0].clone();
1117
1118        let mut new_row: Vec<Ex> = Vec::with_capacity(num_cols);
1119        for j in 0..(num_cols - 1) {
1120            let prev2_j1 = if j + 1 < prev2.len() {
1121                prev2[j + 1].clone()
1122            } else {
1123                coeffs[0].context().int(0)
1124            };
1125            let prev_j1 = if j + 1 < prev.len() {
1126                prev[j + 1].clone()
1127            } else {
1128                coeffs[0].context().int(0)
1129            };
1130            // routh[i][j] = (pivot * prev2[j+1] - prev2[0] * prev[j+1]) / pivot
1131            let numerator = &(&pivot * &prev2_j1) - &(&prev2[0] * &prev_j1);
1132            let mut entry = (&numerator / &pivot).eval();
1133            if entry.contains(&epsilon) {
1134                entry = entry.ratsimp();
1135            }
1136            new_row.push(entry);
1137        }
1138        // Last column is always zero (or not needed), pad if row is too short
1139        if new_row.is_empty() {
1140            new_row.push(coeffs[0].context().int(0));
1141        }
1142        table.push(new_row);
1143    }
1144
1145    Ok(RouthTable {
1146        rows: table,
1147        degenerate,
1148    })
1149}
1150
1151/// Check Routh-Hurwitz stability: all first-column entries must be positive
1152/// (or all negative) for stability.
1153///
1154/// Given coefficients of a characteristic polynomial from highest degree
1155/// to lowest (`coeffs = [aₙ, aₙ₋₁, ..., a₁, a₀]`), builds the Routh
1156/// array and checks for sign changes in the first column.
1157///
1158/// Returns `Some(true)` if stable (no sign changes), `Some(false)` if
1159/// unstable (sign changes, or a zero first-column entry / zero row, which
1160/// means a root with non-negative real part), or `None` if stability cannot
1161/// be determined symbolically.  Leading zero coefficients are ignored; an
1162/// empty or all-zero coefficient list is `Some(false)`.
1163///
1164/// A coefficient that is provably zero or of the opposite sign to the
1165/// leading one is `Some(false)` before the table is consulted (a Hurwitz
1166/// polynomial has all its coefficients of one sign); this decides symbolic
1167/// coefficients whose table entries are undecidable: `s³ + (k − 1)·s² −
1168/// k·s + 1` with `k > 0` (before 0.41 `None`: the sign of `k − 1` is
1169/// unknown).
1170pub fn is_routh_stable(coeffs: &[Ex]) -> Option<bool> {
1171    // The only errors are an empty or zero coefficient list: not stable.
1172    let Ok(table) = routh_table(coeffs) else {
1173        return Some(false);
1174    };
1175    // Necessary condition: a Hurwitz polynomial has all its coefficients
1176    // nonzero and of the leading coefficient's sign (see above).
1177    let mut nonzero = coeffs.iter().skip_while(|c| is_exact_zero(c));
1178    if let Some(lead) = nonzero.next()
1179        && crate::domains::matrix::ex_is_zero(lead).is_false()
1180        && let lead_positive @ (Truth::True | Truth::False) =
1181            crate::domains::matrix::ex_is_positive(lead)
1182    {
1183        for c in nonzero {
1184            let wrong_sign = crate::domains::matrix::ex_is_positive(c) == lead_positive.not()
1185                && crate::domains::matrix::ex_is_zero(c).is_false();
1186            if is_exact_zero(c) || wrong_sign {
1187                return Some(false);
1188            }
1189        }
1190    }
1191    if table.degenerate {
1192        return Some(false);
1193    }
1194
1195    // All first-column entries must have the same sign; the signs are
1196    // decided exactly (certified evaluation), not by an f64 threshold.
1197    let mut first_sign: Option<bool> = None;
1198    for row in &table.rows {
1199        let entry = &row[0];
1200        // `None`: can't decide symbolically.
1201        let positive = match crate::domains::matrix::ex_is_positive(entry) {
1202            Truth::True => true,
1203            Truth::False => false,
1204            Truth::Unknown => return None,
1205        };
1206        if !positive && !crate::domains::matrix::ex_is_zero(entry).is_false() {
1207            return None;
1208        }
1209        match first_sign {
1210            None => first_sign = Some(positive),
1211            Some(s) if s != positive => return Some(false), // Sign change → unstable
1212            Some(_) => {}
1213        }
1214    }
1215    Some(true)
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use super::*;
1221
1222    #[test]
1223    fn state_space_basic_construction() {
1224        let ctx = crate::api::context::Context::new();
1225        let a = Matrix::new(vec![
1226            vec![ctx.int(0), ctx.int(1)],
1227            vec![ctx.int(-2), ctx.int(-3)],
1228        ])
1229        .unwrap();
1230        let b = Matrix::new(vec![vec![ctx.int(0)], vec![ctx.int(1)]]).unwrap();
1231        let c = Matrix::new(vec![vec![ctx.int(1), ctx.int(0)]]).unwrap();
1232        let d = Matrix::new(vec![vec![ctx.int(0)]]).unwrap();
1233        let ss = StateSpace::new(a, b, c, d).unwrap();
1234        assert_eq!(ss.num_states(), 2);
1235        assert_eq!(ss.num_inputs(), 1);
1236        assert_eq!(ss.num_outputs(), 1);
1237    }
1238
1239    #[test]
1240    fn transfer_function_basic_display() {
1241        let ctx = crate::api::context::Context::new();
1242        let s = ctx.symbol("s");
1243        let tf = TransferFunction::new(ctx.int(1), &s * &s + &s * 3 + 2, s);
1244        let display = format!("{tf}");
1245        assert!(
1246            display.contains("/"),
1247            "Display should show fraction: {display}"
1248        );
1249    }
1250
1251    #[test]
1252    fn routh_array_row_count() {
1253        let ctx = crate::api::context::Context::new();
1254        // s^2 + 3s + 2 → 3 coefficients → 3 rows
1255        let coeffs = vec![ctx.int(1), ctx.int(3), ctx.int(2)];
1256        let table = routh_array(&coeffs).unwrap();
1257        assert_eq!(table.len(), 3);
1258    }
1259}