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> {
68 let amp = self.relay_amp_percent;
69 if !amp.is_finite()
70 || !(Self::RELAY_AMP_PERCENT_MIN..=Self::RELAY_AMP_PERCENT_MAX).contains(&)
71 {
72 return Err(LoopConfigError::RelayAmpOutOfRange { value: amp });
73 }
74 if self.num_cycles_count < 1 {
75 return Err(LoopConfigError::CyclesCountMustBeAtLeastOne);
76 }
77 if self.mrft_delay_secs > Self::MRFT_DELAY_SECS_MAX {
78 return Err(LoopConfigError::MrftDelayOutOfRange {
79 value: self.mrft_delay_secs,
80 });
81 }
82 Ok(())
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
88pub enum LoopConfigError {
89 RelayAmpOutOfRange { value: f32 },
92 CyclesCountMustBeAtLeastOne,
95 MrftDelayOutOfRange { value: u32 },
97}
98
99impl fmt::Display for LoopConfigError {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 match self {
102 LoopConfigError::RelayAmpOutOfRange { value } => write!(
103 f,
104 "relay amplitude {value}% is out of range: must be a finite value from {}% to \
105 {}% of the MV range",
106 LoopConfig::RELAY_AMP_PERCENT_MIN,
107 LoopConfig::RELAY_AMP_PERCENT_MAX,
108 ),
109 LoopConfigError::CyclesCountMustBeAtLeastOne => {
110 write!(f, "cycles count must be at least 1")
111 }
112 LoopConfigError::MrftDelayOutOfRange { value } => write!(
113 f,
114 "mrft delay {value}s is out of range: must be at most {}s",
115 LoopConfig::MRFT_DELAY_SECS_MAX,
116 ),
117 }
118 }
119}
120
121impl std::error::Error for LoopConfigError {}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 fn sample() -> LoopConfig {
128 LoopConfig {
129 process_type: ProcessType::Flow,
130 controller_type: ControllerType::Pi,
131 relay_amp_percent: 5.0,
132 num_cycles_skip: 1,
133 num_cycles_count: 2,
134 noise_protection_secs: 3,
135 mrft_delay_secs: 0,
136 }
137 }
138
139 #[test]
140 fn with_process_type_applies_defaults() {
141 let cfg = sample().with_process_type(ProcessType::TemperatureHeatExchange);
142 assert_eq!(cfg.process_type, ProcessType::TemperatureHeatExchange);
143 assert_eq!(cfg.num_cycles_skip, 1);
144 assert_eq!(cfg.num_cycles_count, 1);
145 assert_eq!(cfg.noise_protection_secs, 20);
146 assert_eq!(cfg.relay_amp_percent, 5.0);
148 assert_eq!(cfg.mrft_delay_secs, 0);
149 }
150
151 #[test]
152 fn with_process_type_keeps_controller_type_when_still_allowed() {
153 let cfg = sample().with_process_type(ProcessType::Level);
154 assert_eq!(cfg.controller_type, ControllerType::Pi);
155 }
156
157 #[test]
158 fn with_process_type_downgrades_pid_when_no_longer_allowed() {
159 let mut cfg = sample();
160 cfg.controller_type = ControllerType::Pid;
161 let cfg = cfg.with_process_type(ProcessType::Flow);
162 assert_eq!(cfg.controller_type, ControllerType::Pi);
163 }
164
165 #[test]
166 fn with_process_type_keeps_pid_for_temperature_types() {
167 let mut cfg = sample();
168 cfg.controller_type = ControllerType::Pid;
169 let cfg = cfg.with_process_type(ProcessType::TemperatureMixing);
170 assert_eq!(cfg.controller_type, ControllerType::Pid);
171 }
172
173 #[test]
174 fn serde_round_trip() {
175 let cfg = sample();
176 let json = serde_json::to_string(&cfg).unwrap();
177 let back: LoopConfig = serde_json::from_str(&json).unwrap();
178 assert_eq!(cfg, back);
179 }
180
181 #[test]
182 fn validate_accepts_a_typical_relay_amplitude() {
183 let mut cfg = sample();
184 cfg.relay_amp_percent = 10.0;
185 assert!(cfg.validate().is_ok());
186 }
187
188 #[test]
189 fn validate_accepts_the_minimum_boundary() {
190 let mut cfg = sample();
191 cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MIN;
192 assert!(cfg.validate().is_ok());
193 }
194
195 #[test]
196 fn validate_rejects_just_below_the_minimum() {
197 let mut cfg = sample();
198 cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MIN - 0.01;
199 assert!(matches!(
200 cfg.validate(),
201 Err(LoopConfigError::RelayAmpOutOfRange { .. })
202 ));
203 }
204
205 #[test]
206 fn validate_accepts_the_maximum_boundary() {
207 let mut cfg = sample();
208 cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MAX;
209 assert!(cfg.validate().is_ok());
210 }
211
212 #[test]
213 fn validate_rejects_just_above_the_maximum() {
214 let mut cfg = sample();
215 cfg.relay_amp_percent = LoopConfig::RELAY_AMP_PERCENT_MAX + 0.01;
216 assert!(matches!(
217 cfg.validate(),
218 Err(LoopConfigError::RelayAmpOutOfRange { .. })
219 ));
220 }
221
222 #[test]
223 fn validate_rejects_zero() {
224 let mut cfg = sample();
225 cfg.relay_amp_percent = 0.0;
226 assert!(cfg.validate().is_err());
227 }
228
229 #[test]
230 fn validate_rejects_negative_values() {
231 let mut cfg = sample();
232 cfg.relay_amp_percent = -5.0;
233 assert!(cfg.validate().is_err());
234 }
235
236 #[test]
237 fn validate_rejects_nan() {
238 let mut cfg = sample();
239 cfg.relay_amp_percent = f32::NAN;
240 assert!(cfg.validate().is_err());
241 }
242
243 #[test]
244 fn validate_rejects_infinite() {
245 let mut cfg = sample();
246 cfg.relay_amp_percent = f32::INFINITY;
247 assert!(cfg.validate().is_err());
248 }
249
250 #[test]
253 fn validate_rejects_a_legacy_style_four_digit_value() {
254 let mut cfg = sample();
255 cfg.relay_amp_percent = 2014.0;
256 assert!(matches!(
257 cfg.validate(),
258 Err(LoopConfigError::RelayAmpOutOfRange { value }) if value == 2014.0
259 ));
260 }
261
262 #[test]
263 fn relay_amp_out_of_range_display_names_the_value_and_the_bounds() {
264 let err = LoopConfigError::RelayAmpOutOfRange { value: 2014.0 };
265 let message = err.to_string();
266 assert!(message.contains("2014"));
267 assert!(message.contains(&LoopConfig::RELAY_AMP_PERCENT_MIN.to_string()));
268 assert!(message.contains(&LoopConfig::RELAY_AMP_PERCENT_MAX.to_string()));
269 }
270
271 #[test]
272 fn validate_accepts_a_typical_cycles_count() {
273 let mut cfg = sample();
274 cfg.num_cycles_count = 3;
275 assert!(cfg.validate().is_ok());
276 }
277
278 #[test]
281 fn validate_rejects_zero_cycles_count() {
282 let mut cfg = sample();
283 cfg.num_cycles_count = 0;
284 assert_eq!(
285 cfg.validate(),
286 Err(LoopConfigError::CyclesCountMustBeAtLeastOne)
287 );
288 }
289
290 #[test]
291 fn validate_accepts_one_cycles_count() {
292 let mut cfg = sample();
293 cfg.num_cycles_count = 1;
294 assert!(cfg.validate().is_ok());
295 }
296
297 #[test]
298 fn validate_accepts_zero_mrft_delay() {
299 let mut cfg = sample();
300 cfg.mrft_delay_secs = 0;
301 assert!(cfg.validate().is_ok());
302 }
303
304 #[test]
305 fn validate_accepts_the_mrft_delay_maximum_boundary() {
306 let mut cfg = sample();
307 cfg.mrft_delay_secs = LoopConfig::MRFT_DELAY_SECS_MAX;
308 assert!(cfg.validate().is_ok());
309 }
310
311 #[test]
312 fn validate_rejects_just_above_the_mrft_delay_maximum() {
313 let mut cfg = sample();
314 cfg.mrft_delay_secs = LoopConfig::MRFT_DELAY_SECS_MAX + 1;
315 assert_eq!(
316 cfg.validate(),
317 Err(LoopConfigError::MrftDelayOutOfRange {
318 value: LoopConfig::MRFT_DELAY_SECS_MAX + 1
319 })
320 );
321 }
322
323 #[test]
324 fn cycles_count_error_display_names_the_requirement() {
325 let message = LoopConfigError::CyclesCountMustBeAtLeastOne.to_string();
326 assert!(message.contains("at least 1"));
327 }
328
329 #[test]
330 fn mrft_delay_out_of_range_display_names_the_value_and_the_bound() {
331 let err = LoopConfigError::MrftDelayOutOfRange { value: 9999 };
332 let message = err.to_string();
333 assert!(message.contains("9999"));
334 assert!(message.contains(&LoopConfig::MRFT_DELAY_SECS_MAX.to_string()));
335 }
336
337 #[test]
341 fn loop_config_error_is_a_std_error() {
342 let err = LoopConfigError::RelayAmpOutOfRange { value: 2014.0 };
343 let _: Box<dyn std::error::Error> = Box::new(err);
344 }
345}