1#![forbid(unsafe_code)]
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Stability {
19 Stable,
20 Unstable,
21 Marginal,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum StabilityError {
26 InvalidGain,
27 InvalidLimit,
28 InvalidValues,
29 InvalidBound,
30 InvalidTarget,
31 InvalidTolerance,
32 InvalidTimestep,
33}
34
35pub fn classify_gain(gain: f64, stable_limit: f64) -> Result<Stability, StabilityError> {
36 if !gain.is_finite() {
37 return Err(StabilityError::InvalidGain);
38 }
39
40 if !stable_limit.is_finite() || stable_limit <= 0.0 {
41 return Err(StabilityError::InvalidLimit);
42 }
43
44 let magnitude = gain.abs();
45 if magnitude < stable_limit {
46 Ok(Stability::Stable)
47 } else if magnitude > stable_limit {
48 Ok(Stability::Unstable)
49 } else {
50 Ok(Stability::Marginal)
51 }
52}
53
54pub fn is_bounded(values: &[f64], bound: f64) -> Result<bool, StabilityError> {
55 if !bound.is_finite() || bound <= 0.0 {
56 return Err(StabilityError::InvalidBound);
57 }
58
59 if values.iter().any(|value| !value.is_finite()) {
60 return Err(StabilityError::InvalidValues);
61 }
62
63 Ok(values.iter().all(|value| value.abs() <= bound))
64}
65
66pub fn settling_time(
67 values: &[f64],
68 target: f64,
69 tolerance: f64,
70 dt: f64,
71) -> Result<Option<f64>, StabilityError> {
72 if !target.is_finite() {
73 return Err(StabilityError::InvalidTarget);
74 }
75
76 if !tolerance.is_finite() || tolerance < 0.0 {
77 return Err(StabilityError::InvalidTolerance);
78 }
79
80 if !dt.is_finite() || dt <= 0.0 {
81 return Err(StabilityError::InvalidTimestep);
82 }
83
84 if values.iter().any(|value| !value.is_finite()) {
85 return Err(StabilityError::InvalidValues);
86 }
87
88 for start_index in 0..values.len() {
89 if values[start_index..]
90 .iter()
91 .all(|value| within_tolerance(*value, target, tolerance))
92 {
93 return Ok(Some(start_index as f64 * dt));
94 }
95 }
96
97 Ok(None)
98}
99
100fn within_tolerance(value: f64, target: f64, tolerance: f64) -> bool {
101 let scale = 1.0_f64
102 .max(value.abs())
103 .max(target.abs())
104 .max(tolerance.abs());
105
106 (value - target).abs() <= tolerance + f64::EPSILON * scale * 8.0
107}
108
109#[cfg(test)]
110mod tests {
111 use super::{Stability, StabilityError, classify_gain, is_bounded, settling_time};
112
113 #[test]
114 fn classifies_gain_magnitude() {
115 assert_eq!(classify_gain(0.8, 1.0).unwrap(), Stability::Stable);
116 assert_eq!(classify_gain(1.0, 1.0).unwrap(), Stability::Marginal);
117 assert_eq!(classify_gain(1.2, 1.0).unwrap(), Stability::Unstable);
118 }
119
120 #[test]
121 fn detects_bounded_series() {
122 assert!(is_bounded(&[0.5, -0.5, 0.25], 1.0).unwrap());
123 assert!(!is_bounded(&[0.5, 1.5], 1.0).unwrap());
124 }
125
126 #[test]
127 fn computes_settling_time_when_response_stays_within_tolerance() {
128 let time = settling_time(&[1.4, 1.05, 1.01, 1.0], 1.0, 0.05, 0.5).unwrap();
129 assert_eq!(time, Some(0.5));
130
131 let no_settle = settling_time(&[1.4, 1.2, 1.1], 1.0, 0.05, 0.5).unwrap();
132 assert_eq!(no_settle, None);
133 }
134
135 #[test]
136 fn rejects_invalid_inputs() {
137 assert_eq!(
138 classify_gain(f64::NAN, 1.0),
139 Err(StabilityError::InvalidGain)
140 );
141 assert_eq!(is_bounded(&[1.0], 0.0), Err(StabilityError::InvalidBound));
142 assert_eq!(
143 settling_time(&[1.0], 1.0, -0.1, 0.5),
144 Err(StabilityError::InvalidTolerance)
145 );
146 assert_eq!(
147 settling_time(&[1.0], 1.0, 0.1, 0.0),
148 Err(StabilityError::InvalidTimestep)
149 );
150 }
151}