Skip to main content

bhtune_runtime/tune/
request.rs

1use bhtune_core::{ControllerDirection, ControllerType, ProcessType, ResponseLevel, TagOverrides};
2use bhtune_db::models::TuneDriver;
3
4/// Default simulator process gain used by every tune-request adapter.
5pub const DEFAULT_SIM_GAIN: f32 = 1.0;
6/// Default simulator process time constant in seconds.
7pub const DEFAULT_SIM_TAU: f32 = 2.0;
8/// Default simulator dead time in seconds.
9pub const DEFAULT_SIM_DEAD_TIME: f32 = 5.0;
10/// Default simulator measurement-noise amplitude.
11pub const DEFAULT_SIM_NOISE: f32 = 0.0;
12/// Default simulator random-number-generator seed.
13pub const DEFAULT_SIM_SEED: u64 = 0;
14/// Default simulator initial PV and MV.
15pub const DEFAULT_SIM_INITIAL_VALUE: f32 = 50.0;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DriverKind {
19    Opcda,
20    Simulator,
21}
22
23impl From<DriverKind> for TuneDriver {
24    fn from(value: DriverKind) -> Self {
25        match value {
26            DriverKind::Opcda => Self::Opcda,
27            DriverKind::Simulator => Self::Simulator,
28        }
29    }
30}
31
32impl TryFrom<TuneDriver> for DriverKind {
33    type Error = UnsupportedDriverKind;
34
35    fn try_from(value: TuneDriver) -> Result<Self, Self::Error> {
36        match value {
37            TuneDriver::Opcda => Ok(Self::Opcda),
38            TuneDriver::Simulator => Ok(Self::Simulator),
39            TuneDriver::Replay => Err(UnsupportedDriverKind),
40        }
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct UnsupportedDriverKind;
46
47impl std::fmt::Display for UnsupportedDriverKind {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(
50            f,
51            "the replay driver cannot be used to start a new tune run (it exists only for \
52             offline golden-trace validation, not live/simulated tuning)"
53        )
54    }
55}
56
57impl std::error::Error for UnsupportedDriverKind {}
58
59/// A common tune-request field failed runtime validation.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct TuneRequestValidationError(String);
62
63impl TuneRequestValidationError {
64    fn new(message: impl Into<String>) -> Self {
65        Self(message.into())
66    }
67}
68
69impl std::fmt::Display for TuneRequestValidationError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        f.write_str(&self.0)
72    }
73}
74
75impl std::error::Error for TuneRequestValidationError {}
76
77/// Rejects a non-finite `f32` request value and identifies its field.
78pub fn validate_finite_f32(field: &str, value: f32) -> Result<(), TuneRequestValidationError> {
79    if value.is_finite() {
80        Ok(())
81    } else {
82        Err(TuneRequestValidationError::new(format!(
83            "'{field}' must be a finite number (not NaN or infinite), got {value}"
84        )))
85    }
86}
87
88/// Rejects a zero `u32` value where the request requires a positive count.
89pub fn validate_positive_u32(field: &str, value: u32) -> Result<(), TuneRequestValidationError> {
90    if value > 0 {
91        Ok(())
92    } else {
93        Err(TuneRequestValidationError::new(format!(
94            "{field} must be at least 1"
95        )))
96    }
97}
98
99/// Transport-neutral inputs for a tune run.
100///
101/// CLI and HTTP adapters construct this type from their own request models. Runtime timing
102/// values are resolved from [`crate::config::BhtuneConfig`] during preparation.
103#[derive(Debug, Clone, PartialEq)]
104pub struct TuneRequest {
105    pub tagname: String,
106    pub template: String,
107    pub process_type: ProcessType,
108    pub controller_type: ControllerType,
109    pub relay_amp: f32,
110    pub cycles_skip: Option<u32>,
111    pub cycles_count: Option<u32>,
112    pub noise_protection_secs: Option<u32>,
113    pub driver: DriverKind,
114    pub bridge_host: Option<String>,
115    pub server: Option<String>,
116    pub sim_gain: f32,
117    pub sim_tau: f32,
118    pub sim_dead_time: f32,
119    pub sim_noise: f32,
120    pub sim_seed: u64,
121    pub sim_initial_pv: f32,
122    pub sim_initial_mv: f32,
123    pub pv_range_high: Option<f32>,
124    pub pv_range_low: Option<f32>,
125    pub mv_range_high: Option<f32>,
126    pub mv_range_low: Option<f32>,
127    pub direction: Option<ControllerDirection>,
128    pub tag_overrides: Option<TagOverrides>,
129    pub notes: Option<String>,
130    pub yes: bool,
131    pub write_pid: Option<ResponseLevel>,
132    #[cfg(test)]
133    pub mrft_delay: u32,
134    #[cfg(test)]
135    pub poll_interval_ms: u64,
136    #[cfg(test)]
137    pub timeout_secs: u64,
138    #[cfg(test)]
139    pub op_timeout_secs: u64,
140    #[cfg(test)]
141    pub restore_timeout_secs: u64,
142}
143
144/// A transport-neutral tune request that has passed common input validation.
145///
146/// Adapters use this type to share finite-number, positive-cycle-count, and tag-override
147/// checks before calling [`super::prepare`]. The runtime also accepts an unvalidated
148/// [`TuneRequest`] at that boundary and validates it before any database or driver work.
149#[derive(Debug, Clone, PartialEq)]
150pub struct ValidatedTuneRequest(TuneRequest);
151
152impl ValidatedTuneRequest {
153    /// Borrows the validated transport-neutral inputs.
154    pub fn as_request(&self) -> &TuneRequest {
155        &self.0
156    }
157
158    /// Returns the transport-neutral inputs after validation.
159    pub fn into_request(self) -> TuneRequest {
160        self.0
161    }
162}
163
164impl TryFrom<TuneRequest> for ValidatedTuneRequest {
165    type Error = TuneRequestValidationError;
166
167    fn try_from(request: TuneRequest) -> Result<Self, Self::Error> {
168        for (field, value) in [
169            ("relay_amp", request.relay_amp),
170            ("sim_gain", request.sim_gain),
171            ("sim_tau", request.sim_tau),
172            ("sim_dead_time", request.sim_dead_time),
173            ("sim_noise", request.sim_noise),
174            ("sim_initial_pv", request.sim_initial_pv),
175            ("sim_initial_mv", request.sim_initial_mv),
176        ] {
177            validate_finite_f32(field, value)?;
178        }
179
180        for (field, value) in [
181            ("pv_range_high", request.pv_range_high),
182            ("pv_range_low", request.pv_range_low),
183            ("mv_range_high", request.mv_range_high),
184            ("mv_range_low", request.mv_range_low),
185        ] {
186            if let Some(value) = value {
187                validate_finite_f32(field, value)?;
188            }
189        }
190
191        if let Some(cycles_count) = request.cycles_count {
192            validate_positive_u32("cycles count", cycles_count)?;
193        }
194
195        if let Some(tag_overrides) = &request.tag_overrides {
196            tag_overrides
197                .validate()
198                .map_err(|error| TuneRequestValidationError::new(error.to_string()))?;
199        }
200
201        Ok(Self(request))
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use bhtune_core::{ControllerDirection, ControllerType, ProcessType, TagOverrides};
208    use bhtune_db::models::TuneDriver;
209
210    use super::{
211        DEFAULT_SIM_DEAD_TIME, DEFAULT_SIM_GAIN, DEFAULT_SIM_INITIAL_VALUE, DEFAULT_SIM_NOISE,
212        DEFAULT_SIM_SEED, DEFAULT_SIM_TAU, DriverKind, TuneRequest, TuneRequestValidationError,
213        UnsupportedDriverKind, ValidatedTuneRequest, validate_finite_f32, validate_positive_u32,
214    };
215
216    fn valid_request() -> TuneRequest {
217        TuneRequest {
218            tagname: "Unit1.LIC101.PV".to_owned(),
219            template: "Honeywell".to_owned(),
220            process_type: ProcessType::Flow,
221            controller_type: ControllerType::Pi,
222            relay_amp: 10.0,
223            cycles_skip: None,
224            cycles_count: None,
225            noise_protection_secs: None,
226            driver: DriverKind::Simulator,
227            bridge_host: None,
228            server: None,
229            sim_gain: DEFAULT_SIM_GAIN,
230            sim_tau: DEFAULT_SIM_TAU,
231            sim_dead_time: DEFAULT_SIM_DEAD_TIME,
232            sim_noise: DEFAULT_SIM_NOISE,
233            sim_seed: DEFAULT_SIM_SEED,
234            sim_initial_pv: DEFAULT_SIM_INITIAL_VALUE,
235            sim_initial_mv: DEFAULT_SIM_INITIAL_VALUE,
236            pv_range_high: None,
237            pv_range_low: None,
238            mv_range_high: None,
239            mv_range_low: None,
240            direction: Some(ControllerDirection::Reverse),
241            tag_overrides: None,
242            notes: None,
243            yes: false,
244            write_pid: None,
245            #[cfg(test)]
246            mrft_delay: 0,
247            #[cfg(test)]
248            poll_interval_ms: 800,
249            #[cfg(test)]
250            timeout_secs: 3600,
251            #[cfg(test)]
252            op_timeout_secs: 30,
253            #[cfg(test)]
254            restore_timeout_secs: 30,
255        }
256    }
257
258    #[test]
259    fn driver_kind_maps_supported_drivers_and_rejects_replay() {
260        assert_eq!(TuneDriver::from(DriverKind::Opcda), TuneDriver::Opcda);
261        assert_eq!(
262            DriverKind::try_from(TuneDriver::Opcda),
263            Ok(DriverKind::Opcda)
264        );
265        assert_eq!(
266            DriverKind::try_from(TuneDriver::Simulator),
267            Ok(DriverKind::Simulator)
268        );
269        assert_eq!(
270            DriverKind::try_from(TuneDriver::Replay),
271            Err(UnsupportedDriverKind)
272        );
273    }
274
275    #[test]
276    fn unsupported_driver_error_explains_replay_scope() {
277        assert_eq!(
278            UnsupportedDriverKind.to_string(),
279            "the replay driver cannot be used to start a new tune run (it exists only for offline golden-trace validation, not live/simulated tuning)"
280        );
281    }
282
283    #[test]
284    fn validation_helpers_accept_finite_and_positive_values() {
285        assert!(validate_finite_f32("sim_gain", 1.0).is_ok());
286        assert!(validate_positive_u32("cycles count", 1).is_ok());
287    }
288
289    #[test]
290    fn validation_helpers_reject_non_finite_and_zero_values() {
291        let non_finite = validate_finite_f32("sim_gain", f32::INFINITY).unwrap_err();
292        assert_eq!(
293            non_finite.to_string(),
294            "'sim_gain' must be a finite number (not NaN or infinite), got inf"
295        );
296        let zero = validate_positive_u32("cycles count", 0).unwrap_err();
297        assert_eq!(zero.to_string(), "cycles count must be at least 1");
298    }
299
300    #[test]
301    fn validated_tune_request_preserves_valid_inputs_and_optional_defaults() {
302        let request = valid_request();
303        let validated = ValidatedTuneRequest::try_from(request.clone()).unwrap();
304        assert_eq!(validated.as_request(), &request);
305        assert_eq!(validated.into_request(), request);
306    }
307
308    #[test]
309    fn validated_tune_request_rejects_non_finite_scalar_and_optional_range_values() {
310        macro_rules! assert_invalid {
311            ($field:literal, $set:expr) => {{
312                let mut request = valid_request();
313                $set(&mut request);
314                let error = ValidatedTuneRequest::try_from(request).unwrap_err();
315                assert!(error.to_string().contains($field), "{error}");
316            }};
317        }
318
319        assert_invalid!("relay_amp", |request: &mut TuneRequest| {
320            request.relay_amp = f32::NAN;
321        });
322        assert_invalid!("sim_gain", |request: &mut TuneRequest| {
323            request.sim_gain = f32::INFINITY;
324        });
325        assert_invalid!("sim_tau", |request: &mut TuneRequest| {
326            request.sim_tau = f32::NEG_INFINITY;
327        });
328        assert_invalid!("sim_dead_time", |request: &mut TuneRequest| {
329            request.sim_dead_time = f32::NAN;
330        });
331        assert_invalid!("sim_noise", |request: &mut TuneRequest| {
332            request.sim_noise = f32::INFINITY;
333        });
334        assert_invalid!("sim_initial_pv", |request: &mut TuneRequest| {
335            request.sim_initial_pv = f32::NEG_INFINITY;
336        });
337        assert_invalid!("sim_initial_mv", |request: &mut TuneRequest| {
338            request.sim_initial_mv = f32::NAN;
339        });
340        assert_invalid!("pv_range_high", |request: &mut TuneRequest| {
341            request.pv_range_high = Some(f32::INFINITY);
342        });
343        assert_invalid!("pv_range_low", |request: &mut TuneRequest| {
344            request.pv_range_low = Some(f32::NAN);
345        });
346        assert_invalid!("mv_range_high", |request: &mut TuneRequest| {
347            request.mv_range_high = Some(f32::NEG_INFINITY);
348        });
349        assert_invalid!("mv_range_low", |request: &mut TuneRequest| {
350            request.mv_range_low = Some(f32::INFINITY);
351        });
352    }
353
354    #[test]
355    fn validated_tune_request_rejects_zero_cycles_and_invalid_tag_overrides() {
356        let mut request = valid_request();
357        request.cycles_count = Some(0);
358        assert_eq!(
359            ValidatedTuneRequest::try_from(request).unwrap_err(),
360            TuneRequestValidationError::new("cycles count must be at least 1")
361        );
362
363        let mut request = valid_request();
364        request.cycles_count = Some(1);
365        request.tag_overrides = Some(TagOverrides {
366            process_variable: Some("Unit1.LIC101\nPV".to_owned()),
367            ..TagOverrides::default()
368        });
369        let error = ValidatedTuneRequest::try_from(request).unwrap_err();
370        assert!(error.to_string().contains("process_variable"));
371    }
372}