Skip to main content

robust_pid/
lib.rs

1//! # Robust PID Controller for Critical Vehicle Systems
2//!
3//! A production-grade PID controller designed for aerospace, automotive, and other
4//! safety-critical applications. Features comprehensive safety mechanisms, diagnostics,
5//! and real-time adaptability.
6//!
7//! ## Key Features
8//! - **Anti-windup protection** with multiple strategies
9//! - **Derivative filtering** to reduce noise sensitivity
10//! - **Setpoint ramping** to prevent aggressive changes
11//! - **Safety monitoring** with error detection
12//! - **Variable time step** support for real-time systems
13//! - **Bumpless transfer** when changing modes
14//! - **Output rate limiting** for actuator protection
15//! - **Comprehensive diagnostics** for system analysis
16
17#![cfg_attr(not(feature = "std"), no_std)]
18
19#[cfg(feature = "serde")]
20use serde::{Deserialize, Serialize};
21
22use core::fmt;
23use num_traits::Float;
24
25/// Safety status returned by the controller
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
28pub enum SafetyStatus {
29    /// Normal operation
30    Normal,
31    /// Output is saturated (at limit)
32    OutputSaturated,
33    /// Integral term is saturated
34    IntegralWindup,
35    /// Derivative term is noisy/unstable
36    DerivativeNoisy,
37    /// Time step is too large
38    TimeStepExcessive,
39    /// Multiple safety issues detected
40    MultipleFaults,
41}
42
43/// Anti-windup strategy
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
46pub enum AntiWindupMode {
47    /// Clamp integral when output saturates
48    Clamping,
49    /// Back-calculate integral when saturated (recommended)
50    BackCalculation,
51    /// Conditional integration (don't integrate when saturated)
52    Conditional,
53}
54
55/// Derivative filtering mode
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
58pub enum DerivativeMode {
59    /// No filtering (not recommended for noisy systems)
60    None,
61    /// First-order low-pass filter (recommended)
62    LowPass,
63    /// Simple moving average (fixed alpha)
64    SimpleMovingAverage,
65}
66
67/// Configuration for the PID controller
68#[derive(Debug, Clone, Copy)]
69#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
70pub struct PidConfig<T: Float> {
71    /// Proportional gain
72    pub kp: T,
73    /// Integral gain
74    pub ki: T,
75    /// Derivative gain
76    pub kd: T,
77
78    /// Maximum allowed output
79    pub output_max: T,
80    /// Minimum allowed output
81    pub output_min: T,
82
83    /// Maximum rate of change of output (per second)
84    pub output_rate_limit: Option<T>,
85
86    /// Proportional term limit
87    pub p_limit: T,
88    /// Integral term limit (anti-windup)
89    pub i_limit: T,
90    /// Derivative term limit
91    pub d_limit: T,
92
93    /// Anti-windup strategy
94    pub antiwindup_mode: AntiWindupMode,
95    /// Back-calculation gain (for BackCalculation mode)
96    pub antiwindup_gain: T,
97
98    /// Derivative filtering mode
99    pub derivative_mode: DerivativeMode,
100    /// Derivative filter coefficient (0.0 to 1.0, typically 0.1-0.3)
101    pub derivative_filter_coeff: T,
102
103    /// Maximum allowed time step (seconds) - for safety
104    pub max_dt: T,
105    /// Minimum allowed time step (seconds) - prevents division issues
106    pub min_dt: T,
107
108    /// Enable setpoint ramping (smooth setpoint changes)
109    pub setpoint_ramping: bool,
110    /// Setpoint ramp rate (units per second)
111    pub setpoint_ramp_rate: T,
112}
113
114impl<T: Float> Default for PidConfig<T> {
115    fn default() -> Self {
116        Self {
117            kp: T::one(),
118            ki: T::zero(),
119            kd: T::zero(),
120            output_max: T::from(100.0).unwrap(),
121            output_min: T::from(-100.0).unwrap(),
122            output_rate_limit: None,
123            p_limit: T::infinity(),
124            i_limit: T::from(100.0).unwrap(),
125            d_limit: T::infinity(),
126            antiwindup_mode: AntiWindupMode::BackCalculation,
127            antiwindup_gain: T::one(),
128            derivative_mode: DerivativeMode::LowPass,
129            derivative_filter_coeff: T::from(0.2).unwrap(),
130            max_dt: T::from(1.0).unwrap(),
131            min_dt: T::from(0.0001).unwrap(),
132            setpoint_ramping: false,
133            setpoint_ramp_rate: T::from(10.0).unwrap(),
134        }
135    }
136}
137
138/// Detailed control output with diagnostics
139#[derive(Debug, Clone, Copy)]
140#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
141pub struct ControlOutput<T: Float> {
142    /// Final control output
143    pub output: T,
144    /// Proportional term contribution
145    pub p: T,
146    /// Integral term contribution
147    pub i: T,
148    /// Derivative term contribution
149    pub d: T,
150    /// Current error
151    pub error: T,
152    /// Setpoint used (after ramping if enabled)
153    pub effective_setpoint: T,
154    /// Safety status
155    pub safety_status: SafetyStatus,
156    /// Time delta used for this calculation
157    pub dt: T,
158}
159
160/// Advanced PID controller for critical systems
161#[derive(Clone)]
162#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
163pub struct RobustPid<T: Float> {
164    /// Configuration
165    config: PidConfig<T>,
166    /// Target setpoint
167    setpoint: T,
168    /// Internal setpoint (after ramping)
169    internal_setpoint: T,
170
171    // State variables
172    /// Accumulated integral term (already multiplied by ki)
173    integral: T,
174    /// Previous measurement
175    prev_measurement: Option<T>,
176    /// Filtered derivative term
177    filtered_derivative: T,
178    /// Previous output (for rate limiting)
179    prev_output: T,
180    /// Previous error (for derivative on error, if needed)
181    prev_error: Option<T>,
182
183    // Diagnostics
184    /// Total number of updates
185    update_count: u64,
186    /// Number of times output was saturated
187    saturation_count: u64,
188    /// Maximum observed error magnitude
189    max_error: T,
190}
191
192impl<T: Float> RobustPid<T> {
193    /// Create a new PID controller with the given configuration
194    pub fn new(config: PidConfig<T>) -> Self {
195        Self {
196            internal_setpoint: T::zero(),
197            setpoint: T::zero(),
198            config,
199            integral: T::zero(),
200            prev_measurement: None,
201            filtered_derivative: T::zero(),
202            prev_output: T::zero(),
203            prev_error: None,
204            update_count: 0,
205            saturation_count: 0,
206            max_error: T::zero(),
207        }
208    }
209
210    /// Create a PID controller with default settings
211    pub fn with_gains(kp: T, ki: T, kd: T) -> Self {
212        let mut config = PidConfig::default();
213        config.kp = kp;
214        config.ki = ki;
215        config.kd = kd;
216        Self::new(config)
217    }
218
219    /// Set the target setpoint
220    pub fn set_setpoint(&mut self, setpoint: T) {
221        self.setpoint = setpoint;
222        if !self.config.setpoint_ramping {
223            self.internal_setpoint = setpoint;
224        }
225    }
226
227    /// Update configuration (allows online tuning)
228    pub fn update_config(&mut self, config: PidConfig<T>) {
229        // Recalculate integral if ki changed to prevent jumps
230        if config.ki != self.config.ki && config.ki != T::zero() {
231            self.integral = self.integral * (config.ki / self.config.ki);
232        }
233        self.config = config;
234    }
235
236    /// Get current configuration
237    pub fn config(&self) -> &PidConfig<T> {
238        &self.config
239    }
240
241    /// Compute control output with variable time step
242    ///
243    /// This method uses setpoint tracking mode: error = setpoint - measurement
244    pub fn update(&mut self, measurement: T, dt: T) -> ControlOutput<T> {
245        self.update_count += 1;
246
247        // Validate time step
248        let dt = self.clamp_dt(dt);
249
250        // Update internal setpoint with ramping if enabled
251        if self.config.setpoint_ramping {
252            self.update_setpoint_ramp(dt);
253        }
254
255        // Calculate error
256        let error = self.internal_setpoint - measurement;
257
258        self.update_with_error_and_measurement(error, measurement, dt)
259    }
260
261    /// Compute control output directly from error signal
262    ///
263    /// Use this when you already have the error calculated externally.
264    /// Positive error means the system needs to increase output.
265    /// Negative error means the system needs to decrease output.
266    ///
267    /// # Example
268    /// ```ignore
269    /// // External error source (e.g., from sensor fusion, observer, etc.)
270    /// let error = calculate_tracking_error(); // Could be +/- value
271    ///
272    /// let output = pid.update_from_error(error, dt);
273    /// // Controller will drive error toward zero
274    /// ```
275    pub fn update_from_error(&mut self, error: T, dt: T) -> ControlOutput<T> {
276        self.update_count += 1;
277
278        // Validate time step
279        let dt = self.clamp_dt(dt);
280
281        // For error-based mode, we don't have a measurement, so we calculate it
282        // based on the previous error and the change
283        let calculated_measurement = match self.prev_error {
284            Some(prev_err) => {
285                // If we have previous error, we can estimate the measurement change
286                // from the error change (assuming setpoint didn't change)
287                match self.prev_measurement {
288                    Some(prev_meas) => prev_meas - (error - prev_err),
289                    None => T::zero(), // First call, no previous measurement
290                }
291            }
292            None => T::zero(), // First call
293        };
294
295        self.update_with_error_and_measurement(error, calculated_measurement, dt)
296    }
297
298    /// Internal update logic shared by both update modes
299    fn update_with_error_and_measurement(
300        &mut self,
301        error: T,
302        measurement: T,
303        dt: T,
304    ) -> ControlOutput<T> {
305        // Track maximum error for diagnostics
306        if error.abs() > self.max_error {
307            self.max_error = error.abs();
308        }
309
310        // Proportional term
311        let p_unbounded = error * self.config.kp;
312        let p = self.clamp(p_unbounded, self.config.p_limit);
313
314        // Derivative term (on measurement to avoid derivative kick)
315        let d = self.calculate_derivative(measurement, dt);
316
317        // Calculate tentative output before integral
318        let tentative_output = p + self.integral + d;
319
320        // Integral term with anti-windup
321        let i = self.update_integral(error, dt, tentative_output);
322
323        // Final output calculation
324        let mut output = p + i + d;
325        output = self.clamp_output(output);
326
327        // Apply output rate limiting if configured
328        output = self.apply_rate_limit(output, dt);
329
330        // Determine safety status
331        let safety_status = self.determine_safety_status(output, error, dt);
332
333        // Update state
334        self.prev_measurement = Some(measurement);
335        self.prev_error = Some(error);
336        self.prev_output = output;
337
338        ControlOutput {
339            output,
340            p,
341            i,
342            d,
343            error,
344            effective_setpoint: self.internal_setpoint,
345            safety_status,
346            dt,
347        }
348    }
349
350    /// Calculate derivative term with filtering
351    fn calculate_derivative(&mut self, measurement: T, dt: T) -> T {
352        let raw_derivative = match self.prev_measurement {
353            Some(prev_measurement) => {
354                // Derivative on measurement (prevents derivative kick on setpoint changes)
355                -(measurement - prev_measurement) / dt
356            }
357            None => T::zero(),
358        };
359
360        // Apply filtering based on mode
361        let derivative = match self.config.derivative_mode {
362            DerivativeMode::None => raw_derivative,
363            DerivativeMode::LowPass => {
364                // First-order low-pass filter: d_filtered = α * d_raw + (1-α) * d_prev
365                let alpha = self.config.derivative_filter_coeff;
366                alpha * raw_derivative + (T::one() - alpha) * self.filtered_derivative
367            }
368            DerivativeMode::SimpleMovingAverage => {
369                // Simple moving average with fixed alpha
370                let alpha = self.config.derivative_filter_coeff;
371                alpha * raw_derivative + (T::one() - alpha) * self.filtered_derivative
372            }
373        };
374
375        self.filtered_derivative = derivative;
376
377        let d_unbounded = derivative * self.config.kd;
378        self.clamp(d_unbounded, self.config.d_limit)
379    }
380
381    /// Update integral term with anti-windup protection
382    fn update_integral(&mut self, error: T, dt: T, tentative_output: T) -> T {
383        let error_contribution = error * self.config.ki * dt;
384
385        match self.config.antiwindup_mode {
386            AntiWindupMode::Clamping => {
387                // Simple clamping of integral
388                let tentative_integral = self.integral + error_contribution;
389                self.integral = self.clamp(tentative_integral, self.config.i_limit);
390            }
391            AntiWindupMode::BackCalculation => {
392                // Back-calculate: if output would saturate, reduce integral
393                let clamped_output = self.clamp_output(tentative_output);
394                let output_error = clamped_output - tentative_output;
395
396                // Back-calculation feedback
397                let integral_correction = output_error * self.config.antiwindup_gain * dt;
398                let tentative_integral = self.integral + error_contribution + integral_correction;
399                self.integral = self.clamp(tentative_integral, self.config.i_limit);
400            }
401            AntiWindupMode::Conditional => {
402                // Only integrate if output is not saturated
403                let clamped_output = self.clamp_output(tentative_output);
404                if (tentative_output - clamped_output).abs() < T::epsilon() {
405                    // Not saturated, integrate normally
406                    let tentative_integral = self.integral + error_contribution;
407                    self.integral = self.clamp(tentative_integral, self.config.i_limit);
408                }
409                // Otherwise, don't update integral
410            }
411        }
412
413        self.integral
414    }
415
416    /// Apply setpoint ramping for smooth transitions
417    fn update_setpoint_ramp(&mut self, dt: T) {
418        let error = self.setpoint - self.internal_setpoint;
419        let max_change = self.config.setpoint_ramp_rate * dt;
420
421        if error.abs() <= max_change {
422            self.internal_setpoint = self.setpoint;
423        } else {
424            let sign = if error > T::zero() {
425                T::one()
426            } else {
427                -T::one()
428            };
429            self.internal_setpoint = self.internal_setpoint + sign * max_change;
430        }
431    }
432
433    /// Apply output rate limiting
434    fn apply_rate_limit(&self, output: T, dt: T) -> T {
435        if let Some(rate_limit) = self.config.output_rate_limit {
436            let max_change = rate_limit * dt;
437            let change = output - self.prev_output;
438
439            if change.abs() > max_change {
440                let sign = if change > T::zero() {
441                    T::one()
442                } else {
443                    -T::one()
444                };
445                self.prev_output + sign * max_change
446            } else {
447                output
448            }
449        } else {
450            output
451        }
452    }
453
454    /// Clamp value to symmetric limits
455    fn clamp(&self, value: T, limit: T) -> T {
456        let limit_abs = limit.abs();
457        if value > limit_abs {
458            limit_abs
459        } else if value < -limit_abs {
460            -limit_abs
461        } else {
462            value
463        }
464    }
465
466    /// Clamp output to configured min/max
467    fn clamp_output(&mut self, value: T) -> T {
468        let clamped = if value > self.config.output_max {
469            self.saturation_count += 1;
470            self.config.output_max
471        } else if value < self.config.output_min {
472            self.saturation_count += 1;
473            self.config.output_min
474        } else {
475            value
476        };
477        clamped
478    }
479
480    /// Validate and clamp time step
481    fn clamp_dt(&self, dt: T) -> T {
482        if dt < self.config.min_dt {
483            self.config.min_dt
484        } else if dt > self.config.max_dt {
485            self.config.max_dt
486        } else {
487            dt
488        }
489    }
490
491    /// Determine safety status based on current state
492    fn determine_safety_status(&self, output: T, error: T, dt: T) -> SafetyStatus {
493        let mut fault_count = 0;
494        let mut status = SafetyStatus::Normal;
495
496        // Check output saturation
497        if (output - self.config.output_max).abs() < T::epsilon()
498            || (output - self.config.output_min).abs() < T::epsilon()
499        {
500            status = SafetyStatus::OutputSaturated;
501            fault_count += 1;
502        }
503
504        // Check integral windup
505        if (self.integral.abs() - self.config.i_limit).abs() < T::epsilon() {
506            status = SafetyStatus::IntegralWindup;
507            fault_count += 1;
508        }
509
510        // Check derivative noise (large derivative relative to error)
511        if error != T::zero()
512            && self.filtered_derivative.abs() > error.abs() * T::from(10.0).unwrap()
513        {
514            status = SafetyStatus::DerivativeNoisy;
515            fault_count += 1;
516        }
517
518        // Check time step
519        if dt >= self.config.max_dt {
520            status = SafetyStatus::TimeStepExcessive;
521            fault_count += 1;
522        }
523
524        if fault_count > 1 {
525            SafetyStatus::MultipleFaults
526        } else {
527            status
528        }
529    }
530
531    /// Reset all internal state (use when switching control modes)
532    pub fn reset(&mut self) {
533        self.integral = T::zero();
534        self.prev_measurement = None;
535        self.filtered_derivative = T::zero();
536        self.prev_output = T::zero();
537        self.prev_error = None;
538        self.internal_setpoint = self.setpoint;
539    }
540
541    /// Reset only integral term (for integral windup recovery)
542    pub fn reset_integral(&mut self) {
543        self.integral = T::zero();
544    }
545
546    /// Manual integral preload (for bumpless transfer)
547    pub fn preload_integral(&mut self, value: T) {
548        self.integral = self.clamp(value, self.config.i_limit);
549    }
550
551    /// Get current integral term value
552    pub fn get_integral_term(&self) -> T {
553        self.integral
554    }
555
556    /// Get diagnostic information
557    pub fn diagnostics(&self) -> PidDiagnostics<T> {
558        PidDiagnostics {
559            update_count: self.update_count,
560            saturation_count: self.saturation_count,
561            saturation_ratio: if self.update_count > 0 {
562                T::from(self.saturation_count).unwrap() / T::from(self.update_count).unwrap()
563            } else {
564                T::zero()
565            },
566            max_error: self.max_error,
567            current_integral: self.integral,
568            current_derivative: self.filtered_derivative,
569        }
570    }
571}
572
573/// Diagnostic information about PID performance
574#[derive(Debug, Clone, Copy)]
575pub struct PidDiagnostics<T: Float> {
576    /// Total number of updates
577    pub update_count: u64,
578    /// Number of saturations
579    pub saturation_count: u64,
580    /// Ratio of saturated outputs
581    pub saturation_ratio: T,
582    /// Maximum error observed
583    pub max_error: T,
584    /// Current integral value
585    pub current_integral: T,
586    /// Current derivative value
587    pub current_derivative: T,
588}
589
590impl<T: Float + fmt::Display> fmt::Display for PidDiagnostics<T> {
591    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
592        write!(
593            f,
594            "PID Diagnostics:\n\
595             Updates: {}\n\
596             Saturations: {} ({:.2}%)\n\
597             Max Error: {}\n\
598             Current Integral: {}\n\
599             Current Derivative: {}",
600            self.update_count,
601            self.saturation_count,
602            self.saturation_ratio * T::from(100.0).unwrap(),
603            self.max_error,
604            self.current_integral,
605            self.current_derivative
606        )
607    }
608}
609
610// ============================================================================
611// Builder Pattern for Easier Configuration
612// ============================================================================
613
614pub struct PidBuilder<T: Float> {
615    config: PidConfig<T>,
616}
617
618impl<T: Float> PidBuilder<T> {
619    pub fn new() -> Self {
620        Self {
621            config: PidConfig::default(),
622        }
623    }
624
625    pub fn gains(mut self, kp: T, ki: T, kd: T) -> Self {
626        self.config.kp = kp;
627        self.config.ki = ki;
628        self.config.kd = kd;
629        self
630    }
631
632    pub fn output_limits(mut self, min: T, max: T) -> Self {
633        self.config.output_min = min;
634        self.config.output_max = max;
635        self
636    }
637
638    pub fn term_limits(mut self, p_limit: T, i_limit: T, d_limit: T) -> Self {
639        self.config.p_limit = p_limit;
640        self.config.i_limit = i_limit;
641        self.config.d_limit = d_limit;
642        self
643    }
644
645    pub fn output_rate_limit(mut self, rate: T) -> Self {
646        self.config.output_rate_limit = Some(rate);
647        self
648    }
649
650    pub fn antiwindup(mut self, mode: AntiWindupMode, gain: T) -> Self {
651        self.config.antiwindup_mode = mode;
652        self.config.antiwindup_gain = gain;
653        self
654    }
655
656    pub fn derivative_filter(mut self, mode: DerivativeMode, coeff: T) -> Self {
657        self.config.derivative_mode = mode;
658        self.config.derivative_filter_coeff = coeff;
659        self
660    }
661
662    pub fn setpoint_ramping(mut self, enabled: bool, rate: T) -> Self {
663        self.config.setpoint_ramping = enabled;
664        self.config.setpoint_ramp_rate = rate;
665        self
666    }
667
668    pub fn time_limits(mut self, min_dt: T, max_dt: T) -> Self {
669        self.config.min_dt = min_dt;
670        self.config.max_dt = max_dt;
671        self
672    }
673
674    pub fn build(self) -> RobustPid<T> {
675        RobustPid::new(self.config)
676    }
677}
678
679impl<T: Float> Default for PidBuilder<T> {
680    fn default() -> Self {
681        Self::new()
682    }
683}
684
685// ============================================================================
686// Tests
687// ============================================================================
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn test_proportional_only() {
695        let mut pid = PidBuilder::new()
696            .gains(2.0, 0.0, 0.0)
697            .output_limits(-100.0, 100.0)
698            .build();
699
700        pid.set_setpoint(10.0);
701
702        let output = pid.update(5.0, 1.0);
703        assert_eq!(output.p, 10.0); // (10-5) * 2.0
704        assert_eq!(output.i, 0.0);
705        assert_eq!(output.d, 0.0);
706        assert_eq!(output.output, 10.0);
707    }
708
709    #[test]
710    fn test_integral_accumulation() {
711        let mut pid = PidBuilder::new()
712            .gains(0.0, 1.0, 0.0)
713            .output_limits(-100.0, 100.0)
714            .build();
715
716        pid.set_setpoint(10.0);
717
718        let output1 = pid.update(8.0, 1.0); // error = 2
719        assert_eq!(output1.i, 2.0);
720
721        let output2 = pid.update(8.0, 1.0); // error = 2
722        assert_eq!(output2.i, 4.0);
723    }
724
725    #[test]
726    fn test_output_saturation() {
727        let mut pid = PidBuilder::new()
728            .gains(10.0, 0.0, 0.0)
729            .output_limits(-50.0, 50.0)
730            .build();
731
732        pid.set_setpoint(100.0);
733
734        let output = pid.update(0.0, 1.0);
735        assert_eq!(output.output, 50.0); // Saturated at max
736        assert!(matches!(
737            output.safety_status,
738            SafetyStatus::OutputSaturated | SafetyStatus::MultipleFaults
739        ));
740    }
741}