Skip to main content

mockarchiver/
signals.rs

1//! The synthetic PVs: deterministic functions of time on a fixed sample grid.
2//!
3//! | PV | Signal |
4//! |---|---|
5//! | `MOCK:SINE` | 1 Hz, 10-minute-period sine, EGU `mA`, PREC 3 (MINOR alarm near the top) |
6//! | `MOCK:RAMP` | 0.1 Hz sawtooth 0..100 over an hour |
7//! | `MOCK:NOISE` | 10 Hz noise on a slow drift, seeded per sample (no RNG state) |
8//! | `MOCK:SLOW` | one sample every 5 minutes (so `optimized` returns raw) |
9//! | `MOCK:STEP` | a value that holds for 4 minutes, then jumps (1 s grid) |
10//! | `MOCK:ENUM` | integer 0..3, sampled every minute |
11//! | `MOCK:STRING` | text |
12//! | `MOCK:WAVE` | 8-element array |
13//! | `MOCK:NAN` | sine with a `NaN` every 50th sample and severity-3 `cnxlostepsecs` gaps |
14//! | `MOCK:BIG` | 1 Hz sine of amplitude 3e10 (Java `3.0E10` notation on the wire) |
15//! | `bl01t:A`, `bl01t:B`, `bl01t:SUM`, `BL01T-MO-SIMC-01:M1.RBV`, `BL01T-MO-SIMC-01:M2.RBV` | names bobview-testioc serves, so archive and live join up |
16//!
17//! Every value is a pure function of the sample's absolute grid index
18//! (`t / period`), so two servers started at different times agree on
19//! every sample they both have. "Noise" is a hash of that index.
20
21/// Days of history before the server's start time.
22pub const HISTORY_DAYS: u32 = 30;
23
24/// What a mock PV's archived values are.
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub enum Kind {
27    Double,
28    Int,
29    Enum,
30    Text,
31    Waveform(u32),
32}
33
34impl Kind {
35    /// The appliance's `DBRType` name for this kind.
36    pub fn dbr_type(self) -> &'static str {
37        match self {
38            Kind::Double => "DBR_SCALAR_DOUBLE",
39            Kind::Int => "DBR_SCALAR_INT",
40            Kind::Enum => "DBR_SCALAR_ENUM",
41            Kind::Text => "DBR_SCALAR_STRING",
42            Kind::Waveform(_) => "DBR_WAVEFORM_DOUBLE",
43        }
44    }
45
46    /// Elements per sample.
47    pub fn element_count(self) -> u32 {
48        match self {
49            Kind::Waveform(n) => n,
50            _ => 1,
51        }
52    }
53
54    /// True when the summarising operators apply (numeric scalars).
55    pub fn is_scalar_numeric(self) -> bool {
56        matches!(self, Kind::Double | Kind::Int | Kind::Enum)
57    }
58}
59
60/// What the synthetic signal looks like.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum Shape {
63    Sine,
64    Ramp,
65    Noise,
66    Slow,
67    Step,
68    Enum,
69    Text,
70    Wave,
71    Nan,
72    Big,
73    BeamA,
74    BeamB,
75    BeamSum,
76    Motor1,
77    Motor2,
78}
79
80/// One synthetic PV.
81#[derive(Clone, Copy, Debug)]
82pub struct Signal {
83    pub name: &'static str,
84    pub kind: Kind,
85    /// Seconds between samples.
86    pub period: f64,
87    pub egu: &'static str,
88    pub prec: u32,
89    /// `DESC`.
90    pub desc: &'static str,
91    /// `LOPR`/`HOPR`.
92    pub lopr: f64,
93    pub hopr: f64,
94    pub shape: Shape,
95}
96
97#[allow(clippy::too_many_arguments)]
98const fn sig(
99    name: &'static str,
100    kind: Kind,
101    period: f64,
102    egu: &'static str,
103    prec: u32,
104    desc: &'static str,
105    range: (f64, f64),
106    shape: Shape,
107) -> Signal {
108    Signal {
109        name,
110        kind,
111        period,
112        egu,
113        prec,
114        desc,
115        lopr: range.0,
116        hopr: range.1,
117        shape,
118    }
119}
120
121#[rustfmt::skip]
122static ALL: [Signal; 15] = [
123    sig("MOCK:SINE", Kind::Double, 1.0, "mA", 3, "Sine, 10 minute period", (200.0, 300.0), Shape::Sine),
124    sig("MOCK:RAMP", Kind::Double, 10.0, "%", 1, "Sawtooth over an hour", (0.0, 100.0), Shape::Ramp),
125    sig("MOCK:NOISE", Kind::Double, 0.1, "V", 4, "Noise on a slow drift", (-10.0, 10.0), Shape::Noise),
126    sig("MOCK:SLOW", Kind::Double, 300.0, "degC", 2, "Every five minutes", (15.0, 25.0), Shape::Slow),
127    sig("MOCK:STEP", Kind::Double, 1.0, "mm", 2, "Holds, then jumps", (0.0, 10.0), Shape::Step),
128    sig("MOCK:ENUM", Kind::Enum, 60.0, "", 0, "Mode 0..3", (0.0, 3.0), Shape::Enum),
129    sig("MOCK:STRING", Kind::Text, 600.0, "", 0, "Status text", (0.0, 0.0), Shape::Text),
130    sig("MOCK:WAVE", Kind::Waveform(8), 10.0, "counts", 1, "8-element waveform", (-1.0, 1.0), Shape::Wave),
131    sig("MOCK:NAN", Kind::Double, 1.0, "mA", 3, "Sine with NaNs and disconnects", (-1.0, 1.0), Shape::Nan),
132    sig("MOCK:BIG", Kind::Double, 1.0, "Pa", 3, "Large values", (-3e10, 3e10), Shape::Big),
133    sig("bl01t:A", Kind::Double, 10.0, "", 3, "Input A", (0.0, 10.0), Shape::BeamA),
134    sig("bl01t:B", Kind::Double, 10.0, "", 3, "Input B", (0.0, 10.0), Shape::BeamB),
135    sig("bl01t:SUM", Kind::Double, 10.0, "", 3, "Sum A and B", (0.0, 20.0), Shape::BeamSum),
136    sig("BL01T-MO-SIMC-01:M1.RBV", Kind::Double, 1.0, "mm", 3, "Motor 1 readback", (-10.0, 10.0), Shape::Motor1),
137    sig("BL01T-MO-SIMC-01:M2.RBV", Kind::Double, 1.0, "mm", 3, "Motor 2 readback", (-10.0, 10.0), Shape::Motor2),
138];
139
140/// Every mock PV, in a fixed order.
141pub fn all() -> &'static [Signal] {
142    &ALL
143}
144
145/// The PV called `name`.
146pub fn find(name: &str) -> Option<&'static Signal> {
147    all().iter().find(|s| s.name == name)
148}
149
150/// One archived value.
151#[derive(Clone, Debug, PartialEq)]
152pub enum Value {
153    Double(f64),
154    Int(i64),
155    Text(String),
156    Array(Vec<f64>),
157}
158
159impl Value {
160    /// The number the summarising operators see (element 0 for arrays).
161    pub fn as_f64(&self) -> Option<f64> {
162        match self {
163            Value::Double(v) => Some(*v),
164            Value::Int(i) => Some(*i as f64),
165            Value::Text(_) => None,
166            Value::Array(a) => Some(a.first().copied().unwrap_or(f64::NAN)),
167        }
168    }
169}
170
171/// One archived event.
172#[derive(Clone, Debug, PartialEq)]
173pub struct Event {
174    /// UTC nanoseconds since the epoch.
175    pub t_ns: i64,
176    pub value: Value,
177    pub severity: u8,
178    pub status: u16,
179    /// Extra `fields`, e.g. `cnxlostepsecs`.
180    pub fields: Vec<(&'static str, String)>,
181}
182
183/// `MOCK:NAN` loses its connection after sample `NAN_LOST` of every
184/// `NAN_CYCLE` (absolute index modulo the cycle) and is back at
185/// `NAN_REGAINED`; the samples in between are missing.
186pub const NAN_CYCLE: i64 = 2000;
187pub const NAN_LOST: i64 = 1000;
188pub const NAN_REGAINED: i64 = 1060;
189
190/// splitmix64: a deterministic hash of the sample index.
191fn hash(mut x: u64) -> u64 {
192    x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
193    x = (x ^ (x >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
194    x = (x ^ (x >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
195    x ^ (x >> 31)
196}
197
198/// Uniform in [0, 1).
199fn unit(k: i64, salt: u64) -> f64 {
200    (hash(k as u64 ^ salt.rotate_left(17)) >> 11) as f64 / (1u64 << 53) as f64
201}
202
203/// Roughly normal, mean 0, sd 1 (Irwin-Hall of 4).
204fn normal(k: i64, salt: u64) -> f64 {
205    let s: f64 = (0..4)
206        .map(|i| unit(k, salt.wrapping_add(i).wrapping_mul(0x1000_0001)))
207        .sum();
208    (s - 2.0) * 3.0f64.sqrt()
209}
210
211/// A motor parked at 0 (where bobview-testioc's motors rest, so archive
212/// and live join up) that every 15 minutes moves out to a new position
213/// for two minutes and back, taking 20 s each way.
214fn motor(t: f64, salt: u64) -> f64 {
215    const MOVE: f64 = 900.0;
216    const TRAVEL: f64 = 20.0;
217    const AWAY: f64 = 120.0;
218    let n = (t / MOVE).floor() as i64;
219    let pos = ((unit(n, salt) * 16.0 - 8.0) * 100.0).round() / 100.0;
220    let s = t - n as f64 * MOVE;
221    let out = (s / TRAVEL).clamp(0.0, 1.0);
222    let back = ((s - TRAVEL - AWAY) / TRAVEL).clamp(0.0, 1.0);
223    pos * (out - back)
224}
225
226const TAU: f64 = std::f64::consts::TAU;
227
228impl Signal {
229    /// The sample period in nanoseconds.
230    pub fn period_ns(&self) -> i64 {
231        (self.period * 1e9).round() as i64
232    }
233
234    /// The event at absolute grid index `k` (time `k * period`), or `None`
235    /// when the PV was disconnected then.
236    pub fn event(&self, k: i64) -> Option<Event> {
237        let t_ns = k * self.period_ns();
238        let t = t_ns as f64 * 1e-9;
239        let mut severity = 0;
240        let mut status = 0;
241        let mut fields = Vec::new();
242        let salt = self
243            .name
244            .bytes()
245            .fold(0u64, |h, b| h.wrapping_mul(31) ^ b as u64);
246        let value = match self.shape {
247            Shape::Sine => {
248                let v = 250.0 + 40.0 * (TAU * t / 600.0).sin() + 0.2 * normal(k, salt);
249                if v > 285.0 {
250                    severity = 1;
251                    status = 4; // HIGH
252                }
253                Value::Double(v)
254            }
255            Shape::Ramp => Value::Double((t % 3600.0) / 36.0),
256            Shape::Noise => Value::Double(5.0 * (TAU * t / 10800.0).sin() + normal(k, salt)),
257            Shape::Slow => {
258                Value::Double(20.0 + 3.0 * (TAU * t / 86400.0).sin() + 0.3 * normal(k, salt))
259            }
260            Shape::Step => {
261                let n = (t / 240.0).floor() as i64;
262                Value::Double((unit(n, salt) * 1000.0).round() / 100.0)
263            }
264            Shape::Enum => Value::Int((hash((t / 1800.0).floor() as u64 ^ salt) % 4) as i64),
265            Shape::Text => {
266                const STATES: [&str; 4] = ["Idle", "Running", "Paused", "Fault: \"door\" open"];
267                Value::Text(STATES[k.rem_euclid(4) as usize].to_string())
268            }
269            Shape::Wave => Value::Array(
270                (0..8)
271                    .map(|i| ((TAU * (t / 60.0 + i as f64 / 8.0)).sin() * 10.0).round() / 10.0)
272                    .collect(),
273            ),
274            Shape::Nan => {
275                let phase = k.rem_euclid(NAN_CYCLE);
276                if phase > NAN_LOST && phase < NAN_REGAINED {
277                    return None;
278                }
279                let lost_secs = (k - phase + NAN_LOST) * self.period_ns() / 1_000_000_000;
280                if phase == NAN_LOST {
281                    severity = 3;
282                    status = 9; // LINK
283                    fields.push(("cnxlostepsecs", lost_secs.to_string()));
284                } else if phase == NAN_REGAINED {
285                    fields.push(("cnxlostepsecs", lost_secs.to_string()));
286                    fields.push(("cnxregainedepsecs", (t_ns / 1_000_000_000).to_string()));
287                }
288                if k.rem_euclid(50) == 49 {
289                    Value::Double(f64::NAN)
290                } else {
291                    Value::Double((TAU * t / 300.0).sin())
292                }
293            }
294            Shape::Big => Value::Double(3e10 * (TAU * t / 600.0).sin()),
295            Shape::BeamA => Value::Double(beam_a(t)),
296            Shape::BeamB => Value::Double(beam_b(t)),
297            Shape::BeamSum => Value::Double(beam_a(t) + beam_b(t)),
298            Shape::Motor1 => Value::Double(motor(t, salt)),
299            Shape::Motor2 => Value::Double(motor(t + 450.0, salt)),
300        };
301        Some(Event {
302            t_ns,
303            value,
304            severity,
305            status,
306            fields,
307        })
308    }
309}
310
311/// The beam inputs wander a little about bobview-testioc's values (2.54
312/// and 2.61), so archive and live join up.
313fn beam_a(t: f64) -> f64 {
314    2.54 + 0.02 * (TAU * t / 7200.0).sin()
315}
316
317fn beam_b(t: f64) -> f64 {
318    2.61 + 0.015 * (TAU * t / 10800.0).cos()
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn names_are_unique_and_findable() {
327        for s in all() {
328            assert_eq!(find(s.name).unwrap().name, s.name);
329        }
330        let mut names: Vec<_> = all().iter().map(|s| s.name).collect();
331        names.sort();
332        names.dedup();
333        assert_eq!(names.len(), all().len());
334        assert!(find("NOPE").is_none());
335    }
336
337    #[test]
338    fn periods_divide_the_history_alignment() {
339        for s in all() {
340            assert_eq!(600_000_000_000 % s.period_ns(), 0, "{}", s.name);
341        }
342    }
343
344    #[test]
345    fn testioc_pvs_rest_at_its_values() {
346        // Parked at 0 most of each 15 minutes; SUM near 2.54 + 2.61.
347        assert_eq!(motor(1_800_000_000.0 + 600.0, 1), 0.0);
348        assert_ne!(motor(1_800_000_000.0 + 60.0, 1), 0.0);
349        let sum = beam_a(1.7e9) + beam_b(1.7e9);
350        assert!((sum - 5.15).abs() < 0.05, "{sum}");
351    }
352
353    #[test]
354    fn values_are_deterministic() {
355        let s = find("MOCK:NOISE").unwrap();
356        assert_eq!(s.event(12345), s.event(12345));
357        assert_ne!(s.event(12345), s.event(12346));
358    }
359
360    #[test]
361    fn nan_pv_has_nans_and_gaps() {
362        let s = find("MOCK:NAN").unwrap();
363        let k0 = 1_700_000_000 / NAN_CYCLE * NAN_CYCLE;
364        let lost = s.event(k0 + NAN_LOST).unwrap();
365        assert_eq!(lost.severity, 3);
366        assert_eq!(lost.fields[0].0, "cnxlostepsecs");
367        assert!(s.event(k0 + NAN_LOST + 1).is_none());
368        assert!(s.event(k0 + NAN_REGAINED - 1).is_none());
369        let back = s.event(k0 + NAN_REGAINED).unwrap();
370        assert_eq!(back.fields.len(), 2);
371        let v = s.event(k0 + 49).unwrap().value.as_f64().unwrap();
372        assert!(v.is_nan());
373    }
374
375    #[test]
376    fn kinds() {
377        assert!(Kind::Enum.is_scalar_numeric());
378        assert!(!Kind::Text.is_scalar_numeric());
379        assert!(!Kind::Waveform(8).is_scalar_numeric());
380        assert_eq!(Kind::Waveform(8).element_count(), 8);
381        let wave = find("MOCK:WAVE").unwrap().event(0).unwrap();
382        assert_eq!(wave.value.as_f64(), Some(0.0));
383    }
384
385    #[test]
386    fn testioc_values_line_up() {
387        let a = find("bl01t:A").unwrap().event(170_000_000).unwrap();
388        let b = find("bl01t:B").unwrap().event(170_000_000).unwrap();
389        let sum = find("bl01t:SUM").unwrap().event(170_000_000).unwrap();
390        let (a, b, sum) = (a.value.as_f64(), b.value.as_f64(), sum.value.as_f64());
391        assert!((a.unwrap() + b.unwrap() - sum.unwrap()).abs() < 1e-12);
392    }
393}