1use bhtune_core::{ControllerDirection, ControllerType, ProcessType, ResponseLevel, TagOverrides};
2use bhtune_db::models::TuneDriver;
3
4pub const DEFAULT_SIM_GAIN: f32 = 1.0;
6pub const DEFAULT_SIM_TAU: f32 = 2.0;
8pub const DEFAULT_SIM_DEAD_TIME: f32 = 5.0;
10pub const DEFAULT_SIM_NOISE: f32 = 0.0;
12pub const DEFAULT_SIM_SEED: u64 = 0;
14pub 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#[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
77pub 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
88pub 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#[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#[derive(Debug, Clone, PartialEq)]
150pub struct ValidatedTuneRequest(TuneRequest);
151
152impl ValidatedTuneRequest {
153 pub fn as_request(&self) -> &TuneRequest {
155 &self.0
156 }
157
158 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}