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, LinkType, ProcessAction, ProcessContext,
9    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 [`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 to emit the TRIG write only when the callback DSET (DTYP
257    /// `"Epid Async Soft"`) is selected.
258    dtyp: String,
259    /// Epid-owned `dbCommon.udf` projection, returned by
260    /// [`Record::value_is_undefined`]. C `epidRecord.c` has
261    /// `special = NULL` (line 105) — there is no operator UDF clear,
262    /// and `udf` is cleared ONLY by the two C conditions:
263    ///
264    /// - `epidRecord.c:160-164` init: a CONSTANT `STPL` link holding
265    ///   a valid constant clears `udf` (mirrored by
266    ///   [`Record::post_init_finalize_undef`] / a CONSTANT `STPL`
267    ///   making `value_is_undefined()` return `false`).
268    /// - `epidRecord.c:191-193` process: closed-loop (`SMSL=1`) with
269    ///   a successful `dbGetLink(stpl)` clears `udf`.
270    ///
271    /// `process()` recomputes this each cycle; the framework's
272    /// post-process `common.udf = value_is_undefined()` then keeps a
273    /// supervisory / empty-STPL epid permanently undefined, exactly as
274    /// C leaves `udf == TRUE` forever for such a record.
275    value_undefined: bool,
276    /// Set by [`crate::device_support::epid_soft_callback::
277    /// EpidSoftCallbackDeviceSupport::read`] on the first (trigger) pass
278    /// of a CA-type TRIG link, cleared by `process()`.
279    ///
280    /// C `devEpidSoftCallback.c:143-145`: a CA TRIG link fires the
281    /// readback trigger asynchronously (`dbCaPutLinkCallback`), sets
282    /// `pepid->pact = TRUE` and `return(0)`. C `epidRecord.c:207`
283    /// `if (!pact && pepid->pact) return(0)` then returns BEFORE
284    /// `recGblGetTimeStamp` / `checkAlarms` / `monitor` /
285    /// `recGblFwdLink` — so the trigger pass runs NONE of the
286    /// process tail; the tail runs exactly once, on the callback
287    /// (reprocess) pass.
288    ///
289    /// The Rust framework runs device support `read()` before
290    /// `process()`; `read()` cannot itself short-circuit the cycle.
291    /// This flag is `read()`'s signal to `process()` that the cycle is
292    /// a CA-trigger pass — `process()` consumes it and returns
293    /// `ProcessOutcome::async_pending()`, which makes the framework
294    /// skip the alarm/timestamp/snapshot/OUT/FLNK tail for this cycle
295    /// (the `read()`-returned `WriteDbLink{TRIG}` + `ReprocessAfter`
296    /// actions are still executed). The reprocess pass runs `do_pid`
297    /// and the tail exactly once.
298    ca_trig_pending: bool,
299}
300
301impl Default for EpidRecord {
302    fn default() -> Self {
303        let now = Instant::now();
304        Self {
305            val: 0.0,
306            smsl: 0,
307            stpl: String::new(),
308            inp: String::new(),
309            outl: String::new(),
310            trig: String::new(),
311            tval: 0.0,
312            cval: 0.0,
313            cvlp: 0.0,
314            oval: 0.0,
315            ovlp: 0.0,
316            kp: 0.0,
317            ki: 0.0,
318            kd: 0.0,
319            p: 0.0,
320            pp: 0.0,
321            i: 0.0,
322            ip: 0.0,
323            d: 0.0,
324            dp: 0.0,
325            err: 0.0,
326            errp: 0.0,
327            dt: 0.0,
328            dtp: 0.0,
329            mdt: 0.0,
330            fmod: 0,
331            fbon: 0,
332            fbop: 0,
333            odel: 0.0,
334            prec: 0,
335            egu: PvString::new(),
336            hopr: 0.0,
337            lopr: 0.0,
338            drvh: 0.0,
339            drvl: 0.0,
340            hihi: 0.0,
341            lolo: 0.0,
342            high: 0.0,
343            low: 0.0,
344            hhsv: 0,
345            llsv: 0,
346            hsv: 0,
347            lsv: 0,
348            hyst: 0.0,
349            lalm: 0.0,
350            adel: 0.0,
351            mdel: 0.0,
352            alst: 0.0,
353            mlst: 0.0,
354            ct: now,
355            ctp: now,
356            device_did_compute: false,
357            inp_constant: false,
358            dtyp: String::new(),
359            udf: true,
360            compute_skipped: false,
361            outl_write: false,
362            stpl_resolved: false,
363            outl_seed: None,
364            // C `epidRecord.c` init: `udf` starts TRUE and is cleared
365            // only by the two clear-conditions — see `value_undefined`.
366            value_undefined: true,
367            ca_trig_pending: false,
368        }
369    }
370}
371
372impl EpidRecord {
373    /// Decide the alarm condition using hysteresis-based threshold
374    /// comparison on VAL. Ported from epidRecord.c `checkAlarms()`,
375    /// which mirrors `aiRecord.c::checkAlarms` — per-level hysteresis
376    /// against VAL with `lalm` tracking the last-alarmed threshold.
377    ///
378    /// Returns `Some((stat, sevr, alev))` where `stat` is the canonical
379    /// `epicsAlarmCondition` status code (`HIHI_ALARM`, `HIGH_ALARM`,
380    /// `LOLO_ALARM`, `LOW_ALARM`), `sevr` the configured severity, and
381    /// `alev` the threshold that fired (the candidate `lalm` value).
382    /// Returns `None` when VAL is inside the (hysteresis-adjusted) limits.
383    ///
384    /// `lalm` (last-alarmed threshold) is committed by the caller, NOT
385    /// here, for the alarm case. C `aiRecord.c:403-406` gates the `lalm`
386    /// update on `recGblSetSevr` actually raising the severity:
387    /// `if (recGblSetSevr(...)) prec->lalm = alev;`. A lower-severity
388    /// alarm that loses to an already-higher pending severity must NOT
389    /// advance `lalm`, or the hysteresis band would be silently re-based.
390    /// The [`Record::check_alarms`] trait hook below performs that gate.
391    ///
392    /// The no-alarm case writes `lalm = val` here unconditionally,
393    /// matching C `aiRecord.c:409` (`prec->lalm = val;` — not gated).
394    pub fn check_alarms(&mut self) -> Option<(u16, AlarmSeverity, f64)> {
395        let val = self.val;
396        let hyst = self.hyst;
397        let lalm = self.lalm;
398
399        // HIHI alarm
400        if self.hhsv != 0 && (val >= self.hihi || (lalm == self.hihi && val >= self.hihi - hyst)) {
401            return Some((
402                alarm_status::HIHI_ALARM,
403                AlarmSeverity::from_u16(self.hhsv as u16),
404                self.hihi,
405            ));
406        }
407
408        // LOLO alarm
409        if self.llsv != 0 && (val <= self.lolo || (lalm == self.lolo && val <= self.lolo + hyst)) {
410            return Some((
411                alarm_status::LOLO_ALARM,
412                AlarmSeverity::from_u16(self.llsv as u16),
413                self.lolo,
414            ));
415        }
416
417        // HIGH alarm
418        if self.hsv != 0 && (val >= self.high || (lalm == self.high && val >= self.high - hyst)) {
419            return Some((
420                alarm_status::HIGH_ALARM,
421                AlarmSeverity::from_u16(self.hsv as u16),
422                self.high,
423            ));
424        }
425
426        // LOW alarm
427        if self.lsv != 0 && (val <= self.low || (lalm == self.low && val <= self.low + hyst)) {
428            return Some((
429                alarm_status::LOW_ALARM,
430                AlarmSeverity::from_u16(self.lsv as u16),
431                self.low,
432            ));
433        }
434
435        // No alarm — C `aiRecord.c:409` resets LALM to VAL unconditionally.
436        self.lalm = val;
437        None
438    }
439
440    /// Mark this cycle as a CA-TRIG trigger pass.
441    ///
442    /// Called by [`crate::device_support::epid_soft_callback::
443    /// EpidSoftCallbackDeviceSupport::read`] on the first pass of a
444    /// CA-type TRIG link, before `process()` runs. `process()` consumes
445    /// the flag and returns `ProcessOutcome::async_pending()` so the
446    /// trigger pass skips the process tail (checkAlarms / monitor /
447    /// recGblFwdLink) — C `devEpidSoftCallback.c:143-145` +
448    /// `epidRecord.c:205-210`. See [`EpidRecord::ca_trig_pending`].
449    pub fn set_ca_trig_pending(&mut self) {
450        self.ca_trig_pending = true;
451    }
452
453    /// Owner setter for [`EpidRecord::outl_write`]. Only `do_pid` calls
454    /// this — it clears the flag at entry and re-enables it per `fbon`
455    /// on the success path, mirroring C's OUTL `dbPutLink` gate
456    /// (`devEpidSoft.c:220`). Keeping it private to a setter preserves
457    /// the single-owner invariant.
458    pub fn set_outl_write(&mut self, write: bool) {
459        self.outl_write = write;
460    }
461
462    /// Update monitor tracking fields. Returns list of fields that changed.
463    /// Ported from epidRecord.c `monitor()`.
464    pub fn update_monitors(&mut self) {
465        // Update previous-value fields for change detection
466        self.ovlp = self.oval;
467        self.pp = self.p;
468        self.ip = self.i;
469        self.dp = self.d;
470        self.dtp = self.dt;
471        self.errp = self.err;
472        self.cvlp = self.cval;
473
474        // VAL deadband baselines (MLST/ALST) are NOT advanced here. C
475        // `epidRecord.c:346-374` `monitor()` computes `delta = mlst - val`,
476        // posts VAL when `delta > mdel`, and only THEN sets `mlst = val`
477        // — the post and the advance are one owner. In Rust that owner is
478        // the framework's `check_deadband_ext`
479        // (`record_instance.rs:2180-2203`): it reads MLST, fires the VAL
480        // monitor, then advances `mlst`/`alst` via `put_coerced`. Advancing
481        // them here (before that runs) made the framework see a zero delta
482        // and silently suppress every VAL post. `update_monitors` owns only
483        // the epid-specific previous-value fields above (`pp`/`ip`/`dp`/
484        // `cvlp`/...), not the MLST/ALST deadband state.
485    }
486}
487
488impl Record for EpidRecord {
489    fn record_type(&self) -> &'static str {
490        "epid"
491    }
492
493    /// Bumpless-transfer readback — C `devEpidSoft.c:153-158` (PID) and
494    /// `devEpidSoft.c:178-184` / `devEpidSoftCallback.c:214-220`
495    /// (MaxMin).
496    ///
497    /// On the feedback OFF->ON edge (`FBOP==0 && FBON!=0`) C seeds the
498    /// turn-on state from the `OUTL` output link's *actual current
499    /// value* via `dbGetLink(&pepid->outl, DBR_DOUBLE, ...)`, guarded by
500    /// `outl.type != CONSTANT`. The seeded field differs by FMOD:
501    ///
502    ///   - PID (`fmod==0`), C `devEpidSoft.c:155`:
503    ///     `dbGetLink(&pepid->outl, DBR_DOUBLE, &i, ...)` — the OUTL
504    ///     readback lands in the integral term `I`.
505    ///   - MaxMin (`fmod==1`), C `devEpidSoft.c:181` /
506    ///     `devEpidSoftCallback.c:217`:
507    ///     `dbGetLink(&pepid->outl, DBR_DOUBLE, &oval, ...)` — the OUTL
508    ///     readback lands in the output value `OVAL`.
509    ///
510    /// The Rust framework's `ReadDbLink` pre-process action performs
511    /// exactly that synchronous read of the DB link's target value, but it
512    /// can only run BEFORE `process()` / `do_pid`, whereas C reads OUTL
513    /// *after* the `dt < MDT` gate (`devEpidSoft.c:125`) and the record's
514    /// UDF gate (`epidRecord.c:195`). So the read does NOT land in `.I` /
515    /// `.OVAL` here — it lands in [`EpidRecord::outl_seed`], and `do_pid`
516    /// consumes it at C's line. A cycle that C would have gated leaves the
517    /// staged value unconsumed: no field write, no monitor, and FBOP stays
518    /// 0 so the next ungated cycle re-reads and seeds for real.
519    ///
520    /// `FBOP` still holds the *previous* cycle's `FBON` at this point
521    /// (it is committed at the end of `do_pid`), so the edge is
522    /// detectable here. The action is emitted only for a non-CONSTANT
523    /// `OUTL` link, mirroring C's `outl.type != CONSTANT` guard — for a
524    /// CONSTANT/empty `OUTL` nothing is staged and the seeded field keeps
525    /// its prior value.
526    fn pre_process_actions(&mut self) -> Vec<ProcessAction> {
527        // The staged readback is per-cycle: whatever a previous cycle left
528        // behind must not be mistaken for this cycle's OUTL value.
529        self.outl_seed = None;
530        let edge = self.fbon != 0 && self.fbop == 0;
531        if edge {
532            match link_field_type(&self.outl) {
533                LinkType::Db | LinkType::Ca => {
534                    return vec![ProcessAction::ReadDbLink {
535                        link_field: "OUTL",
536                        target_field: OUTL_SEED_FIELD,
537                    }];
538                }
539                _ => {}
540            }
541        }
542        Vec::new()
543    }
544
545    fn process(&mut self) -> CaResult<ProcessOutcome> {
546        // In the C code, process() always calls pdset->do_pid() — a custom
547        // device support function unique to the epid record. In Rust, the
548        // framework has a generic DeviceSupport trait with read()/write()
549        // and no custom function pointers.
550        //
551        // For non-"Soft Channel" DTYPs (e.g. "Fast Epid"), the framework
552        // calls DeviceSupport::read() BEFORE process(). That read() runs
553        // the driver-specific PID and sets pid_done = true.
554        //
555        // For "Soft Channel" or no device support, the framework skips
556        // read(), so pid_done stays false and process() runs the built-in
557        // PID here.
558
559        // C `epidRecord.c:189-203`: the UDF gate is taken only on the
560        // non-callback pass (`if (!pact)`). `device_did_compute` is the
561        // Rust equivalent of "device support already ran do_pid" — the
562        // callback pass — so the gate applies only when it is false.
563        //
564        // C `epidRecord.c` clears `udf` ONLY at two sites (`special` is
565        // NULL — there is no operator UDF clear):
566        //   - `epidRecord.c:160-164` init: a CONSTANT `STPL` link with a
567        //     valid constant. A constant link's value never changes, so
568        //     it is "defined" on every cycle thereafter.
569        //   - `epidRecord.c:191-193` process: closed-loop (`SMSL=1`)
570        //     with `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))` — an
571        //     ACTUAL fetch success. `self.stpl_resolved` is the
572        //     framework's report of exactly that (a STPL that is empty,
573        //     or whose DB/CA fetch failed, leaves it false).
574        // Otherwise `udf` stays TRUE forever and C `epidRecord.c:195`
575        // `return(0)` skips `do_pid` every cycle — e.g. a supervisory
576        // (`SMSL=0`) epid with an empty/non-constant STPL NEVER runs
577        // `do_pid`.
578        //
579        // `self.udf` is the framework `dbCommon.udf` pushed before
580        // `process()`; it is last cycle's value because the framework
581        // recomputes `common.udf` (from `value_is_undefined()`) only
582        // *after* `process()`. C reads `pepid->udf` at process-start
583        // identically. `udf` is sticky-false: once C clears it, it is
584        // never re-set — so the gate keys off `self.udf`, and a closed-
585        // loop epid whose STPL later fails keeps running `do_pid`.
586        //
587        // `value_undefined` is recomputed here for the framework's
588        // post-process `common.udf = value_is_undefined()`.
589        self.compute_skipped = false;
590
591        // CA-TRIG trigger pass — C `devEpidSoftCallback.c:143-145` +
592        // `epidRecord.c:205-210`. `EpidSoftCallbackDeviceSupport::read`
593        // ran first this cycle, saw a CA-type TRIG link, fired the
594        // asynchronous readback trigger (returning `WriteDbLink{TRIG}` +
595        // `ReprocessAfter` actions), and set `ca_trig_pending` — the
596        // analogue of C `do_pid` setting `pepid->pact = TRUE` and
597        // `return(0)`.
598        //
599        // C `epidRecord.c:207` `if (!pact && pepid->pact) return(0)`
600        // then returns BEFORE `recGblGetTimeStamp` / `checkAlarms` /
601        // `monitor` / `recGblFwdLink`: the trigger pass runs NONE of
602        // the process tail. Return `async_pending` so the framework
603        // skips the alarm/timestamp/snapshot/OUT/FLNK tail for this
604        // cycle. The `read()`-returned actions were merged by the
605        // framework and are still executed; the reprocess pass runs
606        // `do_pid` and the tail exactly once.
607        //
608        // `device_did_compute` is cleared here because the trigger pass
609        // performed NO compute — without this reset the reprocess pass
610        // could observe a stale `true`.
611        if self.ca_trig_pending {
612            self.ca_trig_pending = false;
613            self.device_did_compute = false;
614            return Ok(ProcessOutcome::async_pending());
615        }
616
617        // C clear-conditions, evaluated at process-start:
618        //  - CONSTANT STPL link  → init `recGblInitConstantLink` cleared
619        //    udf permanently (`epidRecord.c:160-164`).
620        //  - closed-loop STPL fetch succeeded this cycle
621        //    (`epidRecord.c:191-193`).
622        //
623        // `stpl_resolved` is a per-cycle signal: consume it and reset
624        // so a later `process_local`-path cycle (which performs no
625        // link resolution and never calls `set_resolved_input_links`)
626        // cannot read a stale "resolved" from an earlier links-path
627        // cycle.
628        let stpl_resolved = self.stpl_resolved;
629        self.stpl_resolved = false;
630        let stpl_clears_udf =
631            link_field_type(&self.stpl) == LinkType::Constant || (self.smsl == 1 && stpl_resolved);
632        // udf state this cycle: undefined unless already cleared
633        // (`!self.udf`) or a clear-condition fires now.
634        self.value_undefined = self.udf && !stpl_clears_udf;
635        if !self.device_did_compute {
636            if self.value_undefined {
637                // C `epidRecord.c:195-202`: while `udf==TRUE`, skip
638                // `do_pid` entirely and `return 0` — *before*
639                // `recGblGetTimeStamp`, `checkAlarms`, `monitor` and
640                // `recGblFwdLink`. The framework's centralised UDF
641                // check (`rec_gbl_check_udf`, run after process())
642                // raises `UDF_ALARM` with `udfs` severity, matching C's
643                // `recGblSetSevr(pepid, UDF_ALARM, pepid->udfs)`.
644                //
645                // `update_monitors()` is deliberately NOT called here:
646                // C's early `return(0)` skips `monitor()`, so the
647                // previous-value fields (`pp`/`ip`/`dp`/...) and the
648                // `mlst`/`alst` deadband baselines must NOT advance
649                // while the record is undefined.
650                //
651                // C `return(0)` is reached before `recGblFwdLink` and
652                // the `do_pid` output write. The Rust framework drives
653                // the OUTL write (`multi_output_links`) and FLNK; flag
654                // this cycle so `multi_output_links` and
655                // `should_fire_forward_link` suppress them — otherwise
656                // a stale OVAL would be pushed to the OUTL target.
657                self.device_did_compute = false;
658                self.compute_skipped = true;
659                return Ok(ProcessOutcome::complete());
660            }
661        }
662
663        if !self.device_did_compute {
664            crate::device_support::epid_soft::EpidSoftDeviceSupport::do_pid(self);
665        }
666        self.device_did_compute = false; // Reset for next cycle
667
668        // Alarm evaluation is NOT done here. The framework invokes the
669        // `Record::check_alarms` trait hook (below) after `process()`,
670        // which is where the computed severity is applied to SEVR/STAT
671        // via `recGblSetSevr`. Calling the inherent `check_alarms` here
672        // would advance `lalm` an extra time and double-step the
673        // hysteresis state, so it is deliberately omitted.
674        self.update_monitors();
675
676        // Device support actions are now merged by the framework
677        let actions = Vec::new();
678        Ok(ProcessOutcome::complete_with(actions))
679    }
680
681    /// Per-record alarm hook — C `epidRecord.c::checkAlarms`.
682    ///
683    /// The framework calls this after `process()`; it computes the
684    /// HIHI/HIGH/LOW/LOLO condition (with `lalm` hysteresis) via the
685    /// inherent [`EpidRecord::check_alarms`] and applies the result to
686    /// the record's pending alarm state with `recGblSetSevr`. That
687    /// accumulates into `nsta`/`nsev` (raise-only / maximize-severity),
688    /// which the framework later transfers to `STAT`/`SEVR` via
689    /// `recGblResetAlarms`. Returning `None` raises nothing, so a value
690    /// that stays inside the limits leaves the record un-alarmed and a
691    /// held value does not re-fire.
692    fn check_alarms(&mut self, common: &mut CommonFields) {
693        // C `devEpidSoft.c:110-112` / `devEpidSoftCallback.c:115-117`:
694        // a CONSTANT `INP` link means "nothing to control" — raise
695        // SOFT_ALARM/INVALID_ALARM. `do_pid` set `inp_constant` and
696        // skipped the compute; apply the severity here (the framework
697        // calls this hook after `process()`).
698        if self.inp_constant {
699            recgbl::rec_gbl_set_sevr(common, alarm_status::SOFT_ALARM, AlarmSeverity::Invalid);
700        }
701        if let Some((stat, sevr, alev)) = EpidRecord::check_alarms(self) {
702            // C `aiRecord.c:403-406`: `if (recGblSetSevr(...)) prec->lalm = alev;`
703            // — the LALM update is gated on `recGblSetSevr` returning TRUE,
704            // i.e. on the alarm actually raising the pending severity.
705            // `rec_gbl_set_sevr` is raise-only and returns nothing, so detect
706            // the raise by observing whether `nsev` increased across the call.
707            let before = common.nsev;
708            recgbl::rec_gbl_set_sevr(common, stat, sevr);
709            if common.nsev != before {
710                self.lalm = alev;
711            }
712        }
713    }
714
715    /// C `epidRecord.c:376` REASSIGNS `monitor_mask = DBE_LOG|DBE_VALUE` after
716    /// VAL's own post, so every secondary the rest of `monitor()` posts
717    /// (:377-406) carries a LITERAL `DBE_VALUE | DBE_LOG` — this cycle's alarm
718    /// bits are discarded, unlike VAL's post (:371), which keeps them. A
719    /// `DBE_ALARM`-only subscriber on `.OVAL`/`.P`/`.I`/... is therefore
720    /// notified on no cycle at all.
721    ///
722    /// C's list is OVAL, P, I, D, CT, DT, ERR, CVAL; `CT` is `DBF_NOACCESS`
723    /// (`epidRecord.dbd:226`) and has no CA-visible field, leaving these seven.
724    fn fields_posted_without_alarm_bits(&self) -> &'static [&'static str] {
725        &["OVAL", "P", "I", "D", "DT", "ERR", "CVAL"]
726    }
727
728    fn get_field(&self, name: &str) -> Option<EpicsValue> {
729        match name {
730            "VAL" => Some(EpicsValue::Double(self.val)),
731            "SMSL" => Some(EpicsValue::Short(self.smsl)),
732            "STPL" => Some(EpicsValue::String(self.stpl.clone().into())),
733            "INP" => Some(EpicsValue::String(self.inp.clone().into())),
734            "OUTL" => Some(EpicsValue::String(self.outl.clone().into())),
735            "TRIG" => Some(EpicsValue::String(self.trig.clone().into())),
736            "TVAL" => Some(EpicsValue::Double(self.tval)),
737            "CVAL" => Some(EpicsValue::Double(self.cval)),
738            "CVLP" => Some(EpicsValue::Double(self.cvlp)),
739            "OVAL" => Some(EpicsValue::Double(self.oval)),
740            "OVLP" => Some(EpicsValue::Double(self.ovlp)),
741            "KP" => Some(EpicsValue::Double(self.kp)),
742            "KI" => Some(EpicsValue::Double(self.ki)),
743            "KD" => Some(EpicsValue::Double(self.kd)),
744            "P" => Some(EpicsValue::Double(self.p)),
745            "PP" => Some(EpicsValue::Double(self.pp)),
746            "I" => Some(EpicsValue::Double(self.i)),
747            "IP" => Some(EpicsValue::Double(self.ip)),
748            "D" => Some(EpicsValue::Double(self.d)),
749            "DP" => Some(EpicsValue::Double(self.dp)),
750            "ERR" => Some(EpicsValue::Double(self.err)),
751            "ERRP" => Some(EpicsValue::Double(self.errp)),
752            "DT" => Some(EpicsValue::Double(self.dt)),
753            "DTP" => Some(EpicsValue::Double(self.dtp)),
754            "MDT" => Some(EpicsValue::Double(self.mdt)),
755            "FMOD" => Some(EpicsValue::Short(self.fmod)),
756            "FBON" => Some(EpicsValue::Short(self.fbon)),
757            "FBOP" => Some(EpicsValue::Short(self.fbop)),
758            "ODEL" => Some(EpicsValue::Double(self.odel)),
759            "PREC" => Some(EpicsValue::Short(self.prec)),
760            "EGU" => Some(EpicsValue::String(self.egu.clone())),
761            "HOPR" => Some(EpicsValue::Double(self.hopr)),
762            "LOPR" => Some(EpicsValue::Double(self.lopr)),
763            "DRVH" => Some(EpicsValue::Double(self.drvh)),
764            "DRVL" => Some(EpicsValue::Double(self.drvl)),
765            "HIHI" => Some(EpicsValue::Double(self.hihi)),
766            "LOLO" => Some(EpicsValue::Double(self.lolo)),
767            "HIGH" => Some(EpicsValue::Double(self.high)),
768            "LOW" => Some(EpicsValue::Double(self.low)),
769            "HHSV" => Some(EpicsValue::Short(self.hhsv)),
770            "LLSV" => Some(EpicsValue::Short(self.llsv)),
771            "HSV" => Some(EpicsValue::Short(self.hsv)),
772            "LSV" => Some(EpicsValue::Short(self.lsv)),
773            "HYST" => Some(EpicsValue::Double(self.hyst)),
774            "LALM" => Some(EpicsValue::Double(self.lalm)),
775            "ADEL" => Some(EpicsValue::Double(self.adel)),
776            "MDEL" => Some(EpicsValue::Double(self.mdel)),
777            "ALST" => Some(EpicsValue::Double(self.alst)),
778            "MLST" => Some(EpicsValue::Double(self.mlst)),
779            _ => None,
780        }
781    }
782
783    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
784        match name {
785            "VAL" => match value {
786                EpicsValue::Double(v) => {
787                    self.val = v;
788                    Ok(())
789                }
790                _ => Err(CaError::TypeMismatch(name.into())),
791            },
792            "SMSL" => match value {
793                EpicsValue::Short(v) => {
794                    self.smsl = v;
795                    Ok(())
796                }
797                _ => Err(CaError::TypeMismatch(name.into())),
798            },
799            "STPL" => match value {
800                EpicsValue::String(v) => {
801                    self.stpl = v.as_str_lossy().into_owned();
802                    Ok(())
803                }
804                _ => Err(CaError::TypeMismatch(name.into())),
805            },
806            "INP" => match value {
807                EpicsValue::String(v) => {
808                    self.inp = v.as_str_lossy().into_owned();
809                    Ok(())
810                }
811                _ => Err(CaError::TypeMismatch(name.into())),
812            },
813            "OUTL" => match value {
814                EpicsValue::String(v) => {
815                    self.outl = v.as_str_lossy().into_owned();
816                    Ok(())
817                }
818                _ => Err(CaError::TypeMismatch(name.into())),
819            },
820            "TRIG" => match value {
821                EpicsValue::String(v) => {
822                    self.trig = v.as_str_lossy().into_owned();
823                    Ok(())
824                }
825                _ => Err(CaError::TypeMismatch(name.into())),
826            },
827            "TVAL" => match value {
828                EpicsValue::Double(v) => {
829                    self.tval = v;
830                    Ok(())
831                }
832                _ => Err(CaError::TypeMismatch(name.into())),
833            },
834            "KP" => match value {
835                EpicsValue::Double(v) => {
836                    self.kp = v;
837                    Ok(())
838                }
839                _ => Err(CaError::TypeMismatch(name.into())),
840            },
841            "KI" => match value {
842                EpicsValue::Double(v) => {
843                    self.ki = v;
844                    Ok(())
845                }
846                _ => Err(CaError::TypeMismatch(name.into())),
847            },
848            "KD" => match value {
849                EpicsValue::Double(v) => {
850                    self.kd = v;
851                    Ok(())
852                }
853                _ => Err(CaError::TypeMismatch(name.into())),
854            },
855            "I" => match value {
856                EpicsValue::Double(v) => {
857                    self.i = v;
858                    Ok(())
859                }
860                _ => Err(CaError::TypeMismatch(name.into())),
861            },
862            "IP" => match value {
863                EpicsValue::Double(v) => {
864                    self.ip = v;
865                    Ok(())
866                }
867                _ => Err(CaError::TypeMismatch(name.into())),
868            },
869            "DT" => match value {
870                EpicsValue::Double(v) => {
871                    self.dt = v;
872                    Ok(())
873                }
874                _ => Err(CaError::TypeMismatch(name.into())),
875            },
876            "MDT" => match value {
877                EpicsValue::Double(v) => {
878                    self.mdt = v;
879                    Ok(())
880                }
881                _ => Err(CaError::TypeMismatch(name.into())),
882            },
883            "FMOD" => match value {
884                EpicsValue::Short(v) => {
885                    self.fmod = v;
886                    Ok(())
887                }
888                _ => Err(CaError::TypeMismatch(name.into())),
889            },
890            "FBON" => match value {
891                EpicsValue::Short(v) => {
892                    self.fbon = v;
893                    Ok(())
894                }
895                _ => Err(CaError::TypeMismatch(name.into())),
896            },
897            "ODEL" => match value {
898                EpicsValue::Double(v) => {
899                    self.odel = v;
900                    Ok(())
901                }
902                _ => Err(CaError::TypeMismatch(name.into())),
903            },
904            "PREC" => match value {
905                EpicsValue::Short(v) => {
906                    self.prec = v;
907                    Ok(())
908                }
909                _ => Err(CaError::TypeMismatch(name.into())),
910            },
911            "EGU" => match value {
912                EpicsValue::String(v) => {
913                    self.egu = v;
914                    Ok(())
915                }
916                _ => Err(CaError::TypeMismatch(name.into())),
917            },
918            "HOPR" => match value {
919                EpicsValue::Double(v) => {
920                    self.hopr = v;
921                    Ok(())
922                }
923                _ => Err(CaError::TypeMismatch(name.into())),
924            },
925            "LOPR" => match value {
926                EpicsValue::Double(v) => {
927                    self.lopr = v;
928                    Ok(())
929                }
930                _ => Err(CaError::TypeMismatch(name.into())),
931            },
932            "DRVH" => match value {
933                EpicsValue::Double(v) => {
934                    self.drvh = v;
935                    Ok(())
936                }
937                _ => Err(CaError::TypeMismatch(name.into())),
938            },
939            "DRVL" => match value {
940                EpicsValue::Double(v) => {
941                    self.drvl = v;
942                    Ok(())
943                }
944                _ => Err(CaError::TypeMismatch(name.into())),
945            },
946            "HIHI" => match value {
947                EpicsValue::Double(v) => {
948                    self.hihi = v;
949                    Ok(())
950                }
951                _ => Err(CaError::TypeMismatch(name.into())),
952            },
953            "LOLO" => match value {
954                EpicsValue::Double(v) => {
955                    self.lolo = v;
956                    Ok(())
957                }
958                _ => Err(CaError::TypeMismatch(name.into())),
959            },
960            "HIGH" => match value {
961                EpicsValue::Double(v) => {
962                    self.high = v;
963                    Ok(())
964                }
965                _ => Err(CaError::TypeMismatch(name.into())),
966            },
967            "LOW" => match value {
968                EpicsValue::Double(v) => {
969                    self.low = v;
970                    Ok(())
971                }
972                _ => Err(CaError::TypeMismatch(name.into())),
973            },
974            "HHSV" => match value {
975                EpicsValue::Short(v) => {
976                    self.hhsv = v;
977                    Ok(())
978                }
979                _ => Err(CaError::TypeMismatch(name.into())),
980            },
981            "LLSV" => match value {
982                EpicsValue::Short(v) => {
983                    self.llsv = v;
984                    Ok(())
985                }
986                _ => Err(CaError::TypeMismatch(name.into())),
987            },
988            "HSV" => match value {
989                EpicsValue::Short(v) => {
990                    self.hsv = v;
991                    Ok(())
992                }
993                _ => Err(CaError::TypeMismatch(name.into())),
994            },
995            "LSV" => match value {
996                EpicsValue::Short(v) => {
997                    self.lsv = v;
998                    Ok(())
999                }
1000                _ => Err(CaError::TypeMismatch(name.into())),
1001            },
1002            "HYST" => match value {
1003                EpicsValue::Double(v) => {
1004                    self.hyst = v;
1005                    Ok(())
1006                }
1007                _ => Err(CaError::TypeMismatch(name.into())),
1008            },
1009            "ADEL" => match value {
1010                EpicsValue::Double(v) => {
1011                    self.adel = v;
1012                    Ok(())
1013                }
1014                _ => Err(CaError::TypeMismatch(name.into())),
1015            },
1016            "MDEL" => match value {
1017                EpicsValue::Double(v) => {
1018                    self.mdel = v;
1019                    Ok(())
1020                }
1021                _ => Err(CaError::TypeMismatch(name.into())),
1022            },
1023            // Read-only fields
1024            "CVAL" | "CVLP" | "OVAL" | "OVLP" | "P" | "PP" | "D" | "DP" | "ERR" | "ERRP"
1025            | "DTP" | "FBOP" | "LALM" | "ALST" | "MLST" => Err(CaError::ReadOnlyField(name.into())),
1026            _ => Err(CaError::FieldNotFound(name.into())),
1027        }
1028    }
1029
1030    fn declared_fields(&self) -> &'static [FieldDesc] {
1031        dbd_generated::EPID_FIELDS
1032    }
1033
1034    fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
1035        Some(self)
1036    }
1037
1038    /// C `epidRecord.c` UDF ownership — see [`EpidRecord::value_undefined`].
1039    ///
1040    /// The framework's post-`process()` step runs
1041    /// `common.udf = value_is_undefined()` (gated on `clears_udf()`,
1042    /// left at its `true` default). Returning the epid-owned
1043    /// `value_undefined` — recomputed in `process()` from the two C
1044    /// clear-conditions — keeps `udf` TRUE for a supervisory / empty-
1045    /// STPL epid (so its UDF gate fires every cycle, as C does) and
1046    /// clears it only on a CONSTANT STPL or a successful closed-loop
1047    /// `dbGetLink(stpl)`.
1048    ///
1049    /// The default `value_is_undefined()` keys off `VAL` being NaN,
1050    /// which for an epid (`VAL` defaults to a finite `0.0`, never NaN)
1051    /// would wrongly clear `udf` after the first cycle — the bug this
1052    /// override fixes.
1053    fn value_is_undefined(&self) -> bool {
1054        self.value_undefined
1055    }
1056
1057    fn set_device_did_compute(&mut self, did_compute: bool) {
1058        self.device_did_compute = did_compute;
1059    }
1060
1061    /// C `epidRecord.c:195` reads `pepid->udf` at the top of
1062    /// `process()`. The framework owns `dbCommon.udf`; this hook
1063    /// captures it so `process()` can gate `do_pid` on it.
1064    fn set_process_context(&mut self, ctx: &ProcessContext) {
1065        self.udf = ctx.udf;
1066        self.dtyp.clear();
1067        self.dtyp.push_str(&ctx.dtyp);
1068    }
1069
1070    /// C `devEpidSoftCallback.c:120-132` — the DB-type TRIG readback
1071    /// link write.
1072    ///
1073    /// `devEpidSoftCallback.c::do_pid`, within ONE process pass, does:
1074    ///   1. `if (ptriglink->type != CA_LINK)` →
1075    ///      `dbPutLink(ptriglink, DBR_DOUBLE, &pepid->tval, 1)`
1076    ///      (`devEpidSoftCallback.c:121-127`) — a synchronous write that
1077    ///      processes the triggered source chain;
1078    ///   2. `dbGetLink(&pepid->inp, DBR_DOUBLE, &pepid->cval, ...)`
1079    ///      (`devEpidSoftCallback.c:151`) — read CVAL from INP;
1080    ///   3. run the PID.
1081    ///
1082    /// So for a DB-type TRIG link the trigger write must land BEFORE
1083    /// this cycle's `INP -> CVAL` fetch. The framework resolves input
1084    /// links before `pre_process_actions`, so the TRIG write is emitted
1085    /// here, from `pre_input_link_actions`, which the framework runs
1086    /// strictly before the input-link fetch.
1087    ///
1088    /// Only the `devEpidSoftCallback` DSET (DTYP `"Epid Async Soft"`)
1089    /// drives the TRIG link — `devEpidSoft` (`devEpidSoft.c`) has no
1090    /// TRIG handling at all. The action is therefore gated on `dtyp`.
1091    ///
1092    /// The CA-type TRIG link is deliberately NOT emitted here: C
1093    /// `devEpidSoftCallback.c:133-147` cannot wait synchronously on a
1094    /// CA link, so it uses `dbCaPutLinkCallback` + `pact=TRUE` and
1095    /// re-processes on the callback. That two-pass path stays in
1096    /// `EpidSoftCallbackDeviceSupport::read` (`WriteDbLink` +
1097    /// `ReprocessAfter`).
1098    fn pre_input_link_actions(&mut self) -> Vec<ProcessAction> {
1099        if self.dtyp != "Epid Async Soft" {
1100            return Vec::new();
1101        }
1102        if link_field_type(&self.trig) == LinkType::Db {
1103            return vec![ProcessAction::WriteDbLink {
1104                link_field: "TRIG",
1105                value: EpicsValue::Double(self.tval),
1106            }];
1107        }
1108        Vec::new()
1109    }
1110
1111    /// Framework report of which `multi_input_links` fetches produced a
1112    /// value this cycle — the analogue of C
1113    /// `RTN_SUCCESS(dbGetLink(&prec->stpl, ...))` (`epidRecord.c:191`).
1114    /// `STPL` is only ever in `multi_input_links` when
1115    /// `SMSL == closed_loop`; its presence here means the closed-loop
1116    /// setpoint fetch actually succeeded this cycle. A STPL that is
1117    /// empty, or a DB/CA link whose fetch failed, is absent — so
1118    /// `stpl_resolved` is reset to false and `udf` is not cleared.
1119    fn set_resolved_input_links(&mut self, resolved: &[&'static str]) {
1120        self.stpl_resolved = resolved.contains(&"STPL");
1121    }
1122
1123    /// C `epidRecord.c:160-164` `init_record`: when `STPL` is a
1124    /// CONSTANT link holding a valid constant, `recGblInitConstantLink`
1125    /// seeds `VAL` from the constant and `udf` is cleared. The
1126    /// framework owns `dbCommon.udf`; this hook is its controlled
1127    /// access point. Runs once after `init_record`.
1128    ///
1129    /// For `SMSL == closed_loop` the framework also fetches `STPL` into
1130    /// `VAL` via `multi_input_links` every cycle; the constant seed
1131    /// here matters for the supervisory (`SMSL=0`) case and for the
1132    /// first cycle before any process.
1133    fn post_init_finalize_undef(&mut self, udf: &mut bool) -> CaResult<()> {
1134        let parsed = epics_base_rs::server::record::parse_link_v2(&self.stpl);
1135        if parsed.link_type() == LinkType::Constant {
1136            if let Some(EpicsValue::Double(v)) = parsed.constant_value() {
1137                self.val = v;
1138                *udf = false;
1139                self.value_undefined = false;
1140            }
1141        }
1142        Ok(())
1143    }
1144
1145    fn put_field_internal(
1146        &mut self,
1147        name: &str,
1148        value: EpicsValue,
1149    ) -> epics_base_rs::error::CaResult<()> {
1150        // Bypass read-only checks for framework-internal writes (ReadDbLink).
1151        // This allows the framework to write to CVAL, OVAL, etc. from link resolution.
1152        match name {
1153            // The bumpless-transfer OUTL readback. Staged, not committed:
1154            // `do_pid` moves it into `I` (PID) or `OVAL` (MaxMin) at C's line,
1155            // after the MDT gate. See `OUTL_SEED_FIELD`.
1156            OUTL_SEED_FIELD => match value {
1157                EpicsValue::Double(v) => {
1158                    self.outl_seed = Some(v);
1159                    Ok(())
1160                }
1161                _ => Err(CaError::TypeMismatch(name.into())),
1162            },
1163            "CVAL" => match value {
1164                EpicsValue::Double(v) => {
1165                    self.cval = v;
1166                    Ok(())
1167                }
1168                _ => Err(CaError::TypeMismatch(name.into())),
1169            },
1170            "OVAL" => match value {
1171                EpicsValue::Double(v) => {
1172                    self.oval = v;
1173                    Ok(())
1174                }
1175                _ => Err(CaError::TypeMismatch(name.into())),
1176            },
1177            "P" => match value {
1178                EpicsValue::Double(v) => {
1179                    self.p = v;
1180                    Ok(())
1181                }
1182                _ => Err(CaError::TypeMismatch(name.into())),
1183            },
1184            "D" => match value {
1185                EpicsValue::Double(v) => {
1186                    self.d = v;
1187                    Ok(())
1188                }
1189                _ => Err(CaError::TypeMismatch(name.into())),
1190            },
1191            "ERR" => match value {
1192                EpicsValue::Double(v) => {
1193                    self.err = v;
1194                    Ok(())
1195                }
1196                _ => Err(CaError::TypeMismatch(name.into())),
1197            },
1198            _ => self.put_field(name, value),
1199        }
1200    }
1201
1202    /// C `epidRecord.c:158-164`:
1203    ///
1204    /// ```c
1205    /// if (pepid->stpl.type == CONSTANT) {
1206    ///     if (recGblInitConstantLink(&pepid->stpl, DBF_DOUBLE, &pepid->val))
1207    ///         pepid->udf = FALSE;
1208    /// }
1209    /// ```
1210    ///
1211    /// The setpoint of a constant-STPL epid is loaded ONCE, here — at process
1212    /// `dbGetLink(&prec->stpl, ...)` delivers nothing (it returns success, so
1213    /// the closed-loop UDF clear at `:191` still fires), which is why an
1214    /// operator `caput REC.VAL` on a constant-STPL epid holds.
1215    ///
1216    /// INP is NOT seeded: a constant INP means "nothing to control" in C —
1217    /// `devEpidSoft.c` raises SOFT/INVALID rather than reading a value.
1218    fn constant_init_links(&self) -> Vec<epics_base_rs::server::record::ConstantInitLink> {
1219        vec![epics_base_rs::server::record::ConstantInitLink::dol_to_val(
1220            "STPL", "VAL",
1221        )]
1222    }
1223
1224    fn multi_input_links(&self) -> &[(&'static str, &'static str)] {
1225        // INP -> CVAL is always resolved.
1226        // STPL -> VAL is only resolved when SMSL == closed_loop (1).
1227        // In supervisory mode (SMSL=0), the operator sets VAL directly
1228        // and STPL must not overwrite it.
1229        if self.smsl == 1 {
1230            // closed_loop: fetch setpoint from STPL into VAL
1231            static WITH_STPL: &[(&str, &str)] = &[("STPL", "VAL"), ("INP", "CVAL")];
1232            WITH_STPL
1233        } else {
1234            // supervisory: VAL is set by operator, don't fetch STPL
1235            static WITHOUT_STPL: &[(&str, &str)] = &[("INP", "CVAL")];
1236            WITHOUT_STPL
1237        }
1238    }
1239
1240    fn multi_output_links(&self) -> &[(&'static str, &'static str)] {
1241        // C `epidRecord.c:195-202`: on a UDF-gated cycle `process()`
1242        // returns before `do_pid` writes the output — suppress the
1243        // OUTL->OVAL write so a stale OVAL is not pushed downstream.
1244        //
1245        // C `devEpidSoft.c:220` / `devEpidSoftCallback.c:256`: the OUTL
1246        // `dbPutLink` fires only when `fbon && outl.type != CONSTANT`,
1247        // and only when `do_pid` reached that line (not the sub-MDT or
1248        // CONSTANT-INP early returns); the Fast support never writes
1249        // OUTL. `do_pid` owns `outl_write` and encodes exactly that
1250        // condition, so the framework OUTL write is gated on it.
1251        if self.compute_skipped || !self.outl_write {
1252            return &[];
1253        }
1254        // OUTL -> OVAL (output link)
1255        static LINKS: &[(&str, &str)] = &[("OUTL", "OVAL")];
1256        LINKS
1257    }
1258
1259    fn should_fire_forward_link(&self) -> bool {
1260        // C `epidRecord.c:201` `return(0)` on a UDF-gated cycle is
1261        // reached before `recGblFwdLink` — no forward link this cycle.
1262        !self.compute_skipped
1263    }
1264}
1265
1266#[cfg(test)]
1267mod menu_choice_tests {
1268    use super::EpidRecord;
1269    use epics_base_rs::server::record::{FieldDeclaration, Record, RecordInstance};
1270    use epics_base_rs::types::EpicsValue;
1271
1272    /// The choices a client sees are the DECLARATION's — `epidRecord.dbd`'s
1273    /// `menu()` on each field — and the index↔string mapping is wire-visible.
1274    /// This used to assert them through `Record::menu_field_choices`, a hand
1275    /// written table that declared the same menus a second time; `SMSL` needed
1276    /// a per-record mapping there only because the shared-menu registry keys
1277    /// `menuOmsl` by the field name `OMSL`. The declaration has no such
1278    /// problem: the `.dbd` says `field(SMSL,DBF_MENU) { menu(menuOmsl) }`, so
1279    /// the FieldDesc points straight at base's `MENU_OMSL`.
1280    #[test]
1281    fn epid_menu_choices_come_from_the_declaration() {
1282        let rec = EpidRecord::default();
1283        let menu = |name: &str| {
1284            rec.field_list()
1285                .iter()
1286                .find(|f| f.name == name)
1287                .unwrap_or_else(|| panic!("{name} is declared"))
1288                .menu
1289        };
1290        assert_eq!(menu("FMOD"), Some(&["PID", "Max/Min"][..]));
1291        let fbstate = &["Off", "On"][..];
1292        assert_eq!(menu("FBON"), Some(fbstate));
1293        assert_eq!(menu("FBOP"), Some(fbstate));
1294        assert_eq!(menu("SMSL"), Some(&["supervisory", "closed_loop"][..]));
1295        assert_eq!(menu("VAL"), None);
1296    }
1297
1298    // End-to-end: SMSL is served as Short; the base snapshot path promotes
1299    // it to DBR_ENUM and attaches the menuOmsl labels.
1300    #[test]
1301    fn epid_smsl_snapshot_is_enum_with_labels() {
1302        let mut rec = EpidRecord::default();
1303        rec.put_field("SMSL", EpicsValue::Short(1)).unwrap(); // closed_loop
1304        let inst = RecordInstance::new("PID:SMSL".into(), rec);
1305
1306        let snap = inst.snapshot_for_field("SMSL").unwrap();
1307        assert_eq!(snap.value, EpicsValue::Enum(1));
1308        assert_eq!(
1309            snap.enums.as_ref().unwrap().strings,
1310            vec!["supervisory", "closed_loop"]
1311        );
1312    }
1313}