Skip to main content

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}