Skip to main content

bhtune_core/
loop_config.rs

1//! Per-run test configuration: process type, controller type, relay amplitude, and MRFT
2//! cycle/timing parameters.
3
4use std::fmt;
5
6use serde::{Deserialize, Serialize};
7
8use crate::{controller_type::ControllerType, process_type::ProcessType};
9
10#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
11#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
12pub struct LoopConfig {
13    pub process_type: ProcessType,
14    pub controller_type: ControllerType,
15    /// Relay amplitude as a percentage of the MV range. Not enforced by the type itself --
16    /// call [`LoopConfig::validate`] on a `LoopConfig` built from external input (CLI flags,
17    /// an imported template) before using it, to catch an out-of-range value before it
18    /// reaches a live loop.
19    pub relay_amp_percent: f32,
20    pub num_cycles_skip: u32,
21    pub num_cycles_count: u32,
22    pub noise_protection_secs: u32,
23    /// Pre/post-test recording padding, in seconds. PV is still read and recorded during
24    /// this period; no switch evaluation happens.
25    pub mrft_delay_secs: u32,
26}
27
28impl LoopConfig {
29    /// Minimum allowed `relay_amp_percent`. A relay step smaller than this is not a
30    /// meaningfully-sized perturbation to reliably induce a detectable oscillation, and is
31    /// far more likely to be a data-entry slip (a misplaced decimal point) than a genuine
32    /// choice.
33    pub const RELAY_AMP_PERCENT_MIN: f32 = 0.1;
34    /// Maximum allowed `relay_amp_percent`. Half of the entire MV span in a single relay step
35    /// is already an unusually aggressive, disruptive perturbation to a live process --
36    /// legitimate tunes use single-digit to low-double-digit percentages. A value above this
37    /// is far more likely to be a mistake than a deliberate choice; this is exactly the
38    /// failure mode this bound closes off, where an unvalidated field let a stray four-digit
39    /// debug shortcut reach a live control loop as a "relay amplitude".
40    pub const RELAY_AMP_PERCENT_MAX: f32 = 50.0;
41
42    /// Maximum allowed `mrft_delay_secs`. Chosen to match the built-in whole-run timeout
43    /// (one hour): genuine pre/post-test recording padding is realistically a few minutes
44    /// at most, so anything larger is far more likely a units mistake (e.g. milliseconds
45    /// typed as seconds) than a deliberate choice.
46    pub const MRFT_DELAY_SECS_MAX: u32 = 3600;
47
48    /// Applies `process_type`'s default skip/test/noise-protection values, keeping the
49    /// existing `controller_type`, `relay_amp_percent`, and `mrft_delay_secs`. Downgrades
50    /// `controller_type` from PID to PI if the new process type doesn't allow PID.
51    pub fn with_process_type(mut self, process_type: ProcessType) -> LoopConfig {
52        self.process_type = process_type;
53        self.num_cycles_skip = process_type.default_cycles_skip();
54        self.num_cycles_count = process_type.default_cycles_test();
55        self.noise_protection_secs = process_type.default_noise_protection_secs();
56        if !self.controller_type.is_allowed_for(process_type) {
57            self.controller_type = ControllerType::Pi;
58        }
59        self
60    }
61
62    /// Validates fields whose legality can't be expressed in the type system alone: relay
63    /// amplitude against `[RELAY_AMP_PERCENT_MIN, RELAY_AMP_PERCENT_MAX]`, `num_cycles_count`
64    /// of at least 1, and `mrft_delay_secs` against `MRFT_DELAY_SECS_MAX`. This model-level
65    /// validation applies regardless of how the `LoopConfig` was built (CLI flags, an
66    /// imported template, or an HTTP request), not just to client-side form input.
67    pub fn validate(&self) -> Result<(), LoopConfigError> {
68        let amp = self.relay_amp_percent;
69        if !amp.is_finite()
70            || !(Self::RELAY_AMP_PERCENT_MIN..=Self::RELAY_AMP_PERCENT_MAX).contains(&amp)
71        {
72            return Err(LoopConfigError::RelayAmpOutOfRange { value: amp });
73        }
74        if self.num_cycles_count < 1 {
75            return Err(LoopConfigError::CyclesCountMustBeAtLeastOne);
76        }
77        if self.mrft_delay_secs > Self::MRFT_DELAY_SECS_MAX {
78            return Err(LoopConfigError::MrftDelayOutOfRange {
79                value: self.mrft_delay_secs,
80            });
81        }
82        Ok(())
83    }
84}
85
86/// Why a [`LoopConfig`] failed [`LoopConfig::validate`].
87#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum LoopConfigError {
89    /// `relay_amp_percent` was non-finite (NaN/infinite) or outside
90    /// `[LoopConfig::RELAY_AMP_PERCENT_MIN, LoopConfig::RELAY_AMP_PERCENT_MAX]`.
91    RelayAmpOutOfRange { value: f32 },
92    /// `num_cycles_count` was `0` -- at least one full relay cycle is required to measure an
93    /// oscillation at all.
94    CyclesCountMustBeAtLeastOne,
95    /// `mrft_delay_secs` exceeded [`LoopConfig::MRFT_DELAY_SECS_MAX`].
96    MrftDelayOutOfRange { value: u32 },
97}
98
99impl fmt::Display for LoopConfigError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            LoopConfigError::RelayAmpOutOfRange { value } => write!(
103                f,
104                "relay amplitude {value}% is out of range: must be a finite value from {}% to \
105                 {}% of the MV range",
106                LoopConfig::RELAY_AMP_PERCENT_MIN,
107                LoopConfig::RELAY_AMP_PERCENT_MAX,
108            ),
109            LoopConfigError::CyclesCountMustBeAtLeastOne => {
110                write!(f, "cycles count must be at least 1")
111            }
112            LoopConfigError::MrftDelayOutOfRange { value } => write!(
113                f,
114                "mrft delay {value}s is out of range: must be at most {}s",
115                LoopConfig::MRFT_DELAY_SECS_MAX,
116            ),
117        }
118    }
119}
120
121impl std::error::Error for LoopConfigError {}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn sample() -> LoopConfig {
128        LoopConfig {
129            process_type: ProcessType::Flow,
130            controller_type: ControllerType::Pi,
131            relay_amp_percent: 5.0,
132            num_cycles_skip: 1,
133            num_cycles_count: 2,
134            noise_protection_secs: 3,
135            mrft_delay_secs: 0,
136        }
137    }
138
139    #[test]
140    fn with_process_type_applies_defaults() {
141        let cfg = sample().with_process_type(ProcessType::TemperatureHeatExchange);
142        assert_eq!(cfg.process_type, ProcessType::TemperatureHeatExchange);
143        assert_eq!(cfg.num_cycles_skip, 1);
144        assert_eq!(cfg.num_cycles_count, 1);
145        assert_eq!(cfg.noise_protection_secs, 20);
146        // unrelated fields untouched
147        assert_eq!(cfg.relay_amp_percent, 5.0);
148        assert_eq!(cfg.mrft_delay_secs, 0);
149    }
150
151    #[test]
152    fn with_process_type_keeps_controller_type_when_still_allowed() {
153        let cfg = sample().with_process_type(ProcessType::Level);
154        assert_eq!(cfg.controller_type, ControllerType::Pi);
155    }
156
157    #[test]
158    fn with_process_type_downgrades_pid_when_no_longer_allowed() {
159        let mut cfg = sample();
160        cfg.controller_type = ControllerType::Pid;
161        let cfg = cfg.with_process_type(ProcessType::Flow);
162        assert_eq!(cfg.controller_type, ControllerType::Pi);
163    }
164
165    #[test]
166    fn with_process_type_keeps_pid_for_temperature_types() {
167        let mut cfg = sample();
168        cfg.controller_type = ControllerType::Pid;
169        let cfg = cfg.with_process_type(ProcessType::TemperatureMixing);
170        assert_eq!(cfg.controller_type, ControllerType::Pid);
171    }
172
173    #[test]
174    fn serde_round_trip() {
175        let cfg = sample();
176        let json = serde_json::to_string(&cfg).unwrap();
177        let back: LoopConfig = serde_json::from_str(&json).unwrap();
178        assert_eq!(cfg, back);
179    }
180
181    #[test]
182    fn validate_accepts_a_typical_relay_amplitude() {
183        let mut cfg = sample();
184        cfg.relay_amp_percent = 10.0;
185        assert!(cfg.validate().is_ok());
186    }
187
188    #[test]
189    fn validate_accepts_the_minimum_boundary() {
190        let mut cfg = sample();
191        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MIN;
192        assert!(cfg.validate().is_ok());
193    }
194
195    #[test]
196    fn validate_rejects_just_below_the_minimum() {
197        let mut cfg = sample();
198        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MIN - 0.01;
199        assert!(matches!(
200            cfg.validate(),
201            Err(LoopConfigError::RelayAmpOutOfRange { .. })
202        ));
203    }
204
205    #[test]
206    fn validate_accepts_the_maximum_boundary() {
207        let mut cfg = sample();
208        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MAX;
209        assert!(cfg.validate().is_ok());
210    }
211
212    #[test]
213    fn validate_rejects_just_above_the_maximum() {
214        let mut cfg = sample();
215        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MAX + 0.01;
216        assert!(matches!(
217            cfg.validate(),
218            Err(LoopConfigError::RelayAmpOutOfRange { .. })
219        ));
220    }
221
222    #[test]
223    fn validate_rejects_zero() {
224        let mut cfg = sample();
225        cfg.relay_amp_percent = 0.0;
226        assert!(cfg.validate().is_err());
227    }
228
229    #[test]
230    fn validate_rejects_negative_values() {
231        let mut cfg = sample();
232        cfg.relay_amp_percent = -5.0;
233        assert!(cfg.validate().is_err());
234    }
235
236    #[test]
237    fn validate_rejects_nan() {
238        let mut cfg = sample();
239        cfg.relay_amp_percent = f32::NAN;
240        assert!(cfg.validate().is_err());
241    }
242
243    #[test]
244    fn validate_rejects_infinite() {
245        let mut cfg = sample();
246        cfg.relay_amp_percent = f32::INFINITY;
247        assert!(cfg.validate().is_err());
248    }
249
250    /// A nonblank value can still exceed the configured relay-amplitude range, so
251    /// `validate` must reject this input.
252    #[test]
253    fn validate_rejects_a_legacy_style_four_digit_value() {
254        let mut cfg = sample();
255        cfg.relay_amp_percent = 2014.0;
256        assert!(matches!(
257            cfg.validate(),
258            Err(LoopConfigError::RelayAmpOutOfRange { value }) if value == 2014.0
259        ));
260    }
261
262    #[test]
263    fn relay_amp_out_of_range_display_names_the_value_and_the_bounds() {
264        let err = LoopConfigError::RelayAmpOutOfRange { value: 2014.0 };
265        let message = err.to_string();
266        assert!(message.contains("2014"));
267        assert!(message.contains(&LoopConfig::RELAY_AMP_PERCENT_MIN.to_string()));
268        assert!(message.contains(&LoopConfig::RELAY_AMP_PERCENT_MAX.to_string()));
269    }
270
271    #[test]
272    fn validate_accepts_a_typical_cycles_count() {
273        let mut cfg = sample();
274        cfg.num_cycles_count = 3;
275        assert!(cfg.validate().is_ok());
276    }
277
278    /// `validate` rejects a zero cycle count before a tune can start or perform any driver
279    /// operations.
280    #[test]
281    fn validate_rejects_zero_cycles_count() {
282        let mut cfg = sample();
283        cfg.num_cycles_count = 0;
284        assert_eq!(
285            cfg.validate(),
286            Err(LoopConfigError::CyclesCountMustBeAtLeastOne)
287        );
288    }
289
290    #[test]
291    fn validate_accepts_one_cycles_count() {
292        let mut cfg = sample();
293        cfg.num_cycles_count = 1;
294        assert!(cfg.validate().is_ok());
295    }
296
297    #[test]
298    fn validate_accepts_zero_mrft_delay() {
299        let mut cfg = sample();
300        cfg.mrft_delay_secs = 0;
301        assert!(cfg.validate().is_ok());
302    }
303
304    #[test]
305    fn validate_accepts_the_mrft_delay_maximum_boundary() {
306        let mut cfg = sample();
307        cfg.mrft_delay_secs = LoopConfig::MRFT_DELAY_SECS_MAX;
308        assert!(cfg.validate().is_ok());
309    }
310
311    #[test]
312    fn validate_rejects_just_above_the_mrft_delay_maximum() {
313        let mut cfg = sample();
314        cfg.mrft_delay_secs = LoopConfig::MRFT_DELAY_SECS_MAX + 1;
315        assert_eq!(
316            cfg.validate(),
317            Err(LoopConfigError::MrftDelayOutOfRange {
318                value: LoopConfig::MRFT_DELAY_SECS_MAX + 1
319            })
320        );
321    }
322
323    #[test]
324    fn cycles_count_error_display_names_the_requirement() {
325        let message = LoopConfigError::CyclesCountMustBeAtLeastOne.to_string();
326        assert!(message.contains("at least 1"));
327    }
328
329    #[test]
330    fn mrft_delay_out_of_range_display_names_the_value_and_the_bound() {
331        let err = LoopConfigError::MrftDelayOutOfRange { value: 9999 };
332        let message = err.to_string();
333        assert!(message.contains("9999"));
334        assert!(message.contains(&LoopConfig::MRFT_DELAY_SECS_MAX.to_string()));
335    }
336
337    /// `LoopConfigError` must be usable as a trait object / via `?` in a `Result<_,
338    /// anyhow::Error>` call site (the `bhtune` CLI's `build_loop_config`, in particular), which
339    /// requires a real `std::error::Error` impl, not just `Display`.
340    #[test]
341    fn loop_config_error_is_a_std_error() {
342        let err = LoopConfigError::RelayAmpOutOfRange { value: 2014.0 };
343        let _: Box<dyn std::error::Error> = Box::new(err);
344    }
345}