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