bhtune_core/
loop_config.rs1use 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 pub relay_amp_percent: f32,
20 pub num_cycles_skip: u32,
21 pub num_cycles_count: u32,
22 pub noise_protection_secs: u32,
23 pub mrft_delay_secs: u32,
26}
27
28impl LoopConfig {
29 pub const RELAY_AMP_PERCENT_MIN: f32 = 0.1;
34 pub const RELAY_AMP_PERCENT_MAX: f32 = 50.0;
41
42 pub const MRFT_DELAY_SECS_MAX: u32 = 3600;
47
48 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 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(&)
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#[derive(Debug, Clone, Copy, PartialEq)]
92pub enum LoopConfigError {
93 RelayAmpOutOfRange { value: f32 },
96 CyclesCountMustBeAtLeastOne,
99 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 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 #[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 #[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 #[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}