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    /// must be at least 1 (zero previously reached `tuning_math::measure_oscillation`'s
65    /// internal `assert!` and panicked mid-run, after the loop had already been switched to
66    /// manual and stroked -- see `docs/internal/v1-checklist.md` §2), and `mrft_delay_secs` against
67    /// `MRFT_DELAY_SECS_MAX`. This is real range validation at the model/construction level,
68    /// not just a client-side keystroke filter or a single "not blank" check, so it applies
69    /// no matter how the `LoopConfig` was built (CLI flags, an imported template, or a
70    /// future web GUI request).
71    pub fn validate(&self) -> Result<(), LoopConfigError> {
72        let amp = self.relay_amp_percent;
73        if !amp.is_finite()
74            || !(Self::RELAY_AMP_PERCENT_MIN..=Self::RELAY_AMP_PERCENT_MAX).contains(&amp)
75        {
76            return Err(LoopConfigError::RelayAmpOutOfRange { value: amp });
77        }
78        if self.num_cycles_count < 1 {
79            return Err(LoopConfigError::CyclesCountMustBeAtLeastOne);
80        }
81        if self.mrft_delay_secs > Self::MRFT_DELAY_SECS_MAX {
82            return Err(LoopConfigError::MrftDelayOutOfRange {
83                value: self.mrft_delay_secs,
84            });
85        }
86        Ok(())
87    }
88}
89
90/// Why a [`LoopConfig`] failed [`LoopConfig::validate`].
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum LoopConfigError {
93    /// `relay_amp_percent` was non-finite (NaN/infinite) or outside
94    /// `[LoopConfig::RELAY_AMP_PERCENT_MIN, LoopConfig::RELAY_AMP_PERCENT_MAX]`.
95    RelayAmpOutOfRange { value: f32 },
96    /// `num_cycles_count` was `0` -- at least one full relay cycle is required to measure an
97    /// oscillation at all.
98    CyclesCountMustBeAtLeastOne,
99    /// `mrft_delay_secs` exceeded [`LoopConfig::MRFT_DELAY_SECS_MAX`].
100    MrftDelayOutOfRange { value: u32 },
101}
102
103impl fmt::Display for LoopConfigError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            LoopConfigError::RelayAmpOutOfRange { value } => write!(
107                f,
108                "relay amplitude {value}% is out of range: must be a finite value from {}% to \
109                 {}% of the MV range",
110                LoopConfig::RELAY_AMP_PERCENT_MIN,
111                LoopConfig::RELAY_AMP_PERCENT_MAX,
112            ),
113            LoopConfigError::CyclesCountMustBeAtLeastOne => {
114                write!(f, "cycles count must be at least 1")
115            }
116            LoopConfigError::MrftDelayOutOfRange { value } => write!(
117                f,
118                "mrft delay {value}s is out of range: must be at most {}s",
119                LoopConfig::MRFT_DELAY_SECS_MAX,
120            ),
121        }
122    }
123}
124
125impl std::error::Error for LoopConfigError {}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn sample() -> LoopConfig {
132        LoopConfig {
133            process_type: ProcessType::Flow,
134            controller_type: ControllerType::Pi,
135            relay_amp_percent: 5.0,
136            num_cycles_skip: 1,
137            num_cycles_count: 2,
138            noise_protection_secs: 3,
139            mrft_delay_secs: 0,
140        }
141    }
142
143    #[test]
144    fn with_process_type_applies_defaults() {
145        let cfg = sample().with_process_type(ProcessType::TemperatureHeatExchange);
146        assert_eq!(cfg.process_type, ProcessType::TemperatureHeatExchange);
147        assert_eq!(cfg.num_cycles_skip, 1);
148        assert_eq!(cfg.num_cycles_count, 1);
149        assert_eq!(cfg.noise_protection_secs, 20);
150        // unrelated fields untouched
151        assert_eq!(cfg.relay_amp_percent, 5.0);
152        assert_eq!(cfg.mrft_delay_secs, 0);
153    }
154
155    #[test]
156    fn with_process_type_keeps_controller_type_when_still_allowed() {
157        let cfg = sample().with_process_type(ProcessType::Level);
158        assert_eq!(cfg.controller_type, ControllerType::Pi);
159    }
160
161    #[test]
162    fn with_process_type_downgrades_pid_when_no_longer_allowed() {
163        let mut cfg = sample();
164        cfg.controller_type = ControllerType::Pid;
165        let cfg = cfg.with_process_type(ProcessType::Flow);
166        assert_eq!(cfg.controller_type, ControllerType::Pi);
167    }
168
169    #[test]
170    fn with_process_type_keeps_pid_for_temperature_types() {
171        let mut cfg = sample();
172        cfg.controller_type = ControllerType::Pid;
173        let cfg = cfg.with_process_type(ProcessType::TemperatureMixing);
174        assert_eq!(cfg.controller_type, ControllerType::Pid);
175    }
176
177    #[test]
178    fn serde_round_trip() {
179        let cfg = sample();
180        let json = serde_json::to_string(&cfg).unwrap();
181        let back: LoopConfig = serde_json::from_str(&json).unwrap();
182        assert_eq!(cfg, back);
183    }
184
185    #[test]
186    fn validate_accepts_a_typical_relay_amplitude() {
187        let mut cfg = sample();
188        cfg.relay_amp_percent = 10.0;
189        assert!(cfg.validate().is_ok());
190    }
191
192    #[test]
193    fn validate_accepts_the_minimum_boundary() {
194        let mut cfg = sample();
195        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MIN;
196        assert!(cfg.validate().is_ok());
197    }
198
199    #[test]
200    fn validate_rejects_just_below_the_minimum() {
201        let mut cfg = sample();
202        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MIN - 0.01;
203        assert!(matches!(
204            cfg.validate(),
205            Err(LoopConfigError::RelayAmpOutOfRange { .. })
206        ));
207    }
208
209    #[test]
210    fn validate_accepts_the_maximum_boundary() {
211        let mut cfg = sample();
212        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MAX;
213        assert!(cfg.validate().is_ok());
214    }
215
216    #[test]
217    fn validate_rejects_just_above_the_maximum() {
218        let mut cfg = sample();
219        cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MAX + 0.01;
220        assert!(matches!(
221            cfg.validate(),
222            Err(LoopConfigError::RelayAmpOutOfRange { .. })
223        ));
224    }
225
226    #[test]
227    fn validate_rejects_zero() {
228        let mut cfg = sample();
229        cfg.relay_amp_percent = 0.0;
230        assert!(cfg.validate().is_err());
231    }
232
233    #[test]
234    fn validate_rejects_negative_values() {
235        let mut cfg = sample();
236        cfg.relay_amp_percent = -5.0;
237        assert!(cfg.validate().is_err());
238    }
239
240    #[test]
241    fn validate_rejects_nan() {
242        let mut cfg = sample();
243        cfg.relay_amp_percent = f32::NAN;
244        assert!(cfg.validate().is_err());
245    }
246
247    #[test]
248    fn validate_rejects_infinite() {
249        let mut cfg = sample();
250        cfg.relay_amp_percent = f32::INFINITY;
251        assert!(cfg.validate().is_err());
252    }
253
254    /// The motivating case: BHTune's predecessor let a leftover debug shortcut leave a
255    /// four-digit value in this exact field with only a "not blank" check to catch it (see
256    /// `docs/internal/v1-checklist.md` §2). `validate` must reject it.
257    #[test]
258    fn validate_rejects_a_legacy_style_four_digit_value() {
259        let mut cfg = sample();
260        cfg.relay_amp_percent = 2014.0;
261        assert!(matches!(
262            cfg.validate(),
263            Err(LoopConfigError::RelayAmpOutOfRange { value }) if value == 2014.0
264        ));
265    }
266
267    #[test]
268    fn relay_amp_out_of_range_display_names_the_value_and_the_bounds() {
269        let err = LoopConfigError::RelayAmpOutOfRange { value: 2014.0 };
270        let message = err.to_string();
271        assert!(message.contains("2014"));
272        assert!(message.contains(&LoopConfig::RELAY_AMP_PERCENT_MIN.to_string()));
273        assert!(message.contains(&LoopConfig::RELAY_AMP_PERCENT_MAX.to_string()));
274    }
275
276    #[test]
277    fn validate_accepts_a_typical_cycles_count() {
278        let mut cfg = sample();
279        cfg.num_cycles_count = 3;
280        assert!(cfg.validate().is_ok());
281    }
282
283    /// The reproduced panic: `--cycles-count 0` used to reach
284    /// `tuning_math::measure_oscillation`'s internal `assert!` and panic mid-run, after the
285    /// loop had already been switched to manual and stroked. `validate` must reject it before
286    /// any of that happens.
287    #[test]
288    fn validate_rejects_zero_cycles_count() {
289        let mut cfg = sample();
290        cfg.num_cycles_count = 0;
291        assert_eq!(
292            cfg.validate(),
293            Err(LoopConfigError::CyclesCountMustBeAtLeastOne)
294        );
295    }
296
297    #[test]
298    fn validate_accepts_one_cycles_count() {
299        let mut cfg = sample();
300        cfg.num_cycles_count = 1;
301        assert!(cfg.validate().is_ok());
302    }
303
304    #[test]
305    fn validate_accepts_zero_mrft_delay() {
306        let mut cfg = sample();
307        cfg.mrft_delay_secs = 0;
308        assert!(cfg.validate().is_ok());
309    }
310
311    #[test]
312    fn validate_accepts_the_mrft_delay_maximum_boundary() {
313        let mut cfg = sample();
314        cfg.mrft_delay_secs = LoopConfig::MRFT_DELAY_SECS_MAX;
315        assert!(cfg.validate().is_ok());
316    }
317
318    #[test]
319    fn validate_rejects_just_above_the_mrft_delay_maximum() {
320        let mut cfg = sample();
321        cfg.mrft_delay_secs = LoopConfig::MRFT_DELAY_SECS_MAX + 1;
322        assert_eq!(
323            cfg.validate(),
324            Err(LoopConfigError::MrftDelayOutOfRange {
325                value: LoopConfig::MRFT_DELAY_SECS_MAX + 1
326            })
327        );
328    }
329
330    #[test]
331    fn cycles_count_error_display_names_the_requirement() {
332        let message = LoopConfigError::CyclesCountMustBeAtLeastOne.to_string();
333        assert!(message.contains("at least 1"));
334    }
335
336    #[test]
337    fn mrft_delay_out_of_range_display_names_the_value_and_the_bound() {
338        let err = LoopConfigError::MrftDelayOutOfRange { value: 9999 };
339        let message = err.to_string();
340        assert!(message.contains("9999"));
341        assert!(message.contains(&LoopConfig::MRFT_DELAY_SECS_MAX.to_string()));
342    }
343
344    /// `LoopConfigError` must be usable as a trait object / via `?` in a `Result<_,
345    /// anyhow::Error>` call site (`bhtune-cli`'s `build_loop_config`, in particular), which
346    /// requires a real `std::error::Error` impl, not just `Display`.
347    #[test]
348    fn loop_config_error_is_a_std_error() {
349        let err = LoopConfigError::RelayAmpOutOfRange { value: 2014.0 };
350        let _: Box<dyn std::error::Error> = Box::new(err);
351    }
352}