use crate::error::{CaError, CaResult};
use crate::server::record::{
CyclePostMask, InputFetchPolicy, OutTarget, ProcessAction, ProcessOutcome, Record,
RecordProcessResult,
};
use crate::types::{EpicsValue, PvString};
use super::calc_compile;
use crate::calc::{ArrayInputs, CompiledExpr, ExprKind, acalc_eval};
use super::link_status::LINK_CON;
const VERSION: f64 = 1.4;
struct CalcPass {
result: Option<(f64, Vec<f64>, bool)>,
inputs: ArrayInputs,
}
const ARR_NAMES: [&str; 12] = [
"AA", "BB", "CC", "DD", "EE", "FF", "GG", "HH", "II", "JJ", "KK", "LL",
];
const ACALCOUT_NUMERIC_INPUTS: usize = 12;
const ACALCOUT_INPUT_LINKS: &[(&str, &str)] = &[
("INPA", "A"),
("INPB", "B"),
("INPC", "C"),
("INPD", "D"),
("INPE", "E"),
("INPF", "F"),
("INPG", "G"),
("INPH", "H"),
("INPI", "I"),
("INPJ", "J"),
("INPK", "K"),
("INPL", "L"),
("INAA", "AA"),
("INBB", "BB"),
("INCC", "CC"),
("INDD", "DD"),
("INEE", "EE"),
("INFF", "FF"),
("INGG", "GG"),
("INHH", "HH"),
("INII", "II"),
("INJJ", "JJ"),
("INKK", "KK"),
("INLL", "LL"),
];
const INP_NAMES: [&str; 12] = [
"INPA", "INPB", "INPC", "INPD", "INPE", "INPF", "INPG", "INPH", "INPI", "INPJ", "INPK", "INPL",
];
const INA_NAMES: [&str; 12] = [
"INAA", "INBB", "INCC", "INDD", "INEE", "INFF", "INGG", "INHH", "INII", "INJJ", "INKK", "INLL",
];
const INAV_NAMES: [&str; 12] = [
"INAV", "INBV", "INCV", "INDV", "INEV", "INFV", "INGV", "INHV", "INIV", "INJV", "INKV", "INLV",
];
const IAAV_NAMES: [&str; 12] = [
"IAAV", "IBBV", "ICCV", "IDDV", "IEEV", "IFFV", "IGGV", "IHHV", "IIIV", "IJJV", "IKKV", "ILLV",
];
const PA_NAMES: [&str; 12] = [
"PA", "PB", "PC", "PD", "PE", "PF", "PG", "PH", "PI", "PJ", "PK", "PL",
];
pub struct AcalcoutRecord {
pub val: f64,
aval: Vec<f64>,
pval: f64,
nelm: u32,
nuse: u32,
nuse_post_pending: Option<CyclePostMask>,
pub calc: String,
compiled_calc: CompiledExpr,
clcv: i32,
pub ocal: String,
compiled_ocal: CompiledExpr,
oclv: i32,
dopt: i16, oval: f64,
oav: Vec<f64>,
povl: f64,
num_vals: [f64; 12],
inp_links: [String; 12],
pa: [f64; 12],
arr_vals: [Vec<f64>; 12],
ina_links: [String; 12],
inav: [i16; 12], iaav: [i16; 12], outv: i16,
out: String,
oopt: i16, odly: f64,
wait: i16,
dlya: u16,
oevt: u16,
ivoa: i16, ivov: f64,
egu: PvString,
prec: i16,
hopr: f64,
lopr: f64,
hihi: f64,
lolo: f64,
high: f64,
low: f64,
hhsv: i16,
llsv: i16,
hsv: i16,
lsv: i16,
hyst: f64,
adel: f64,
mdel: f64,
lalm: f64,
alst: f64,
mlst: f64,
size: i16, cstat: i32,
cact: u8,
amask: u32,
amem: i32,
pmem: i32,
newm: u32,
calc_alarm: bool,
fetch_gate_failed: bool,
cached_should_output: bool,
pending_output: bool,
}
impl Default for AcalcoutRecord {
fn default() -> Self {
Self {
val: 0.0,
aval: Vec::new(),
pval: 0.0,
nelm: 1, nuse: 0, nuse_post_pending: None,
calc: String::new(),
compiled_calc: CompiledExpr::empty(ExprKind::Array),
clcv: 0,
ocal: String::new(),
compiled_ocal: CompiledExpr::empty(ExprKind::Array),
oclv: 0,
dopt: 0,
oval: 0.0,
oav: Vec::new(),
povl: 0.0,
num_vals: [0.0; 12],
inp_links: Default::default(),
pa: [0.0; 12],
arr_vals: Default::default(),
ina_links: Default::default(),
inav: [LINK_CON; 12],
iaav: [LINK_CON; 12],
outv: LINK_CON,
out: String::new(),
oopt: 0,
odly: 0.0,
wait: 0,
dlya: 0,
oevt: 0,
ivoa: 0,
ivov: 0.0,
egu: PvString::new(),
prec: 0,
hopr: 0.0,
lopr: 0.0,
hihi: 0.0,
lolo: 0.0,
high: 0.0,
low: 0.0,
hhsv: 0,
llsv: 0,
hsv: 0,
lsv: 0,
hyst: 0.0,
adel: 0.0,
mdel: 0.0,
lalm: 0.0,
alst: 0.0,
mlst: 0.0,
size: 0,
cstat: 0,
cact: 0,
amask: 0,
amem: 0,
pmem: 0,
newm: 0,
calc_alarm: false,
fetch_gate_failed: false,
cached_should_output: false,
pending_output: false,
}
}
}
impl AcalcoutRecord {
pub fn new() -> Self {
Self::default()
}
fn clamp_nuse(&mut self, mask: CyclePostMask) -> bool {
if self.nuse > self.nelm {
self.nuse = self.nelm;
self.nuse_post_pending = Some(mask);
return true;
}
false
}
fn num_elements(&self) -> usize {
let n = if self.nuse > 0 && self.nuse < self.nelm {
self.nuse
} else {
self.nelm
};
(n as usize).max(1)
}
fn dbaddr_no_elements(&self) -> usize {
if self.size == ACALCOUT_SIZE_NUSE {
self.num_elements()
} else {
(self.nelm as usize).max(1)
}
}
fn array_field_value(&self, data: &[f64]) -> EpicsValue {
let n = self.num_elements();
let mut out = data.to_vec();
out.resize(n, 0.0);
EpicsValue::DoubleArray(out)
}
fn zero_fill_window(buf: &mut [f64], from: usize, window: usize) {
let len = buf.len();
let (from, window) = (from.min(len), window.min(len));
if from >= window {
return;
}
for v in &mut buf[from..window] {
*v = 0.0;
}
}
fn write_array_field(
&mut self,
src: &[f64],
bound: usize,
select: impl FnOnce(&mut Self) -> &mut Vec<f64>,
) {
let nelm = (self.nelm as usize).max(1);
let window = self.num_elements();
let buf = select(self);
buf.resize(nelm, 0.0);
let n = src.len().min(bound).min(nelm);
buf[..n].copy_from_slice(&src[..n]);
Self::zero_fill_window(buf, n, window);
}
fn build_inputs(&self, n: usize, prev_val: f64, prev_aval: &[f64]) -> ArrayInputs {
let mut inputs = ArrayInputs::with_counts(n, 12, 12);
for i in 0..12 {
inputs.num_vars[i] = self.num_vals[i];
}
for i in 0..12 {
let mut arr = self.arr_vals[i].clone();
arr.resize(n, 0.0);
inputs.arrays[i] = arr;
}
inputs.prev_val = prev_val;
inputs.prev_aval = prev_aval.to_vec();
inputs.prev_aval.resize(n, 0.0);
inputs
}
fn eval(
&self,
compiled: &CompiledExpr,
n: usize,
prev_val: f64,
prev_aval: &[f64],
) -> CalcPass {
let mut inputs = self.build_inputs(n, prev_val, prev_aval);
let result = match acalc_eval(compiled, &mut inputs) {
Ok(result) => {
let v = result.as_f64().unwrap_or(0.0);
let mut arr = result.to_array(n);
arr.resize(n, 0.0);
Some((v, arr, v.is_finite()))
}
Err(_) => None,
};
CalcPass { result, inputs }
}
fn apply_stores(&mut self, inputs: &ArrayInputs, n: usize) {
let scalars = self.num_vals.len();
self.num_vals.copy_from_slice(&inputs.num_vars[..scalars]);
for (dst, src) in self.arr_vals.iter_mut().zip(&inputs.arrays) {
if dst.len() < n {
dst.resize(n, 0.0);
}
dst[..n].copy_from_slice(&src[..n]);
}
}
fn oopt_should_output(&self) -> bool {
match self.oopt {
0 => true, 1 => (self.pval - self.val).abs() > self.mdel, 2 => self.val == 0.0, 3 => self.val != 0.0, 4 => self.pval != 0.0 && self.val == 0.0, 5 => self.pval == 0.0 && self.val != 0.0, 6 => false, _ => false,
}
}
fn set_output_to_ivov(&mut self) {
self.oval = self.ivov;
}
fn recompile_calc(&mut self) {
let compiled = calc_compile::acalc_postfix("acalcout", "CALC", &self.calc);
self.clcv = compiled.status;
self.compiled_calc = compiled.program;
}
fn recompile_ocal(&mut self) {
let compiled = calc_compile::acalc_postfix("acalcout", "OCAL", &self.ocal);
self.oclv = compiled.status;
self.compiled_ocal = compiled.program;
}
fn num_index(name: &str) -> Option<usize> {
if name.len() == 1 {
let c = name.as_bytes()[0];
if (b'A'..=b'L').contains(&c) {
return Some((c - b'A') as usize);
}
}
None
}
fn arr_index(name: &str) -> Option<usize> {
ARR_NAMES.iter().position(|&n| n == name)
}
fn inp_index(name: &str) -> Option<usize> {
INP_NAMES.iter().position(|&n| n == name)
}
fn ina_index(name: &str) -> Option<usize> {
INA_NAMES.iter().position(|&n| n == name)
}
fn inav_index(name: &str) -> Option<usize> {
INAV_NAMES.iter().position(|&n| n == name)
}
fn iaav_index(name: &str) -> Option<usize> {
IAAV_NAMES.iter().position(|&n| n == name)
}
fn is_link_status_field(name: &str) -> bool {
name == "OUTV" || Self::inav_index(name).is_some() || Self::iaav_index(name).is_some()
}
fn pa_index(name: &str) -> Option<usize> {
PA_NAMES.iter().position(|&n| n == name)
}
fn coerce_array(value: EpicsValue) -> Option<Vec<f64>> {
match value {
EpicsValue::DoubleArray(v) => Some(v),
EpicsValue::FloatArray(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::LongArray(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::ShortArray(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::UShortArray(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::ULongArray(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::Int64Array(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::UInt64Array(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::EnumArray(v) => Some(v.into_iter().map(|x| x as f64).collect()),
EpicsValue::CharArray(v) => Some(v.into_iter().map(|x| (x as i8) as f64).collect()),
other => other.to_f64().map(|v| vec![v]),
}
}
}
const ACALCOUT_OOPT_CHOICES: &[&str] = &[
"Every Time",
"On Change",
"When Zero",
"When Non-zero",
"Transition To Zero",
"Transition To Non-zero",
"Never",
];
const ACALCOUT_DOPT_CHOICES: &[&str] = &["Use CALC", "Use OCAL"];
const ACALCOUT_WAIT_CHOICES: &[&str] = &["NoWait", "Wait"];
const ACALCOUT_SIZE_CHOICES: &[&str] = &["NELM", "NUSE"];
const ACALCOUT_SIZE_NUSE: i16 = 1;
const ACALCOUT_INAV_CHOICES: &[&str] = &["Ext PV NC", "Ext PV OK", "Local PV", "Constant"];
impl Record for AcalcoutRecord {
fn record_type(&self) -> &'static str {
"acalcout"
}
fn init_record(&mut self, pass: u8) -> CaResult<()> {
if pass == 0 {
self.clamp_nuse(CyclePostMask::ValueLog);
self.recompile_calc();
self.recompile_ocal();
}
Ok(())
}
fn implements_field(&self, name: &str) -> bool {
if Self::is_link_status_field(name) {
return false;
}
self.get_field(name).is_some()
}
fn special(&mut self, field: &str, after: bool) -> CaResult<()> {
if !after {
return Ok(());
}
match field {
"CALC" => self.recompile_calc(),
"OCAL" => self.recompile_ocal(),
"NUSE" => {
if self.clamp_nuse(CyclePostMask::Value) {
return Err(CaError::InvalidValue(
"NUSE exceeds NELM; clamped to NELM".into(),
));
}
}
_ => {}
}
Ok(())
}
fn monitor_side_effect_fields(&self, put_field: &str) -> &'static [&'static str] {
match put_field {
"CALC" => &["CLCV"],
"OCAL" => &["OCLV"],
_ => &[],
}
}
fn process(&mut self) -> CaResult<ProcessOutcome> {
if self.dlya == 1 {
self.dlya = 0;
self.cached_should_output = self.pending_output;
self.pending_output = false;
return Ok(ProcessOutcome::complete());
}
let n = self.num_elements();
self.clamp_nuse(CyclePostMask::ValueLog);
self.pa = self.num_vals;
if self.fetch_gate_failed {
self.cached_should_output = false;
return Ok(ProcessOutcome::complete());
}
self.calc_alarm = false;
self.cstat = 0;
let mut calc_failed = false;
let pass = self.eval(&self.compiled_calc, n, self.val, &self.aval);
self.amask = pass.inputs.amask;
self.apply_stores(&pass.inputs, n);
match pass.result {
Some((v, arr, finite)) => {
self.val = v;
self.aval = arr;
if !finite {
calc_failed = true; }
}
None => calc_failed = true,
}
if self.dopt == 1 {
let pass = self.eval(&self.compiled_ocal, n, self.oval, &self.oav);
self.amask |= pass.inputs.amask;
self.apply_stores(&pass.inputs, n);
match pass.result {
Some((v, arr, finite)) => {
self.oval = v;
self.oav = arr;
if !finite {
calc_failed = true; }
}
None => calc_failed = true,
}
}
if calc_failed {
self.calc_alarm = true;
self.cstat = -1;
}
let oopt_fires = self.oopt_should_output();
let mut write_out = oopt_fires;
if calc_failed && self.ivoa == 1 {
write_out = false;
}
self.cached_should_output = write_out;
self.pval = self.val;
self.povl = self.oval;
if oopt_fires && self.odly > 0.0 {
self.dlya = 1;
self.pending_output = write_out;
self.cached_should_output = false;
let delay = std::time::Duration::from_secs_f64(self.odly);
return Ok(ProcessOutcome {
result: RecordProcessResult::AsyncPendingNotify(vec![(
"DLYA".to_string(),
EpicsValue::UShort(1),
)]),
actions: vec![ProcessAction::ReprocessAfter(delay)],
device_did_compute: false,
});
}
Ok(ProcessOutcome::complete())
}
fn check_alarms(&mut self, common: &mut crate::server::record::CommonFields) {
use crate::server::recgbl::{self, alarm_status};
use crate::server::record::AlarmSeverity;
if self.fetch_gate_failed {
return;
}
if std::mem::take(&mut self.calc_alarm) {
recgbl::rec_gbl_set_sevr(common, alarm_status::CALC_ALARM, AlarmSeverity::Invalid);
} else {
common.udf = 0;
}
if common.udf == 1 {
return;
}
let val = self.val;
let hyst = self.hyst;
let lalm = self.lalm;
if self.hhsv != 0 && (val >= self.hihi || (lalm == self.hihi && val >= self.hihi - hyst)) {
recgbl::rec_gbl_set_sevr(
common,
alarm_status::HIHI_ALARM,
AlarmSeverity::from_u16(self.hhsv as u16),
);
self.lalm = self.hihi;
} else if self.llsv != 0
&& (val <= self.lolo || (lalm == self.lolo && val <= self.lolo + hyst))
{
recgbl::rec_gbl_set_sevr(
common,
alarm_status::LOLO_ALARM,
AlarmSeverity::from_u16(self.llsv as u16),
);
self.lalm = self.lolo;
} else if self.hsv != 0
&& (val >= self.high || (lalm == self.high && val >= self.high - hyst))
{
recgbl::rec_gbl_set_sevr(
common,
alarm_status::HIGH_ALARM,
AlarmSeverity::from_u16(self.hsv as u16),
);
self.lalm = self.high;
} else if self.lsv != 0 && (val <= self.low || (lalm == self.low && val <= self.low + hyst))
{
recgbl::rec_gbl_set_sevr(
common,
alarm_status::LOW_ALARM,
AlarmSeverity::from_u16(self.lsv as u16),
);
self.lalm = self.low;
} else {
self.lalm = val;
}
}
fn clears_udf(&self) -> bool {
false
}
fn udf_alarm_on_exact_one(&self) -> bool {
true
}
fn apply_invalid_output_value(&mut self, _ivov: EpicsValue) -> CaResult<()> {
if self.cached_should_output {
self.set_output_to_ivov();
}
Ok(())
}
fn get_field(&self, name: &str) -> Option<EpicsValue> {
match name {
"VAL" => Some(EpicsValue::Double(self.val)),
"AVAL" => Some(self.array_field_value(&self.aval)),
"PVAL" => Some(EpicsValue::Double(self.pval)),
"NELM" => Some(EpicsValue::ULong(self.nelm)),
"NUSE" => Some(EpicsValue::ULong(self.nuse)),
"CALC" => Some(EpicsValue::String(self.calc.clone().into())),
"CLCV" => Some(EpicsValue::Long(self.clcv)),
"OCAL" => Some(EpicsValue::String(self.ocal.clone().into())),
"OCLV" => Some(EpicsValue::Long(self.oclv)),
"DOPT" => Some(EpicsValue::Short(self.dopt)),
"OVAL" => Some(EpicsValue::Double(self.oval)),
"OAV" => Some(self.array_field_value(&self.oav)),
"POVL" => Some(EpicsValue::Double(self.povl)),
"OUT" => Some(EpicsValue::String(self.out.clone().into())),
"OOPT" => Some(EpicsValue::Short(self.oopt)),
"ODLY" => Some(EpicsValue::Double(self.odly)),
"WAIT" => Some(EpicsValue::Short(self.wait)),
"DLYA" => Some(EpicsValue::UShort(self.dlya)),
"OEVT" => Some(EpicsValue::UShort(self.oevt)),
"IVOA" => Some(EpicsValue::Short(self.ivoa)),
"IVOV" => Some(EpicsValue::Double(self.ivov)),
"OUTV" => Some(EpicsValue::Short(self.outv)),
"EGU" => Some(EpicsValue::String(self.egu.clone())),
"PREC" => Some(EpicsValue::Short(self.prec)),
"HOPR" => Some(EpicsValue::Double(self.hopr)),
"LOPR" => Some(EpicsValue::Double(self.lopr)),
"HIHI" => Some(EpicsValue::Double(self.hihi)),
"LOLO" => Some(EpicsValue::Double(self.lolo)),
"HIGH" => Some(EpicsValue::Double(self.high)),
"LOW" => Some(EpicsValue::Double(self.low)),
"HHSV" => Some(EpicsValue::Short(self.hhsv)),
"LLSV" => Some(EpicsValue::Short(self.llsv)),
"HSV" => Some(EpicsValue::Short(self.hsv)),
"LSV" => Some(EpicsValue::Short(self.lsv)),
"HYST" => Some(EpicsValue::Double(self.hyst)),
"ADEL" => Some(EpicsValue::Double(self.adel)),
"MDEL" => Some(EpicsValue::Double(self.mdel)),
"LALM" => Some(EpicsValue::Double(self.lalm)),
"ALST" => Some(EpicsValue::Double(self.alst)),
"MLST" => Some(EpicsValue::Double(self.mlst)),
"SIZE" => Some(EpicsValue::Short(self.size)),
"NEWM" => Some(EpicsValue::ULong(self.newm)),
"CACT" => Some(EpicsValue::Char(self.cact)),
"CSTAT" => Some(EpicsValue::Long(self.cstat)),
"AMASK" => Some(EpicsValue::ULong(self.amask)),
"AMEM" => Some(EpicsValue::Long(self.amem)),
"PMEM" => Some(EpicsValue::Long(self.pmem)),
"VERS" => Some(EpicsValue::Double(VERSION)),
_ => {
if let Some(idx) = Self::num_index(name) {
return Some(EpicsValue::Double(self.num_vals[idx]));
}
if let Some(idx) = Self::arr_index(name) {
return Some(self.array_field_value(&self.arr_vals[idx]));
}
if let Some(idx) = Self::inp_index(name) {
return Some(EpicsValue::String(self.inp_links[idx].clone().into()));
}
if let Some(idx) = Self::ina_index(name) {
return Some(EpicsValue::String(self.ina_links[idx].clone().into()));
}
if let Some(idx) = Self::inav_index(name) {
return Some(EpicsValue::Short(self.inav[idx]));
}
if let Some(idx) = Self::iaav_index(name) {
return Some(EpicsValue::Short(self.iaav[idx]));
}
if let Some(idx) = Self::pa_index(name) {
return Some(EpicsValue::Double(self.pa[idx]));
}
None
}
}
}
fn put_field_internal(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
let Some(i) = Self::arr_index(name) else {
return crate::server::record::put_field_internal_default(self, name, value);
};
let before = self.array_field_value(&self.arr_vals[i]);
let value = match Self::coerce_array(value) {
Some(mut src) => {
src.truncate(self.num_elements());
EpicsValue::DoubleArray(src)
}
None => return Err(CaError::TypeMismatch(name.into())),
};
crate::server::record::put_field_internal_default(self, name, value)?;
if self.array_field_value(&self.arr_vals[i]) != before {
self.newm |= 1 << i;
}
Ok(())
}
fn put_field(&mut self, name: &str, value: EpicsValue) -> CaResult<()> {
match name {
"VAL" => {
self.val = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("VAL".into()))?;
Ok(())
}
"AVAL" => {
let src = Self::coerce_array(value)
.ok_or_else(|| CaError::TypeMismatch("AVAL".into()))?;
self.write_array_field(&src, self.dbaddr_no_elements(), |r| &mut r.aval);
Ok(())
}
"PVAL" => {
self.pval = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("PVAL".into()))?;
Ok(())
}
"NELM" => {
self.nelm = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("NELM".into()))?
as u32;
Ok(())
}
"NUSE" => {
self.nuse = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("NUSE".into()))?
as u32;
Ok(())
}
"CALC" => match value {
EpicsValue::String(s) => {
self.calc = s.as_str_lossy().into_owned();
Ok(())
}
_ => Err(CaError::TypeMismatch("CALC".into())),
},
"CLCV" => {
self.clcv = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("CLCV".into()))?
as i32;
Ok(())
}
"OCAL" => match value {
EpicsValue::String(s) => {
self.ocal = s.as_str_lossy().into_owned();
Ok(())
}
_ => Err(CaError::TypeMismatch("OCAL".into())),
},
"OCLV" => {
self.oclv = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("OCLV".into()))?
as i32;
Ok(())
}
"DOPT" => match value {
EpicsValue::Short(v) => {
self.dopt = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("DOPT".into())),
},
"OVAL" => {
self.oval = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("OVAL".into()))?;
Ok(())
}
"OAV" => {
let src =
Self::coerce_array(value).ok_or_else(|| CaError::TypeMismatch("OAV".into()))?;
self.write_array_field(&src, self.dbaddr_no_elements(), |r| &mut r.oav);
Ok(())
}
"POVL" => {
self.povl = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("POVL".into()))?;
Ok(())
}
"OUT" => match value {
EpicsValue::String(s) => {
self.out = s.as_str_lossy().into_owned();
Ok(())
}
_ => Err(CaError::TypeMismatch("OUT".into())),
},
"OOPT" => match value {
EpicsValue::Short(v) => {
self.oopt = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("OOPT".into())),
},
"ODLY" => {
self.odly = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("ODLY".into()))?;
Ok(())
}
"WAIT" => match value {
EpicsValue::Short(v) => {
self.wait = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("WAIT".into())),
},
"DLYA" => {
self.dlya = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("DLYA".into()))?
as u16;
Ok(())
}
"OEVT" => {
self.oevt = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("OEVT".into()))?
as u16;
Ok(())
}
"IVOA" => match value {
EpicsValue::Short(v) => {
self.ivoa = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("IVOA".into())),
},
"IVOV" => {
self.ivov = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("IVOV".into()))?;
Ok(())
}
"OUTV" => Err(CaError::ReadOnlyField("OUTV".into())),
"EGU" => match value {
EpicsValue::String(s) => {
self.egu = s;
Ok(())
}
_ => Err(CaError::TypeMismatch("EGU".into())),
},
"PREC" => match value {
EpicsValue::Short(v) => {
self.prec = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("PREC".into())),
},
"HOPR" => {
self.hopr = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("HOPR".into()))?;
Ok(())
}
"LOPR" => {
self.lopr = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("LOPR".into()))?;
Ok(())
}
"HIHI" => {
self.hihi = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("HIHI".into()))?;
Ok(())
}
"LOLO" => {
self.lolo = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("LOLO".into()))?;
Ok(())
}
"HIGH" => {
self.high = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("HIGH".into()))?;
Ok(())
}
"LOW" => {
self.low = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("LOW".into()))?;
Ok(())
}
"HHSV" => match value {
EpicsValue::Short(v) => {
self.hhsv = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("HHSV".into())),
},
"LLSV" => match value {
EpicsValue::Short(v) => {
self.llsv = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("LLSV".into())),
},
"HSV" => match value {
EpicsValue::Short(v) => {
self.hsv = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("HSV".into())),
},
"LSV" => match value {
EpicsValue::Short(v) => {
self.lsv = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("LSV".into())),
},
"HYST" => {
self.hyst = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("HYST".into()))?;
Ok(())
}
"ADEL" => {
self.adel = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("ADEL".into()))?;
Ok(())
}
"MDEL" => {
self.mdel = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("MDEL".into()))?;
Ok(())
}
"LALM" => {
self.lalm = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("LALM".into()))?;
Ok(())
}
"ALST" => {
self.alst = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("ALST".into()))?;
Ok(())
}
"MLST" => {
self.mlst = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("MLST".into()))?;
Ok(())
}
"SIZE" => match value {
EpicsValue::Short(v) => {
self.size = v;
Ok(())
}
_ => Err(CaError::TypeMismatch("SIZE".into())),
},
"NEWM" => {
self.newm = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("NEWM".into()))?
as u32;
Ok(())
}
"CACT" => {
self.cact = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("CACT".into()))?
as u8;
Ok(())
}
"CSTAT" => {
self.cstat = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("CSTAT".into()))?
as i32;
Ok(())
}
"AMASK" => {
self.amask = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("AMASK".into()))?
as u32;
Ok(())
}
"AMEM" => {
self.amem = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("AMEM".into()))?
as i32;
Ok(())
}
"PMEM" => {
self.pmem = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch("PMEM".into()))?
as i32;
Ok(())
}
"VERS" => Ok(()),
_ => {
if let Some(idx) = Self::num_index(name) {
self.num_vals[idx] = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch(name.into()))?;
return Ok(());
}
if let Some(idx) = Self::arr_index(name) {
let src = Self::coerce_array(value)
.ok_or_else(|| CaError::TypeMismatch(name.into()))?;
self.write_array_field(&src, self.dbaddr_no_elements(), |r| {
&mut r.arr_vals[idx]
});
return Ok(());
}
if let Some(idx) = Self::inp_index(name) {
match value {
EpicsValue::String(s) => {
self.inp_links[idx] = s.as_str_lossy().into_owned();
return Ok(());
}
_ => return Err(CaError::TypeMismatch(name.into())),
}
}
if let Some(idx) = Self::ina_index(name) {
match value {
EpicsValue::String(s) => {
self.ina_links[idx] = s.as_str_lossy().into_owned();
return Ok(());
}
_ => return Err(CaError::TypeMismatch(name.into())),
}
}
if Self::inav_index(name).is_some() || Self::iaav_index(name).is_some() {
return Err(CaError::ReadOnlyField(name.to_string()));
}
if let Some(idx) = Self::pa_index(name) {
self.pa[idx] = value
.to_f64()
.ok_or_else(|| CaError::TypeMismatch(name.into()))?;
return Ok(());
}
Err(CaError::FieldNotFound(name.to_string()))
}
}
}
fn constant_init_links(&self) -> Vec<crate::server::record::ConstantInitLink> {
crate::server::record::seed_input_links(self.multi_input_links())
}
fn multi_input_links(&self) -> &[(&'static str, &'static str)] {
ACALCOUT_INPUT_LINKS
}
fn special_reseed_input_links(&self) -> &[(&'static str, &'static str)] {
&ACALCOUT_INPUT_LINKS[..ACALCOUT_NUMERIC_INPUTS]
}
fn input_fetch_policy(&self) -> InputFetchPolicy {
InputFetchPolicy::AbortOnFirstFailure
}
fn set_fetch_gate_failed(&mut self, failed: bool) {
self.fetch_gate_failed = failed;
}
fn multi_output_links(&self) -> &[(&'static str, &'static str)] {
if !self.cached_should_output {
&[]
} else if self.dopt == 1 {
&[("OUT", "OAV")]
} else {
&[("OUT", "AVAL")]
}
}
fn multi_output_buffer(
&self,
link_field: &str,
staged: EpicsValue,
target: &OutTarget,
) -> EpicsValue {
if link_field != "OUT" {
return staged;
}
if staged.count() > 1 && target.element_count > 1 {
return staged;
}
let scalar = if self.dopt == 1 { "OVAL" } else { "VAL" };
self.get_field(scalar).unwrap_or(staged)
}
fn output_event(&self) -> Option<String> {
if self.cached_should_output && self.oevt > 0 {
Some(self.oevt.to_string())
} else {
None
}
}
fn menu_field_choices(&self, field: &str) -> Option<&'static [&'static str]> {
match field {
"OOPT" => Some(ACALCOUT_OOPT_CHOICES),
"DOPT" => Some(ACALCOUT_DOPT_CHOICES),
"WAIT" => Some(ACALCOUT_WAIT_CHOICES),
"SIZE" => Some(ACALCOUT_SIZE_CHOICES),
_ => {
if Self::inav_index(field).is_some()
|| Self::iaav_index(field).is_some()
|| field == "OUTV"
{
Some(ACALCOUT_INAV_CHOICES)
} else {
None
}
}
}
}
fn alarm_cycle_monitored_fields(&self) -> &'static [&'static str] {
&["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"]
}
fn event_posted_fields(&self) -> &'static [&'static str] {
&[
"PVAL", "POVL", "LALM", "ALST", "MLST", "CSTAT", "PA", "PB", "PC", "PD", "PE", "PF",
"PG", "PH", "PI", "PJ", "PK", "PL",
]
}
fn take_cycle_posted_fields(&mut self) -> Vec<(&'static str, CyclePostMask)> {
let (amask, newm) = (self.amask, self.newm);
self.newm = 0;
let mut marks = Vec::new();
if let Some(mask) = self.nuse_post_pending.take() {
marks.push(("NUSE", mask));
}
for (i, name) in ARR_NAMES.iter().enumerate() {
let bit = 1u32 << i;
if amask & bit != 0 {
marks.push((*name, CyclePostMask::ValueLog));
}
if newm & bit != 0 {
marks.push((*name, CyclePostMask::MonitorValueLog));
}
}
marks
}
fn fields_posted_only_when_marked(&self) -> &'static [&'static str] {
&ARR_NAMES
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::record::FieldDeclaration;
#[test]
fn test_acalcout_default_matches_dbd_initials() {
let rec = AcalcoutRecord::new();
assert_eq!(rec.record_type(), "acalcout");
assert_eq!(rec.get_field("NELM"), Some(EpicsValue::ULong(1)));
assert_eq!(rec.get_field("NUSE"), Some(EpicsValue::ULong(0)));
assert_eq!(rec.get_field("VERS"), Some(EpicsValue::Double(1.4)));
assert_eq!(rec.get_field("INAV"), Some(EpicsValue::Short(3)));
assert_eq!(rec.get_field("ILLV"), Some(EpicsValue::Short(3)));
assert_eq!(rec.get_field("OUTV"), Some(EpicsValue::Short(3)));
assert_eq!(rec.get_field("VAL"), Some(EpicsValue::Double(0.0)));
}
#[test]
fn link_status_fields_reject_put_and_keep_derived_value() {
for name in ["INAV", "INLV", "IAAV", "ILLV", "OUTV"] {
let mut rec = AcalcoutRecord::new();
assert_eq!(
rec.get_field(name),
Some(EpicsValue::Short(LINK_CON)),
"{name} should start at derived Constant(3)"
);
let err = rec.put_field(name, EpicsValue::Short(0));
assert!(
matches!(err, Err(CaError::ReadOnlyField(_))),
"{name} put should be rejected as read-only, got {err:?}"
);
assert_eq!(
rec.get_field(name),
Some(EpicsValue::Short(LINK_CON)),
"{name} must stay at the derived Constant(3) after a rejected put"
);
}
}
#[test]
fn test_acalcout_field_list_count() {
assert_eq!(AcalcoutRecord::default().field_list().len(), 132);
}
#[test]
fn test_acalcout_scalar_calc() {
let mut rec = AcalcoutRecord::new();
rec.put_field("A", EpicsValue::Double(3.0)).unwrap();
rec.put_field("B", EpicsValue::Double(4.0)).unwrap();
rec.put_field("CALC", EpicsValue::String("A+B".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
assert_eq!(rec.val, 7.0);
assert_eq!(
rec.get_field("AVAL"),
Some(EpicsValue::DoubleArray(vec![7.0]))
);
}
#[test]
fn test_acalcout_array_calc_broadcasts_and_aval() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(4)).unwrap();
rec.put_field("AA", EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0, 4.0]))
.unwrap();
rec.put_field("CALC", EpicsValue::String("AA+1".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
assert_eq!(
rec.get_field("AVAL"),
Some(EpicsValue::DoubleArray(vec![2.0, 3.0, 4.0, 5.0]))
);
assert_eq!(rec.val, 2.0);
}
#[test]
fn test_acalcout_dopt_use_ocal_array() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(3)).unwrap();
rec.put_field("AA", EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0]))
.unwrap();
rec.put_field("CALC", EpicsValue::String("AA".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OCAL", EpicsValue::String("AA*2".into()))
.unwrap();
rec.special("OCAL", true).unwrap();
rec.put_field("DOPT", EpicsValue::Short(1)).unwrap();
rec.process().unwrap();
assert_eq!(
rec.get_field("AVAL"),
Some(EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0]))
);
assert_eq!(
rec.get_field("OAV"),
Some(EpicsValue::DoubleArray(vec![2.0, 4.0, 6.0]))
);
assert_eq!(rec.oval, 2.0);
}
#[test]
fn test_acalcout_oopt_every_outputs_aval() {
let mut rec = AcalcoutRecord::new();
rec.put_field("CALC", EpicsValue::String("42".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OOPT", EpicsValue::Short(0)).unwrap();
rec.process().unwrap();
assert_eq!(rec.multi_output_links(), &[("OUT", "AVAL")]);
}
#[test]
fn test_acalcout_oopt_never_suppresses_output() {
let mut rec = AcalcoutRecord::new();
rec.put_field("CALC", EpicsValue::String("42".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OOPT", EpicsValue::Short(6)).unwrap(); rec.process().unwrap();
assert_eq!(rec.multi_output_links(), &[]);
}
#[test]
fn test_acalcout_oopt_on_change_uses_mdel() {
let mut rec = AcalcoutRecord::new();
rec.put_field("CALC", EpicsValue::String("A".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OOPT", EpicsValue::Short(1)).unwrap(); rec.put_field("MDEL", EpicsValue::Double(0.5)).unwrap();
rec.put_field("A", EpicsValue::Double(5.0)).unwrap();
rec.process().unwrap();
assert!(rec.cached_should_output);
rec.process().unwrap();
assert!(!rec.cached_should_output);
}
#[test]
fn test_acalcout_dopt_use_ocal_drives_oav() {
let mut rec = AcalcoutRecord::new();
rec.put_field("CALC", EpicsValue::String("1".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OCAL", EpicsValue::String("2".into()))
.unwrap();
rec.special("OCAL", true).unwrap();
rec.put_field("DOPT", EpicsValue::Short(1)).unwrap();
rec.process().unwrap();
assert_eq!(rec.multi_output_links(), &[("OUT", "OAV")]);
}
#[test]
fn test_acalcout_ocal_val_token_reads_oval() {
let mut rec = AcalcoutRecord::new();
rec.put_field("CALC", EpicsValue::String("5".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.put_field("OCAL", EpicsValue::String("VAL+10".into()))
.unwrap();
rec.special("OCAL", true).unwrap();
rec.put_field("DOPT", EpicsValue::Short(1)).unwrap();
rec.process().unwrap();
assert_eq!(rec.get_field("OVAL"), Some(EpicsValue::Double(10.0)));
rec.process().unwrap();
assert_eq!(rec.get_field("OVAL"), Some(EpicsValue::Double(20.0)));
rec.process().unwrap();
assert_eq!(rec.get_field("OVAL"), Some(EpicsValue::Double(30.0)));
}
#[test]
fn test_acalcout_calc_failure_sets_calc_alarm() {
let mut rec = AcalcoutRecord::new();
rec.calc = "???bad".into();
rec.compiled_calc = CompiledExpr::empty(ExprKind::Array);
rec.process().unwrap();
assert!(rec.calc_alarm);
assert_eq!(rec.cstat, -1);
}
#[test]
fn udf_cleared_only_by_a_successful_calc() {
use crate::server::record::CommonFields;
let mut rec = AcalcoutRecord::new();
let mut common = CommonFields::default();
assert_eq!(common.udf, 1, "fresh common is undefined (init 1)");
rec.process().unwrap();
rec.check_alarms(&mut common);
assert_eq!(
common.udf, 1,
"empty CALC failed → UDF stays 1, not isnan()=0"
);
rec.put_field("CALC", EpicsValue::String("1+1".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
rec.check_alarms(&mut common);
assert_eq!(rec.get_field("VAL"), Some(EpicsValue::Double(2.0)));
assert_eq!(common.udf, 0, "finite result clears UDF");
rec.put_field("CALC", EpicsValue::String("1e300*1e300".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
assert!(rec.calc_alarm, "non-finite result raises CALC_ALARM");
rec.check_alarms(&mut common);
assert_eq!(
common.udf, 0,
"a failed calc after a success leaves UDF cleared, matching C"
);
}
#[test]
fn failing_calc_leaves_the_udf_byte_untouched() {
use crate::server::record::CommonFields;
let mut rec = AcalcoutRecord::new(); let mut common = CommonFields::default();
common.udf = 255;
rec.process().unwrap();
rec.check_alarms(&mut common);
assert_eq!(
common.udf, 255,
"empty-CALC re-process must not touch the put byte"
);
common.udf = 0;
rec.process().unwrap();
rec.check_alarms(&mut common);
assert_eq!(common.udf, 0, "caput UDF 0 stands over a failing calc");
}
#[test]
fn test_acalcout_nonfinite_result_written_to_val() {
let mut rec = AcalcoutRecord::new();
rec.put_field("CALC", EpicsValue::String("1e300*1e300".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => {
assert!(!v.is_finite(), "VAL = {v}, expected non-finite")
}
other => panic!("expected Double VAL, got {other:?}"),
}
assert!(rec.calc_alarm);
}
#[test]
fn test_acalcout_nan_scalar_fills_aval_with_zeros() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(4)).unwrap();
rec.put_field("CALC", EpicsValue::String("ACOS(2)".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
match rec.get_field("VAL") {
Some(EpicsValue::Double(v)) => assert!(v.is_nan(), "VAL = {v}, expected NaN"),
other => panic!("expected Double VAL, got {other:?}"),
}
assert_eq!(rec.aval, vec![0.0; 4], "C fills AVAL with zeros for a NaN");
assert!(rec.calc_alarm);
}
#[test]
fn test_acalcout_ivoa_dont_drive_on_failure() {
let mut rec = AcalcoutRecord::new();
rec.calc = "???bad".into();
rec.compiled_calc = CompiledExpr::empty(ExprKind::Array);
rec.put_field("IVOA", EpicsValue::Short(1)).unwrap(); rec.process().unwrap();
assert!(!rec.cached_should_output);
assert_eq!(rec.multi_output_links(), &[]);
}
#[test]
fn test_acalcout_ivoa_set_ivov_on_failure() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(2)).unwrap();
rec.calc = "???bad".into();
rec.compiled_calc = CompiledExpr::empty(ExprKind::Array);
rec.put_field("IVOA", EpicsValue::Short(2)).unwrap(); rec.put_field("IVOV", EpicsValue::Double(9.0)).unwrap();
rec.process().unwrap();
let val_before = rec.val;
let aval_before = rec.get_field("AVAL");
rec.apply_invalid_output_value(EpicsValue::Double(9.0))
.unwrap();
assert_eq!(rec.oval, 9.0, "C sets only the scalar OVAL");
assert_eq!(rec.val, val_before, "VAL is not IVOV's target");
assert_eq!(
rec.get_field("AVAL"),
aval_before,
"AVAL is not IVOV's target"
);
}
#[test]
fn test_acalcout_nuse_special_clamps_posts_and_refuses_the_put() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(4)).unwrap();
rec.put_field("NUSE", EpicsValue::ULong(10)).unwrap();
assert_eq!(
rec.get_field("NUSE"),
Some(EpicsValue::ULong(10)),
"the raw store keeps what the client sent; special() has not run yet"
);
let status = rec.special("NUSE", true);
assert!(status.is_err(), "C returns -1, so the put must fail");
assert_eq!(
rec.get_field("NUSE"),
Some(EpicsValue::ULong(4)),
"and the clamped value stays"
);
assert_eq!(
rec.take_cycle_posted_fields(),
vec![("NUSE", CyclePostMask::Value)],
"posted with C's literal DBE_VALUE (:497)"
);
}
#[test]
fn nuse_is_clamped_at_init_against_the_final_nelm_not_the_default() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NUSE", EpicsValue::ULong(5)).unwrap();
rec.put_field("NELM", EpicsValue::ULong(10)).unwrap();
rec.init_record(0).unwrap();
assert_eq!(rec.get_field("NUSE"), Some(EpicsValue::ULong(5)));
assert!(
rec.take_cycle_posted_fields().is_empty(),
"nothing was clamped, so nothing is posted"
);
}
#[test]
fn init_clamps_an_illegal_restored_nuse_and_posts_it() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(4)).unwrap();
rec.put_field("NUSE", EpicsValue::ULong(99)).unwrap();
rec.init_record(0).unwrap();
assert_eq!(rec.get_field("NUSE"), Some(EpicsValue::ULong(4)));
assert_eq!(
rec.take_cycle_posted_fields(),
vec![("NUSE", CyclePostMask::ValueLog)],
"C's init post is DBE_VALUE|DBE_LOG (:189)"
);
}
#[test]
fn test_acalcout_size_nuse_reports_nuse_count() {
let mut rec = AcalcoutRecord::new();
rec.put_field("NELM", EpicsValue::ULong(8)).unwrap();
rec.put_field("NUSE", EpicsValue::ULong(3)).unwrap();
rec.put_field("AA", EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0, 4.0, 5.0]))
.unwrap();
rec.put_field("CALC", EpicsValue::String("AA".into()))
.unwrap();
rec.special("CALC", true).unwrap();
rec.process().unwrap();
assert_eq!(
rec.get_field("AVAL"),
Some(EpicsValue::DoubleArray(vec![1.0, 2.0, 3.0]))
);
}
#[test]
fn test_acalcout_field_not_found() {
let mut rec = AcalcoutRecord::new();
assert!(rec.put_field("ZZZ", EpicsValue::Double(1.0)).is_err());
assert!(rec.get_field("ZZZ").is_none());
}
#[test]
fn test_acalcout_menu_choices() {
let rec = AcalcoutRecord::new();
assert_eq!(rec.menu_field_choices("OOPT"), Some(ACALCOUT_OOPT_CHOICES));
assert_eq!(rec.menu_field_choices("DOPT"), Some(ACALCOUT_DOPT_CHOICES));
assert_eq!(rec.menu_field_choices("WAIT"), Some(ACALCOUT_WAIT_CHOICES));
assert_eq!(rec.menu_field_choices("SIZE"), Some(ACALCOUT_SIZE_CHOICES));
assert_eq!(rec.menu_field_choices("INAV"), Some(ACALCOUT_INAV_CHOICES));
assert_eq!(rec.menu_field_choices("ILLV"), Some(ACALCOUT_INAV_CHOICES));
assert_eq!(rec.menu_field_choices("OUTV"), Some(ACALCOUT_INAV_CHOICES));
assert_eq!(rec.menu_field_choices("HHSV"), None);
assert_eq!(rec.menu_field_choices("IVOA"), None);
}
}