Skip to main content

bhtune_runtime/config/
tuning.rs

1#![allow(rustdoc::broken_intra_doc_links)]
2
3use serde::{Deserialize, Serialize};
4
5/// Built-in pre/post-MRFT recording padding when `[tuning].mrft_delay_secs` is absent.
6pub const DEFAULT_TUNING_MRFT_DELAY_SECS: u32 = 0;
7
8/// Built-in driver polling interval when `[tuning].poll_interval_ms` is absent.
9pub const DEFAULT_TUNING_POLL_INTERVAL_MS: u64 = 800;
10
11/// Built-in whole-run timeout when `[tuning].timeout_secs` is absent.
12pub const DEFAULT_TUNING_TIMEOUT_SECS: u64 = 3_600;
13
14/// Built-in per-driver-operation timeout when `[tuning].op_timeout_secs` is absent.
15pub const DEFAULT_TUNING_OP_TIMEOUT_SECS: u64 = 30;
16
17/// Built-in post-run restoration timeout when `[tuning].restore_timeout_secs` is absent.
18pub const DEFAULT_TUNING_RESTORE_TIMEOUT_SECS: u64 = 30;
19
20/// Largest supported pre/post-MRFT recording delay.
21pub const MAX_TUNING_MRFT_DELAY_SECS: u32 = 3_600;
22
23/// Minimum restoration timeout for OPC DA runs.
24pub const MIN_OPC_RESTORE_TIMEOUT_SECS: u64 = 4;
25
26/// Optional values authored in the `[tuning]` table.
27///
28/// Missing keys stay `None` so callers can distinguish an explicit TOML value from a
29/// built-in default. Use [`resolve_tuning_config`] to obtain the concrete values used by a
30/// tune and [`validate_tuning_config`] before preparing the run.
31#[derive(Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
32#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
33pub struct TuningConfig {
34    #[cfg_attr(feature = "schemars", schemars(range(max = 3_600)))]
35    pub mrft_delay_secs: Option<u32>,
36    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
37    pub poll_interval_ms: Option<u64>,
38    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
39    pub timeout_secs: Option<u64>,
40    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
41    pub op_timeout_secs: Option<u64>,
42    #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
43    pub restore_timeout_secs: Option<u64>,
44}
45
46/// Concrete tuning timing policy after absent TOML keys have received built-in defaults.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct EffectiveTuningConfig {
49    pub mrft_delay_secs: u32,
50    pub poll_interval_ms: u64,
51    pub timeout_secs: u64,
52    pub op_timeout_secs: u64,
53    pub restore_timeout_secs: u64,
54}
55
56impl Default for EffectiveTuningConfig {
57    fn default() -> Self {
58        Self {
59            mrft_delay_secs: DEFAULT_TUNING_MRFT_DELAY_SECS,
60            poll_interval_ms: DEFAULT_TUNING_POLL_INTERVAL_MS,
61            timeout_secs: DEFAULT_TUNING_TIMEOUT_SECS,
62            op_timeout_secs: DEFAULT_TUNING_OP_TIMEOUT_SECS,
63            restore_timeout_secs: DEFAULT_TUNING_RESTORE_TIMEOUT_SECS,
64        }
65    }
66}
67
68/// Origin of one effective tuning value.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum TuningConfigSource {
71    Toml,
72    BuiltInDefault,
73}
74
75/// Per-field provenance for the effective `[tuning]` policy.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct TuningConfigSources {
78    pub mrft_delay_secs: TuningConfigSource,
79    pub poll_interval_ms: TuningConfigSource,
80    pub timeout_secs: TuningConfigSource,
81    pub op_timeout_secs: TuningConfigSource,
82    pub restore_timeout_secs: TuningConfigSource,
83}
84
85/// Validation error for concrete tuning timing values.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum TuningConfigError {
88    MrftDelayOutOfRange { value: u32 },
89    PollIntervalTooSmall { value: u64 },
90    TimeoutTooSmall { value: u64 },
91    OpTimeoutTooSmall { value: u64 },
92    RestoreTimeoutTooSmall { value: u64, minimum: u64 },
93}
94
95impl std::fmt::Display for TuningConfigError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Self::MrftDelayOutOfRange { value } => write!(
99                f,
100                "tuning.mrft_delay_secs must be between 0 and {MAX_TUNING_MRFT_DELAY_SECS}, got {value}"
101            ),
102            Self::PollIntervalTooSmall { value } => {
103                write!(f, "tuning.poll_interval_ms must be at least 1, got {value}")
104            }
105            Self::TimeoutTooSmall { value } => {
106                write!(f, "tuning.timeout_secs must be at least 1, got {value}")
107            }
108            Self::OpTimeoutTooSmall { value } => {
109                write!(f, "tuning.op_timeout_secs must be at least 1, got {value}")
110            }
111            Self::RestoreTimeoutTooSmall { value, minimum } => write!(
112                f,
113                "tuning.restore_timeout_secs must be at least {minimum}, got {value}"
114            ),
115        }
116    }
117}
118
119impl std::error::Error for TuningConfigError {}
120
121/// Resolve optional `[tuning]` values against the built-in defaults.
122pub fn resolve_tuning_config(config: &TuningConfig) -> EffectiveTuningConfig {
123    EffectiveTuningConfig {
124        mrft_delay_secs: config
125            .mrft_delay_secs
126            .unwrap_or(DEFAULT_TUNING_MRFT_DELAY_SECS),
127        poll_interval_ms: config
128            .poll_interval_ms
129            .unwrap_or(DEFAULT_TUNING_POLL_INTERVAL_MS),
130        timeout_secs: config.timeout_secs.unwrap_or(DEFAULT_TUNING_TIMEOUT_SECS),
131        op_timeout_secs: config
132            .op_timeout_secs
133            .unwrap_or(DEFAULT_TUNING_OP_TIMEOUT_SECS),
134        restore_timeout_secs: config
135            .restore_timeout_secs
136            .unwrap_or(DEFAULT_TUNING_RESTORE_TIMEOUT_SECS),
137    }
138}
139
140/// Report whether each effective tuning value came from TOML or a built-in default.
141pub fn tuning_config_sources(config: &TuningConfig) -> TuningConfigSources {
142    fn source<T>(value: Option<T>) -> TuningConfigSource {
143        if value.is_some() {
144            TuningConfigSource::Toml
145        } else {
146            TuningConfigSource::BuiltInDefault
147        }
148    }
149
150    TuningConfigSources {
151        mrft_delay_secs: source(config.mrft_delay_secs),
152        poll_interval_ms: source(config.poll_interval_ms),
153        timeout_secs: source(config.timeout_secs),
154        op_timeout_secs: source(config.op_timeout_secs),
155        restore_timeout_secs: source(config.restore_timeout_secs),
156    }
157}
158
159/// Validate concrete tuning timing values.
160///
161/// `require_opc_restore_minimum` raises the restoration minimum from one second to
162/// [`MIN_OPC_RESTORE_TIMEOUT_SECS`], matching the live OPC DA actuation-confirmation window.
163pub fn validate_tuning_config(
164    config: &EffectiveTuningConfig,
165    require_opc_restore_minimum: bool,
166) -> Result<(), TuningConfigError> {
167    if config.mrft_delay_secs > MAX_TUNING_MRFT_DELAY_SECS {
168        return Err(TuningConfigError::MrftDelayOutOfRange {
169            value: config.mrft_delay_secs,
170        });
171    }
172    if config.poll_interval_ms == 0 {
173        return Err(TuningConfigError::PollIntervalTooSmall {
174            value: config.poll_interval_ms,
175        });
176    }
177    if config.timeout_secs == 0 {
178        return Err(TuningConfigError::TimeoutTooSmall {
179            value: config.timeout_secs,
180        });
181    }
182    if config.op_timeout_secs == 0 {
183        return Err(TuningConfigError::OpTimeoutTooSmall {
184            value: config.op_timeout_secs,
185        });
186    }
187    let restore_minimum = if require_opc_restore_minimum {
188        MIN_OPC_RESTORE_TIMEOUT_SECS
189    } else {
190        1
191    };
192    if config.restore_timeout_secs < restore_minimum {
193        return Err(TuningConfigError::RestoreTimeoutTooSmall {
194            value: config.restore_timeout_secs,
195            minimum: restore_minimum,
196        });
197    }
198    Ok(())
199}
200
201/// Resolve and validate a raw `[tuning]` table in one step.
202pub fn resolve_and_validate_tuning_config(
203    config: &TuningConfig,
204    require_opc_restore_minimum: bool,
205) -> Result<EffectiveTuningConfig, TuningConfigError> {
206    let effective = resolve_tuning_config(config);
207    validate_tuning_config(&effective, require_opc_restore_minimum)?;
208    Ok(effective)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::super::model::parse_config_contents;
214    use super::*;
215    #[test]
216    fn missing_tuning_table_preserves_none_and_resolves_built_in_defaults() {
217        let config = parse_config_contents("bridge_host = \"gateway:7600\"\n").unwrap();
218
219        assert_eq!(config.tuning, TuningConfig::default());
220        assert_eq!(
221            resolve_and_validate_tuning_config(&config.tuning, true).unwrap(),
222            EffectiveTuningConfig::default()
223        );
224    }
225
226    #[test]
227    fn tuning_table_round_trips_all_optional_values() {
228        let raw = r"
229[tuning]
230mrft_delay_secs = 12
231poll_interval_ms = 900
232timeout_secs = 4000
233op_timeout_secs = 31
234restore_timeout_secs = 32
235";
236        let config = parse_config_contents(raw).unwrap();
237
238        assert_eq!(
239            config.tuning,
240            TuningConfig {
241                mrft_delay_secs: Some(12),
242                poll_interval_ms: Some(900),
243                timeout_secs: Some(4_000),
244                op_timeout_secs: Some(31),
245                restore_timeout_secs: Some(32),
246            }
247        );
248        let encoded = toml::to_string(&config).unwrap();
249        assert_eq!(parse_config_contents(&encoded).unwrap(), config);
250    }
251
252    #[test]
253    fn tuning_resolution_preserves_partial_raw_values_and_tracks_sources() {
254        let raw = TuningConfig {
255            poll_interval_ms: Some(250),
256            restore_timeout_secs: Some(8),
257            ..Default::default()
258        };
259
260        assert_eq!(
261            resolve_tuning_config(&raw),
262            EffectiveTuningConfig {
263                poll_interval_ms: 250,
264                restore_timeout_secs: 8,
265                ..Default::default()
266            }
267        );
268        assert_eq!(
269            tuning_config_sources(&raw),
270            TuningConfigSources {
271                mrft_delay_secs: TuningConfigSource::BuiltInDefault,
272                poll_interval_ms: TuningConfigSource::Toml,
273                timeout_secs: TuningConfigSource::BuiltInDefault,
274                op_timeout_secs: TuningConfigSource::BuiltInDefault,
275                restore_timeout_secs: TuningConfigSource::Toml,
276            }
277        );
278    }
279
280    #[test]
281    fn tuning_validation_rejects_each_invalid_general_value() {
282        let cases = [
283            (
284                EffectiveTuningConfig {
285                    mrft_delay_secs: MAX_TUNING_MRFT_DELAY_SECS + 1,
286                    ..Default::default()
287                },
288                TuningConfigError::MrftDelayOutOfRange {
289                    value: MAX_TUNING_MRFT_DELAY_SECS + 1,
290                },
291            ),
292            (
293                EffectiveTuningConfig {
294                    poll_interval_ms: 0,
295                    ..Default::default()
296                },
297                TuningConfigError::PollIntervalTooSmall { value: 0 },
298            ),
299            (
300                EffectiveTuningConfig {
301                    timeout_secs: 0,
302                    ..Default::default()
303                },
304                TuningConfigError::TimeoutTooSmall { value: 0 },
305            ),
306            (
307                EffectiveTuningConfig {
308                    op_timeout_secs: 0,
309                    ..Default::default()
310                },
311                TuningConfigError::OpTimeoutTooSmall { value: 0 },
312            ),
313            (
314                EffectiveTuningConfig {
315                    restore_timeout_secs: 0,
316                    ..Default::default()
317                },
318                TuningConfigError::RestoreTimeoutTooSmall {
319                    value: 0,
320                    minimum: 1,
321                },
322            ),
323        ];
324
325        for (config, expected) in cases {
326            assert_eq!(validate_tuning_config(&config, false), Err(expected));
327            assert!(!expected.to_string().is_empty());
328        }
329    }
330
331    #[test]
332    fn opc_restore_timeout_requires_four_seconds_but_general_validation_allows_one() {
333        let config = EffectiveTuningConfig {
334            restore_timeout_secs: MIN_OPC_RESTORE_TIMEOUT_SECS - 1,
335            ..Default::default()
336        };
337
338        assert_eq!(validate_tuning_config(&config, false), Ok(()));
339        assert_eq!(
340            validate_tuning_config(&config, true),
341            Err(TuningConfigError::RestoreTimeoutTooSmall {
342                value: MIN_OPC_RESTORE_TIMEOUT_SECS - 1,
343                minimum: MIN_OPC_RESTORE_TIMEOUT_SECS,
344            })
345        );
346        assert!(
347            validate_tuning_config(
348                &EffectiveTuningConfig {
349                    restore_timeout_secs: MIN_OPC_RESTORE_TIMEOUT_SECS,
350                    ..Default::default()
351                },
352                true,
353            )
354            .is_ok()
355        );
356    }
357}