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}