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
120 // loop turns on without a bump. The framework
121 // reads OUTL's current value into `I` BEFORE
122 // this runs via `EpidRecord::pre_process_actions`
123 // (a `ReadDbLink` on `OUTL`). So `i` already
124 // holds the readback value here — keep it.
125 // When OUTL is CONSTANT/empty there is no
126 // ReadDbLink and `i` keeps its prior value,
127 // matching C's `outl.type != CONSTANT` guard.
128 // (`i` was loaded from `epid.i` above.)
129 } else {
130 // Anti-windup: only accumulate integral if output not saturated,
131 // or if the integral change would move away from saturation.
132 if (oval > epid.drvl && oval < epid.drvh)
133 || (oval >= epid.drvh && di < 0.0)
134 || (oval <= epid.drvl && di > 0.0)
135 {
136 i += di;
137 if i < epid.drvl {
138 i = epid.drvl;
139 }
140 if i > epid.drvh {
141 i = epid.drvh;
142 }
143 }
144 }
145 }
146 // If KI is zero, zero the integral term
147 if ki == 0.0 {
148 i = 0.0;
149 }
150 // Derivative term
151 d = if dt > 0.0 { kp * kd * (de / dt) } else { 0.0 };
152 oval = p + i + d;
153 }
154 1 => {
155 // MaxMin mode
156 if epid.fbon != 0 {
157 if epid.fbop == 0 {
158 // Feedback just transitioned OFF -> ON (bumpless
159 // turn-on). C `devEpidSoft.c:178-184` /
160 // `devEpidSoftCallback.c:214-220`:
161 // if (pepid->outl.type != CONSTANT) {
162 // if (dbGetLink(&pepid->outl,DBR_DOUBLE,
163 // &oval,..))
164 // recGblSetSevr(...,LINK_ALARM,INVALID);
165 // }
166 // — the output is seeded from the OUTL output
167 // link's *actual current value*. The framework
168 // reads OUTL's current value into `OVAL` BEFORE
169 // this runs via `EpidRecord::pre_process_actions`
170 // (a `ReadDbLink` on `OUTL` into `OVAL` for the
171 // FMOD==1 edge). So `epid.oval` already holds the
172 // read-back value here. When OUTL is
173 // CONSTANT/empty there is no ReadDbLink and
174 // `epid.oval` keeps its prior value, matching
175 // C's `outl.type != CONSTANT` guard.
176 oval = epid.oval;
177 } else {
178 e = cval - pcval;
179 let sign = if d > 0.0 { 1.0 } else { -1.0 };
180 let sign = if (kp > 0.0 && e < 0.0) || (kp < 0.0 && e > 0.0) {
181 -sign
182 } else {
183 sign
184 };
185 d = kp * sign;
186 oval = epid.oval + d;
187 }
188 }
189 }
190 _ => {
191 tracing::warn!("Invalid feedback mode {} in epid record", epid.fmod);
192 }
193 }
194
195 // Clamp output to drive limits
196 if oval > epid.drvh {
197 oval = epid.drvh;
198 }
199 if oval < epid.drvl {
200 oval = epid.drvl;
201 }
202
203 // Update record fields — C `devEpidSoft.c:206-209`.
204 epid.ct = ct;
205 epid.dt = dt;
206 // C `devEpidSoft.c:208` writes ERR unconditionally for every mode.
207 epid.err = e;
208 epid.cval = cval;
209
210 // Apply output deadband
211 if epid.odel == 0.0 || (epid.oval - oval).abs() > epid.odel {
212 epid.oval = oval;
213 }
214
215 epid.p = p;
216 epid.i = i;
217 epid.d = d;
218 epid.fbop = epid.fbon;
219
220 // C `devEpidSoft.c:218-224` / `devEpidSoftCallback.c:254-258`:
221 // if (pepid->fbon && (pepid->outl.type != CONSTANT))
222 // dbPutLink(&pepid->outl, DBR_DOUBLE, &pepid->oval, 1);
223 // The OUTL write is reached only here (after the early returns)
224 // and only with feedback ON. The framework performs the actual
225 // `dbPutLink` via `multi_output_links`; gate it on `fbon`. The
226 // `outl.type != CONSTANT` half is the framework's no-op on a
227 // constant/empty OUTL link.
228 epid.set_outl_write(epid.fbon != 0);
229 }
230}
231
232impl DeviceSupport for EpidSoftDeviceSupport {
233 fn dtyp(&self) -> &str {
234 "Epid Soft"
235 }
236
237 fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
238 let epid = record
239 .as_any_mut()
240 .and_then(|a| a.downcast_mut::<EpidRecord>())
241 .expect("EpidSoftDeviceSupport requires an EpidRecord");
242
243 Self::do_pid(epid);
244 Ok(DeviceReadOutcome::computed())
245 }
246
247 fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
248 Ok(())
249 }
250}