Skip to main content

bhtune_core/
range.rs

1//! Validated PV/MV range types -- the boundary a live OPC DA read or a CLI flag override
2//! must pass through before an untrusted number is treated as a range with a known,
3//! trustworthy shape (finite bounds, correctly ordered, non-zero span). Validating external
4//! values before they reach the engine prevents malformed ranges from interrupting a live run.
5//!
6//! [`PvRange`] and [`MvRange`] both still expose plain public fields and can be constructed
7//! directly with a struct literal -- deliberately, since that's how already-trusted values
8//! (test fixtures, values reloaded from `bhtune-db` that were already validated once before
9//! being stored) are constructed elsewhere in the codebase. [`PvRange::new`]/[`MvRange::new`]
10//! are the *validating* constructors: the ones any caller reading a number from a live
11//! driver or an external CLI flag/config value must go through.
12
13use serde::{Deserialize, Serialize};
14
15/// PV scale range, read once before the test starts (`PvSH`/`PvSL` in the legacy app's
16/// `ReadInitialOPCvalues`) -- distinct from [`MvRange`], used only to express the
17/// oscillation amplitude as a percentage in `core-tuning-math::measure_oscillation`.
18#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
19pub struct PvRange {
20    pub high: f32,
21    pub low: f32,
22}
23
24impl PvRange {
25    /// Validates a PV range read from an untrusted source (a live OPC DA tag, a CLI flag
26    /// override). Rejects a non-finite bound (NaN/infinite) or a zero span (`high == low`,
27    /// which would make `measure_oscillation`'s amplitude-as-a-percentage-of-range
28    /// calculation divide by zero). Unlike [`MvRange`], the two bounds are not required to
29    /// be in `low < high` order -- only genuinely distinct -- since the PV range is used
30    /// solely as a span magnitude here, not as an inequality bound the way the MV range is
31    /// in `clamp_relay_amplitude`.
32    pub fn new(high: f32, low: f32) -> Result<Self, RangeError> {
33        if !high.is_finite() {
34            return Err(RangeError::NotFinite {
35                field: "pv_range_high",
36                value: high,
37            });
38        }
39        if !low.is_finite() {
40            return Err(RangeError::NotFinite {
41                field: "pv_range_low",
42                value: low,
43            });
44        }
45        if high == low {
46            return Err(RangeError::ZeroSpan { high, low });
47        }
48        Ok(PvRange { high, low })
49    }
50}
51
52/// MV range floor/ceiling (`MvMSL`/`MvMSH` in the legacy app) -- the bounds
53/// [`crate::mrft::clamp_relay_amplitude`] clamps the relay step within, and the range an
54/// initial MV reading must fall inside before a test can safely begin stroking it.
55#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
56pub struct MvRange {
57    pub high: f32,
58    pub low: f32,
59}
60
61impl MvRange {
62    /// Validates an MV range read from an untrusted source. Rejects a non-finite bound, and
63    /// (unlike [`PvRange`]) requires strict `low < high` ordering --
64    /// `clamp_relay_amplitude`'s boundary-clamping comparisons (`mv_ini + amp >
65    /// mv_range_high`, `mv_ini - amp < mv_range_low`) assume that orientation, and
66    /// `high - low` is used directly as a positive span multiplier for the relay amplitude.
67    pub fn new(high: f32, low: f32) -> Result<Self, RangeError> {
68        if !high.is_finite() {
69            return Err(RangeError::NotFinite {
70                field: "mv_range_high",
71                value: high,
72            });
73        }
74        if !low.is_finite() {
75            return Err(RangeError::NotFinite {
76                field: "mv_range_low",
77                value: low,
78            });
79        }
80        if low >= high {
81            return Err(RangeError::LowNotBelowHigh { low, high });
82        }
83        Ok(MvRange { high, low })
84    }
85
86    /// Whether `value` falls within `[low, high]` (inclusive) -- used to check an initial MV
87    /// reading actually lies inside its own reported range before a test starts stroking it.
88    pub fn contains(&self, value: f32) -> bool {
89        value >= self.low && value <= self.high
90    }
91}
92
93/// Why [`PvRange::new`]/[`MvRange::new`] rejected a range.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub enum RangeError {
96    /// One bound was NaN or infinite.
97    NotFinite { field: &'static str, value: f32 },
98    /// [`PvRange`]'s two bounds were exactly equal (a zero-width range).
99    ZeroSpan { high: f32, low: f32 },
100    /// [`MvRange`]'s low bound was not strictly below its high bound.
101    LowNotBelowHigh { low: f32, high: f32 },
102}
103
104impl std::fmt::Display for RangeError {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            RangeError::NotFinite { field, value } => {
108                write!(f, "{field} value {value} is not a finite number")
109            }
110            RangeError::ZeroSpan { high, low } => write!(
111                f,
112                "range has zero span: high ({high}) and low ({low}) must not be equal"
113            ),
114            RangeError::LowNotBelowHigh { low, high } => write!(
115                f,
116                "range low ({low}) must be strictly less than high ({high})"
117            ),
118        }
119    }
120}
121
122impl std::error::Error for RangeError {}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn pv_range_accepts_a_typical_range() {
130        assert!(PvRange::new(100.0, 0.0).is_ok());
131    }
132
133    #[test]
134    fn pv_range_accepts_a_descending_range() {
135        // Only distinctness is required for PvRange, not a particular order.
136        assert!(PvRange::new(0.0, 100.0).is_ok());
137    }
138
139    #[test]
140    fn pv_range_rejects_zero_span() {
141        assert_eq!(
142            PvRange::new(50.0, 50.0),
143            Err(RangeError::ZeroSpan {
144                high: 50.0,
145                low: 50.0
146            })
147        );
148    }
149
150    #[test]
151    fn pv_range_rejects_non_finite_high() {
152        assert!(matches!(
153            PvRange::new(f32::NAN, 0.0),
154            Err(RangeError::NotFinite {
155                field: "pv_range_high",
156                ..
157            })
158        ));
159    }
160
161    #[test]
162    fn pv_range_rejects_non_finite_low() {
163        assert!(matches!(
164            PvRange::new(100.0, f32::INFINITY),
165            Err(RangeError::NotFinite {
166                field: "pv_range_low",
167                ..
168            })
169        ));
170    }
171
172    #[test]
173    fn mv_range_accepts_a_typical_range() {
174        assert!(MvRange::new(100.0, 0.0).is_ok());
175    }
176
177    #[test]
178    fn mv_range_rejects_equal_bounds() {
179        assert!(matches!(
180            MvRange::new(50.0, 50.0),
181            Err(RangeError::LowNotBelowHigh { .. })
182        ));
183    }
184
185    #[test]
186    fn mv_range_rejects_descending_bounds() {
187        assert!(matches!(
188            MvRange::new(0.0, 100.0),
189            Err(RangeError::LowNotBelowHigh { .. })
190        ));
191    }
192
193    #[test]
194    fn mv_range_rejects_non_finite_high() {
195        assert!(matches!(
196            MvRange::new(f32::NAN, 0.0),
197            Err(RangeError::NotFinite {
198                field: "mv_range_high",
199                ..
200            })
201        ));
202    }
203
204    #[test]
205    fn mv_range_rejects_non_finite_low() {
206        assert!(matches!(
207            MvRange::new(100.0, f32::NAN),
208            Err(RangeError::NotFinite {
209                field: "mv_range_low",
210                ..
211            })
212        ));
213    }
214
215    #[test]
216    fn mv_range_contains_checks_inclusive_bounds() {
217        let range = MvRange::new(100.0, 0.0).unwrap();
218        assert!(range.contains(0.0));
219        assert!(range.contains(100.0));
220        assert!(range.contains(50.0));
221        assert!(!range.contains(-0.01));
222        assert!(!range.contains(100.01));
223    }
224
225    #[test]
226    fn range_error_is_a_std_error() {
227        let err = RangeError::ZeroSpan {
228            high: 1.0,
229            low: 1.0,
230        };
231        let _: Box<dyn std::error::Error> = Box::new(err);
232    }
233
234    #[test]
235    fn range_error_display_names_the_field() {
236        let err = RangeError::NotFinite {
237            field: "pv_range_high",
238            value: f32::NAN,
239        };
240        assert!(err.to_string().contains("pv_range_high"));
241    }
242
243    #[test]
244    fn range_error_display_describes_every_invalid_range_shape() {
245        assert!(
246            RangeError::ZeroSpan {
247                high: 50.0,
248                low: 50.0,
249            }
250            .to_string()
251            .contains("zero span")
252        );
253        assert!(
254            RangeError::LowNotBelowHigh {
255                low: 100.0,
256                high: 0.0,
257            }
258            .to_string()
259            .contains("strictly less")
260        );
261    }
262
263    #[test]
264    fn serde_round_trip() {
265        let range = PvRange {
266            high: 100.0,
267            low: 0.0,
268        };
269        let json = serde_json::to_string(&range).unwrap();
270        let back: PvRange = serde_json::from_str(&json).unwrap();
271        assert_eq!(range, back);
272
273        let range = MvRange {
274            high: 100.0,
275            low: 0.0,
276        };
277        let json = serde_json::to_string(&range).unwrap();
278        let back: MvRange = serde_json::from_str(&json).unwrap();
279        assert_eq!(range, back);
280    }
281}