#![allow(clippy::field_reassign_with_default)]
use epics_base_rs::server::recgbl::alarm_status;
use epics_base_rs::server::record::{AlarmSeverity, CommonFields, Record};
use epics_base_rs::types::EpicsValue;
use std_rs::EpidRecord;
use std_rs::device_support::epid_soft::EpidSoftDeviceSupport;
#[test]
fn test_record_type() {
let rec = EpidRecord::default();
assert_eq!(rec.record_type(), "epid");
}
#[test]
fn test_default_values() {
let rec = EpidRecord::default();
assert_eq!(rec.val, 0.0);
assert_eq!(rec.kp, 0.0);
assert_eq!(rec.ki, 0.0);
assert_eq!(rec.kd, 0.0);
assert_eq!(rec.fmod, 0); assert_eq!(rec.fbon, 0); assert_eq!(rec.oval, 0.0);
assert_eq!(rec.err, 0.0);
}
#[test]
fn test_as_any_mut() {
let mut rec = EpidRecord::default();
assert!(rec.as_any_mut().is_some());
}
#[test]
fn test_get_put_val() {
let mut rec = EpidRecord::default();
rec.put_field("VAL", EpicsValue::Double(50.0)).unwrap();
assert_eq!(rec.get_field("VAL"), Some(EpicsValue::Double(50.0)));
}
#[test]
fn test_get_put_gains() {
let mut rec = EpidRecord::default();
rec.put_field("KP", EpicsValue::Double(1.0)).unwrap();
rec.put_field("KI", EpicsValue::Double(0.5)).unwrap();
rec.put_field("KD", EpicsValue::Double(0.1)).unwrap();
assert_eq!(rec.get_field("KP"), Some(EpicsValue::Double(1.0)));
assert_eq!(rec.get_field("KI"), Some(EpicsValue::Double(0.5)));
assert_eq!(rec.get_field("KD"), Some(EpicsValue::Double(0.1)));
}
#[test]
fn test_read_only_fields() {
let mut rec = EpidRecord::default();
assert!(rec.put_field("CVAL", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("OVAL", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("P", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("D", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("ERR", EpicsValue::Double(1.0)).is_err());
assert!(rec.put_field("FBOP", EpicsValue::Short(1)).is_err());
}
#[test]
fn test_i_is_writable() {
let mut rec = EpidRecord::default();
rec.put_field("I", EpicsValue::Double(5.0)).unwrap();
assert_eq!(rec.get_field("I"), Some(EpicsValue::Double(5.0)));
}
#[test]
fn test_type_mismatch() {
let mut rec = EpidRecord::default();
assert!(
rec.put_field("KP", EpicsValue::String("bad".into()))
.is_err()
);
assert!(rec.put_field("FMOD", EpicsValue::Double(1.0)).is_err());
}
#[test]
fn test_unknown_field() {
let rec = EpidRecord::default();
assert!(rec.get_field("NONEXISTENT").is_none());
let mut rec = rec;
assert!(
rec.put_field("NONEXISTENT", EpicsValue::Double(1.0))
.is_err()
);
}
#[test]
fn test_display_fields() {
let mut rec = EpidRecord::default();
rec.put_field("PREC", EpicsValue::Short(3)).unwrap();
rec.put_field("EGU", EpicsValue::String("degC".into()))
.unwrap();
rec.put_field("HOPR", EpicsValue::Double(100.0)).unwrap();
rec.put_field("LOPR", EpicsValue::Double(0.0)).unwrap();
assert_eq!(rec.get_field("PREC"), Some(EpicsValue::Short(3)));
assert_eq!(
rec.get_field("EGU"),
Some(EpicsValue::String("degC".into()))
);
}
#[test]
fn test_alarm_fields() {
let mut rec = EpidRecord::default();
rec.put_field("HIHI", EpicsValue::Double(100.0)).unwrap();
rec.put_field("HIGH", EpicsValue::Double(80.0)).unwrap();
rec.put_field("LOW", EpicsValue::Double(20.0)).unwrap();
rec.put_field("LOLO", EpicsValue::Double(0.0)).unwrap();
rec.put_field("HHSV", EpicsValue::Short(2)).unwrap();
rec.put_field("HYST", EpicsValue::Double(1.0)).unwrap();
assert_eq!(rec.get_field("HIHI"), Some(EpicsValue::Double(100.0)));
assert_eq!(rec.get_field("HHSV"), Some(EpicsValue::Short(2)));
assert_eq!(rec.get_field("HYST"), Some(EpicsValue::Double(1.0)));
}
#[test]
fn test_check_alarms_hihi() {
let mut rec = EpidRecord::default();
rec.hihi = 100.0;
rec.hhsv = 2; rec.val = 105.0;
let alarm = rec.check_alarms();
assert!(alarm.is_some());
let (status, severity, alev) = alarm.unwrap();
assert_eq!(status, alarm_status::HIHI_ALARM);
assert_eq!(severity, AlarmSeverity::Major);
assert_eq!(alev, 100.0);
}
#[test]
fn test_check_alarms_lolo() {
let mut rec = EpidRecord::default();
rec.lolo = 10.0;
rec.llsv = 2;
rec.val = 5.0;
let alarm = rec.check_alarms();
assert!(alarm.is_some());
let (status, severity, alev) = alarm.unwrap();
assert_eq!(status, alarm_status::LOLO_ALARM);
assert_eq!(severity, AlarmSeverity::Major);
assert_eq!(alev, 10.0);
}
#[test]
fn test_check_alarms_no_alarm() {
let mut rec = EpidRecord::default();
rec.hihi = 100.0;
rec.high = 80.0;
rec.low = 20.0;
rec.lolo = 10.0;
rec.hhsv = 2;
rec.hsv = 1;
rec.lsv = 1;
rec.llsv = 2;
rec.val = 50.0; let alarm = rec.check_alarms();
assert!(alarm.is_none());
}
#[test]
fn test_check_alarms_hysteresis() {
let mut rec = EpidRecord::default();
rec.hihi = 100.0;
rec.hhsv = 2;
rec.hyst = 5.0;
rec.val = 100.0;
let mut common = CommonFields::default();
Record::check_alarms(&mut rec, &mut common);
assert_eq!(rec.lalm, 100.0);
rec.val = 96.0;
let alarm = rec.check_alarms();
assert!(alarm.is_some(), "Should still alarm within hysteresis band");
rec.val = 94.0;
let alarm = rec.check_alarms();
assert!(alarm.is_none(), "Should clear alarm below hysteresis band");
}
#[test]
fn test_check_alarms_lalm_gated_on_severity_raise() {
let mut rec = EpidRecord::default();
rec.high = 80.0;
rec.hsv = 1; rec.val = 85.0;
let lalm_before = rec.lalm;
let mut common = CommonFields::default();
common.nsev = AlarmSeverity::Invalid;
common.nsta = alarm_status::COMM_ALARM;
Record::check_alarms(&mut rec, &mut common);
assert_eq!(common.nsev, AlarmSeverity::Invalid);
assert_eq!(
rec.lalm, lalm_before,
"LALM must not advance when the alarm did not raise the severity"
);
let mut rec = EpidRecord::default();
rec.high = 80.0;
rec.hsv = 1; rec.val = 85.0;
let mut common = CommonFields::default();
Record::check_alarms(&mut rec, &mut common);
assert_eq!(common.nsev, AlarmSeverity::Minor);
assert_eq!(
rec.lalm, 80.0,
"LALM must advance to the alarmed threshold when the severity was raised"
);
}
#[test]
fn test_check_alarms_hook_applies_severity() {
let mut rec = EpidRecord::default();
rec.high = 80.0;
rec.hsv = 1; rec.val = 85.0;
let mut common = CommonFields::default();
Record::check_alarms(&mut rec, &mut common);
assert_eq!(
common.nsev,
AlarmSeverity::Minor,
"HIGH crossing must raise SEVR"
);
assert_eq!(
common.nsta,
alarm_status::HIGH_ALARM,
"HIGH crossing must set STAT"
);
let mut rec = EpidRecord::default();
rec.hihi = 100.0;
rec.hhsv = 2; rec.val = 110.0;
let mut common = CommonFields::default();
Record::check_alarms(&mut rec, &mut common);
assert_eq!(
common.nsev,
AlarmSeverity::Major,
"HIHI crossing must raise SEVR"
);
assert_eq!(
common.nsta,
alarm_status::HIHI_ALARM,
"HIHI crossing must set STAT"
);
let mut rec = EpidRecord::default();
rec.hihi = 100.0;
rec.high = 80.0;
rec.low = 20.0;
rec.lolo = 10.0;
rec.hhsv = 2;
rec.hsv = 1;
rec.lsv = 1;
rec.llsv = 2;
rec.val = 50.0;
let mut common = CommonFields::default();
Record::check_alarms(&mut rec, &mut common);
assert_eq!(
common.nsev,
AlarmSeverity::NoAlarm,
"in-limits must not raise SEVR"
);
assert_eq!(
common.nsta,
alarm_status::NO_ALARM,
"in-limits must not set STAT"
);
}
#[test]
fn test_check_alarms_hook_maximizes_severity() {
let mut rec = EpidRecord::default();
rec.hihi = 100.0;
rec.hhsv = 2; rec.val = 110.0;
let mut common = CommonFields::default();
common.nsev = AlarmSeverity::Minor;
common.nsta = alarm_status::LINK_ALARM;
Record::check_alarms(&mut rec, &mut common);
assert_eq!(
common.nsev,
AlarmSeverity::Major,
"higher epid alarm must raise the pending severity"
);
assert_eq!(common.nsta, alarm_status::HIHI_ALARM);
let mut rec = EpidRecord::default();
rec.high = 80.0;
rec.hsv = 1; rec.val = 85.0;
let mut common = CommonFields::default();
common.nsev = AlarmSeverity::Invalid;
common.nsta = alarm_status::COMM_ALARM;
Record::check_alarms(&mut rec, &mut common);
assert_eq!(
common.nsev,
AlarmSeverity::Invalid,
"lower epid alarm must not lower a higher pending severity"
);
assert_eq!(common.nsta, alarm_status::COMM_ALARM);
}
#[test]
fn test_pid_p_only() {
let mut rec = EpidRecord::default();
rec.kp = 2.0;
rec.ki = 0.0;
rec.kd = 0.0;
rec.val = 100.0; rec.cval = 90.0; rec.fbon = 1; rec.fbop = 1; rec.drvh = 200.0;
rec.drvl = -200.0;
rec.mdt = 0.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert!(
(rec.p - 20.0).abs() < 1e-6,
"P should be ~20.0, got {}",
rec.p
);
assert!(
rec.i.abs() < 1e-6,
"I should be ~0 with KI=0, got {}",
rec.i
);
assert!(
(rec.oval - 20.0).abs() < 1.0,
"OVAL should be ~20.0, got {}",
rec.oval
);
}
#[test]
fn test_pid_output_clamping() {
let mut rec = EpidRecord::default();
rec.kp = 100.0;
rec.ki = 0.0;
rec.kd = 0.0;
rec.val = 100.0;
rec.cval = 0.0; rec.fbon = 1;
rec.fbop = 1;
rec.drvh = 50.0;
rec.drvl = -50.0;
rec.mdt = 0.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert!(
rec.oval <= 50.0,
"Output should be clamped to DRVH, got {}",
rec.oval
);
}
#[test]
fn test_pid_feedback_off_no_change() {
let mut rec = EpidRecord::default();
rec.kp = 1.0;
rec.ki = 1.0;
rec.val = 100.0;
rec.cval = 50.0;
rec.fbon = 0; rec.fbop = 0;
rec.drvh = 200.0;
rec.drvl = -200.0;
rec.mdt = 0.0;
let i_before = rec.i;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_eq!(rec.i, i_before, "I should not change with feedback off");
}
#[test]
fn test_pid_mdt_skip() {
let mut rec = EpidRecord::default();
rec.kp = 1.0;
rec.ki = 0.0;
rec.kd = 0.0;
rec.val = 100.0;
rec.cval = 50.0;
rec.fbon = 1;
rec.fbop = 1;
rec.drvh = 200.0;
rec.drvl = -200.0;
rec.mdt = 100.0;
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_eq!(rec.oval, 0.0, "Should skip when dt < mdt");
}
#[test]
fn test_pid_output_deadband() {
let mut rec = EpidRecord::default();
rec.kp = 1.0;
rec.ki = 0.0;
rec.kd = 0.0;
rec.val = 100.0;
rec.cval = 95.0; rec.fbon = 1;
rec.fbop = 1;
rec.drvh = 200.0;
rec.drvl = -200.0;
rec.mdt = 0.0;
rec.odel = 10.0; rec.oval = 7.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_eq!(
rec.oval, 7.0,
"OVAL should not change within deadband, got {}",
rec.oval
);
}
#[test]
fn test_pid_output_deadband_exceeded() {
let mut rec = EpidRecord::default();
rec.kp = 10.0;
rec.ki = 0.0;
rec.kd = 0.0;
rec.val = 100.0;
rec.cval = 50.0; rec.fbon = 1;
rec.fbop = 1;
rec.drvh = 1000.0;
rec.drvl = -1000.0;
rec.mdt = 0.0;
rec.odel = 10.0;
rec.oval = 7.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_ne!(rec.oval, 7.0, "OVAL should change when deadband exceeded");
}
#[test]
fn test_pid_bumpless_turn_on() {
let mut rec = EpidRecord::default();
rec.kp = 1.0;
rec.ki = 1.0;
rec.kd = 0.0;
rec.val = 100.0;
rec.cval = 50.0;
rec.fbon = 1; rec.fbop = 0; rec.oval = 42.0; rec.drvh = 200.0;
rec.drvl = -200.0;
rec.mdt = 0.0;
rec.i = 37.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert!(
(rec.i - 37.0).abs() < 1e-6,
"I must keep the OUTL readback value on bumpless turn-on, got {}",
rec.i
);
}
#[test]
fn test_maxmin_error_uses_cvlp_previous_value() {
fn one_cycle(cvlp: f64, cval: f64) -> f64 {
let mut rec = EpidRecord::default();
rec.fmod = 1; rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 1; rec.d = 1.0; rec.drvh = 1000.0;
rec.drvl = -1000.0;
rec.mdt = 0.0;
rec.oval = 0.0;
rec.cvlp = cvlp; rec.cval = cval; std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
rec.oval
}
let oval_up = one_cycle(100.0, 110.0);
let oval_down = one_cycle(100.0, 90.0);
assert!(
oval_up > 0.0,
"CVAL rising should drive output step positive, got {oval_up}"
);
assert!(
oval_down < 0.0,
"CVAL falling should drive output step negative, got {oval_down}"
);
assert_ne!(
oval_up.signum(),
oval_down.signum(),
"output step sign must depend on CVAL movement direction (non-zero error)"
);
}
#[test]
fn test_maxmin_mode() {
let mut rec = EpidRecord::default();
rec.fmod = 1; rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 1; rec.cval = 100.0;
rec.d = 1.0; rec.drvh = 200.0;
rec.drvl = -200.0;
rec.mdt = 0.0;
rec.oval = 50.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_ne!(rec.oval, 50.0, "MaxMin should change output");
}
#[test]
fn test_update_monitors_tracks_previous() {
let mut rec = EpidRecord::default();
rec.p = 10.0;
rec.i = 20.0;
rec.d = 30.0;
rec.dt = 0.5;
rec.err = 5.0;
rec.cval = 42.0;
rec.update_monitors();
assert_eq!(rec.pp, 10.0);
assert_eq!(rec.ip, 20.0);
assert_eq!(rec.dp, 30.0);
assert_eq!(rec.dtp, 0.5);
assert_eq!(rec.errp, 5.0);
assert_eq!(rec.cvlp, 42.0);
}
#[test]
fn test_multi_input_links() {
let rec = EpidRecord::default();
let links = rec.multi_input_links();
assert_eq!(links.len(), 1);
assert_eq!(links[0], ("INP", "CVAL"));
}
#[test]
fn test_multi_output_links() {
let rec = EpidRecord::default();
let links = rec.multi_output_links();
assert_eq!(links.len(), 1);
assert_eq!(links[0], ("OUTL", "OVAL"));
}
#[test]
fn test_maxmin_err_is_cval_delta() {
let mut rec = EpidRecord::default();
rec.fmod = 1; rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 1; rec.d = 1.0;
rec.drvh = 1000.0;
rec.drvl = -1000.0;
rec.mdt = 0.0;
rec.cvlp = 100.0; rec.cval = 130.0; rec.err = -999.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert!(
(rec.err - 30.0).abs() < 1e-9,
"MaxMin ERR must be cval - pcval = 30.0, got {}",
rec.err
);
}
#[test]
fn test_maxmin_err_zero_on_bumpless_edge() {
let mut rec = EpidRecord::default();
rec.fmod = 1; rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 0; rec.drvh = 1000.0;
rec.drvl = -1000.0;
rec.mdt = 0.0;
rec.cvlp = 100.0;
rec.cval = 130.0;
rec.err = -999.0;
std::thread::sleep(std::time::Duration::from_millis(5));
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_eq!(
rec.err, 0.0,
"MaxMin bumpless-edge ERR must be 0.0, got {}",
rec.err
);
}
use std_rs::device_support::epid_fast::EpidFastPvt;
#[test]
fn test_fast_compute_num_average() {
let mut pvt = EpidFastPvt::default();
pvt.callback_interval = 0.001; pvt.time_per_point_requested = 0.010;
pvt.compute_num_average();
assert_eq!(pvt.num_average, 10);
assert!((pvt.time_per_point_actual - 0.010).abs() < 1e-12);
pvt.time_per_point_requested = 0.0;
pvt.compute_num_average();
assert_eq!(pvt.num_average, 1);
assert!((pvt.time_per_point_actual - 0.001).abs() < 1e-12);
}
#[test]
fn test_fast_interval_callback_recomputes_average() {
let mut pvt = EpidFastPvt::default();
pvt.time_per_point_requested = 0.010;
pvt.interval_callback(0.002);
assert_eq!(pvt.num_average, 5);
assert!((pvt.callback_interval - 0.002).abs() < 1e-12);
assert!((pvt.time_per_point_actual - 0.010).abs() < 1e-12);
}
#[test]
fn test_fast_do_pid_uses_callback_interval_as_dt() {
let mut pvt = EpidFastPvt::default();
pvt.callback_interval = 0.5; pvt.num_average = 1;
pvt.kp = 1.0;
pvt.ki = 0.0;
pvt.kd = 2.0;
pvt.drvh = 1000.0;
pvt.drvl = -1000.0;
pvt.val = 100.0;
pvt.fbon = true;
pvt.fbop = true;
pvt.err = 0.0;
pvt.do_pid(90.0);
assert!(
(pvt.d - 40.0).abs() < 1e-9,
"D must use callback_interval (0.5s) as dt -> 40.0, got {}",
pvt.d
);
assert!((pvt.p - 10.0).abs() < 1e-9, "P must be 10.0, got {}", pvt.p);
}
#[test]
fn test_fast_do_pid_inverted_limits_no_panic() {
let mut pvt = EpidFastPvt::default();
assert!(pvt.drvl > pvt.drvh, "default must seed inverted C limits");
pvt.callback_interval = 0.001;
pvt.num_average = 1;
pvt.ki = 1.0;
pvt.val = 100.0;
pvt.fbon = true;
pvt.fbop = true;
pvt.do_pid(50.0);
}
#[test]
fn test_fast_do_pid_averaging() {
let mut pvt = EpidFastPvt::default();
pvt.callback_interval = 0.001;
pvt.num_average = 4;
pvt.kp = 1.0;
pvt.ki = 0.0;
pvt.kd = 0.0;
pvt.drvh = 1000.0;
pvt.drvl = -1000.0;
pvt.val = 100.0;
pvt.fbon = true;
pvt.fbop = true;
pvt.do_pid(10.0);
pvt.do_pid(20.0);
pvt.do_pid(30.0);
assert_eq!(pvt.p, 0.0, "no compute before num_average points");
pvt.do_pid(40.0);
assert!(
(pvt.cval - 25.0).abs() < 1e-9,
"averaged cval must be 25.0, got {}",
pvt.cval
);
assert!((pvt.p - 75.0).abs() < 1e-9, "P must be 75.0, got {}", pvt.p);
}
#[test]
fn test_fast_do_pid_ignores_fmod() {
let make = || {
let mut pvt = EpidFastPvt::default();
pvt.callback_interval = 0.5;
pvt.num_average = 1;
pvt.kp = 1.0;
pvt.ki = 0.0;
pvt.kd = 2.0;
pvt.drvh = 1000.0;
pvt.drvl = -1000.0;
pvt.val = 100.0;
pvt.fbon = true;
pvt.fbop = true;
pvt
};
let mut a = make();
let mut b = make();
a.do_pid(90.0);
b.do_pid(90.0);
assert!(
(a.oval - 50.0).abs() < 1e-9,
"Fast do_pid must run PID unconditionally -> oval 50.0, got {}",
a.oval
);
assert_eq!(
a.oval, b.oval,
"Fast support has no FMOD branch; output must be deterministic"
);
assert_eq!(a.p, b.p);
assert_eq!(a.d, b.d);
}
#[test]
fn test_fast_bumpless_edge_seeds_i_from_output_reader() {
use std::sync::{Arc, Mutex};
let mut pvt = EpidFastPvt::default();
pvt.callback_interval = 0.001;
pvt.num_average = 1;
pvt.kp = 1.0;
pvt.ki = 1.0; pvt.kd = 0.0;
pvt.drvh = 1000.0;
pvt.drvl = -1000.0;
pvt.val = 100.0;
pvt.oval = 7.0;
let reader_value = 42.0;
pvt.output_reader = Some(Arc::new(Mutex::new(move || Some(reader_value))));
pvt.fbon = true;
pvt.fbop = false;
pvt.do_pid(90.0);
assert!(
(pvt.i - 42.0).abs() < 1e-9,
"bumpless edge must seed I from output_reader value 42.0, got {}",
pvt.i
);
assert!(
(pvt.oval - 52.0).abs() < 1e-9,
"oval must reflect reader-seeded I -> 52.0, got {}",
pvt.oval
);
}
#[test]
fn test_fast_bumpless_edge_falls_back_to_oval_without_reader() {
let mut pvt = EpidFastPvt::default();
pvt.callback_interval = 0.001;
pvt.num_average = 1;
pvt.kp = 1.0;
pvt.ki = 1.0;
pvt.kd = 0.0;
pvt.drvh = 1000.0;
pvt.drvl = -1000.0;
pvt.val = 100.0;
pvt.oval = 7.0; assert!(pvt.output_reader.is_none(), "no reader wired");
pvt.fbon = true;
pvt.fbop = false;
pvt.do_pid(90.0);
assert!(
(pvt.i - 7.0).abs() < 1e-9,
"bumpless edge without reader must fall back to oval 7.0, got {}",
pvt.i
);
}
#[test]
fn test_fast_set_output_port_wires_reader_from_asyn_port() {
use asyn_rs::manager::PortManager;
use asyn_rs::param::ParamType;
use asyn_rs::port::{PortDriver, PortDriverBase, PortFlags};
use asyn_rs::sync_io::SyncIOHandle;
use std::time::Duration;
use std_rs::device_support::epid_fast::EpidFastDeviceSupport;
struct OutputPort {
base: PortDriverBase,
}
impl PortDriver for OutputPort {
fn base(&self) -> &PortDriverBase {
&self.base
}
fn base_mut(&mut self) -> &mut PortDriverBase {
&mut self.base
}
}
let mut base = PortDriverBase::new("EPID_OUT_PORT", 1, PortFlags::default());
let reason = base.create_param("OUTPUT", ParamType::Float64).unwrap();
base.set_float64_param(reason, 0, 33.0).unwrap();
let manager = PortManager::new();
manager.register_port(OutputPort { base }).unwrap();
let sync_io = SyncIOHandle::connect(&manager, "EPID_OUT_PORT", 0, Duration::from_secs(1))
.expect("connect to output port");
assert!((sync_io.read_float64(reason).unwrap() - 33.0).abs() < 1e-9);
let dev = EpidFastDeviceSupport::new();
dev.set_output_port(sync_io, reason);
let pvt_arc = dev.pvt();
let mut pvt = pvt_arc.lock().unwrap();
pvt.callback_interval = 0.001;
pvt.num_average = 1;
pvt.kp = 1.0;
pvt.ki = 1.0;
pvt.kd = 0.0;
pvt.drvh = 1000.0;
pvt.drvl = -1000.0;
pvt.val = 100.0;
pvt.oval = 7.0; pvt.fbon = true;
pvt.fbop = false;
assert!(
pvt.output_reader.is_some(),
"set_output_port must wire reader"
);
assert!(
pvt.output_writer.is_some(),
"set_output_port must wire writer"
);
pvt.do_pid(90.0);
assert!(
(pvt.i - 33.0).abs() < 1e-9,
"bumpless edge must seed I from asyn output port value 33.0, got {}",
pvt.i
);
}
use epics_base_rs::server::device_support::DeviceSupport;
use epics_base_rs::server::record::{LinkType, ProcessAction, link_field_type};
use std_rs::device_support::epid_soft_callback::EpidSoftCallbackDeviceSupport;
#[test]
fn test_constant_inp_raises_soft_alarm_and_skips_compute() {
let mut rec = EpidRecord::default();
rec.inp = "3.14".to_string();
rec.kp = 2.0;
rec.val = 100.0;
rec.cval = 10.0;
rec.fbon = 1;
rec.fmod = 0;
let oval_before = rec.oval;
assert_eq!(link_field_type(&rec.inp), LinkType::Constant);
EpidSoftDeviceSupport::do_pid(&mut rec);
assert!(rec.inp_constant, "CONSTANT INP must set inp_constant");
assert_eq!(
rec.oval, oval_before,
"PID compute must be skipped for a CONSTANT INP link"
);
let mut common = CommonFields::default();
Record::check_alarms(&mut rec, &mut common);
assert_eq!(common.nsta, alarm_status::SOFT_ALARM);
assert_eq!(common.nsev, AlarmSeverity::Invalid);
}
#[test]
fn test_db_inp_does_not_raise_soft_alarm() {
let mut rec = EpidRecord::default();
rec.inp = "OTHER:REC.VAL".to_string();
assert_eq!(link_field_type(&rec.inp), LinkType::Db);
rec.kp = 2.0;
rec.val = 100.0;
rec.cval = 10.0;
rec.fbon = 1;
rec.fbop = 1; rec.mdt = 0.0;
EpidSoftDeviceSupport::do_pid(&mut rec);
assert!(!rec.inp_constant, "DB INP must not set inp_constant");
}
#[test]
fn test_db_trig_link_synchronous_fallthrough() {
let mut rec = EpidRecord::default();
rec.trig = "READBACK:REC.VAL".to_string();
assert_eq!(link_field_type(&rec.trig), LinkType::Db);
rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 1;
rec.set_process_context(&ctx_with_dtyp("Epid Async Soft"));
let pre = rec.pre_input_link_actions();
assert!(
pre.iter().any(|a| matches!(
a,
ProcessAction::WriteDbLink {
link_field: "TRIG",
..
}
)),
"DB TRIG must emit a WriteDbLink as a pre-input-link action"
);
let mut dev = EpidSoftCallbackDeviceSupport::new();
let outcome = dev.read(&mut rec).expect("read ok");
assert!(outcome.did_compute, "DB TRIG: PID runs this pass");
assert!(
outcome.actions.is_empty(),
"DB TRIG: read() emits no actions — TRIG write is pre-input"
);
assert!(
!outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::ReprocessAfter(_))),
"DB TRIG must NOT defer to a second pass"
);
}
#[test]
fn test_non_callback_dtyp_emits_no_trig_write() {
let mut rec = EpidRecord::default();
rec.trig = "READBACK:REC.VAL".to_string();
assert_eq!(link_field_type(&rec.trig), LinkType::Db);
rec.set_process_context(&ctx_with_dtyp("Soft Channel"));
assert!(
rec.pre_input_link_actions().is_empty(),
"non-callback DSET must not drive the TRIG link"
);
}
#[test]
fn test_ca_trig_link_deferred_second_pass() {
let mut rec = EpidRecord::default();
rec.trig = "ca://REMOTE:READBACK".to_string();
assert_eq!(link_field_type(&rec.trig), LinkType::Ca);
rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 1;
let mut dev = EpidSoftCallbackDeviceSupport::new();
let outcome = dev.read(&mut rec).expect("read ok");
assert!(
outcome.actions.iter().any(|a| matches!(
a,
ProcessAction::WriteDbLink {
link_field: "TRIG",
..
}
)),
"CA TRIG must emit a WriteDbLink"
);
assert!(
outcome
.actions
.iter()
.any(|a| matches!(a, ProcessAction::ReprocessAfter(_))),
"CA TRIG must defer PID to a second pass"
);
let outcome2 = dev.read(&mut rec).expect("second read ok");
assert!(outcome2.did_compute);
assert!(
outcome2.actions.is_empty(),
"second pass must not re-trigger"
);
}
#[test]
fn test_bumpless_edge_emits_outl_readback_for_db_link() {
let mut rec = EpidRecord::default();
rec.outl = "DAC:REC.VAL".to_string();
rec.fmod = 0; rec.fbon = 1;
rec.fbop = 0;
let actions = rec.pre_process_actions();
assert!(
actions.iter().any(|a| matches!(
a,
ProcessAction::ReadDbLink {
link_field: "OUTL",
target_field: "I"
}
)),
"bumpless OFF->ON edge with a DB OUTL must read OUTL into I"
);
}
#[test]
fn test_bumpless_edge_no_readback_for_constant_outl() {
let mut rec = EpidRecord::default();
rec.outl = "5.0".to_string(); assert_eq!(link_field_type(&rec.outl), LinkType::Constant);
rec.fmod = 0;
rec.fbon = 1;
rec.fbop = 0;
let actions = rec.pre_process_actions();
assert!(
actions.is_empty(),
"CONSTANT OUTL must not emit a bumpless readback"
);
}
#[test]
fn test_no_bumpless_readback_when_no_edge() {
let mut rec = EpidRecord::default();
rec.outl = "DAC:REC.VAL".to_string();
rec.fmod = 0;
rec.fbon = 1;
rec.fbop = 1;
assert!(rec.pre_process_actions().is_empty());
}
use epics_base_rs::server::record::ProcessContext;
fn ctx_with_udf(udf: bool) -> ProcessContext {
ProcessContext {
udf,
udfs: AlarmSeverity::Invalid,
phas: 0,
tse: 0,
tsel: String::new(),
dtyp: String::new(),
}
}
fn ctx_with_dtyp(dtyp: &str) -> ProcessContext {
ProcessContext {
udf: false,
udfs: AlarmSeverity::Invalid,
phas: 0,
tse: 0,
tsel: String::new(),
dtyp: dtyp.to_string(),
}
}
#[test]
fn test_process_skips_do_pid_while_udf_set() {
let mut rec = EpidRecord::default();
rec.kp = 2.0;
rec.val = 100.0; rec.cval = 10.0; rec.fbon = 1;
rec.fbop = 1;
rec.mdt = 0.0;
let oval_before = rec.oval;
let p_before = rec.p;
rec.set_process_context(&ctx_with_udf(true));
rec.process().unwrap();
assert_eq!(
rec.oval, oval_before,
"do_pid must be skipped while UDF is set -> OVAL unchanged"
);
assert_eq!(
rec.p, p_before,
"do_pid must be skipped while UDF is set -> P unchanged"
);
}
#[test]
fn test_process_runs_do_pid_once_udf_cleared() {
let mut rec = EpidRecord::default();
rec.kp = 2.0;
rec.val = 100.0;
rec.cval = 10.0; rec.fbon = 1;
rec.fbop = 1;
rec.mdt = 0.0;
std::thread::sleep(std::time::Duration::from_millis(5));
rec.set_process_context(&ctx_with_udf(false));
rec.process().unwrap();
assert!(
(rec.p - 180.0).abs() < 1e-6,
"do_pid must run once UDF is clear; P should be ~180.0, got {}",
rec.p
);
}
#[test]
fn test_process_closed_loop_runs_do_pid_when_stpl_gave_value() {
let mut rec = EpidRecord::default();
rec.kp = 2.0;
rec.smsl = 1; rec.val = 100.0; rec.cval = 10.0;
rec.fbon = 1;
rec.fbop = 1;
rec.mdt = 0.0;
std::thread::sleep(std::time::Duration::from_millis(5));
rec.set_resolved_input_links(&["STPL"]);
rec.set_process_context(&ctx_with_udf(true));
rec.process().unwrap();
assert!(
(rec.p - 180.0).abs() < 1e-6,
"closed-loop epid whose STPL fetch succeeded must run do_pid \
even when the pushed udf is stale-TRUE; P should be ~180.0, \
got {}",
rec.p
);
}
#[test]
fn test_process_closed_loop_keeps_udf_when_stpl_fetch_failed() {
let mut rec = EpidRecord::default();
rec.kp = 2.0;
rec.smsl = 1; rec.val = 100.0; rec.cval = 10.0;
rec.fbon = 1;
rec.fbop = 1;
rec.mdt = 0.0;
let p_before = rec.p;
std::thread::sleep(std::time::Duration::from_millis(5));
rec.set_resolved_input_links(&[]);
rec.set_process_context(&ctx_with_udf(true));
rec.process().unwrap();
assert_eq!(
rec.p, p_before,
"closed-loop epid whose STPL fetch failed must keep udf set and \
skip do_pid — !val.is_nan() must NOT proxy fetch success"
);
assert!(
rec.value_is_undefined(),
"value_is_undefined() must stay true so the framework keeps \
common.udf set for the next cycle"
);
}
#[test]
fn test_process_with_device_did_compute_ignores_udf_gate() {
let mut rec = EpidRecord::default();
rec.kp = 2.0;
rec.val = 100.0;
rec.cval = 10.0;
rec.fbon = 1;
rec.fbop = 1;
let oval_before = rec.oval;
let p_before = rec.p;
rec.set_process_context(&ctx_with_udf(true));
rec.set_device_did_compute(true); rec.process().unwrap();
assert_eq!(
rec.oval, oval_before,
"device-computed pass must not re-run do_pid"
);
assert_eq!(
rec.p, p_before,
"device-computed pass must not run the built-in PID"
);
}
use epics_base_rs::server::record::RecordProcessResult;
#[test]
fn test_ca_trig_trigger_pass_returns_async_pending_tail_skipped() {
let mut rec = EpidRecord::default();
rec.trig = "ca://REMOTE:READBACK".to_string();
assert_eq!(link_field_type(&rec.trig), LinkType::Ca);
rec.kp = 1.0;
rec.fbon = 1;
rec.fbop = 1;
rec.stpl = "10.0".to_string();
let mut udf = true;
rec.post_init_finalize_undef(&mut udf).unwrap();
let mut dev = EpidSoftCallbackDeviceSupport::new();
let outcome1 = dev.read(&mut rec).expect("trigger-pass read ok");
assert!(
outcome1.actions.iter().any(|a| matches!(
a,
ProcessAction::WriteDbLink {
link_field: "TRIG",
..
}
)),
"CA TRIG trigger pass must emit a WriteDbLink{{TRIG}}"
);
assert!(
outcome1
.actions
.iter()
.any(|a| matches!(a, ProcessAction::ReprocessAfter(_))),
"CA TRIG trigger pass must emit a ReprocessAfter"
);
rec.set_device_did_compute(outcome1.did_compute);
let proc1 = rec.process().expect("trigger-pass process ok");
assert_eq!(
proc1.result,
RecordProcessResult::AsyncPending,
"CA-TRIG trigger pass: process() MUST return AsyncPending so the \
framework skips the checkAlarms / monitor / recGblFwdLink tail \
(C epidRecord.c:207 returns before the tail)"
);
let outcome2 = dev.read(&mut rec).expect("reprocess-pass read ok");
assert!(
outcome2.actions.is_empty(),
"reprocess pass must not re-trigger"
);
rec.set_device_did_compute(outcome2.did_compute);
let proc2 = rec.process().expect("reprocess-pass process ok");
assert_eq!(
proc2.result,
RecordProcessResult::Complete,
"CA-TRIG reprocess pass: process() MUST return Complete so the \
process tail (FLNK / monitors) runs — exactly once for the cycle"
);
}
#[test]
fn test_maxmin_bumpless_edge_emits_outl_readback_into_oval() {
let mut rec = EpidRecord::default();
rec.outl = "DAC:REC.VAL".to_string();
rec.fmod = 1; rec.fbon = 1;
rec.fbop = 0;
let actions = rec.pre_process_actions();
assert!(
actions.iter().any(|a| matches!(
a,
ProcessAction::ReadDbLink {
link_field: "OUTL",
target_field: "OVAL"
}
)),
"MaxMin bumpless OFF->ON edge with a DB OUTL must read OUTL into \
OVAL (devEpidSoft.c:181 reads into &oval), got {actions:?}"
);
assert!(
!actions.iter().any(|a| matches!(
a,
ProcessAction::ReadDbLink {
target_field: "I",
..
}
)),
"MaxMin edge must NOT read OUTL into I — that is PID-mode only"
);
}
#[test]
fn test_maxmin_bumpless_edge_no_readback_for_constant_outl() {
let mut rec = EpidRecord::default();
rec.outl = "5.0".to_string(); assert_eq!(link_field_type(&rec.outl), LinkType::Constant);
rec.fmod = 1; rec.fbon = 1;
rec.fbop = 0;
assert!(
rec.pre_process_actions().is_empty(),
"CONSTANT OUTL must not emit a MaxMin bumpless readback"
);
}
#[test]
fn test_maxmin_bumpless_edge_output_uses_readback_oval() {
let mut rec = EpidRecord::default();
rec.fmod = 1; rec.fbon = 1;
rec.fbop = 0; rec.kp = 1.0;
rec.drvh = 1000.0;
rec.drvl = -1000.0;
rec.mdt = 0.0;
rec.oval = 7.0;
EpidSoftDeviceSupport::do_pid(&mut rec);
assert_eq!(
rec.oval, 7.0,
"MaxMin bumpless edge output must be the OUTL read-back value"
);
}