std_rs/device_support/epid_fast.rs
1use std::sync::{Arc, Mutex};
2use std::time::Instant;
3
4use epics_base_rs::error::CaResult;
5use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport};
6use epics_base_rs::server::record::Record;
7
8use crate::records::epid::EpidRecord;
9
10/// Fast Epid device support using asyn driver for high-speed (1+ kHz) PID.
11///
12/// Ported from `devEpidFast.c`. The PID computation runs in a background
13/// tokio task driven by asyn interrupt callbacks, not during record
14/// processing. The record merely copies parameters to/from the fast
15/// computation thread.
16///
17/// # Architecture
18///
19/// ```text
20/// ┌─────────────┐ interrupt ┌──────────────────┐
21/// │ asyn driver │ ──────────────► │ PID callback task │
22/// │ (input ADC) │ (new cval) │ (tokio::spawn) │
23/// └─────────────┘ │ runs do_pid() │
24/// │ writes output │
25/// ┌──────────────────────────┤ to output driver │
26/// │ shared EpidFastPvt └──────────────────┘
27/// │ (Arc<Mutex>) ▲
28/// ▼ │
29/// ┌─────────────┐ read() params │
30/// │ EpidRecord │ ◄─────── copy ──────────┘
31/// │ (process) │ ────────► copy ──────────►
32/// └─────────────┘ results
33/// ```
34///
35/// The `start_callback_loop()` method spawns the background task.
36/// Call it after connecting to the asyn input port.
37pub struct EpidFastDeviceSupport {
38 pvt: Arc<Mutex<EpidFastPvt>>,
39}
40
41/// Private state for the fast PID loop, shared between the
42/// record process thread and the interrupt callback task.
43pub struct EpidFastPvt {
44 // PID parameters (copied from record on each process cycle)
45 pub kp: f64,
46 pub ki: f64,
47 pub kd: f64,
48 pub drvh: f64,
49 pub drvl: f64,
50 pub val: f64, // setpoint
51 pub fbon: bool,
52
53 // PID state (updated by callback, read by record process)
54 pub cval: f64,
55 pub oval: f64,
56 pub err: f64,
57 pub p: f64,
58 pub i: f64,
59 pub d: f64,
60 pub dt: f64,
61 pub ct: Instant,
62 pub fbop: bool,
63
64 // Averaging — C `devEpidFast.c` `epidFastPvt`
65 /// Interval (seconds) between successive driver data callbacks.
66 /// C `callbackInterval`, set by `intervalCallback` from the driver
67 /// and also used directly as `dt` in `do_PID` (devEpidFast.c:430).
68 pub callback_interval: f64,
69 /// Requested time-per-point (C `timePerPointRequested`), copied from
70 /// the record's `DT` field by `update_params` (devEpidFast.c:320).
71 pub time_per_point_requested: f64,
72 /// Actual time-per-point achieved (C `timePerPointActual`) —
73 /// `num_average * callback_interval`.
74 pub time_per_point_actual: f64,
75 pub num_average: u32,
76 pub accumulated: f64,
77 pub count: u32,
78
79 // Output port writer (set by start_callback_loop)
80 pub output_writer: Option<Arc<Mutex<dyn FnMut(f64) + Send>>>,
81
82 /// Output port reader — C `devEpidFast.c:446-448` reads the actual
83 /// current value of the output (DAC) on the feedback OFF->ON edge
84 /// via `pPvt->pfloat64Output->read(...)` so the integral term is
85 /// seeded bumplessly from the hardware's real output, not the
86 /// last value the loop happened to command. When `None` (no
87 /// output-port reader wired), the bumpless edge falls back to the
88 /// last commanded `oval` — see `do_pid`.
89 pub output_reader: Option<Arc<Mutex<dyn FnMut() -> Option<f64> + Send>>>,
90}
91
92impl Default for EpidFastPvt {
93 fn default() -> Self {
94 let now = Instant::now();
95 Self {
96 // C `devEpidFast.c:121-123` init_record seeds KP=1 and
97 // inverted drive limits (lowLimit=1, highLimit=-1) so that
98 // before `update_params` runs the output clamp is a no-op.
99 kp: 1.0,
100 ki: 0.0,
101 kd: 0.0,
102 drvh: -1.0,
103 drvl: 1.0,
104 val: 0.0,
105 fbon: false,
106 cval: 0.0,
107 oval: 0.0,
108 err: 0.0,
109 p: 0.0,
110 i: 0.0,
111 d: 0.0,
112 dt: 0.0,
113 ct: now,
114 fbop: false,
115 callback_interval: 0.0,
116 time_per_point_requested: 0.0,
117 time_per_point_actual: 0.0,
118 num_average: 1,
119 accumulated: 0.0,
120 count: 0,
121 output_writer: None,
122 output_reader: None,
123 }
124 }
125}
126
127impl EpidFastPvt {
128 /// Recompute the number of points to average and the resulting
129 /// actual time-per-point. C `devEpidFast.c::computeNumAverage`
130 /// (devEpidFast.c:356-362):
131 /// `numAverage = 0.5 + timePerPointRequested/callbackInterval`,
132 /// clamped to `>= 1`, then `timePerPointActual = numAverage *
133 /// callbackInterval`.
134 pub fn compute_num_average(&mut self) {
135 let n = if self.callback_interval > 0.0 {
136 (0.5 + self.time_per_point_requested / self.callback_interval) as i64
137 } else {
138 1
139 };
140 self.num_average = n.max(1) as u32;
141 self.time_per_point_actual = self.num_average as f64 * self.callback_interval;
142 }
143
144 /// Callback from the driver when the data-callback interval changes.
145 /// C `devEpidFast.c::intervalCallback` (devEpidFast.c:367-375):
146 /// updates `callbackInterval` then recomputes `numAverage`.
147 pub fn interval_callback(&mut self, seconds: f64) {
148 self.callback_interval = seconds;
149 self.compute_num_average();
150 }
151
152 /// Execute one PID cycle on new data. Called from the interrupt callback task.
153 /// After computing the output, writes to the output port if configured.
154 ///
155 /// Mirrors C `devEpidFast.c::dataCallback` (averaging) +
156 /// `do_PID` (devEpidFast.c:379-482).
157 pub fn do_pid(&mut self, new_cval: f64) {
158 // Averaging — C `dataCallback` (devEpidFast.c:379-398).
159 // C shortcuts when numAverage == 1 (no need to accumulate).
160 let cval = if self.num_average <= 1 {
161 new_cval
162 } else {
163 self.accumulated += new_cval;
164 self.count += 1;
165 if self.count < self.num_average {
166 return;
167 }
168 // C divides averageStore by `accumulated` (the running count),
169 // which equals `count` here.
170 let avg = self.accumulated / self.count as f64;
171 self.accumulated = 0.0;
172 self.count = 0;
173 avg
174 };
175
176 self.cval = cval;
177
178 // C `do_PID` (devEpidFast.c:430) uses `dt = pPvt->callbackInterval`
179 // — the configured driver callback interval, NOT a measured
180 // wall-clock difference. C devEpidFast keeps no `ct` timestamp.
181 let dt = self.callback_interval;
182 self.dt = self.time_per_point_actual;
183 self.ct = Instant::now();
184
185 let ep = self.err;
186 let mut oval = self.oval;
187
188 // C `do_PID` (devEpidFast.c:400-482) runs the PID algorithm
189 // UNCONDITIONALLY — `epidFastPvt` has no `fmod` field and
190 // `do_PID` never branches on FMOD. FMOD/MaxMin is honoured only
191 // by the Soft device supports (`devEpidSoft.c:137`,
192 // `devEpidSoftCallback.c:173` have `switch (pepid->fmod)`); the
193 // Fast support ignores FMOD entirely.
194 let e = self.val - cval;
195 let de = e - ep;
196 self.p = self.kp * e;
197 let di = self.kp * self.ki * e * dt;
198
199 if self.fbon {
200 if !self.fbop {
201 // Bumpless OFF->ON — C `devEpidFast.c:445-448`:
202 // pPvt->pfloat64Output->read(pPvt->float64OutputPvt,
203 // pPvt->pfloat64OutputAsynUser, &pPvt->I);
204 // The integral term is seeded from the output port's
205 // *actual current value* (the real DAC output) so the
206 // loop turns on without a bump. When an `output_reader`
207 // is wired, read it; otherwise fall back to the last
208 // commanded `oval` (the closest available estimate).
209 self.i = match &self.output_reader {
210 Some(reader) => reader
211 .lock()
212 .ok()
213 .and_then(|mut r| r())
214 .unwrap_or(self.oval),
215 None => self.oval,
216 };
217 } else if (oval > self.drvl && oval < self.drvh)
218 || (oval >= self.drvh && di < 0.0)
219 || (oval <= self.drvl && di > 0.0)
220 {
221 self.i += di;
222 // C `devEpidFast.c:455-456` does two sequential
223 // `if` clamps (low then high). `f64::clamp`
224 // panics when min > max, which happens before
225 // `update_params` seeds the limits — replicate
226 // the panic-free sequential form.
227 if self.i < self.drvl {
228 self.i = self.drvl;
229 }
230 if self.i > self.drvh {
231 self.i = self.drvh;
232 }
233 }
234 }
235 if self.ki == 0.0 {
236 self.i = 0.0;
237 }
238 self.d = if dt > 0.0 {
239 self.kp * self.kd * (de / dt)
240 } else {
241 0.0
242 };
243 self.err = e;
244 oval = self.p + self.i + self.d;
245
246 // Clamp output — C `devEpidFast.c:464-465` sequential `if`
247 // clamps (panic-free vs `f64::clamp` when drvl > drvh).
248 if oval > self.drvh {
249 oval = self.drvh;
250 }
251 if oval < self.drvl {
252 oval = self.drvl;
253 }
254 self.oval = oval;
255 self.fbop = self.fbon;
256
257 // Write output to hardware if configured
258 if self.fbon {
259 if let Some(ref writer) = self.output_writer {
260 if let Ok(mut w) = writer.lock() {
261 w(self.oval);
262 }
263 }
264 }
265 }
266}
267
268impl Default for EpidFastDeviceSupport {
269 fn default() -> Self {
270 Self::new()
271 }
272}
273
274impl EpidFastDeviceSupport {
275 pub fn new() -> Self {
276 Self {
277 pvt: Arc::new(Mutex::new(EpidFastPvt::default())),
278 }
279 }
280
281 /// Get a handle to the shared PID state for callback registration.
282 pub fn pvt(&self) -> Arc<Mutex<EpidFastPvt>> {
283 Arc::clone(&self.pvt)
284 }
285
286 /// Wire the output-port readback used for bumpless transfer.
287 ///
288 /// C `devEpidFast.c:446-448` reads the actual current output value
289 /// (`pfloat64Output->read`) on the feedback OFF->ON edge to seed
290 /// the integral term. Supply a closure that returns the output
291 /// port's current value; without it the bumpless edge falls back
292 /// to the last commanded `OVAL`.
293 pub fn set_output_reader(&self, reader: Arc<Mutex<dyn FnMut() -> Option<f64> + Send>>) {
294 if let Ok(mut p) = self.pvt.lock() {
295 p.output_reader = Some(reader);
296 }
297 }
298
299 /// Wire the asyn output port: install both the output writer and
300 /// the bumpless-transfer output reader from a single asyn Float64
301 /// port handle.
302 ///
303 /// This mirrors C `devEpidFast.c` exactly. `init_record`
304 /// (devEpidFast.c:264-303) connects to *one* output asyn port and
305 /// captures `pPvt->pfloat64Output` / `float64OutputPvt`. `do_PID`
306 /// then uses that same interface for both directions:
307 ///
308 /// * the bumpless OFF->ON edge reads the output port's present
309 /// value — `pPvt->pfloat64Output->read(...)` (devEpidFast.c:446-448);
310 /// * the feedback write — `pPvt->pfloat64Output->write(...)`
311 /// (devEpidFast.c:471-473).
312 ///
313 /// `sync_io` is the asyn Float64 output port handle and `reason`
314 /// the parameter index (asyn `drvUser` "outputDataString" channel,
315 /// devEpidFast.c:296-303). The reader calls `read_float64(reason)`
316 /// on it; a failed read yields `None`, so `do_pid` falls back to
317 /// the last commanded `OVAL` safety net.
318 pub fn set_output_port(&self, sync_io: asyn_rs::sync_io::SyncIOHandle, reason: usize) {
319 let sync_io = Arc::new(sync_io);
320 let writer_io = Arc::clone(&sync_io);
321 let reader_io = Arc::clone(&sync_io);
322
323 let writer: Arc<Mutex<dyn FnMut(f64) + Send>> = Arc::new(Mutex::new(move |v: f64| {
324 // C `devEpidFast.c:471-478` — write the output, log on error.
325 let _ = writer_io.write_float64(reason, v);
326 }));
327 let reader: Arc<Mutex<dyn FnMut() -> Option<f64> + Send>> =
328 Arc::new(Mutex::new(move || reader_io.read_float64(reason).ok()));
329
330 if let Ok(mut p) = self.pvt.lock() {
331 p.output_writer = Some(writer);
332 p.output_reader = Some(reader);
333 }
334 }
335
336 /// Start the interrupt-driven PID callback loop.
337 ///
338 /// Spawns a tokio task that receives new readback values from `input_rx`
339 /// and runs `do_pid()` on each. This is the high-speed PID path that
340 /// runs at the interrupt rate (1kHz+), independent of record processing.
341 ///
342 /// `input_rx`: receives new controlled-variable values from the input driver
343 /// `output_fn`: called with each new output value (writes to output driver)
344 ///
345 /// This bare-closure form leaves the bumpless-transfer `output_reader`
346 /// untouched — wire it separately with [`set_output_reader`] or
347 /// [`set_output_port`], otherwise the OFF->ON edge falls back to the
348 /// last commanded `OVAL`. When the output is a real asyn Float64
349 /// port, prefer [`start_callback_loop_with_port`], which installs
350 /// both writer and reader from the same port handle (mirroring C
351 /// `devEpidFast.c` `pPvt->pfloat64Output`).
352 ///
353 /// [`set_output_reader`]: Self::set_output_reader
354 /// [`set_output_port`]: Self::set_output_port
355 /// [`start_callback_loop_with_port`]: Self::start_callback_loop_with_port
356 pub fn start_callback_loop(
357 &self,
358 mut input_rx: tokio::sync::mpsc::Receiver<f64>,
359 output_fn: Arc<Mutex<dyn FnMut(f64) + Send>>,
360 ) {
361 let pvt = Arc::clone(&self.pvt);
362
363 // Store the output writer in pvt
364 {
365 let mut p = pvt.lock().unwrap();
366 p.output_writer = Some(output_fn);
367 }
368
369 tokio::spawn(async move {
370 while let Some(new_cval) = input_rx.recv().await {
371 let mut p = pvt.lock().unwrap();
372 p.do_pid(new_cval);
373 }
374 });
375 }
376
377 /// Start the PID callback loop driven by an asyn Float64 output port.
378 ///
379 /// Identical to [`start_callback_loop`] but takes the output asyn
380 /// port handle directly and installs *both* the output writer and
381 /// the bumpless-transfer output reader from it via
382 /// [`set_output_port`] — mirroring C `devEpidFast.c` `init_record`,
383 /// which captures a single `pPvt->pfloat64Output` interface and uses
384 /// it for both `read` (devEpidFast.c:446-448) and `write`
385 /// (devEpidFast.c:471-473).
386 ///
387 /// `output_sync_io` is the asyn Float64 output port handle and
388 /// `output_reason` the parameter index for the output channel.
389 ///
390 /// [`start_callback_loop`]: Self::start_callback_loop
391 /// [`set_output_port`]: Self::set_output_port
392 pub fn start_callback_loop_with_port(
393 &self,
394 input_rx: tokio::sync::mpsc::Receiver<f64>,
395 output_sync_io: asyn_rs::sync_io::SyncIOHandle,
396 output_reason: usize,
397 ) {
398 self.set_output_port(output_sync_io, output_reason);
399
400 let pvt = Arc::clone(&self.pvt);
401 let mut input_rx = input_rx;
402 tokio::spawn(async move {
403 while let Some(new_cval) = input_rx.recv().await {
404 let mut p = pvt.lock().unwrap();
405 p.do_pid(new_cval);
406 }
407 });
408 }
409
410 /// Start from an asyn interrupt subscription.
411 ///
412 /// Subscribes to Float64 interrupts from the given broadcast sender
413 /// and feeds them into the PID callback loop.
414 ///
415 /// This bare-closure form leaves the bumpless-transfer `output_reader`
416 /// untouched — wire it separately with [`set_output_reader`] or
417 /// [`set_output_port`], or use [`start_from_asyn_interrupts_with_port`]
418 /// to install both writer and reader from one asyn output port.
419 ///
420 /// [`set_output_reader`]: Self::set_output_reader
421 /// [`set_output_port`]: Self::set_output_port
422 /// [`start_from_asyn_interrupts_with_port`]: Self::start_from_asyn_interrupts_with_port
423 pub fn start_from_asyn_interrupts(
424 &self,
425 interrupt_rx: tokio::sync::broadcast::Receiver<asyn_rs::interrupt::InterruptValue>,
426 input_reason: usize,
427 output_fn: Arc<Mutex<dyn FnMut(f64) + Send>>,
428 ) {
429 {
430 let mut p = self.pvt.lock().unwrap();
431 p.output_writer = Some(output_fn);
432 }
433 self.spawn_interrupt_loop(interrupt_rx, input_reason);
434 }
435
436 /// Start from an asyn interrupt subscription, driven by an asyn
437 /// Float64 output port.
438 ///
439 /// Identical to [`start_from_asyn_interrupts`] but takes the output
440 /// asyn port handle directly and installs *both* the output writer
441 /// and the bumpless-transfer output reader from it via
442 /// [`set_output_port`] — mirroring C `devEpidFast.c` `init_record`,
443 /// which captures a single `pPvt->pfloat64Output` interface and uses
444 /// it for both `read` (devEpidFast.c:446-448) and `write`
445 /// (devEpidFast.c:471-473).
446 ///
447 /// [`start_from_asyn_interrupts`]: Self::start_from_asyn_interrupts
448 /// [`set_output_port`]: Self::set_output_port
449 pub fn start_from_asyn_interrupts_with_port(
450 &self,
451 interrupt_rx: tokio::sync::broadcast::Receiver<asyn_rs::interrupt::InterruptValue>,
452 input_reason: usize,
453 output_sync_io: asyn_rs::sync_io::SyncIOHandle,
454 output_reason: usize,
455 ) {
456 self.set_output_port(output_sync_io, output_reason);
457 self.spawn_interrupt_loop(interrupt_rx, input_reason);
458 }
459
460 /// Spawn the tokio task that feeds asyn Float64 interrupts into
461 /// `do_pid`. Shared by both `start_from_asyn_interrupts` variants.
462 fn spawn_interrupt_loop(
463 &self,
464 mut interrupt_rx: tokio::sync::broadcast::Receiver<asyn_rs::interrupt::InterruptValue>,
465 input_reason: usize,
466 ) {
467 let pvt = Arc::clone(&self.pvt);
468 tokio::spawn(async move {
469 loop {
470 match interrupt_rx.recv().await {
471 Ok(iv) => {
472 if iv.reason == input_reason {
473 let v = match &iv.value {
474 asyn_rs::param::ParamValue::Float64(f) => Some(*f),
475 asyn_rs::param::ParamValue::Int32(i) => Some(*i as f64),
476 asyn_rs::param::ParamValue::Int64(i) => Some(*i as f64),
477 _ => None,
478 };
479 if let Some(v) = v {
480 let mut p = pvt.lock().unwrap();
481 p.do_pid(v);
482 }
483 }
484 }
485 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
486 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
487 // Dropped some interrupts — continue
488 }
489 }
490 }
491 });
492 }
493
494 /// Copy parameters from record to fast PID state.
495 ///
496 /// C `devEpidFast.c::update_params` (devEpidFast.c:312-354): when the
497 /// record's `DT` field (requested time-per-point) differs from the
498 /// achieved `timePerPointActual`, the requested value is adopted and
499 /// `numAverage` is recomputed.
500 fn update_params_from_record(&self, epid: &EpidRecord) {
501 let mut pvt = self.pvt.lock().unwrap();
502 // C devEpidFast.c:319-322 — recompute averaging if DT changed.
503 if epid.dt != pvt.time_per_point_actual {
504 pvt.time_per_point_requested = epid.dt;
505 pvt.compute_num_average();
506 }
507 pvt.kp = epid.kp;
508 pvt.ki = epid.ki;
509 pvt.kd = epid.kd;
510 pvt.drvh = epid.drvh;
511 pvt.drvl = epid.drvl;
512 pvt.val = epid.val;
513 pvt.fbon = epid.fbon != 0;
514 // C `devEpidFast.c::update_params` (devEpidFast.c:332-339) copies
515 // FBON/DRVH/DRVL/KP/KI/KD/VAL only — `epidFastPvt` has no `fmod`
516 // field and the Fast support never reads FMOD.
517 }
518
519 /// Copy computed results from fast PID state back to record.
520 fn update_record_from_params(&self, epid: &mut EpidRecord) {
521 let pvt = self.pvt.lock().unwrap();
522 epid.cval = pvt.cval;
523 epid.oval = pvt.oval;
524 epid.err = pvt.err;
525 epid.p = pvt.p;
526 epid.i = pvt.i;
527 epid.d = pvt.d;
528 epid.dt = pvt.dt;
529 epid.fbop = if pvt.fbop { 1 } else { 0 };
530 }
531}
532
533impl DeviceSupport for EpidFastDeviceSupport {
534 fn dtyp(&self) -> &str {
535 "Fast Epid"
536 }
537
538 fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
539 let epid = record
540 .as_any_mut()
541 .and_then(|a| a.downcast_mut::<EpidRecord>())
542 .expect("EpidFastDeviceSupport requires an EpidRecord");
543
544 // Copy parameters to fast PID (so callback loop uses latest gains)
545 self.update_params_from_record(epid);
546 // Copy latest results back to record (for display/alarm)
547 self.update_record_from_params(epid);
548 Ok(DeviceReadOutcome::computed())
549 }
550
551 fn write(&mut self, _record: &mut dyn Record) -> CaResult<()> {
552 Ok(())
553 }
554}