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}