Skip to main content

std_rs/device_support/
epid_soft.rs

1use std::time::Instant;
2
3use epics_base_rs::error::CaResult;
4use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport};
5use epics_base_rs::server::record::Record;
6
7use crate::records::epid::EpidRecord;
8
9/// Soft Channel device support for the epid record.
10///
11/// Implements the PID and MaxMin feedback algorithms.
12/// Ported from `devEpidSoft.c`.
13///
14/// PID algorithm:
15/// ```text
16/// E(n) = Setpoint - ControlledValue
17/// P(n) = KP * E(n)
18/// I(n) = I(n-1) + KP * KI * E(n) * dT  (with anti-windup)
19/// D(n) = KP * KD * (E(n) - E(n-1)) / dT
20/// Output = P + I + D
21/// ```
22pub struct EpidSoftDeviceSupport;
23
24impl Default for EpidSoftDeviceSupport {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl EpidSoftDeviceSupport {
31    pub fn new() -> Self {
32        Self
33    }
34
35    /// Execute the PID algorithm on the epid record.
36    /// This is the core computation, equivalent to `do_pid()` in devEpidSoft.c.
37    pub fn do_pid(epid: &mut EpidRecord) {
38        // C `devEpidSoft.c:110-112`:
39        //   if (pepid->inp.type == CONSTANT) { /* nothing to control */
40        //       if (recGblSetSevr(pepid,SOFT_ALARM,INVALID_ALARM)) return(0);
41        //   }
42        // A CONSTANT `INP` link is a literal value, not a PV to read —
43        // there is nothing to feed back on, so PID is skipped and the
44        // record is flagged SOFT/INVALID. The framework `check_alarms`
45        // hook raises the severity from this flag.
46        if epics_base_rs::server::record::link_field_type(&epid.inp)
47            == epics_base_rs::server::record::LinkType::Constant
48        {
49            epid.inp_constant = true;
50            return;
51        }
52        epid.inp_constant = false;
53
54        // Previous controlled value: CVLP, maintained by
55        // `EpidRecord::update_monitors()` (`epid.rs` — `self.cvlp = self.cval`).
56        // The MaxMin sign-detection in `fmod==1` needs the value from the
57        // *previous* cycle, not the current CVAL. Reading `epid.cval` for
58        // both would make `e = cval - pcval` identically 0.0. This matches
59        // the in-tree fast path `epid_fast.rs` which uses `pcval = self.cval`
60        // captured before `self.cval = cval`.
61        let pcval = epid.cvlp;
62        let setp = epid.val;
63        let cval = epid.cval;
64
65        // Compute delta time
66        let ctp = epid.ct;
67        let ct = Instant::now();
68        let dt = ct.duration_since(ctp).as_secs_f64();
69
70        // Skip if delta time is less than minimum
71        if dt < epid.mdt {
72            return;
73        }
74
75        let kp = epid.kp;
76        let ki = epid.ki;
77        let kd = epid.kd;
78        let ep = epid.err;
79        let mut oval = epid.oval;
80        let mut p = epid.p;
81        let mut i = epid.i;
82        let mut d = epid.d;
83        // C `devEpidSoft.c:98` declares `double e = 0.;` at function scope.
84        // `devEpidSoft.c:208` writes `pepid->err = e;` *unconditionally*,
85        // regardless of feedback mode. So ERR must always be assigned:
86        //   - PID mode      → e = setp - cval (devEpidSoft.c:139)
87        //   - MaxMin, FB on after the OFF->ON edge → e = cval - pcval
88        //     (devEpidSoft.c:186)
89        //   - MaxMin bumpless edge / MaxMin FB off / invalid mode → e = 0.0
90        //     (the initial value from devEpidSoft.c:98 is never overwritten)
91        let mut e = 0.0_f64;
92
93        match epid.fmod {
94            0 => {
95                // PID mode
96                e = setp - cval;
97                let de = e - ep;
98                p = kp * e;
99
100                // Integral term with sanity checks
101                let di = kp * ki * e * dt;
102                if epid.fbon != 0 {
103                    if epid.fbop == 0 {
104                        // Feedback just transitioned OFF -> ON (bumpless
105                        // turn-on). C `devEpidSoft.c:153-158`:
106                        //   if (pepid->outl.type != CONSTANT) {
107                        //       if (dbGetLink(&pepid->outl,DBR_DOUBLE,&i,..))
108                        //           recGblSetSevr(...,LINK_ALARM,INVALID);
109                        //   }
110                        // — the integral term is seeded from the OUTL
111                        // output link's *actual current value* so the
112                        // loop turns on without a bump. The framework
113                        // reads OUTL's current value into `I` BEFORE
114                        // this runs via `EpidRecord::pre_process_actions`
115                        // (a `ReadDbLink` on `OUTL`). So `i` already
116                        // holds the readback value here — keep it.
117                        // When OUTL is CONSTANT/empty there is no
118                        // ReadDbLink and `i` keeps its prior value,
119                        // matching C's `outl.type != CONSTANT` guard.
120                        // (`i` was loaded from `epid.i` above.)
121                    } else {
122                        // Anti-windup: only accumulate integral if output not saturated,
123                        // or if the integral change would move away from saturation.
124                        if (oval > epid.drvl && oval < epid.drvh)
125                            || (oval >= epid.drvh && di < 0.0)
126                            || (oval <= epid.drvl && di > 0.0)
127                        {
128                            i += di;
129                            if i < epid.drvl {
130                                i = epid.drvl;
131                            }
132                            if i > epid.drvh {
133                                i = epid.drvh;
134                            }
135                        }
136                    }
137                }
138                // If KI is zero, zero the integral term
139                if ki == 0.0 {
140                    i = 0.0;
141                }
142                // Derivative term
143                d = if dt > 0.0 { kp * kd * (de / dt) } else { 0.0 };
144                oval = p + i + d;
145            }
146            1 => {
147                // MaxMin mode
148                if epid.fbon != 0 {
149                    if epid.fbop == 0 {
150                        // Feedback just transitioned OFF -> ON (bumpless
151                        // turn-on). C `devEpidSoft.c:178-184` /
152                        // `devEpidSoftCallback.c:214-220`:
153                        //   if (pepid->outl.type != CONSTANT) {
154                        //       if (dbGetLink(&pepid->outl,DBR_DOUBLE,
155                        //                     &oval,..))
156                        //           recGblSetSevr(...,LINK_ALARM,INVALID);
157                        //   }
158                        // — the output is seeded from the OUTL output
159                        // link's *actual current value*. The framework
160                        // reads OUTL's current value into `OVAL` BEFORE
161                        // this runs via `EpidRecord::pre_process_actions`
162                        // (a `ReadDbLink` on `OUTL` into `OVAL` for the
163                        // FMOD==1 edge). So `epid.oval` already holds the
164                        // read-back value here. When OUTL is
165                        // CONSTANT/empty there is no ReadDbLink and
166                        // `epid.oval` keeps its prior value, matching
167                        // C's `outl.type != CONSTANT` guard.
168                        oval = epid.oval;
169                    } else {
170                        e = cval - pcval;
171                        let sign = if d > 0.0 { 1.0 } else { -1.0 };
172                        let sign = if (kp > 0.0 && e < 0.0) || (kp < 0.0 && e > 0.0) {
173                            -sign
174                        } else {
175                            sign
176                        };
177                        d = kp * sign;
178                        oval = epid.oval + d;
179                    }
180                }
181            }
182            _ => {
183                tracing::warn!("Invalid feedback mode {} in epid record", epid.fmod);
184            }
185        }
186
187        // Clamp output to drive limits
188        if oval > epid.drvh {
189            oval = epid.drvh;
190        }
191        if oval < epid.drvl {
192            oval = epid.drvl;
193        }
194
195        // Update record fields — C `devEpidSoft.c:206-209`.
196        epid.ct = ct;
197        epid.dt = dt;
198        // C `devEpidSoft.c:208` writes ERR unconditionally for every mode.
199        epid.err = e;
200        epid.cval = cval;
201
202        // Apply output deadband
203        if epid.odel == 0.0 || (epid.oval - oval).abs() > epid.odel {
204            epid.oval = oval;
205        }
206
207        epid.p = p;
208        epid.i = i;
209        epid.d = d;
210        epid.fbop = epid.fbon;
211    }
212}
213
214impl DeviceSupport for EpidSoftDeviceSupport {
215    fn dtyp(&self) -> &str {
216        "Epid Soft"
217    }
218
219    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
220        let epid = record
221            .as_any_mut()
222            .and_then(|a| a.downcast_mut::<EpidRecord>())
223            .expect("EpidSoftDeviceSupport requires an EpidRecord");
224
225        Self::do_pid(epid);
226        Ok(DeviceReadOutcome::computed())
227    }
228
229    fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
230        Ok(())
231    }
232}