std_rs/records/epid.rs
1use std::any::Any;
2use std::time::Instant;
3
4use super::dbd_generated;
5use epics_base_rs::error::{CaError, CaResult};
6use epics_base_rs::server::recgbl::{self, alarm_status};
7use epics_base_rs::server::record::{
8 AlarmSeverity, CommonFields, FieldDesc, FieldMetadataOverride, LinkType, ProcessAction,
9 ProcessContext, ProcessOutcome, Record, link_field_type,
10};
11use epics_base_rs::types::{EpicsValue, PvString};
12
13/// Record-specific `DBF_MENU` choice tables, in `.dbd` value order (the
14/// index↔string mapping is wire-visible to clients). Source: the C
15/// `epidRecord.dbd` menu definitions (std module). `FMOD` is
16/// `menu(epidFeedbackMode)`; `FBON`/`FBOP` are `menu(epidFeedbackState)`.
17/// The alarm severities are shared menus resolved by the base registry.
18/// `SMSL` ("Setpoint Mode Select", `epidRecord.dbd:17`) is `menu(menuOmsl)`,
19/// but its field *name* is record-specific — the base registry keys the
20/// shared `menuOmsl` table by the standard name `OMSL` — so it is mapped
21/// per record to [`epics_base_rs::server::record::dbd_generated::MENU_OMSL`].
22/// `ReadDbLink` target for the bumpless-transfer OUTL readback.
23///
24/// Deliberately NOT a `.dbd` field: it names the internal staging cell
25/// `EpidRecord::outl_seed`, not a CA-visible one. C reads OUTL inside
26/// `do_pid`, after the MDT gate, so the value must not be observable (nor
27/// monitor-posted) on a cycle C would have gated — see `pre_process_actions`.
28const OUTL_SEED_FIELD: &str = "__OUTL_SEED";
29
30/// Feedback mode for the epid record.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32#[repr(i16)]
33pub enum FeedbackMode {
34 #[default]
35 Pid = 0,
36 MaxMin = 1,
37}
38
39impl From<i16> for FeedbackMode {
40 fn from(v: i16) -> Self {
41 match v {
42 1 => FeedbackMode::MaxMin,
43 _ => FeedbackMode::Pid,
44 }
45 }
46}
47
48/// Feedback on/off state.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50#[repr(i16)]
51pub enum FeedbackState {
52 #[default]
53 Off = 0,
54 On = 1,
55}
56
57impl From<i16> for FeedbackState {
58 fn from(v: i16) -> Self {
59 match v {
60 1 => FeedbackState::On,
61 _ => FeedbackState::Off,
62 }
63 }
64}
65
66/// Extended PID feedback control record.
67///
68/// Ported from EPICS std module `epidRecord.c`.
69/// Supports PID and Max/Min feedback modes with anti-windup,
70/// bumpless turn-on, output deadband, and hysteresis-based alarms.
71pub struct EpidRecord {
72 // --- PID control ---
73 /// Setpoint (VAL)
74 pub val: f64,
75 /// Setpoint mode: 0=supervisory, 1=closed_loop (SMSL)
76 pub smsl: i16,
77 /// Setpoint input link (STPL) — resolved by framework
78 pub stpl: String,
79 /// Controlled value input link (INP) — resolved by framework
80 pub inp: String,
81 /// Output link (OUTL) — resolved by framework
82 pub outl: String,
83 /// Readback trigger link (TRIG)
84 pub trig: String,
85 /// Trigger value (TVAL)
86 pub tval: f64,
87 /// Controlled value (CVAL), read-only
88 pub cval: f64,
89 /// Previous controlled value (CVLP), read-only
90 pub cvlp: f64,
91 /// Output value (OVAL), read-only
92 pub oval: f64,
93 /// Previous output value (OVLP), read-only
94 pub ovlp: f64,
95 /// Proportional gain (KP)
96 pub kp: f64,
97 /// Integral gain — repeats per second (KI)
98 pub ki: f64,
99 /// Derivative gain (KD)
100 pub kd: f64,
101 /// Proportional component (P), read-only
102 pub p: f64,
103 /// Previous P (PP), read-only
104 pub pp: f64,
105 /// Integral component (I), writable for bumpless init
106 pub i: f64,
107 /// Previous I (IP)
108 pub ip: f64,
109 /// Derivative component (D), read-only
110 pub d: f64,
111 /// Previous D (DP), read-only
112 pub dp: f64,
113 /// Error = setpoint - controlled value (ERR), read-only
114 pub err: f64,
115 /// Previous error (ERRP), read-only
116 pub errp: f64,
117 /// Delta time in seconds (DT), writable for fast mode
118 pub dt: f64,
119 /// Previous delta time (DTP)
120 pub dtp: f64,
121 /// Minimum delta time between calculations (MDT)
122 pub mdt: f64,
123 /// Feedback mode: PID or MaxMin (FMOD)
124 pub fmod: i16,
125 /// Feedback on/off (FBON)
126 pub fbon: i16,
127 /// Previous feedback on/off (FBOP)
128 pub fbop: i16,
129 /// Output deadband (ODEL)
130 pub odel: f64,
131
132 // --- Display ---
133 /// Display precision (PREC)
134 pub prec: i16,
135 /// Engineering units (EGU)
136 pub egu: PvString,
137 /// High operating range (HOPR)
138 pub hopr: f64,
139 /// Low operating range (LOPR)
140 pub lopr: f64,
141 /// High drive limit (DRVH)
142 pub drvh: f64,
143 /// Low drive limit (DRVL)
144 pub drvl: f64,
145
146 // --- Alarm ---
147 /// Hihi deviation limit (HIHI)
148 pub hihi: f64,
149 /// Lolo deviation limit (LOLO)
150 pub lolo: f64,
151 /// High deviation limit (HIGH)
152 pub high: f64,
153 /// Low deviation limit (LOW)
154 pub low: f64,
155 /// Hihi severity (HHSV)
156 pub hhsv: i16,
157 /// Lolo severity (LLSV)
158 pub llsv: i16,
159 /// High severity (HSV)
160 pub hsv: i16,
161 /// Low severity (LSV)
162 pub lsv: i16,
163 /// Alarm deadband / hysteresis (HYST)
164 pub hyst: f64,
165 /// Last value alarmed (LALM), read-only
166 pub lalm: f64,
167
168 // --- Monitor deadband ---
169 /// Archive deadband (ADEL)
170 pub adel: f64,
171 /// Monitor deadband (MDEL)
172 pub mdel: f64,
173 /// Last value archived (ALST), read-only
174 pub alst: f64,
175 /// Last value monitored (MLST), read-only
176 pub mlst: f64,
177
178 // --- Internal time tracking ---
179 /// Current time (CT) — used for delta-T computation
180 pub(crate) ct: Instant,
181 /// Previous time (CTP) — tracked for monitor change detection
182 #[allow(dead_code)]
183 pub(crate) ctp: Instant,
184
185 // --- Internal flags ---
186 /// Set by the framework (via set_device_did_compute) to indicate
187 /// device support's read() already performed the PID computation.
188 /// process() checks this to avoid running the built-in PID a second time.
189 device_did_compute: bool,
190 /// Set by `do_pid` when the `INP` link is a CONSTANT link (a literal
191 /// value, not a PV reference). C `devEpidSoft.c:110-112`
192 /// (`if (pepid->inp.type == CONSTANT) recGblSetSevr(...,SOFT_ALARM,
193 /// INVALID_ALARM)`): with a constant INP there is "nothing to
194 /// control", so the PID compute is skipped and SOFT/INVALID is
195 /// raised. The framework `check_alarms` hook reads this flag and
196 /// applies the severity via `recGblSetSevr`.
197 pub inp_constant: bool,
198 /// Framework-owned `dbCommon.udf`, pushed by the framework via
199 /// [`Record::set_process_context`] immediately before `process()`.
200 /// C `epidRecord.c:195` reads `pepid->udf` at the top of
201 /// `process()` and skips `do_pid` entirely while it is set. The
202 /// matching `UDF_ALARM` (C `epidRecord.c:199`,
203 /// `recGblSetSevr(pepid,UDF_ALARM,pepid->udfs)`) is raised by the
204 /// framework's centralised `rec_gbl_check_udf` after `process()`.
205 udf: bool,
206 /// Set by `process()` for a cycle on which the UDF gate skipped
207 /// `do_pid`. C `epidRecord.c:201` `return(0)` is reached before
208 /// `recGblFwdLink` and before `do_pid` writes the output, so on
209 /// such a cycle the framework must NOT write the OUTL link
210 /// (`multi_output_links`) or fire the forward link.
211 compute_skipped: bool,
212 /// True iff the device-support compute decided to write the OUTL
213 /// output link this cycle. In C the OUTL `dbPutLink` lives INSIDE
214 /// `do_pid` and fires only when `pepid->fbon && outl.type != CONSTANT`
215 /// (`devEpidSoft.c:220`, `devEpidSoftCallback.c:256`), and only when
216 /// `do_pid` reached that line — i.e. NOT on the sub-MDT early return
217 /// (`devEpidSoft.c:125`) nor the CONSTANT-INP early return
218 /// (`devEpidSoft.c:110-112`). The Fast support (`devEpidFast.c`)
219 /// drives the DAC through its own output port and never writes OUTL.
220 /// `do_pid` is the single owner: it clears this at entry (so every
221 /// early return leaves it false) and sets it to `fbon != 0` only on
222 /// the success path. Records whose device support never calls
223 /// `do_pid` (Fast) leave it false → no framework OUTL write, matching
224 /// C. The CONSTANT/empty-link skip (`outl.type != CONSTANT`) is the
225 /// framework's no-op on a constant OUTL `WriteDbLink`.
226 outl_write: bool,
227 /// True iff the framework's input-link fetch for `STPL` actually
228 /// produced a value this cycle — the framework analogue of C
229 /// `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))`. Pushed by the
230 /// framework via [`Record::set_resolved_input_links`] after the
231 /// `multi_input_links` fetch (STPL is only in that list when
232 /// `SMSL == closed_loop`). C `epidRecord.c:191-193` clears `udf`
233 /// only on this success — a STPL that is empty, or a DB/CA link
234 /// whose fetch failed, leaves `udf` set.
235 stpl_resolved: bool,
236 /// The OUTL readback captured for THIS cycle's bumpless turn-on, or
237 /// `None` if OUTL was not read.
238 ///
239 /// C reads OUTL *inside* `do_pid`, after the `if (dt<pepid->mdt)
240 /// return(1);` gate (`devEpidSoft.c:125`), and lands the value straight in
241 /// `do_pid`'s local `i` / `oval` (`:150-158`, `:178-184`) — a sub-MDT (or
242 /// UDF-gated) cycle therefore never reads OUTL and never touches `.I` /
243 /// `.OVAL`. The framework's `ReadDbLink` can only run *before* `process()`,
244 /// so it lands here instead of in the CA-visible field: this cell is the
245 /// staging slot, written only by that pre-process read and consumed only
246 /// by `do_pid` at C's line. A gated cycle simply leaves it unconsumed —
247 /// no field write, no monitor, and FBOP stays 0 so the next full cycle
248 /// re-reads and seeds for real.
249 pub(crate) outl_seed: Option<f64>,
250 /// Framework-owned `dbCommon.dtyp`, pushed by the framework via
251 /// [`Record::set_process_context`] before the input-link fetch.
252 /// C device support for the epid record lives in two distinct
253 /// DSETs — `devEpidSoft` (`devEpidSoft.c`, no TRIG handling) and
254 /// `devEpidSoftCallback` (`devEpidSoftCallback.c`, which drives the
255 /// TRIG readback link). [`Record::pre_input_link_actions`] checks
256 /// this via [`EpidRecord::is_async_callback_dtyp`] to emit the TRIG
257 /// write only when the callback DSET (`stdSupport.dbd:14`, DTYP
258 /// `"Async Soft Channel"`) is selected.
259 dtyp: String,
260 /// Epid-owned `dbCommon.udf` projection, returned by
261 /// [`Record::value_is_undefined`]. C `epidRecord.c` has
262 /// `special = NULL` (line 105) — there is no operator UDF clear,
263 /// and `udf` is cleared ONLY by the two C conditions:
264 ///
265 /// - `epidRecord.c:160-164` init: a CONSTANT `STPL` link holding
266 /// a valid constant clears `udf` (mirrored by
267 /// [`Record::post_init_finalize_undef`] / a CONSTANT `STPL`
268 /// making `value_is_undefined()` return `false`).
269 /// - `epidRecord.c:191-193` process: closed-loop (`SMSL=1`) with
270 /// a successful `dbGetLink(stpl)` clears `udf`.
271 ///
272 /// `process()` recomputes this each cycle; the framework's
273 /// post-process `common.udf = value_is_undefined()` then keeps a
274 /// supervisory / empty-STPL epid permanently undefined, exactly as
275 /// C leaves `udf == TRUE` forever for such a record.
276 value_undefined: bool,
277 /// Which pass of a CA-type TRIG link this record is in.
278 ///
279 /// Owned end-to-end by [`EpidRecord::pre_input_link_actions`], the
280 /// single site that fires TRIG: it advances `Idle ->
281 /// AwaitingCallback` when it fires an asynchronous trigger and back
282 /// to `Idle` on the callback (reprocess) pass. This is C's
283 /// `pepid->pact`, which `devEpidSoftCallback.c:116` reads as
284 /// `if (!pepid->pact)` to skip the whole trigger block on the
285 /// second pass.
286 ///
287 /// C `devEpidSoftCallback.c:143-145`: a CA TRIG link fires the
288 /// readback trigger asynchronously (`dbCaPutLinkCallback`), sets
289 /// `pepid->pact = TRUE` and `return(0)`. C `epidRecord.c:207`
290 /// `if (!pact && pepid->pact) return(0)` then returns BEFORE
291 /// `recGblGetTimeStamp` / `checkAlarms` / `monitor` /
292 /// `recGblFwdLink` — so the trigger pass runs NONE of the
293 /// process tail; the tail runs exactly once, on the callback
294 /// (reprocess) pass.
295 ///
296 /// `process()` reads (but does not clear) `AwaitingCallback` and
297 /// returns `ProcessOutcome::async_pending()`, which makes the
298 /// framework skip the alarm/timestamp/snapshot/OUT/FLNK tail for
299 /// the trigger cycle while still executing the emitted
300 /// `WriteDbLink{TRIG}` + `ReprocessAfter`. The reprocess pass runs
301 /// `do_pid` and the tail exactly once.
302 ca_trig: CaTrigPhase,
303}
304
305/// Which pass of an asynchronous (CA-link) TRIG readback an epid record
306/// is in — the port's `pepid->pact` for the trigger path.
307///
308/// Two variants rather than a bool because the state has exactly one
309/// owner and one transition each way; a bool invited a second copy of it
310/// to live in the device support, where the record could not see it.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
312pub enum CaTrigPhase {
313 /// No trigger outstanding. The next process pass may fire one.
314 #[default]
315 Idle,
316 /// A `dbCaPutLinkCallback`-equivalent trigger is outstanding; the
317 /// next pass is the callback pass and must run the PID, not
318 /// re-trigger.
319 AwaitingCallback,
320}
321
322impl Default for EpidRecord {
323 fn default() -> Self {
324 let now = Instant::now();
325 Self {
326 val: 0.0,
327 smsl: 0,
328 stpl: String::new(),
329 inp: String::new(),
330 outl: String::new(),
331 trig: String::new(),
332 tval: 0.0,
333 cval: 0.0,
334 cvlp: 0.0,
335 oval: 0.0,
336 ovlp: 0.0,
337 kp: 0.0,
338 ki: 0.0,
339 kd: 0.0,
340 p: 0.0,
341 pp: 0.0,
342 i: 0.0,
343 ip: 0.0,
344 d: 0.0,
345 dp: 0.0,
346 err: 0.0,
347 errp: 0.0,
348 dt: 0.0,
349 dtp: 0.0,
350 mdt: 0.0,
351 fmod: 0,
352 fbon: 0,
353 fbop: 0,
354 odel: 0.0,
355 prec: 0,
356 egu: PvString::new(),
357 hopr: 0.0,
358 lopr: 0.0,
359 drvh: 0.0,
360 drvl: 0.0,
361 hihi: 0.0,
362 lolo: 0.0,
363 high: 0.0,
364 low: 0.0,
365 hhsv: 0,
366 llsv: 0,
367 hsv: 0,
368 lsv: 0,
369 hyst: 0.0,
370 lalm: 0.0,
371 adel: 0.0,
372 mdel: 0.0,
373 alst: 0.0,
374 mlst: 0.0,
375 ct: now,
376 ctp: now,
377 device_did_compute: false,
378 inp_constant: false,
379 dtyp: String::new(),
380 udf: true,
381 compute_skipped: false,
382 outl_write: false,
383 stpl_resolved: false,
384 outl_seed: None,
385 // C `epidRecord.c` init: `udf` starts TRUE and is cleared
386 // only by the two clear-conditions — see `value_undefined`.
387 value_undefined: true,
388 ca_trig: CaTrigPhase::Idle,
389 }
390 }
391}
392
393impl EpidRecord {
394 /// Decide the alarm condition using hysteresis-based threshold
395 /// comparison on VAL. Ported from epidRecord.c `checkAlarms()`,
396 /// which mirrors `aiRecord.c::checkAlarms` — per-level hysteresis
397 /// against VAL with `lalm` tracking the last-alarmed threshold.
398 ///
399 /// Returns `Some((stat, sevr, alev))` where `stat` is the canonical
400 /// `epicsAlarmCondition` status code (`HIHI_ALARM`, `HIGH_ALARM`,
401 /// `LOLO_ALARM`, `LOW_ALARM`), `sevr` the configured severity, and
402 /// `alev` the threshold that fired (the candidate `lalm` value).
403 /// Returns `None` when VAL is inside the (hysteresis-adjusted) limits.
404 ///
405 /// `lalm` (last-alarmed threshold) is committed by the caller, NOT
406 /// here, for the alarm case. C `aiRecord.c:403-406` gates the `lalm`
407 /// update on `recGblSetSevr` actually raising the severity:
408 /// `if (recGblSetSevr(...)) prec->lalm = alev;`. A lower-severity
409 /// alarm that loses to an already-higher pending severity must NOT
410 /// advance `lalm`, or the hysteresis band would be silently re-based.
411 /// The [`Record::check_alarms`] trait hook below performs that gate.
412 ///
413 /// The no-alarm case writes `lalm = val` here unconditionally,
414 /// matching C `aiRecord.c:409` (`prec->lalm = val;` — not gated).
415 pub fn check_alarms(&mut self) -> Option<(u16, AlarmSeverity, f64)> {
416 let val = self.val;
417 let hyst = self.hyst;
418 let lalm = self.lalm;
419
420 // HIHI alarm
421 if self.hhsv != 0 && (val >= self.hihi || (lalm == self.hihi && val >= self.hihi - hyst)) {
422 return Some((
423 alarm_status::HIHI_ALARM,
424 AlarmSeverity::from_u16(self.hhsv as u16),
425 self.hihi,
426 ));
427 }
428
429 // LOLO alarm
430 if self.llsv != 0 && (val <= self.lolo || (lalm == self.lolo && val <= self.lolo + hyst)) {
431 return Some((
432 alarm_status::LOLO_ALARM,
433 AlarmSeverity::from_u16(self.llsv as u16),
434 self.lolo,
435 ));
436 }
437
438 // HIGH alarm
439 if self.hsv != 0 && (val >= self.high || (lalm == self.high && val >= self.high - hyst)) {
440 return Some((
441 alarm_status::HIGH_ALARM,
442 AlarmSeverity::from_u16(self.hsv as u16),
443 self.high,
444 ));
445 }
446
447 // LOW alarm
448 if self.lsv != 0 && (val <= self.low || (lalm == self.low && val <= self.low + hyst)) {
449 return Some((
450 alarm_status::LOW_ALARM,
451 AlarmSeverity::from_u16(self.lsv as u16),
452 self.low,
453 ));
454 }
455
456 // No alarm — C `aiRecord.c:409` resets LALM to VAL unconditionally.
457 self.lalm = val;
458 None
459 }
460
461 /// Mark this cycle as a CA-TRIG trigger pass.
462 ///
463 /// Called by [`crate::device_support::epid_soft_callback::
464 /// EpidSoftCallbackDeviceSupport::read`] on the first pass of a
465 /// CA-type TRIG link, before `process()` runs. `process()` consumes
466 /// the flag and returns `ProcessOutcome::async_pending()` so the
467 /// trigger pass skips the process tail (checkAlarms / monitor /
468 /// recGblFwdLink) — C `devEpidSoftCallback.c:143-145` +
469 /// `epidRecord.c:205-210`. See `EpidRecord::ca_trig`.
470 pub fn ca_trig_phase(&self) -> CaTrigPhase {
471 self.ca_trig
472 }
473
474 /// True when DTYP selects the `devEpidSoftCB` DSET — the only epid
475 /// device support that touches the TRIG readback link.
476 ///
477 /// The string is the one `stdSupport.dbd:14` registers,
478 /// `device(epid,CONSTANT,devEpidSoftCB,"Async Soft Channel")`, and
479 /// is what a `.db` written against the C module sets (this crate
480 /// ships one: `db/async_pid_control.db`). epid deliberately reuses
481 /// base's soft-channel DTYP strings for record-specific behaviour,
482 /// so device selection is by the (record type, DTYP) pair and the
483 /// match belongs here in the epid body rather than in base's
484 /// `is_soft_dtyp`, which is keyed on DTYP alone.
485 pub fn is_async_callback_dtyp(&self) -> bool {
486 self.dtyp == "Async Soft Channel"
487 }
488
489 /// Owner setter for `EpidRecord::outl_write`. Only `do_pid` calls
490 /// this — it clears the flag at entry and re-enables it per `fbon`
491 /// on the success path, mirroring C's OUTL `dbPutLink` gate
492 /// (`devEpidSoft.c:220`). Keeping it private to a setter preserves
493 /// the single-owner invariant.
494 pub fn set_outl_write(&mut self, write: bool) {
495 self.outl_write = write;
496 }
497
498 /// Update monitor tracking fields. Returns list of fields that changed.
499 /// Ported from epidRecord.c `monitor()`.
500 pub fn update_monitors(&mut self) {
501 // Update previous-value fields for change detection
502 self.ovlp = self.oval;
503 self.pp = self.p;
504 self.ip = self.i;
505 self.dp = self.d;
506 self.dtp = self.dt;
507 self.errp = self.err;
508 self.cvlp = self.cval;
509
510 // VAL deadband baselines (MLST/ALST) are NOT advanced here. C
511 // `epidRecord.c:346-374` `monitor()` computes `delta = mlst - val`,
512 // posts VAL when `delta > mdel`, and only THEN sets `mlst = val`
513 // — the post and the advance are one owner. In Rust that owner is
514 // the framework's `check_deadband_ext`
515 // (`record_instance.rs:2180-2203`): it reads MLST, fires the VAL
516 // monitor, then advances `mlst`/`alst` via `put_coerced`. Advancing
517 // them here (before that runs) made the framework see a zero delta
518 // and silently suppress every VAL post. `update_monitors` owns only
519 // the epid-specific previous-value fields above (`pp`/`ip`/`dp`/
520 // `cvlp`/...), not the MLST/ALST deadband state.
521 }
522}
523
524impl Record for EpidRecord {
525 fn record_type(&self) -> &'static str {
526 "epid"
527 }
528
529 /// `epidRecord.c:238-261` and `:263-286` are one switch with TWO windows,
530 /// not one: `VAL`/`HIHI`/`HIGH`/`LOW`/`LOLO`/`CVAL` answer `hopr`/`lopr`,
531 /// and `OVAL`/`P`/`I`/`D` — the controller output and its three gain terms
532 /// — answer `drvh`/`drvl` instead, because they live on the actuator's
533 /// scale and not the process variable's. The first window is the
534 /// record-level cache; the second cannot be, since a record has only one
535 /// cache and epid needs two ranges at once.
536 fn field_metadata_override(&self, field: &str) -> Option<FieldMetadataOverride> {
537 ["OVAL", "P", "I", "D"]
538 .iter()
539 .any(|f| field.eq_ignore_ascii_case(f))
540 .then(|| FieldMetadataOverride {
541 disp_limits: Some((self.drvh, self.drvl)),
542 ctrl_limits: Some((self.drvh, self.drvl)),
543 ..Default::default()
544 })
545 }
546
547 /// Bumpless-transfer readback — C `devEpidSoft.c:153-158` (PID) and
548 /// `devEpidSoft.c:178-184` / `devEpidSoftCallback.c:214-220`
549 /// (MaxMin).
550 ///
551 /// On the feedback OFF->ON edge (`FBOP==0 && FBON!=0`) C seeds the
552 /// turn-on state from the `OUTL` output link's *actual current
553 /// value* via `dbGetLink(&pepid->outl, DBR_DOUBLE, ...)`, guarded by
554 /// `outl.type != CONSTANT`. The seeded field differs by FMOD:
555 ///
556 /// - PID (`fmod==0`), C `devEpidSoft.c:155`:
557 /// `dbGetLink(&pepid->outl, DBR_DOUBLE, &i, ...)` — the OUTL
558 /// readback lands in the integral term `I`.
559 /// - MaxMin (`fmod==1`), C `devEpidSoft.c:181` /
560 /// `devEpidSoftCallback.c:217`:
561 /// `dbGetLink(&pepid->outl, DBR_DOUBLE, &oval, ...)` — the OUTL
562 /// readback lands in the output value `OVAL`.
563 ///
564 /// The Rust framework's `ReadDbLink` pre-process action performs
565 /// exactly that synchronous read of the DB link's target value, but it
566 /// can only run BEFORE `process()` / `do_pid`, whereas C reads OUTL
567 /// *after* the `dt < MDT` gate (`devEpidSoft.c:125`) and the record's
568 /// UDF gate (`epidRecord.c:195`). So the read does NOT land in `.I` /
569 /// `.OVAL` here — it lands in `EpidRecord::outl_seed`, and `do_pid`
570 /// consumes it at C's line. A cycle that C would have gated leaves the
571 /// staged value unconsumed: no field write, no monitor, and FBOP stays
572 /// 0 so the next ungated cycle re-reads and seeds for real.
573 ///
574 /// `FBOP` still holds the *previous* cycle's `FBON` at this point
575 /// (it is committed at the end of `do_pid`), so the edge is
576 /// detectable here. The action is emitted only for a non-CONSTANT
577 /// `OUTL` link, mirroring C's `outl.type != CONSTANT` guard — for a
578 /// CONSTANT/empty `OUTL` nothing is staged and the seeded field keeps
579 /// its prior value.
580 fn pre_process_actions(&mut self) -> Vec<ProcessAction> {
581 // The staged readback is per-cycle: whatever a previous cycle left
582 // behind must not be mistaken for this cycle's OUTL value.
583 self.outl_seed = None;
584 let edge = self.fbon != 0 && self.fbop == 0;
585 if edge {
586 match link_field_type(&self.outl) {
587 LinkType::Db | LinkType::Ca => {
588 return vec![ProcessAction::ReadDbLink {
589 link_field: "OUTL",
590 target_field: OUTL_SEED_FIELD,
591 }];
592 }
593 _ => {}
594 }
595 }
596 Vec::new()
597 }
598
599 fn process(&mut self) -> CaResult<ProcessOutcome> {
600 // In the C code, process() always calls pdset->do_pid() — a custom
601 // device support function unique to the epid record. In Rust, the
602 // framework has a generic DeviceSupport trait with read()/write()
603 // and no custom function pointers.
604 //
605 // For non-"Soft Channel" DTYPs (e.g. "Fast Epid"), the framework
606 // calls DeviceSupport::read() BEFORE process(). That read() runs
607 // the driver-specific PID and sets pid_done = true.
608 //
609 // For "Soft Channel" or no device support, the framework skips
610 // read(), so pid_done stays false and process() runs the built-in
611 // PID here.
612
613 // C `epidRecord.c:189-203`: the UDF gate is taken only on the
614 // non-callback pass (`if (!pact)`). `device_did_compute` is the
615 // Rust equivalent of "device support already ran do_pid" — the
616 // callback pass — so the gate applies only when it is false.
617 //
618 // C `epidRecord.c` clears `udf` ONLY at two sites (`special` is
619 // NULL — there is no operator UDF clear):
620 // - `epidRecord.c:160-164` init: a CONSTANT `STPL` link with a
621 // valid constant. A constant link's value never changes, so
622 // it is "defined" on every cycle thereafter.
623 // - `epidRecord.c:191-193` process: closed-loop (`SMSL=1`)
624 // with `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))` — an
625 // ACTUAL fetch success. `self.stpl_resolved` is the
626 // framework's report of exactly that (a STPL that is empty,
627 // or whose DB/CA fetch failed, leaves it false).
628 // Otherwise `udf` stays TRUE forever and C `epidRecord.c:195`
629 // `return(0)` skips `do_pid` every cycle — e.g. a supervisory
630 // (`SMSL=0`) epid with an empty/non-constant STPL NEVER runs
631 // `do_pid`.
632 //
633 // `self.udf` is the framework `dbCommon.udf` pushed before
634 // `process()`; it is last cycle's value because the framework
635 // recomputes `common.udf` (from `value_is_undefined()`) only
636 // *after* `process()`. C reads `pepid->udf` at process-start
637 // identically. `udf` is sticky-false: once C clears it, it is
638 // never re-set — so the gate keys off `self.udf`, and a closed-
639 // loop epid whose STPL later fails keeps running `do_pid`.
640 //
641 // `value_undefined` is recomputed here for the framework's
642 // post-process `common.udf = value_is_undefined()`.
643 self.compute_skipped = false;
644
645 // CA-TRIG trigger pass — C `devEpidSoftCallback.c:143-145` +
646 // `epidRecord.c:205-210`. `pre_input_link_actions` ran first
647 // this cycle, saw a CA-type TRIG link, fired the asynchronous
648 // readback trigger (`WriteDbLink{TRIG}` + `ReprocessAfter`) and
649 // moved `ca_trig` to `AwaitingCallback` — the analogue of C
650 // `do_pid` setting `pepid->pact = TRUE` and `return(0)`. The
651 // phase is NOT cleared here: `pre_input_link_actions` owns both
652 // transitions, and clears it on the callback pass.
653 //
654 // C `epidRecord.c:207` `if (!pact && pepid->pact) return(0)`
655 // then returns BEFORE `recGblGetTimeStamp` / `checkAlarms` /
656 // `monitor` / `recGblFwdLink`: the trigger pass runs NONE of
657 // the process tail. Return `async_pending` so the framework
658 // skips the alarm/timestamp/snapshot/OUT/FLNK tail for this
659 // cycle. The emitted actions were merged by the framework and
660 // are still executed; the reprocess pass runs
661 // `do_pid` and the tail exactly once.
662 //
663 // `device_did_compute` is cleared here because the trigger pass
664 // performed NO compute — without this reset the reprocess pass
665 // could observe a stale `true`.
666 if self.ca_trig == CaTrigPhase::AwaitingCallback {
667 self.device_did_compute = false;
668 return Ok(ProcessOutcome::async_pending());
669 }
670
671 // C clear-conditions, evaluated at process-start:
672 // - CONSTANT STPL link → init `recGblInitConstantLink` cleared
673 // udf permanently (`epidRecord.c:160-164`).
674 // - closed-loop STPL fetch succeeded this cycle
675 // (`epidRecord.c:191-193`).
676 //
677 // `stpl_resolved` is a per-cycle signal: consume it and reset
678 // so a later `process_local`-path cycle (which performs no
679 // link resolution and never calls `set_resolved_input_links`)
680 // cannot read a stale "resolved" from an earlier links-path
681 // cycle.
682 let stpl_resolved = self.stpl_resolved;
683 self.stpl_resolved = false;
684 let stpl_clears_udf =
685 link_field_type(&self.stpl) == LinkType::Constant || (self.smsl == 1 && stpl_resolved);
686 // udf state this cycle: undefined unless already cleared
687 // (`!self.udf`) or a clear-condition fires now.
688 self.value_undefined = self.udf && !stpl_clears_udf;
689 if !self.device_did_compute {
690 if self.value_undefined {
691 // C `epidRecord.c:195-202`: while `udf==TRUE`, skip
692 // `do_pid` entirely and `return 0` — *before*
693 // `recGblGetTimeStamp`, `checkAlarms`, `monitor` and
694 // `recGblFwdLink`. The framework's centralised UDF
695 // check (`rec_gbl_check_udf`, run after process())
696 // raises `UDF_ALARM` with `udfs` severity, matching C's
697 // `recGblSetSevr(pepid, UDF_ALARM, pepid->udfs)`.
698 //
699 // `update_monitors()` is deliberately NOT called here:
700 // C's early `return(0)` skips `monitor()`, so the
701 // previous-value fields (`pp`/`ip`/`dp`/...) and the
702 // `mlst`/`alst` deadband baselines must NOT advance
703 // while the record is undefined.
704 //
705 // C `return(0)` is reached before `recGblFwdLink` and
706 // the `do_pid` output write. The Rust framework drives
707 // the OUTL write (`multi_output_links`) and FLNK; flag
708 // this cycle so `multi_output_links` and
709 // `should_fire_forward_link` suppress them — otherwise
710 // a stale OVAL would be pushed to the OUTL target.
711 self.device_did_compute = false;
712 self.compute_skipped = true;
713 return Ok(ProcessOutcome::complete());
714 }
715 }
716
717 if !self.device_did_compute {
718 crate::device_support::epid_soft::EpidSoftDeviceSupport::do_pid(self);
719 }
720 self.device_did_compute = false; // Reset for next cycle
721
722 // Alarm evaluation is NOT done here. The framework invokes the
723 // `Record::check_alarms` trait hook (below) after `process()`,
724 // which is where the computed severity is applied to SEVR/STAT
725 // via `recGblSetSevr`. Calling the inherent `check_alarms` here
726 // would advance `lalm` an extra time and double-step the
727 // hysteresis state, so it is deliberately omitted.
728 self.update_monitors();
729
730 // Device support actions are now merged by the framework
731 let actions = Vec::new();
732 Ok(ProcessOutcome::complete_with(actions))
733 }
734
735 /// Per-record alarm hook — C `epidRecord.c::checkAlarms`.
736 ///
737 /// The framework calls this after `process()`; it computes the
738 /// HIHI/HIGH/LOW/LOLO condition (with `lalm` hysteresis) via the
739 /// inherent [`EpidRecord::check_alarms`] and applies the result to
740 /// the record's pending alarm state with `recGblSetSevr`. That
741 /// accumulates into `nsta`/`nsev` (raise-only / maximize-severity),
742 /// which the framework later transfers to `STAT`/`SEVR` via
743 /// `recGblResetAlarms`. Returning `None` raises nothing, so a value
744 /// that stays inside the limits leaves the record un-alarmed and a
745 /// held value does not re-fire.
746 fn check_alarms(&mut self, common: &mut CommonFields) {
747 // C `devEpidSoft.c:110-112` / `devEpidSoftCallback.c:115-117`:
748 // a CONSTANT `INP` link means "nothing to control" — raise
749 // SOFT_ALARM/INVALID_ALARM. `do_pid` set `inp_constant` and
750 // skipped the compute; apply the severity here (the framework
751 // calls this hook after `process()`).
752 if self.inp_constant {
753 recgbl::rec_gbl_set_sevr(common, alarm_status::SOFT_ALARM, AlarmSeverity::Invalid);
754 }
755 if let Some((stat, sevr, alev)) = EpidRecord::check_alarms(self) {
756 // C `aiRecord.c:404-406`: `if (recGblSetSevr(...)) prec->lalm = alev;`
757 // — the LALM update is gated on `recGblSetSevr` returning TRUE,
758 // i.e. on the alarm actually raising the pending severity.
759 if recgbl::rec_gbl_set_sevr(common, stat, sevr) {
760 self.lalm = alev;
761 }
762 }
763 }
764
765 /// C `epidRecord.c:376` REASSIGNS `monitor_mask = DBE_LOG|DBE_VALUE` after
766 /// VAL's own post, so every secondary the rest of `monitor()` posts
767 /// (:377-406) carries a LITERAL `DBE_VALUE | DBE_LOG` — this cycle's alarm
768 /// bits are discarded, unlike VAL's post (:371), which keeps them. A
769 /// `DBE_ALARM`-only subscriber on `.OVAL`/`.P`/`.I`/... is therefore
770 /// notified on no cycle at all.
771 ///
772 /// C's list is OVAL, P, I, D, CT, DT, ERR, CVAL; `CT` is `DBF_NOACCESS`
773 /// (`epidRecord.dbd:226`) and has no CA-visible field, leaving these seven.
774 fn fields_posted_without_alarm_bits(&self) -> &'static [&'static str] {
775 &["OVAL", "P", "I", "D", "DT", "ERR", "CVAL"]
776 }
777
778 fn get_field(&self, name: &str) -> Option<EpicsValue> {
779 match name {
780 "VAL" => Some(EpicsValue::Double(self.val)),
781 "SMSL" => Some(EpicsValue::Short(self.smsl)),
782 "STPL" => Some(EpicsValue::String(self.stpl.clone().into())),
783 "INP" => Some(EpicsValue::String(self.inp.clone().into())),
784 "OUTL" => Some(EpicsValue::String(self.outl.clone().into())),
785 "TRIG" => Some(EpicsValue::String(self.trig.clone().into())),
786 "TVAL" => Some(EpicsValue::Double(self.tval)),
787 "CVAL" => Some(EpicsValue::Double(self.cval)),
788 "CVLP" => Some(EpicsValue::Double(self.cvlp)),
789 "OVAL" => Some(EpicsValue::Double(self.oval)),
790 "OVLP" => Some(EpicsValue::Double(self.ovlp)),
791 "KP" => Some(EpicsValue::Double(self.kp)),
792 "KI" => Some(EpicsValue::Double(self.ki)),
793 "KD" => Some(EpicsValue::Double(self.kd)),
794 "P" => Some(EpicsValue::Double(self.p)),
795 "PP" => Some(EpicsValue::Double(self.pp)),
796 "I" => Some(EpicsValue::Double(self.i)),
797 "IP" => Some(EpicsValue::Double(self.ip)),
798 "D" => Some(EpicsValue::Double(self.d)),
799 "DP" => Some(EpicsValue::Double(self.dp)),
800 "ERR" => Some(EpicsValue::Double(self.err)),
801 "ERRP" => Some(EpicsValue::Double(self.errp)),
802 "DT" => Some(EpicsValue::Double(self.dt)),
803 "DTP" => Some(EpicsValue::Double(self.dtp)),
804 "MDT" => Some(EpicsValue::Double(self.mdt)),
805 "FMOD" => Some(EpicsValue::Short(self.fmod)),
806 "FBON" => Some(EpicsValue::Short(self.fbon)),
807 "FBOP" => Some(EpicsValue::Short(self.fbop)),
808 "ODEL" => Some(EpicsValue::Double(self.odel)),
809 "PREC" => Some(EpicsValue::Short(self.prec)),
810 "EGU" => Some(EpicsValue::String(self.egu.clone())),
811 "HOPR" => Some(EpicsValue::Double(self.hopr)),
812 "LOPR" => Some(EpicsValue::Double(self.lopr)),
813 "DRVH" => Some(EpicsValue::Double(self.drvh)),
814 "DRVL" => Some(EpicsValue::Double(self.drvl)),
815 "HIHI" => Some(EpicsValue::Double(self.hihi)),
816 "LOLO" => Some(EpicsValue::Double(self.lolo)),
817 "HIGH" => Some(EpicsValue::Double(self.high)),
818 "LOW" => Some(EpicsValue::Double(self.low)),
819 "HHSV" => Some(EpicsValue::Short(self.hhsv)),
820 "LLSV" => Some(EpicsValue::Short(self.llsv)),
821 "HSV" => Some(EpicsValue::Short(self.hsv)),
822 "LSV" => Some(EpicsValue::Short(self.lsv)),
823 "HYST" => Some(EpicsValue::Double(self.hyst)),
824 "LALM" => Some(EpicsValue::Double(self.lalm)),
825 "ADEL" => Some(EpicsValue::Double(self.adel)),
826 "MDEL" => Some(EpicsValue::Double(self.mdel)),
827 "ALST" => Some(EpicsValue::Double(self.alst)),
828 "MLST" => Some(EpicsValue::Double(self.mlst)),
829 _ => None,
830 }
831 }
832
833 fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
834 match name {
835 "VAL" => match value {
836 EpicsValue::Double(v) => {
837 self.val = v;
838 Ok(())
839 }
840 _ => Err(CaError::TypeMismatch(name.into())),
841 },
842 "SMSL" => match value {
843 EpicsValue::Short(v) => {
844 self.smsl = v;
845 Ok(())
846 }
847 _ => Err(CaError::TypeMismatch(name.into())),
848 },
849 "STPL" => match value {
850 EpicsValue::String(v) => {
851 self.stpl = v.as_str_lossy().into_owned();
852 Ok(())
853 }
854 _ => Err(CaError::TypeMismatch(name.into())),
855 },
856 "INP" => match value {
857 EpicsValue::String(v) => {
858 self.inp = v.as_str_lossy().into_owned();
859 Ok(())
860 }
861 _ => Err(CaError::TypeMismatch(name.into())),
862 },
863 "OUTL" => match value {
864 EpicsValue::String(v) => {
865 self.outl = v.as_str_lossy().into_owned();
866 Ok(())
867 }
868 _ => Err(CaError::TypeMismatch(name.into())),
869 },
870 "TRIG" => match value {
871 EpicsValue::String(v) => {
872 self.trig = v.as_str_lossy().into_owned();
873 Ok(())
874 }
875 _ => Err(CaError::TypeMismatch(name.into())),
876 },
877 "TVAL" => match value {
878 EpicsValue::Double(v) => {
879 self.tval = v;
880 Ok(())
881 }
882 _ => Err(CaError::TypeMismatch(name.into())),
883 },
884 "KP" => match value {
885 EpicsValue::Double(v) => {
886 self.kp = v;
887 Ok(())
888 }
889 _ => Err(CaError::TypeMismatch(name.into())),
890 },
891 "KI" => match value {
892 EpicsValue::Double(v) => {
893 self.ki = v;
894 Ok(())
895 }
896 _ => Err(CaError::TypeMismatch(name.into())),
897 },
898 "KD" => match value {
899 EpicsValue::Double(v) => {
900 self.kd = v;
901 Ok(())
902 }
903 _ => Err(CaError::TypeMismatch(name.into())),
904 },
905 "I" => match value {
906 EpicsValue::Double(v) => {
907 self.i = v;
908 Ok(())
909 }
910 _ => Err(CaError::TypeMismatch(name.into())),
911 },
912 "IP" => match value {
913 EpicsValue::Double(v) => {
914 self.ip = v;
915 Ok(())
916 }
917 _ => Err(CaError::TypeMismatch(name.into())),
918 },
919 "DT" => match value {
920 EpicsValue::Double(v) => {
921 self.dt = v;
922 Ok(())
923 }
924 _ => Err(CaError::TypeMismatch(name.into())),
925 },
926 "MDT" => match value {
927 EpicsValue::Double(v) => {
928 self.mdt = v;
929 Ok(())
930 }
931 _ => Err(CaError::TypeMismatch(name.into())),
932 },
933 "FMOD" => match value {
934 EpicsValue::Short(v) => {
935 self.fmod = v;
936 Ok(())
937 }
938 _ => Err(CaError::TypeMismatch(name.into())),
939 },
940 "FBON" => match value {
941 EpicsValue::Short(v) => {
942 self.fbon = v;
943 Ok(())
944 }
945 _ => Err(CaError::TypeMismatch(name.into())),
946 },
947 "ODEL" => match value {
948 EpicsValue::Double(v) => {
949 self.odel = v;
950 Ok(())
951 }
952 _ => Err(CaError::TypeMismatch(name.into())),
953 },
954 "PREC" => match value {
955 EpicsValue::Short(v) => {
956 self.prec = v;
957 Ok(())
958 }
959 _ => Err(CaError::TypeMismatch(name.into())),
960 },
961 "EGU" => match value {
962 EpicsValue::String(v) => {
963 self.egu = v;
964 Ok(())
965 }
966 _ => Err(CaError::TypeMismatch(name.into())),
967 },
968 "HOPR" => match value {
969 EpicsValue::Double(v) => {
970 self.hopr = v;
971 Ok(())
972 }
973 _ => Err(CaError::TypeMismatch(name.into())),
974 },
975 "LOPR" => match value {
976 EpicsValue::Double(v) => {
977 self.lopr = v;
978 Ok(())
979 }
980 _ => Err(CaError::TypeMismatch(name.into())),
981 },
982 "DRVH" => match value {
983 EpicsValue::Double(v) => {
984 self.drvh = v;
985 Ok(())
986 }
987 _ => Err(CaError::TypeMismatch(name.into())),
988 },
989 "DRVL" => match value {
990 EpicsValue::Double(v) => {
991 self.drvl = v;
992 Ok(())
993 }
994 _ => Err(CaError::TypeMismatch(name.into())),
995 },
996 "HIHI" => match value {
997 EpicsValue::Double(v) => {
998 self.hihi = v;
999 Ok(())
1000 }
1001 _ => Err(CaError::TypeMismatch(name.into())),
1002 },
1003 "LOLO" => match value {
1004 EpicsValue::Double(v) => {
1005 self.lolo = v;
1006 Ok(())
1007 }
1008 _ => Err(CaError::TypeMismatch(name.into())),
1009 },
1010 "HIGH" => match value {
1011 EpicsValue::Double(v) => {
1012 self.high = v;
1013 Ok(())
1014 }
1015 _ => Err(CaError::TypeMismatch(name.into())),
1016 },
1017 "LOW" => match value {
1018 EpicsValue::Double(v) => {
1019 self.low = v;
1020 Ok(())
1021 }
1022 _ => Err(CaError::TypeMismatch(name.into())),
1023 },
1024 "HHSV" => match value {
1025 EpicsValue::Short(v) => {
1026 self.hhsv = v;
1027 Ok(())
1028 }
1029 _ => Err(CaError::TypeMismatch(name.into())),
1030 },
1031 "LLSV" => match value {
1032 EpicsValue::Short(v) => {
1033 self.llsv = v;
1034 Ok(())
1035 }
1036 _ => Err(CaError::TypeMismatch(name.into())),
1037 },
1038 "HSV" => match value {
1039 EpicsValue::Short(v) => {
1040 self.hsv = v;
1041 Ok(())
1042 }
1043 _ => Err(CaError::TypeMismatch(name.into())),
1044 },
1045 "LSV" => match value {
1046 EpicsValue::Short(v) => {
1047 self.lsv = v;
1048 Ok(())
1049 }
1050 _ => Err(CaError::TypeMismatch(name.into())),
1051 },
1052 "HYST" => match value {
1053 EpicsValue::Double(v) => {
1054 self.hyst = v;
1055 Ok(())
1056 }
1057 _ => Err(CaError::TypeMismatch(name.into())),
1058 },
1059 "ADEL" => match value {
1060 EpicsValue::Double(v) => {
1061 self.adel = v;
1062 Ok(())
1063 }
1064 _ => Err(CaError::TypeMismatch(name.into())),
1065 },
1066 "MDEL" => match value {
1067 EpicsValue::Double(v) => {
1068 self.mdel = v;
1069 Ok(())
1070 }
1071 _ => Err(CaError::TypeMismatch(name.into())),
1072 },
1073 // Read-only fields
1074 "CVAL" | "CVLP" | "OVAL" | "OVLP" | "P" | "PP" | "D" | "DP" | "ERR" | "ERRP"
1075 | "DTP" | "FBOP" | "LALM" | "ALST" | "MLST" => Err(CaError::ReadOnlyField(name.into())),
1076 _ => Err(CaError::FieldNotFound(name.into())),
1077 }
1078 }
1079
1080 fn declared_fields(&self) -> &'static [FieldDesc] {
1081 dbd_generated::EPID_FIELDS
1082 }
1083
1084 fn declared_noaccess_fields(&self) -> &'static [&'static str] {
1085 dbd_generated::EPID_NOACCESS
1086 }
1087
1088 fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
1089 Some(self)
1090 }
1091
1092 /// C `epidRecord.c` UDF ownership — see `EpidRecord::value_undefined`.
1093 ///
1094 /// The framework's post-`process()` step runs
1095 /// `common.udf = value_is_undefined()` (gated on `clears_udf()`,
1096 /// left at its `true` default). Returning the epid-owned
1097 /// `value_undefined` — recomputed in `process()` from the two C
1098 /// clear-conditions — keeps `udf` TRUE for a supervisory / empty-
1099 /// STPL epid (so its UDF gate fires every cycle, as C does) and
1100 /// clears it only on a CONSTANT STPL or a successful closed-loop
1101 /// `dbGetLink(stpl)`.
1102 ///
1103 /// The default `value_is_undefined()` keys off `VAL` being NaN,
1104 /// which for an epid (`VAL` defaults to a finite `0.0`, never NaN)
1105 /// would wrongly clear `udf` after the first cycle — the bug this
1106 /// override fixes.
1107 fn value_is_undefined(&self) -> bool {
1108 self.value_undefined
1109 }
1110
1111 fn set_device_did_compute(&mut self, did_compute: bool) {
1112 self.device_did_compute = did_compute;
1113 }
1114
1115 /// C `epidRecord.c:195` reads `pepid->udf` at the top of
1116 /// `process()`. The framework owns `dbCommon.udf`; this hook
1117 /// captures it so `process()` can gate `do_pid` on it.
1118 fn set_process_context(&mut self, ctx: &ProcessContext) {
1119 self.udf = ctx.udf;
1120 self.dtyp.clear();
1121 self.dtyp.push_str(&ctx.dtyp);
1122 }
1123
1124 /// C `devEpidSoftCallback.c:120-132` — the DB-type TRIG readback
1125 /// link write.
1126 ///
1127 /// `devEpidSoftCallback.c::do_pid`, within ONE process pass, does:
1128 /// 1. `if (ptriglink->type != CA_LINK)` →
1129 /// `dbPutLink(ptriglink, DBR_DOUBLE, &pepid->tval, 1)`
1130 /// (`devEpidSoftCallback.c:121-127`) — a synchronous write that
1131 /// processes the triggered source chain;
1132 /// 2. `dbGetLink(&pepid->inp, DBR_DOUBLE, &pepid->cval, ...)`
1133 /// (`devEpidSoftCallback.c:151`) — read CVAL from INP;
1134 /// 3. run the PID.
1135 ///
1136 /// So for a DB-type TRIG link the trigger write must land BEFORE
1137 /// this cycle's `INP -> CVAL` fetch. The framework resolves input
1138 /// links before `pre_process_actions`, so the TRIG write is emitted
1139 /// here, from `pre_input_link_actions`, which the framework runs
1140 /// strictly before the input-link fetch.
1141 ///
1142 /// Only the `devEpidSoftCallback` DSET drives the TRIG link —
1143 /// `devEpidSoft` (`devEpidSoft.c`) and `devEpidFast`
1144 /// (`devEpidFast.c`) contain no reference to `trig` at all. That
1145 /// DSET is selected by `stdSupport.dbd:14`
1146 /// `device(epid,CONSTANT,devEpidSoftCB,"Async Soft Channel")`, so
1147 /// the gate is the dbd DTYP string and nothing else.
1148 ///
1149 /// Both TRIG link types are fired from here, making this the single
1150 /// site that writes TRIG. C branches on the link type inside one
1151 /// `if (!pepid->pact)` block (`devEpidSoftCallback.c:116-147`): a
1152 /// DB link is written synchronously and falls through to the PID in
1153 /// the same pass; a CA link cannot be waited on, so C fires
1154 /// `dbCaPutLinkCallback`, sets `pact` and returns, and the PID runs
1155 /// on the callback pass. `ca_trig` is that `pact`, and the
1156 /// `AwaitingCallback` arm below is C's `!pepid->pact` guard: on the
1157 /// callback pass the trigger block is skipped entirely.
1158 fn pre_input_link_actions(&mut self) -> Vec<ProcessAction> {
1159 if !self.is_async_callback_dtyp() {
1160 return Vec::new();
1161 }
1162 // C `devEpidSoftCallback.c:116` `if (!pepid->pact)` — the
1163 // callback pass re-processes to run the PID, never to re-fire.
1164 if self.ca_trig == CaTrigPhase::AwaitingCallback {
1165 self.ca_trig = CaTrigPhase::Idle;
1166 return Vec::new();
1167 }
1168 let write = ProcessAction::WriteDbLink {
1169 link_field: "TRIG",
1170 value: EpicsValue::Double(self.tval),
1171 };
1172 match link_field_type(&self.trig) {
1173 LinkType::Db => vec![write],
1174 LinkType::Ca => {
1175 self.ca_trig = CaTrigPhase::AwaitingCallback;
1176 vec![
1177 write,
1178 ProcessAction::ReprocessAfter(std::time::Duration::from_millis(1)),
1179 ]
1180 }
1181 // C `ptriglink->type` is CONSTANT/empty: `dbPutLink` to a
1182 // constant link is a no-op, and the PID runs in this pass.
1183 LinkType::Constant | LinkType::Empty | LinkType::Other => Vec::new(),
1184 }
1185 }
1186
1187 /// Framework report of which `multi_input_links` fetches produced a
1188 /// value this cycle — the analogue of C
1189 /// `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))` (`epidRecord.c:191`).
1190 /// `STPL` is only ever in `multi_input_links` when
1191 /// `SMSL == closed_loop`; its presence here means the closed-loop
1192 /// setpoint fetch actually succeeded this cycle. A STPL that is
1193 /// empty, or a DB/CA link whose fetch failed, is absent — so
1194 /// `stpl_resolved` is reset to false and `udf` is not cleared.
1195 fn set_resolved_input_links(&mut self, resolved: &[&'static str]) {
1196 self.stpl_resolved = resolved.contains(&"STPL");
1197 }
1198
1199 /// C `epidRecord.c:160-164` `init_record`: when `STPL` is a
1200 /// CONSTANT link holding a valid constant, `recGblInitConstantLink`
1201 /// seeds `VAL` from the constant and `udf` is cleared. The
1202 /// framework owns `dbCommon.udf`; this hook is its controlled
1203 /// access point. Runs once after `init_record`.
1204 ///
1205 /// For `SMSL == closed_loop` the framework also fetches `STPL` into
1206 /// `VAL` via `multi_input_links` every cycle; the constant seed
1207 /// here matters for the supervisory (`SMSL=0`) case and for the
1208 /// first cycle before any process.
1209 fn post_init_finalize_undef(&mut self, udf: &mut bool) -> CaResult<()> {
1210 let parsed = epics_base_rs::server::record::parse_link_v2(&self.stpl);
1211 if parsed.link_type() == LinkType::Constant {
1212 if let Some(EpicsValue::Double(v)) = parsed.constant_value() {
1213 self.val = v;
1214 *udf = false;
1215 self.value_undefined = false;
1216 }
1217 }
1218 Ok(())
1219 }
1220
1221 fn put_field_internal(
1222 &mut self,
1223 name: &str,
1224 value: EpicsValue,
1225 ) -> epics_base_rs::error::CaResult<()> {
1226 // Bypass read-only checks for framework-internal writes (ReadDbLink).
1227 // This allows the framework to write to CVAL, OVAL, etc. from link resolution.
1228 match name {
1229 // The bumpless-transfer OUTL readback. Staged, not committed:
1230 // `do_pid` moves it into `I` (PID) or `OVAL` (MaxMin) at C's line,
1231 // after the MDT gate. See `OUTL_SEED_FIELD`.
1232 OUTL_SEED_FIELD => match value {
1233 EpicsValue::Double(v) => {
1234 self.outl_seed = Some(v);
1235 Ok(())
1236 }
1237 _ => Err(CaError::TypeMismatch(name.into())),
1238 },
1239 "CVAL" => match value {
1240 EpicsValue::Double(v) => {
1241 self.cval = v;
1242 Ok(())
1243 }
1244 _ => Err(CaError::TypeMismatch(name.into())),
1245 },
1246 "OVAL" => match value {
1247 EpicsValue::Double(v) => {
1248 self.oval = v;
1249 Ok(())
1250 }
1251 _ => Err(CaError::TypeMismatch(name.into())),
1252 },
1253 "P" => match value {
1254 EpicsValue::Double(v) => {
1255 self.p = v;
1256 Ok(())
1257 }
1258 _ => Err(CaError::TypeMismatch(name.into())),
1259 },
1260 "D" => match value {
1261 EpicsValue::Double(v) => {
1262 self.d = v;
1263 Ok(())
1264 }
1265 _ => Err(CaError::TypeMismatch(name.into())),
1266 },
1267 "ERR" => match value {
1268 EpicsValue::Double(v) => {
1269 self.err = v;
1270 Ok(())
1271 }
1272 _ => Err(CaError::TypeMismatch(name.into())),
1273 },
1274 _ => self.put_field(name, value),
1275 }
1276 }
1277
1278 /// C `epidRecord.c:158-164`:
1279 ///
1280 /// ```c
1281 /// if (pepid->stpl.type == CONSTANT) {
1282 /// if (recGblInitConstantLink(&pepid->stpl, DBF_DOUBLE, &pepid->val))
1283 /// pepid->udf = FALSE;
1284 /// }
1285 /// ```
1286 ///
1287 /// The setpoint of a constant-STPL epid is loaded ONCE, here — at process
1288 /// `dbGetLink(&prec->stpl, ...)` delivers nothing (it returns success, so
1289 /// the closed-loop UDF clear at `:191` still fires), which is why an
1290 /// operator `caput REC.VAL` on a constant-STPL epid holds.
1291 ///
1292 /// INP is NOT seeded: a constant INP means "nothing to control" in C —
1293 /// `devEpidSoft.c` raises SOFT/INVALID rather than reading a value.
1294 fn constant_init_links(&self) -> Vec<epics_base_rs::server::record::ConstantInitLink> {
1295 vec![epics_base_rs::server::record::ConstantInitLink::dol_to_val(
1296 "STPL", "VAL",
1297 )]
1298 }
1299
1300 fn multi_input_links(&self) -> &[(&'static str, &'static str)] {
1301 // INP -> CVAL is always resolved.
1302 // STPL -> VAL is only resolved when SMSL == closed_loop (1).
1303 // In supervisory mode (SMSL=0), the operator sets VAL directly
1304 // and STPL must not overwrite it.
1305 if self.smsl == 1 {
1306 // closed_loop: fetch setpoint from STPL into VAL
1307 static WITH_STPL: &[(&str, &str)] = &[("STPL", "VAL"), ("INP", "CVAL")];
1308 WITH_STPL
1309 } else {
1310 // supervisory: VAL is set by operator, don't fetch STPL
1311 static WITHOUT_STPL: &[(&str, &str)] = &[("INP", "CVAL")];
1312 WITHOUT_STPL
1313 }
1314 }
1315
1316 fn multi_output_links(&self) -> &[(&'static str, &'static str)] {
1317 // C `epidRecord.c:195-202`: on a UDF-gated cycle `process()`
1318 // returns before `do_pid` writes the output — suppress the
1319 // OUTL->OVAL write so a stale OVAL is not pushed downstream.
1320 //
1321 // C `devEpidSoft.c:220` / `devEpidSoftCallback.c:256`: the OUTL
1322 // `dbPutLink` fires only when `fbon && outl.type != CONSTANT`,
1323 // and only when `do_pid` reached that line (not the sub-MDT or
1324 // CONSTANT-INP early returns); the Fast support never writes
1325 // OUTL. `do_pid` owns `outl_write` and encodes exactly that
1326 // condition, so the framework OUTL write is gated on it.
1327 if self.compute_skipped || !self.outl_write {
1328 return &[];
1329 }
1330 // OUTL -> OVAL (output link)
1331 static LINKS: &[(&str, &str)] = &[("OUTL", "OVAL")];
1332 LINKS
1333 }
1334
1335 fn should_fire_forward_link(&self) -> bool {
1336 // C `epidRecord.c:201` `return(0)` on a UDF-gated cycle is
1337 // reached before `recGblFwdLink` — no forward link this cycle.
1338 !self.compute_skipped
1339 }
1340}
1341
1342#[cfg(test)]
1343mod menu_choice_tests {
1344 use super::EpidRecord;
1345 use epics_base_rs::server::record::{FieldDeclaration, Record, RecordInstance};
1346 use epics_base_rs::types::EpicsValue;
1347
1348 /// The choices a client sees are the DECLARATION's — `epidRecord.dbd`'s
1349 /// `menu()` on each field — and the index↔string mapping is wire-visible.
1350 /// This used to assert them through `Record::menu_field_choices`, a hand
1351 /// written table that declared the same menus a second time; `SMSL` needed
1352 /// a per-record mapping there only because the shared-menu registry keys
1353 /// `menuOmsl` by the field name `OMSL`. The declaration has no such
1354 /// problem: the `.dbd` says `field(SMSL,DBF_MENU) { menu(menuOmsl) }`, so
1355 /// the FieldDesc points straight at base's `MENU_OMSL`.
1356 #[test]
1357 fn epid_menu_choices_come_from_the_declaration() {
1358 let rec = EpidRecord::default();
1359 let menu = |name: &str| {
1360 rec.field_list()
1361 .iter()
1362 .find(|f| f.name == name)
1363 .unwrap_or_else(|| panic!("{name} is declared"))
1364 .menu
1365 };
1366 assert_eq!(menu("FMOD"), Some(&["PID", "Max/Min"][..]));
1367 let fbstate = &["Off", "On"][..];
1368 assert_eq!(menu("FBON"), Some(fbstate));
1369 assert_eq!(menu("FBOP"), Some(fbstate));
1370 assert_eq!(menu("SMSL"), Some(&["supervisory", "closed_loop"][..]));
1371 assert_eq!(menu("VAL"), None);
1372 }
1373
1374 // End-to-end: SMSL is served as Short; the base snapshot path promotes
1375 // it to DBR_ENUM and attaches the menuOmsl labels.
1376 #[test]
1377 fn epid_smsl_snapshot_is_enum_with_labels() {
1378 let mut rec = EpidRecord::default();
1379 rec.put_field("SMSL", EpicsValue::Short(1)).unwrap(); // closed_loop
1380 let inst = RecordInstance::new("PID:SMSL".into(), rec);
1381
1382 let snap = inst.snapshot_for_field("SMSL").unwrap();
1383 assert_eq!(snap.value, EpicsValue::Enum(1));
1384 assert_eq!(
1385 snap.enums.as_ref().unwrap().strings,
1386 vec!["supervisory", "closed_loop"]
1387 );
1388 }
1389}