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 // OUTL-write ownership: clear at entry so every early return
39 // below (CONSTANT INP, sub-MDT) leaves the framework OUTL write
40 // suppressed. C performs the OUTL `dbPutLink` only at the very
41 // end of `do_pid` and only when `fbon` (`devEpidSoft.c:220`); a
42 // sub-MDT (`:125`) or CONSTANT-INP (`:110-112`) return happens
43 // before that line. `epid.set_outl_write(...)` at the success
44 // path re-enables it per `fbon`.
45 epid.set_outl_write(false);
46 // C `devEpidSoft.c:110-112`:
47 // if (pepid->inp.type == CONSTANT) { /* nothing to control */
48 // if (recGblSetSevr(pepid,SOFT_ALARM,INVALID_ALARM)) return(0);
49 // }
50 // A CONSTANT `INP` link is a literal value, not a PV to read —
51 // there is nothing to feed back on, so PID is skipped and the
52 // record is flagged SOFT/INVALID. The framework `check_alarms`
53 // hook raises the severity from this flag.
54 if epics_base_rs::server::record::link_field_type(&epid.inp)
55 == epics_base_rs::server::record::LinkType::Constant
56 {
57 epid.inp_constant = true;
58 return;
59 }
60 epid.inp_constant = false;
61
62 // Previous controlled value: CVLP, maintained by
63 // `EpidRecord::update_monitors()` (`epid.rs` — `self.cvlp = self.cval`).
64 // The MaxMin sign-detection in `fmod==1` needs the value from the
65 // *previous* cycle, not the current CVAL. Reading `epid.cval` for
66 // both would make `e = cval - pcval` identically 0.0. This matches
67 // the in-tree fast path `epid_fast.rs` which uses `pcval = self.cval`
68 // captured before `self.cval = cval`.
69 let pcval = epid.cvlp;
70 let setp = epid.val;
71 let cval = epid.cval;
72
73 // Compute delta time
74 let ctp = epid.ct;
75 let ct = Instant::now();
76 let dt = ct.duration_since(ctp).as_secs_f64();
77
78 // Skip if delta time is less than minimum
79 if dt < epid.mdt {
80 return;
81 }
82
83 let kp = epid.kp;
84 let ki = epid.ki;
85 let kd = epid.kd;
86 let ep = epid.err;
87 let mut oval = epid.oval;
88 let mut p = epid.p;
89 let mut i = epid.i;
90 let mut d = epid.d;
91 // C `devEpidSoft.c:98` declares `double e = 0.;` at function scope.
92 // `devEpidSoft.c:208` writes `pepid->err = e;` *unconditionally*,
93 // regardless of feedback mode. So ERR must always be assigned:
94 // - PID mode → e = setp - cval (devEpidSoft.c:139)
95 // - MaxMin, FB on after the OFF->ON edge → e = cval - pcval
96 // (devEpidSoft.c:186)
97 // - MaxMin bumpless edge / MaxMin FB off / invalid mode → e = 0.0
98 // (the initial value from devEpidSoft.c:98 is never overwritten)
99 let mut e = 0.0_f64;
100
101 match epid.fmod {
102 0 => {
103 // PID mode
104 e = setp - cval;
105 let de = e - ep;
106 p = kp * e;
107
108 // Integral term with sanity checks
109 let di = kp * ki * e * dt;
110 if epid.fbon != 0 {
111 if epid.fbop == 0 {
112 // Feedback just transitioned OFF -> ON (bumpless
113 // turn-on). C `devEpidSoft.c:153-158`:
114 // if (pepid->outl.type != CONSTANT) {
115 // if (dbGetLink(&pepid->outl,DBR_DOUBLE,&i,..))
116 // recGblSetSevr(...,LINK_ALARM,INVALID);
117 // }
118 // — the integral term is seeded from the OUTL
119 // output link's *actual current value* so the loop
120 // turns on without a bump. This is C's line, and it
121 // is reached only AFTER the `dt < mdt` gate above:
122 // consume the staged readback here, so a gated cycle
123 // never touches `.I`. `outl_seed` is `None` for a
124 // CONSTANT/empty OUTL (nothing was read), and then `i`
125 // keeps its prior value — C's `outl.type != CONSTANT`
126 // guard. (`i` was loaded from `epid.i` above.)
127 if let Some(seed) = epid.outl_seed {
128 i = seed;
129 }
130 } else {
131 // Anti-windup: only accumulate integral if output not saturated,
132 // or if the integral change would move away from saturation.
133 if (oval > epid.drvl && oval < epid.drvh)
134 || (oval >= epid.drvh && di < 0.0)
135 || (oval <= epid.drvl && di > 0.0)
136 {
137 i += di;
138 if i < epid.drvl {
139 i = epid.drvl;
140 }
141 if i > epid.drvh {
142 i = epid.drvh;
143 }
144 }
145 }
146 }
147 // If KI is zero, zero the integral term
148 if ki == 0.0 {
149 i = 0.0;
150 }
151 // Derivative term
152 d = if dt > 0.0 { kp * kd * (de / dt) } else { 0.0 };
153 oval = p + i + d;
154 }
155 1 => {
156 // MaxMin mode
157 if epid.fbon != 0 {
158 if epid.fbop == 0 {
159 // Feedback just transitioned OFF -> ON (bumpless
160 // turn-on). C `devEpidSoft.c:178-184` /
161 // `devEpidSoftCallback.c:214-220`:
162 // if (pepid->outl.type != CONSTANT) {
163 // if (dbGetLink(&pepid->outl,DBR_DOUBLE,
164 // &oval,..))
165 // recGblSetSevr(...,LINK_ALARM,INVALID);
166 // }
167 // — the output is seeded from the OUTL output link's
168 // *actual current value*. This is C's line, reached
169 // only AFTER the `dt < mdt` gate above: consume the
170 // staged readback here, so a gated cycle never touches
171 // `.OVAL`. `outl_seed` is `None` for a CONSTANT/empty
172 // OUTL (nothing was read), and then OVAL keeps its
173 // prior value — C's `outl.type != CONSTANT` guard.
174 oval = epid.outl_seed.unwrap_or(epid.oval);
175 } else {
176 e = cval - pcval;
177 let sign = if d > 0.0 { 1.0 } else { -1.0 };
178 let sign = if (kp > 0.0 && e < 0.0) || (kp < 0.0 && e > 0.0) {
179 -sign
180 } else {
181 sign
182 };
183 d = kp * sign;
184 oval = epid.oval + d;
185 }
186 }
187 }
188 _ => {
189 tracing::warn!("Invalid feedback mode {} in epid record", epid.fmod);
190 }
191 }
192
193 // Clamp output to drive limits
194 if oval > epid.drvh {
195 oval = epid.drvh;
196 }
197 if oval < epid.drvl {
198 oval = epid.drvl;
199 }
200
201 // Update record fields — C `devEpidSoft.c:206-209`.
202 epid.ct = ct;
203 epid.dt = dt;
204 // C `devEpidSoft.c:208` writes ERR unconditionally for every mode.
205 epid.err = e;
206 epid.cval = cval;
207
208 // Apply output deadband
209 if epid.odel == 0.0 || (epid.oval - oval).abs() > epid.odel {
210 epid.oval = oval;
211 }
212
213 epid.p = p;
214 epid.i = i;
215 epid.d = d;
216 epid.fbop = epid.fbon;
217
218 // C `devEpidSoft.c:218-224` / `devEpidSoftCallback.c:254-258`:
219 // if (pepid->fbon && (pepid->outl.type != CONSTANT))
220 // dbPutLink(&pepid->outl, DBR_DOUBLE, &pepid->oval, 1);
221 // The OUTL write is reached only here (after the early returns)
222 // and only with feedback ON. The framework performs the actual
223 // `dbPutLink` via `multi_output_links`; gate it on `fbon`. The
224 // `outl.type != CONSTANT` half is the framework's no-op on a
225 // constant/empty OUTL link.
226 epid.set_outl_write(epid.fbon != 0);
227 }
228}
229
230impl DeviceSupport for EpidSoftDeviceSupport {
231 fn dtyp(&self) -> &str {
232 "Epid Soft"
233 }
234
235 fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
236 let epid = record
237 .as_any_mut()
238 .and_then(|a| a.downcast_mut::<EpidRecord>())
239 .expect("EpidSoftDeviceSupport requires an EpidRecord");
240
241 Self::do_pid(epid);
242 Ok(DeviceReadOutcome::computed())
243 }
244
245 fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
246 Ok(())
247 }
248}