Skip to main content

std_rs/records/
throttle.rs

1use std::time::Instant;
2
3use epics_base_rs::error::{CaError, CaResult};
4use epics_base_rs::server::record::{
5    FieldDesc, LinkType, ProcessAction, ProcessOutcome, Record, link_field_type,
6};
7use epics_base_rs::types::{DbFieldType, EpicsValue};
8
9/// Throttle record — rate-limits value changes to prevent device damage.
10///
11/// Ported from EPICS std module `throttleRecord.c`.
12///
13/// When VAL is written, the record checks drive limits, optionally clips
14/// the value, sets WAIT=True, then writes SENT to the OUT link only after
15/// the minimum delay (DLY) has elapsed since the last output. If a new
16/// value arrives during the delay, it queues the latest value and sends
17/// it when the delay expires.
18pub struct ThrottleRecord {
19    /// Set value (VAL)
20    pub val: f64,
21    /// Previous set value (OVAL), read-only
22    pub oval: f64,
23    /// Last sent value (SENT), read-only
24    pub sent: f64,
25    /// Previous sent value (OSENT), read-only
26    pub osent: f64,
27    /// Busy flag (WAIT): 0=False, 1=True, read-only
28    pub wait: i16,
29    /// High operating range (HOPR)
30    pub hopr: f64,
31    /// Low operating range (LOPR)
32    pub lopr: f64,
33    /// High drive limit (DRVLH)
34    pub drvlh: f64,
35    /// Low drive limit (DRVLL)
36    pub drvll: f64,
37    /// Limit status: 0=Normal, 1=Low, 2=High (DRVLS), read-only
38    pub drvls: i16,
39    /// Limit clipping: 0=Off, 1=On (DRVLC)
40    pub drvlc: i16,
41    /// Code version string (VER), read-only
42    pub ver: String,
43    /// Record status: 0=Unknown, 1=Error, 2=Success (STS), read-only
44    pub sts: i16,
45    /// Display precision (PREC)
46    pub prec: i16,
47    /// Delay display precision (DPREC)
48    pub dprec: i16,
49    /// Delay between outputs in seconds (DLY)
50    pub dly: f64,
51    /// Output link (OUT)
52    pub out: String,
53    /// Output link valid: 0=ExtNC, 1=Ext, 2=Local, 3=Constant (OV), read-only
54    pub ov: i16,
55    /// Sync input link (SINP)
56    pub sinp: String,
57    /// Sync input link valid (SIV), read-only
58    pub siv: i16,
59    /// Sync trigger: 0=Idle, 1=Process (SYNC)
60    pub sync: i16,
61
62    // --- Private runtime state ---
63    /// Whether limits are active (drvlh > drvll)
64    limit_flag: bool,
65    /// Whether a delay is currently in progress
66    delay_active: bool,
67    /// When the last output was sent (for delay enforcement)
68    last_send_time: Option<Instant>,
69    /// Value queued during delay period (sent when delay expires)
70    pending_value: Option<f64>,
71    /// Whether the most recent `process()` cycle actually issued an OUT
72    /// write. C `throttleRecord.c:308` has `recGblFwdLink` commented out
73    /// in `process()`; the forward link fires ONLY inside `valuePut`
74    /// (`throttleRecord.c:580`), i.e. only on a cycle where the OUT link
75    /// was written. `should_fire_forward_link` returns this flag so a
76    /// queuing-during-delay cycle or a rejected out-of-range cycle does
77    /// NOT fire FLNK.
78    out_written: bool,
79}
80
81impl Default for ThrottleRecord {
82    fn default() -> Self {
83        Self {
84            val: 0.0,
85            oval: 0.0,
86            sent: 0.0,
87            osent: 0.0,
88            wait: 0,
89            hopr: 0.0,
90            lopr: 0.0,
91            drvlh: 0.0,
92            drvll: 0.0,
93            drvls: 0, // Normal
94            drvlc: 0, // Off
95            // C `throttleRecord.c:51` `#define VERSION "0-2-1"`,
96            // copied into VER by `init_record` pass 0 (line 149).
97            ver: "0-2-1".to_string(),
98            sts: 0, // Unknown
99            prec: 0,
100            dprec: 0,
101            dly: 0.0,
102            out: String::new(),
103            ov: 3, // Constant
104            sinp: String::new(),
105            siv: 3,  // Constant
106            sync: 0, // Idle
107            limit_flag: false,
108            delay_active: false,
109            last_send_time: None,
110            pending_value: None,
111            out_written: false,
112        }
113    }
114}
115
116/// Upper bound (exclusive) on the `DLY` field, in seconds.
117///
118/// `process()` converts `self.dly` into a `std::time::Duration` via
119/// `Duration::from_secs_f64`, which panics not only on a non-finite
120/// argument but on any finite value too large for a `Duration` to
121/// represent (≈ `u64::MAX` seconds ≈ 1.8e19, message "value is either
122/// too big or NaN"). A CA put of e.g. `DLY = 1e300` is a perfectly
123/// finite f64 and would otherwise slip past an `is_finite()` guard and
124/// panic the record task.
125///
126/// A throttle delay of 24 hours is already far past any realistic
127/// device-protection interval, so this finite cap is the operational
128/// ceiling for `DLY`. It is also orders of magnitude below the
129/// `Duration` overflow point, so any `self.dly` accepted by the writer
130/// guard is guaranteed safe for `Duration::from_secs_f64`.
131const MAX_DLY: f64 = 86_400.0;
132
133/// Validate a candidate `DLY` value (seconds).
134///
135/// Returns `Ok(())` only for a value that can never make
136/// `Duration::from_secs_f64(self.dly)` panic in `process()`: it must
137/// be finite and at most [`MAX_DLY`]. A negative value is accepted
138/// here — C `special()` clamps it to 0 and `process()` treats any
139/// `dly <= 0.0` as "no delay" without constructing a `Duration` — so
140/// negativity is not a panic hazard. This is the single guard every
141/// writer of `self.dly` must pass through to hold the invariant
142/// "`self.dly` can never make `Duration::from_secs_f64` panic".
143fn validate_dly(v: f64) -> CaResult<()> {
144    if !v.is_finite() {
145        return Err(CaError::InvalidValue(format!(
146            "throttle DLY must be finite, got {v}"
147        )));
148    }
149    if v > MAX_DLY {
150        return Err(CaError::InvalidValue(format!(
151            "throttle DLY must not exceed {MAX_DLY} seconds, got {v}"
152        )));
153    }
154    Ok(())
155}
156
157impl ThrottleRecord {
158    /// Check drive limits and optionally clip the value.
159    ///
160    /// Mirrors the limit block of C `throttleRecord.c:242-283`. When
161    /// `limit_flag` is set the value is tested against the low limit
162    /// first, then the high limit (same order as C lines 246/260).
163    /// `DRVLS` is updated to the resulting limit status; when limits
164    /// are inactive it is forced to Normal (C line 275 sets
165    /// `throttleDRVLS_NORM`).
166    ///
167    /// Returns `Ok(value)` when the value is acceptable (clipped to the
168    /// limit when `DRVLC` is On), or `Err(())` when it is out of range
169    /// and clipping is Off — C's `proc_flag = 0` rejection path. C does
170    /// **not** touch `STS` on a rejection (lines 254-257, 268-271); the
171    /// caller must not set it either.
172    fn check_limits(&mut self, val: f64) -> Result<f64, ()> {
173        if !self.limit_flag {
174            self.drvls = 0; // throttleDRVLS_NORM
175            return Ok(val);
176        }
177
178        if val < self.drvll {
179            self.drvls = 1; // throttleDRVLS_LOW
180            if self.drvlc == 1 {
181                return Ok(self.drvll);
182            }
183            return Err(());
184        }
185
186        if val > self.drvlh {
187            self.drvls = 2; // throttleDRVLS_HIGH
188            if self.drvlc == 1 {
189                return Ok(self.drvlh);
190            }
191            return Err(());
192        }
193
194        self.drvls = 0; // throttleDRVLS_NORM
195        Ok(val)
196    }
197
198    /// Send the value to the output — C `throttleRecord.c::valuePut`
199    /// (lines 540-594).
200    ///
201    /// C `valuePut` line 557 branches on the OUT link type:
202    ///   - `if (plink->type != CONSTANT)` — `dbPutLink` is issued and
203    ///     STS is set from its result (`throttleSTS_SUC` on success,
204    ///     `throttleSTS_ERR` on failure), SENT/OSENT advance, the
205    ///     forward link fires (line 580).
206    ///   - `else` (CONSTANT/empty OUT) — no write happens, STS is forced
207    ///     to `throttleSTS_ERR`, SENT/OSENT do NOT advance, no FLNK.
208    ///
209    /// Returns `true` when the caller must emit the `WriteDbLink{OUT}`
210    /// action (a real, non-CONSTANT link). The port cannot observe the
211    /// `dbPutLink` result inline, so a real link is treated optimistically
212    /// as STS=Success — the emitted write either lands or the framework
213    /// raises its own link alarm.
214    fn send_value(&mut self, value: f64) -> bool {
215        if link_field_type(&self.out) == LinkType::Constant
216            || link_field_type(&self.out) == LinkType::Empty
217        {
218            // CONSTANT / empty OUT — C `valuePut` else branch: STS=Error,
219            // SENT/OSENT unchanged, no write, no FLNK.
220            self.sts = 1; // throttleSTS_ERR
221            self.out_written = false;
222            return false;
223        }
224        self.osent = self.sent;
225        self.sent = value;
226        self.last_send_time = Some(Instant::now());
227        self.sts = 2; // throttleSTS_SUC
228        self.out_written = true;
229        true
230    }
231
232    /// Check if the delay period has elapsed since last send.
233    fn delay_elapsed(&self) -> bool {
234        if self.dly <= 0.0 {
235            return true;
236        }
237        match self.last_send_time {
238            Some(t) => t.elapsed().as_secs_f64() >= self.dly,
239            None => true, // Never sent before
240        }
241    }
242}
243
244static FIELDS: &[FieldDesc] = &[
245    FieldDesc {
246        name: "VAL",
247        dbf_type: DbFieldType::Double,
248        read_only: false,
249    },
250    FieldDesc {
251        name: "OVAL",
252        dbf_type: DbFieldType::Double,
253        read_only: true,
254    },
255    FieldDesc {
256        name: "SENT",
257        dbf_type: DbFieldType::Double,
258        read_only: true,
259    },
260    FieldDesc {
261        name: "OSENT",
262        dbf_type: DbFieldType::Double,
263        read_only: true,
264    },
265    FieldDesc {
266        name: "WAIT",
267        dbf_type: DbFieldType::Short,
268        read_only: true,
269    },
270    FieldDesc {
271        name: "HOPR",
272        dbf_type: DbFieldType::Double,
273        read_only: false,
274    },
275    FieldDesc {
276        name: "LOPR",
277        dbf_type: DbFieldType::Double,
278        read_only: false,
279    },
280    FieldDesc {
281        name: "DRVLH",
282        dbf_type: DbFieldType::Double,
283        read_only: false,
284    },
285    FieldDesc {
286        name: "DRVLL",
287        dbf_type: DbFieldType::Double,
288        read_only: false,
289    },
290    FieldDesc {
291        name: "DRVLS",
292        dbf_type: DbFieldType::Short,
293        read_only: true,
294    },
295    FieldDesc {
296        name: "DRVLC",
297        dbf_type: DbFieldType::Short,
298        read_only: false,
299    },
300    FieldDesc {
301        name: "VER",
302        dbf_type: DbFieldType::String,
303        read_only: true,
304    },
305    FieldDesc {
306        name: "STS",
307        dbf_type: DbFieldType::Short,
308        read_only: true,
309    },
310    FieldDesc {
311        name: "PREC",
312        dbf_type: DbFieldType::Short,
313        read_only: false,
314    },
315    FieldDesc {
316        name: "DPREC",
317        dbf_type: DbFieldType::Short,
318        read_only: false,
319    },
320    FieldDesc {
321        name: "DLY",
322        dbf_type: DbFieldType::Double,
323        read_only: false,
324    },
325    FieldDesc {
326        name: "OUT",
327        dbf_type: DbFieldType::String,
328        read_only: false,
329    },
330    FieldDesc {
331        name: "OV",
332        dbf_type: DbFieldType::Short,
333        read_only: true,
334    },
335    FieldDesc {
336        name: "SINP",
337        dbf_type: DbFieldType::String,
338        read_only: false,
339    },
340    FieldDesc {
341        name: "SIV",
342        dbf_type: DbFieldType::Short,
343        read_only: true,
344    },
345    FieldDesc {
346        name: "SYNC",
347        dbf_type: DbFieldType::Short,
348        read_only: false,
349    },
350];
351
352impl Record for ThrottleRecord {
353    fn record_type(&self) -> &'static str {
354        "throttle"
355    }
356
357    fn pre_process_actions(&mut self) -> Vec<ProcessAction> {
358        // When SYNC=1, read SINP into VAL BEFORE process() runs.
359        // This matches C EPICS where dbGetLink is synchronous/immediate.
360        if self.sync == 1 {
361            self.sync = 0;
362            return vec![ProcessAction::ReadDbLink {
363                link_field: "SINP",
364                target_field: "VAL",
365            }];
366        }
367        Vec::new()
368    }
369
370    fn process(&mut self) -> CaResult<ProcessOutcome> {
371        // C `throttleRecord.c:231-312`. The control flow here mirrors C's
372        // `process()`:
373        //
374        //   1. The drive-limit block (C lines 242-283) runs on EVERY
375        //      process() call, regardless of whether a delay is pending.
376        //      It updates DRVLS and, on a clip-off out-of-range value,
377        //      sets `proc_flag = 0` (reject: restore `val = oval`, skip
378        //      the send).
379        //   2. If `proc_flag` (C lines 285-296): the value is "entered".
380        //      C `enterValue()` sets `wait_flag = 1`; if no delay is in
381        //      progress (`!delay_flag`) it calls `valuePut()` to write
382        //      OUT immediately and arm the delay timer. If a delay IS in
383        //      progress the value just waits — the running delay timer
384        //      will pick up the latest `prec->val` when it fires.
385        //
386        // The Rust port has no `callbackRequestDelayed` handle, so the
387        // delay timer is modelled by `ReprocessAfter`: the current cycle
388        // writes OUT, then the framework re-invokes `process()` after
389        // DLY. `delay_active` is C's `delay_flag`; `pending_value` plus
390        // re-entry through this same limit block reproduces C taking the
391        // latest limit-checked `prec->val` at timer-fire time.
392        let mut actions = Vec::new();
393
394        // C `throttleRecord.c:308` keeps `recGblFwdLink` commented out in
395        // `process()`; the forward link fires ONLY from `valuePut`'s
396        // non-CONSTANT branch (line 580). Reset the per-cycle FLNK flag
397        // here so a queuing-during-delay cycle, a rejected out-of-range
398        // cycle, or a drain with nothing queued does NOT fire FLNK —
399        // only a real OUT write (via `send_value`) sets it true.
400        self.out_written = false;
401
402        // --- Drain path: the post-delay timer callback (C `valuePut()`
403        //     reached via `delayFuncCallback`, lines 530-538/540-594) ---
404        //
405        // C runs the drain in `valuePut()`, a code path SEPARATE from
406        // `process()`: it does NOT re-run the drive-limit block and does
407        // NOT touch the OVAL end-of-process update. The port models the
408        // timer with `ReprocessAfter`, so the drain arrives as a
409        // re-entrant `process()` call — identified here by an armed
410        // delay whose window has elapsed. It must therefore short-circuit
411        // BEFORE the limit block so a previously limit-checked queued
412        // value is sent as-is and DRVLS (set by the queuing process()) is
413        // left intact.
414        if self.delay_active && self.delay_elapsed() {
415            self.delay_active = false;
416            self.wait = 0;
417            match self.pending_value.take() {
418                // C `valuePut`: `wait_flag` set -> a value arrived during
419                // the delay; send the (already limit-checked) queued
420                // value, set SENT/OSENT/STS, and re-arm the timer.
421                Some(pv) => {
422                    // C `valuePut`: a CONSTANT/empty OUT yields STS=Error
423                    // and no write; a real link yields the WriteDbLink.
424                    if self.send_value(pv) {
425                        actions.push(ProcessAction::WriteDbLink {
426                            link_field: "OUT",
427                            value: EpicsValue::Double(self.sent),
428                        });
429                    }
430                    if self.dly > 0.0 {
431                        self.delay_active = true;
432                        self.wait = 1;
433                        let delay = std::time::Duration::from_secs_f64(self.dly);
434                        actions.push(ProcessAction::ReprocessAfter(delay));
435                    }
436                    return Ok(ProcessOutcome::complete_with(actions));
437                }
438                // C `valuePut`: `wait_flag` clear -> nothing queued; the
439                // callback merely clears `delay_flag` (line 597).
440                None => {
441                    return Ok(ProcessOutcome::complete_with(actions));
442                }
443            }
444        }
445
446        // --- Step 1: drive-limit block (C lines 242-283), runs on every
447        //     fresh process() call ---
448        //
449        // C restores `prec->val = prec->oval` and sets `proc_flag = 0` on
450        // a rejected (out-of-range, clipping Off) value; it does NOT set
451        // STS and does NOT touch WAIT. STS is only ever written after a
452        // real link operation (valuePut / valueSync).
453        let proc_flag = match self.check_limits(self.val) {
454            Ok(clamped) => {
455                self.val = clamped;
456                true
457            }
458            Err(()) => {
459                self.val = self.oval;
460                false
461            }
462        };
463
464        if !proc_flag {
465            // Rejected: skip enterValue entirely (C `proc_flag == 0`).
466            // A delay already in progress is left running — its
467            // ReprocessAfter still fires and drains whatever value was
468            // queued. C's end-of-process OVAL block is a no-op here
469            // because `val` was just restored to `oval`.
470            return Ok(ProcessOutcome::complete_with(actions));
471        }
472
473        // OVAL end-of-process update (C lines 299-303): on a fresh,
474        // accepted process() OVAL tracks the just-checked VAL.
475        self.oval = self.val;
476
477        // --- Step 2: enterValue() (C lines 518-528) ---
478        //
479        // A delay timer is in progress. C `enterValue()` sets
480        // `wait_flag = 1` and returns; the running `delayFuncCb` will
481        // call `valuePut()` and send whatever `prec->val` is when it
482        // fires. The port stashes the latest limit-checked value (last
483        // value wins, as in C) so the drain re-process sends it. WAIT
484        // stays True; the in-flight ReprocessAfter is left to fire.
485        if self.delay_active {
486            self.pending_value = Some(self.val);
487            self.wait = 1;
488            let remaining = self.dly
489                - self
490                    .last_send_time
491                    .map(|t| t.elapsed().as_secs_f64())
492                    .unwrap_or(0.0);
493            let delay = std::time::Duration::from_secs_f64(remaining.max(0.001));
494            actions.push(ProcessAction::ReprocessAfter(delay));
495            return Ok(ProcessOutcome::complete_with(actions));
496        }
497
498        // No delay in progress: send immediately (C `enterValue` calls
499        // `valuePut` directly when `!delay_flag`). C `valuePut` writes the
500        // OUT link and sets SENT/OSENT and STS=Success only for a
501        // non-CONSTANT OUT; a CONSTANT/empty OUT yields STS=Error and no
502        // write.
503        if self.send_value(self.val) {
504            actions.push(ProcessAction::WriteDbLink {
505                link_field: "OUT",
506                value: EpicsValue::Double(self.sent),
507            });
508        }
509
510        // Arm the delay timer (C `callbackRequestDelayed`, lines 592-593)
511        // when DLY > 0. WAIT is True for the duration of the delay: C
512        // sets `prec->wait = TRUE` before enterValue, and although
513        // `valuePut` clears it after the OUT write, the freshly-armed
514        // timer means the operator-visible post-cycle state is Busy
515        // until the drain completes.
516        if self.dly > 0.0 {
517            self.delay_active = true;
518            self.wait = 1;
519            let delay = std::time::Duration::from_secs_f64(self.dly);
520            actions.push(ProcessAction::ReprocessAfter(delay));
521            return Ok(ProcessOutcome::complete_with(actions));
522        }
523
524        // No delay: C `valuePut` sets WAIT=False after the immediate
525        // write (lines 575/587).
526        self.delay_active = false;
527        self.wait = 0;
528        Ok(ProcessOutcome::complete_with(actions))
529    }
530
531    fn can_device_write(&self) -> bool {
532        true
533    }
534
535    fn special(&mut self, field: &str, after: bool) -> CaResult<()> {
536        if !after {
537            return Ok(());
538        }
539        match field {
540            // C `special()` DLY case (lines 392-409). A negative delay
541            // is clamped to 0. C also cancels/restarts the in-flight
542            // `delayFuncCb` so a previously-set huge delay does not keep
543            // the record Busy; the port re-derives the remaining delay
544            // from `last_send_time` + the new DLY on the next process,
545            // so a shrunk DLY takes effect on the next drain attempt.
546            //
547            // `special()` runs after the field write. `put_field("DLY")`
548            // already rejects non-finite and huge-but-finite values via
549            // `validate_dly`, so a CA/db path can never leave `self.dly`
550            // out of range here. The clamp below additionally enforces
551            // the `Duration::from_secs_f64` invariant for any other
552            // writer of `self.dly` (e.g. in-process callers), so every
553            // reader downstream of `special()` is safe.
554            "DLY" => {
555                if self.dly < 0.0 {
556                    self.dly = 0.0;
557                } else if validate_dly(self.dly).is_err() {
558                    // Non-finite or >= MAX_DLY: clamp to the operational
559                    // ceiling so `process()` never panics.
560                    self.dly = MAX_DLY;
561                }
562            }
563            // C `special()` DRVLH/DRVLL case (lines 411-440). When the
564            // new limits disable limiting (`drvlh <= drvll`) DRVLS goes
565            // Normal. When limiting is (re)enabled DRVLS is recomputed
566            // immediately against the *current* VAL — Low if below the
567            // low limit, High if above the high limit, else Normal.
568            "DRVLH" | "DRVLL" => {
569                self.limit_flag = self.drvlh > self.drvll;
570                if !self.limit_flag {
571                    self.drvls = 0; // throttleDRVLS_NORM
572                } else if self.val < self.drvll {
573                    self.drvls = 1; // throttleDRVLS_LOW
574                } else if self.val > self.drvlh {
575                    self.drvls = 2; // throttleDRVLS_HIGH
576                } else {
577                    self.drvls = 0; // throttleDRVLS_NORM
578                }
579            }
580            _ => {}
581        }
582        Ok(())
583    }
584
585    fn get_field(&self, name: &str) -> Option<EpicsValue> {
586        match name {
587            "VAL" => Some(EpicsValue::Double(self.val)),
588            "OVAL" => Some(EpicsValue::Double(self.oval)),
589            "SENT" => Some(EpicsValue::Double(self.sent)),
590            "OSENT" => Some(EpicsValue::Double(self.osent)),
591            "WAIT" => Some(EpicsValue::Short(self.wait)),
592            "HOPR" => Some(EpicsValue::Double(self.hopr)),
593            "LOPR" => Some(EpicsValue::Double(self.lopr)),
594            "DRVLH" => Some(EpicsValue::Double(self.drvlh)),
595            "DRVLL" => Some(EpicsValue::Double(self.drvll)),
596            "DRVLS" => Some(EpicsValue::Short(self.drvls)),
597            "DRVLC" => Some(EpicsValue::Short(self.drvlc)),
598            "VER" => Some(EpicsValue::String(self.ver.clone())),
599            "STS" => Some(EpicsValue::Short(self.sts)),
600            "PREC" => Some(EpicsValue::Short(self.prec)),
601            "DPREC" => Some(EpicsValue::Short(self.dprec)),
602            "DLY" => Some(EpicsValue::Double(self.dly)),
603            "OUT" => Some(EpicsValue::String(self.out.clone())),
604            "OV" => Some(EpicsValue::Short(self.ov)),
605            "SINP" => Some(EpicsValue::String(self.sinp.clone())),
606            "SIV" => Some(EpicsValue::Short(self.siv)),
607            "SYNC" => Some(EpicsValue::Short(self.sync)),
608            _ => None,
609        }
610    }
611
612    fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
613        match name {
614            "VAL" => match value {
615                EpicsValue::Double(v) => {
616                    self.val = v;
617                    Ok(())
618                }
619                _ => Err(CaError::TypeMismatch(name.into())),
620            },
621            "HOPR" => match value {
622                EpicsValue::Double(v) => {
623                    self.hopr = v;
624                    Ok(())
625                }
626                _ => Err(CaError::TypeMismatch(name.into())),
627            },
628            "LOPR" => match value {
629                EpicsValue::Double(v) => {
630                    self.lopr = v;
631                    Ok(())
632                }
633                _ => Err(CaError::TypeMismatch(name.into())),
634            },
635            "DRVLH" => match value {
636                EpicsValue::Double(v) => {
637                    self.drvlh = v;
638                    Ok(())
639                }
640                _ => Err(CaError::TypeMismatch(name.into())),
641            },
642            "DRVLL" => match value {
643                EpicsValue::Double(v) => {
644                    self.drvll = v;
645                    Ok(())
646                }
647                _ => Err(CaError::TypeMismatch(name.into())),
648            },
649            "DRVLC" => match value {
650                EpicsValue::Short(v) => {
651                    self.drvlc = v;
652                    Ok(())
653                }
654                _ => Err(CaError::TypeMismatch(name.into())),
655            },
656            "PREC" => match value {
657                EpicsValue::Short(v) => {
658                    self.prec = v;
659                    Ok(())
660                }
661                _ => Err(CaError::TypeMismatch(name.into())),
662            },
663            "DPREC" => match value {
664                EpicsValue::Short(v) => {
665                    self.dprec = v;
666                    Ok(())
667                }
668                _ => Err(CaError::TypeMismatch(name.into())),
669            },
670            "DLY" => match value {
671                EpicsValue::Double(v) => {
672                    // C `throttleRecord.c` models the delay with
673                    // `Duration::from_secs_f64(self.dly)` in `process()`,
674                    // which panics not only on a non-finite argument but
675                    // on any finite value too large for a `Duration`
676                    // (≈ 1.8e19; message "value is either too big or
677                    // NaN"). C's `special()` DLY handler (lines 392-409)
678                    // only ever anticipated a negative delay; a CA put of
679                    // `+inf`, `NaN`, or a huge-but-finite f64 like `1e300`
680                    // is not a value any real delay can represent. Reject
681                    // it here, at the single writer of `self.dly`, so the
682                    // record task can never panic — `validate_dly` is the
683                    // gate that holds the invariant "`self.dly` can never
684                    // make `Duration::from_secs_f64` panic".
685                    validate_dly(v)?;
686                    self.dly = v;
687                    Ok(())
688                }
689                _ => Err(CaError::TypeMismatch(name.into())),
690            },
691            "OUT" => match value {
692                EpicsValue::String(v) => {
693                    self.out = v;
694                    Ok(())
695                }
696                _ => Err(CaError::TypeMismatch(name.into())),
697            },
698            "SINP" => match value {
699                EpicsValue::String(v) => {
700                    self.sinp = v;
701                    Ok(())
702                }
703                _ => Err(CaError::TypeMismatch(name.into())),
704            },
705            "SYNC" => match value {
706                EpicsValue::Short(v) => {
707                    self.sync = v;
708                    Ok(())
709                }
710                _ => Err(CaError::TypeMismatch(name.into())),
711            },
712            // Read-only fields
713            "OVAL" | "SENT" | "OSENT" | "WAIT" | "DRVLS" | "VER" | "STS" | "OV" | "SIV" => {
714                Err(CaError::ReadOnlyField(name.into()))
715            }
716            _ => Err(CaError::FieldNotFound(name.into())),
717        }
718    }
719
720    fn field_list(&self) -> &'static [FieldDesc] {
721        FIELDS
722    }
723
724    /// C `throttleRecord.c:308` keeps `recGblFwdLink(prec)` commented
725    /// out in `process()` — the forward link is fired ONLY from
726    /// `valuePut`'s non-CONSTANT branch (`throttleRecord.c:580`), i.e.
727    /// only on a cycle where a real OUT write actually occurred. The
728    /// framework default fires FLNK every `process()`, which would also
729    /// fire it on a queuing-during-delay cycle, a rejected out-of-range
730    /// cycle, a drain with nothing queued, and a CONSTANT-OUT cycle —
731    /// none of which write OUT in C. `process()` maintains `out_written`
732    /// (reset to false each cycle, set true only by `send_value` on a
733    /// real OUT write); this hook returns it.
734    fn should_fire_forward_link(&self) -> bool {
735        self.out_written
736    }
737
738    fn init_record(&mut self, pass: u8) -> CaResult<()> {
739        // C `init_record` (throttleRecord.c:133-228). Pass 0 copies the
740        // VERSION string into VER; the Rust port sets VER in `Default`
741        // instead (the framework constructs the record before init).
742        //
743        // Pass 1 (C lines 156-167): STS is reset to Unknown and VAL to
744        // 0, and `limit_flag` is derived from `drvlh > drvll`. C also
745        // resets the private delay/wait/sync flags to 0 — mirrored by
746        // the runtime-state fields below.
747        if pass == 1 {
748            self.sts = 0; // throttleSTS_UNK
749            self.val = 0.0;
750            self.limit_flag = self.drvlh > self.drvll;
751            self.delay_active = false;
752            self.last_send_time = None;
753            self.pending_value = None;
754            self.out_written = false;
755        }
756        Ok(())
757    }
758}