1use std::{collections::VecDeque, sync::Mutex};
17
18use async_trait::async_trait;
19use rand::{RngExt, SeedableRng, rngs::StdRng};
20
21use crate::{
22 driver::Driver,
23 error::{DriverError, DriverResult, poisoned_lock},
24 types::{
25 BrowsePage, BrowsePageRequest, DriverCapabilities, Quality, SearchEvent, SearchRequest,
26 TagId, TagValue, TagWrite, WriteOutcome,
27 },
28};
29
30#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct FopdtConfig {
36 pub gain: f32,
38 pub time_constant_s: f32,
42 pub dead_time_s: f32,
47 pub tick_interval_s: f32,
53 pub noise_amplitude: f32,
58}
59
60impl FopdtConfig {
61 pub fn new(
64 gain: f32,
65 time_constant_s: f32,
66 dead_time_s: f32,
67 tick_interval_s: f32,
68 ) -> FopdtConfig {
69 FopdtConfig {
70 gain,
71 time_constant_s,
72 dead_time_s,
73 tick_interval_s,
74 noise_amplitude: 0.0,
75 }
76 }
77
78 pub fn with_noise_amplitude(mut self, noise_amplitude: f32) -> FopdtConfig {
80 self.noise_amplitude = noise_amplitude;
81 self
82 }
83}
84
85#[derive(Debug)]
100pub struct FopdtProcess {
101 config: FopdtConfig,
102 bias: f32,
108 decay: f32,
111 pv: f32,
112 current_mv: f32,
114 mv_delay_line: VecDeque<f32>,
118 rng: StdRng,
119}
120
121impl FopdtProcess {
122 pub fn new(config: FopdtConfig, initial_pv: f32, initial_mv: f32, seed: u64) -> FopdtProcess {
129 let bias = initial_pv - config.gain * initial_mv;
130 let decay = if config.time_constant_s > 0.0 {
131 (-config.tick_interval_s / config.time_constant_s).exp()
132 } else {
133 0.0
134 };
135 let delay_ticks = if config.dead_time_s > 0.0 && config.tick_interval_s > 0.0 {
136 (config.dead_time_s / config.tick_interval_s).ceil() as usize
137 } else {
138 0
139 };
140 FopdtProcess {
141 config,
142 bias,
143 decay,
144 pv: initial_pv,
145 current_mv: initial_mv,
146 mv_delay_line: std::iter::repeat_n(initial_mv, delay_ticks).collect(),
147 rng: StdRng::seed_from_u64(seed),
148 }
149 }
150
151 pub fn pv(&self) -> f32 {
154 self.pv
155 }
156
157 pub fn mv(&self) -> f32 {
161 self.current_mv
162 }
163
164 pub fn set_mv(&mut self, mv: f32) {
167 self.current_mv = mv;
168 }
169
170 pub fn step(&mut self) -> f32 {
174 self.mv_delay_line.push_back(self.current_mv);
175 let effective_mv = self.mv_delay_line.pop_front().unwrap_or(self.current_mv);
176
177 self.pv = self.pv * self.decay
178 + (1.0 - self.decay) * (self.bias + self.config.gain * effective_mv);
179
180 if self.config.noise_amplitude > 0.0 {
181 self.pv += self
182 .rng
183 .random_range(-self.config.noise_amplitude..=self.config.noise_amplitude);
184 }
185
186 self.pv
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq)]
195pub struct VirtualPidConfig {
196 pub kc: f32,
198 pub ti_s: Option<f32>,
201 pub td_s: Option<f32>,
204 pub output_min: f32,
205 pub output_max: f32,
206 pub output_bias: f32,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq)]
221pub struct VirtualPid {
222 config: VirtualPidConfig,
223 integral: f32,
224 prev_pv: Option<f32>,
225}
226
227impl VirtualPid {
228 pub fn new(config: VirtualPidConfig) -> VirtualPid {
229 VirtualPid {
230 config,
231 integral: 0.0,
232 prev_pv: None,
233 }
234 }
235
236 pub fn step(&mut self, setpoint: f32, pv: f32, dt: f32) -> f32 {
245 let error = setpoint - pv;
246
247 let integral_gain = self
248 .config
249 .ti_s
250 .filter(|ti| *ti > 0.0)
251 .map(|ti| self.config.kc / ti);
252 let candidate_integral = self.integral + error * dt;
253
254 let derivative_term = match (self.config.td_s, self.prev_pv) {
255 (Some(td), Some(prev_pv)) if td > 0.0 && dt > 0.0 => {
256 -self.config.kc * td * (pv - prev_pv) / dt
257 }
258 _ => 0.0,
259 };
260 self.prev_pv = Some(pv);
261
262 let proportional_term = self.config.kc * error;
263 let integral_term = integral_gain.map_or(0.0, |ki| ki * candidate_integral);
264 let raw_output =
265 self.config.output_bias + proportional_term + integral_term + derivative_term;
266 let clamped_output = raw_output.clamp(self.config.output_min, self.config.output_max);
267
268 if clamped_output == raw_output {
274 self.integral = candidate_integral;
275 }
276
277 clamped_output
278 }
279}
280
281#[derive(Debug)]
294pub struct SimulatorDriver {
295 pv_tag: TagId,
296 mv_tag: TagId,
297 process: Mutex<FopdtProcess>,
298}
299
300impl SimulatorDriver {
301 pub fn new(
306 pv_tag: impl Into<TagId>,
307 mv_tag: impl Into<TagId>,
308 config: FopdtConfig,
309 initial_pv: f32,
310 initial_mv: f32,
311 seed: u64,
312 ) -> SimulatorDriver {
313 SimulatorDriver {
314 pv_tag: pv_tag.into(),
315 mv_tag: mv_tag.into(),
316 process: Mutex::new(FopdtProcess::new(config, initial_pv, initial_mv, seed)),
317 }
318 }
319}
320
321#[async_trait]
322impl Driver for SimulatorDriver {
323 async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
324 let mut process = poisoned_lock(self.process.lock())?;
325 tags.iter()
326 .map(|tag| {
327 let value = if *tag == self.pv_tag {
328 process.step()
329 } else if *tag == self.mv_tag {
330 process.mv()
331 } else {
332 return Err(DriverError::InvalidTagValue {
333 tag: tag.clone(),
334 message: "SimulatorDriver only knows its configured PV/MV tags".to_string(),
335 });
336 };
337 Ok(TagValue {
338 tag: tag.clone(),
339 value: value.to_string(),
340 quality: Quality::Good,
341 timestamp: None,
342 })
343 })
344 .collect()
345 }
346
347 async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
348 if *tag != self.mv_tag {
349 return Err(DriverError::InvalidTagValue {
350 tag: tag.clone(),
351 message: "SimulatorDriver only accepts writes to its configured MV tag".to_string(),
352 });
353 }
354 let mv = match value {
355 TagWrite::Float(f) => f,
356 TagWrite::Raw(s) => match s.parse::<f32>() {
357 Ok(f) => f,
358 Err(_) => {
359 return Ok(WriteOutcome::failure(format!(
360 "'{s}' is not a valid numeric MV value"
361 )));
362 }
363 },
364 };
365 poisoned_lock(self.process.lock())?.set_mv(mv);
366 Ok(WriteOutcome::success())
367 }
368
369 async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
370 Err(DriverError::Unsupported {
371 operation: "capabilities",
372 })
373 }
374
375 async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
376 Err(DriverError::Unsupported {
377 operation: "browse",
378 })
379 }
380
381 async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
382 Err(DriverError::Unsupported {
383 operation: "browse-session close",
384 })
385 }
386
387 async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
388 Err(DriverError::Unsupported {
389 operation: "search",
390 })
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
401 fn step_response_settles_at_gain_times_mv_at_steady_state() {
402 let config = FopdtConfig::new(2.0, 5.0, 0.0, 1.0);
403 let mut process = FopdtProcess::new(config, 50.0, 25.0, 0);
404 process.set_mv(30.0);
405
406 let mut pv = 50.0;
407 for _ in 0..200 {
408 pv = process.step();
409 }
410
411 assert!((pv - 60.0).abs() < 1e-3, "expected pv near 60.0, got {pv}");
413 }
414
415 #[test]
416 fn matches_the_multi_tick_closed_form_solution() {
417 let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0);
421 let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
422 process.set_mv(20.0);
423
424 let decay: f32 = (-1.0_f32 / 4.0).exp();
425 let target = 20.0; for n in 1..=6 {
427 let pv = process.step();
428 let expected = 10.0 * decay.powi(n) + target * (1.0 - decay.powi(n));
429 assert!(
430 (pv - expected).abs() < 1e-4,
431 "tick {n}: pv={pv} expected={expected}"
432 );
433 }
434 }
435
436 #[test]
437 fn dead_time_delays_the_response_by_the_configured_number_of_ticks() {
438 let config = FopdtConfig::new(1.0, 4.0, 3.0, 1.0); let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
440 process.set_mv(20.0);
441
442 for tick in 0..3 {
445 let pv = process.step();
446 assert!(
447 (pv - 10.0).abs() < 1e-4,
448 "tick {tick}: expected pv to stay at 10.0 during dead time, got {pv}"
449 );
450 }
451 let pv_after_delay = process.step();
454 assert!(
455 pv_after_delay > 10.1,
456 "expected pv to start moving after dead time elapsed, got {pv_after_delay}"
457 );
458 }
459
460 #[test]
461 fn zero_dead_time_responds_on_the_very_first_tick() {
462 let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0);
463 let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
464 process.set_mv(20.0);
465 assert!(process.step() > 10.1);
466 }
467
468 #[tokio::test]
469 async fn unsupported_namespace_operations_are_reported() {
470 let driver = SimulatorDriver::new(
471 "PV",
472 "MV",
473 FopdtConfig::new(1.0, 2.0, 0.0, 1.0),
474 0.0,
475 0.0,
476 1,
477 );
478 assert!(matches!(
479 driver.capabilities().await,
480 Err(DriverError::Unsupported {
481 operation: "capabilities"
482 })
483 ));
484 assert!(matches!(
485 driver.browse(BrowsePageRequest::root(1)).await,
486 Err(DriverError::Unsupported {
487 operation: "browse"
488 })
489 ));
490 assert!(matches!(
491 driver.close_browse_session("s").await,
492 Err(DriverError::Unsupported {
493 operation: "browse-session close"
494 })
495 ));
496 assert!(matches!(
497 driver
498 .search(SearchRequest {
499 query: "PV".into(),
500 match_mode: crate::types::SearchMatchMode::Exact,
501 session_id: None,
502 scope_node_key: None,
503 max_results: 1,
504 include_branches: false,
505 refresh: false,
506 })
507 .await,
508 Err(DriverError::Unsupported {
509 operation: "search"
510 })
511 ));
512 }
513
514 #[test]
515 fn same_seed_produces_identical_pv_sequences() {
516 let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0).with_noise_amplitude(0.5);
517 let mut a = FopdtProcess::new(config, 10.0, 10.0, 42);
518 let mut b = FopdtProcess::new(config, 10.0, 10.0, 42);
519 a.set_mv(15.0);
520 b.set_mv(15.0);
521 let seq_a: Vec<f32> = (0..20).map(|_| a.step()).collect();
522 let seq_b: Vec<f32> = (0..20).map(|_| b.step()).collect();
523 assert_eq!(seq_a, seq_b);
524 }
525
526 #[test]
527 fn different_seeds_produce_different_pv_sequences() {
528 let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0).with_noise_amplitude(0.5);
529 let mut a = FopdtProcess::new(config, 10.0, 10.0, 1);
530 let mut b = FopdtProcess::new(config, 10.0, 10.0, 2);
531 a.set_mv(15.0);
532 b.set_mv(15.0);
533 let seq_a: Vec<f32> = (0..20).map(|_| a.step()).collect();
534 let seq_b: Vec<f32> = (0..20).map(|_| b.step()).collect();
535 assert_ne!(seq_a, seq_b);
536 }
537
538 #[test]
539 fn noise_never_exceeds_the_configured_amplitude() {
540 let config = FopdtConfig::new(0.0, 0.0, 0.0, 1.0).with_noise_amplitude(0.3);
545 let mut process = FopdtProcess::new(config, 0.0, 0.0, 7);
546 for _ in 0..500 {
547 let pv = process.step();
548 assert!(pv.abs() <= 0.3, "noise sample {pv} exceeded amplitude 0.3");
549 }
550 }
551
552 #[test]
553 fn noise_is_added_to_the_computed_process_value() {
554 let seed = 123;
555 let noise_amplitude = 0.5;
556 let config = FopdtConfig::new(0.0, 0.0, 0.0, 1.0).with_noise_amplitude(noise_amplitude);
557 let mut expected_rng = StdRng::seed_from_u64(seed);
558 let expected_noise = expected_rng.random_range(-noise_amplitude..=noise_amplitude);
559
560 let mut process = FopdtProcess::new(config, 0.0, 0.0, seed);
561 assert_eq!(process.step(), expected_noise);
562 }
563
564 #[test]
565 fn mv_reports_the_last_written_value_without_advancing_pv() {
566 let config = FopdtConfig::new(1.0, 4.0, 0.0, 1.0);
567 let mut process = FopdtProcess::new(config, 10.0, 10.0, 0);
568 assert_eq!(process.mv(), 10.0);
569 process.set_mv(25.0);
570 assert_eq!(process.mv(), 25.0);
571 assert_eq!(process.pv(), 10.0); }
573
574 #[test]
577 fn proportional_only_output_matches_kc_times_error_plus_bias() {
578 let config = VirtualPidConfig {
579 kc: 2.0,
580 ti_s: None,
581 td_s: None,
582 output_min: -1000.0,
583 output_max: 1000.0,
584 output_bias: 5.0,
585 };
586 let mut pid = VirtualPid::new(config);
587 let output = pid.step(60.0, 50.0, 1.0);
588 assert!((output - 25.0).abs() < 1e-4);
590 }
591
592 #[test]
593 fn zero_integral_time_disables_integral_action() {
594 let config = VirtualPidConfig {
595 kc: 1.0,
596 ti_s: Some(0.0),
597 td_s: None,
598 output_min: -1000.0,
599 output_max: 1000.0,
600 output_bias: 0.0,
601 };
602 let mut pid = VirtualPid::new(config);
603
604 assert_eq!(pid.step(10.0, 0.0, 1.0), 10.0);
605 }
606
607 #[test]
608 fn integral_action_scales_the_error_by_elapsed_time() {
609 let config = VirtualPidConfig {
610 kc: 1.0,
611 ti_s: Some(2.0),
612 td_s: None,
613 output_min: -1000.0,
614 output_max: 1000.0,
615 output_bias: 0.0,
616 };
617 let mut pid = VirtualPid::new(config);
618
619 assert_eq!(pid.step(10.0, 0.0, 2.0), 20.0);
621 }
622
623 #[test]
624 fn anti_windup_prevents_the_integral_from_growing_while_saturated() {
625 let config = VirtualPidConfig {
626 kc: 1.0,
627 ti_s: Some(2.0),
628 td_s: None,
629 output_min: 0.0,
630 output_max: 10.0,
631 output_bias: 0.0,
632 };
633 let mut pid = VirtualPid::new(config);
634
635 for _ in 0..100 {
639 assert_eq!(pid.step(1000.0, 0.0, 1.0), 10.0);
640 }
641
642 let output = pid.step(-1.0, 0.0, 1.0);
648 assert!(
649 output < 10.0,
650 "expected output to leave saturation, got {output}"
651 );
652 }
653
654 #[test]
655 fn derivative_acts_on_measurement_so_a_setpoint_step_causes_no_kick() {
656 let config = VirtualPidConfig {
657 kc: 1.0,
658 ti_s: None,
659 td_s: Some(5.0),
660 output_min: -1000.0,
661 output_max: 1000.0,
662 output_bias: 0.0,
663 };
664 let mut pid = VirtualPid::new(config);
665
666 pid.step(50.0, 50.0, 1.0);
668
669 let output = pid.step(90.0, 50.0, 1.0);
674 assert!((output - 40.0).abs() < 1e-4);
676 }
677
678 #[test]
679 fn derivative_action_uses_measurement_delta_and_elapsed_time() {
680 let config = VirtualPidConfig {
681 kc: 2.0,
682 ti_s: None,
683 td_s: Some(3.0),
684 output_min: -1000.0,
685 output_max: 1000.0,
686 output_bias: 0.0,
687 };
688 let mut pid = VirtualPid::new(config);
689
690 pid.step(0.0, 0.0, 1.0);
691 assert_eq!(pid.step(0.0, 1.0, 2.0), -5.0);
693 }
694
695 #[test]
696 fn non_positive_derivative_inputs_disable_derivative_action() {
697 let config = VirtualPidConfig {
698 kc: 1.0,
699 ti_s: None,
700 td_s: Some(-1.0),
701 output_min: -1000.0,
702 output_max: 1000.0,
703 output_bias: 0.0,
704 };
705 let mut negative_td = VirtualPid::new(config);
706 negative_td.step(0.0, 0.0, 1.0);
707 assert_eq!(negative_td.step(0.0, 1.0, 1.0), -1.0);
708
709 let mut negative_dt = VirtualPid::new(VirtualPidConfig {
710 td_s: Some(1.0),
711 ..config
712 });
713 negative_dt.step(0.0, 0.0, 1.0);
714 assert_eq!(negative_dt.step(0.0, 1.0, -1.0), -1.0);
715 }
716
717 #[test]
718 fn zero_derivative_time_and_elapsed_time_are_safe() {
719 let config = VirtualPidConfig {
720 kc: 1.0,
721 ti_s: None,
722 td_s: Some(0.0),
723 output_min: -1000.0,
724 output_max: 1000.0,
725 output_bias: 0.0,
726 };
727 let mut pid = VirtualPid::new(config);
728
729 pid.step(0.0, 0.0, 1.0);
730 let output = pid.step(0.0, 1.0, 0.0);
731 assert_eq!(output, -1.0);
732 }
733
734 #[test]
735 fn zero_elapsed_time_skips_derivative_action() {
736 let config = VirtualPidConfig {
737 kc: 1.0,
738 ti_s: None,
739 td_s: Some(1.0),
740 output_min: -1000.0,
741 output_max: 1000.0,
742 output_bias: 0.0,
743 };
744 let mut pid = VirtualPid::new(config);
745
746 pid.step(0.0, 0.0, 1.0);
747 assert_eq!(pid.step(0.0, 1.0, 0.0), -1.0);
748 }
749
750 #[test]
751 fn pid_and_fopdt_process_together_converge_to_the_setpoint() {
752 let process_config = FopdtConfig::new(2.0, 5.0, 1.0, 1.0);
755 let mut process = FopdtProcess::new(process_config, 20.0, 10.0, 0);
756
757 let pid_config = VirtualPidConfig {
758 kc: 0.8,
759 ti_s: Some(6.0),
760 td_s: None,
761 output_min: 0.0,
762 output_max: 100.0,
763 output_bias: 10.0, };
765 let mut pid = VirtualPid::new(pid_config);
766
767 let setpoint = 45.0;
768 let mut pv = process.pv();
769 for _ in 0..500 {
770 let mv = pid.step(setpoint, pv, 1.0);
771 process.set_mv(mv);
772 pv = process.step();
773 }
774
775 assert!(
776 (pv - setpoint).abs() < 0.5,
777 "expected convergence near {setpoint}, got {pv}"
778 );
779 }
780
781 fn driver() -> SimulatorDriver {
784 SimulatorDriver::new(
785 "Loop.PV",
786 "Loop.MV",
787 FopdtConfig::new(1.0, 4.0, 0.0, 1.0),
788 50.0,
789 50.0,
790 0,
791 )
792 }
793
794 #[tokio::test]
795 async fn read_mv_tag_reports_current_mv_without_advancing_pv() {
796 let driver = driver();
797 let first = driver.read(&["Loop.MV".to_string()]).await.unwrap();
798 let second = driver.read(&["Loop.MV".to_string()]).await.unwrap();
799 assert_eq!(first[0].value, "50");
800 assert_eq!(second[0].value, "50");
801 assert_eq!(first[0].quality, Quality::Good);
802 }
803
804 #[tokio::test]
805 async fn read_pv_tag_advances_the_simulated_process_each_call() {
806 let driver = driver();
807 driver
808 .write(&"Loop.MV".to_string(), TagWrite::Float(80.0))
809 .await
810 .unwrap();
811
812 let mut values = Vec::new();
813 for _ in 0..5 {
814 let read = driver.read(&["Loop.PV".to_string()]).await.unwrap();
815 values.push(read[0].value.parse::<f32>().unwrap());
816 }
817 for pair in values.windows(2) {
820 assert!(pair[1] > pair[0], "expected monotonic approach: {values:?}");
821 }
822 }
823
824 #[tokio::test]
825 async fn write_accepts_a_raw_string_that_parses_as_a_number() {
826 let driver = driver();
827 let outcome = driver
828 .write(&"Loop.MV".to_string(), TagWrite::Raw("65.5".to_string()))
829 .await
830 .unwrap();
831 assert!(outcome.success);
832 let read = driver.read(&["Loop.MV".to_string()]).await.unwrap();
833 assert_eq!(read[0].value, "65.5");
834 }
835
836 #[tokio::test]
837 async fn write_rejects_a_raw_string_that_does_not_parse_as_a_number() {
838 let driver = driver();
839 let outcome = driver
840 .write(
841 &"Loop.MV".to_string(),
842 TagWrite::Raw("not-a-number".to_string()),
843 )
844 .await
845 .unwrap();
846 assert!(!outcome.success);
847 assert!(outcome.error_message.is_some());
848 }
849
850 #[tokio::test]
851 async fn read_unknown_tag_is_invalid_tag_value_not_a_panic() {
852 let driver = driver();
853 let err = driver
854 .read(&["Nonexistent.Tag".to_string()])
855 .await
856 .unwrap_err();
857 assert!(matches!(err, DriverError::InvalidTagValue { .. }));
858 }
859
860 #[tokio::test]
861 async fn write_unknown_tag_is_invalid_tag_value_not_a_panic() {
862 let driver = driver();
863 let err = driver
864 .write(&"Nonexistent.Tag".to_string(), TagWrite::Float(1.0))
865 .await
866 .unwrap_err();
867 assert!(matches!(err, DriverError::InvalidTagValue { .. }));
868 }
869
870 #[tokio::test]
871 async fn browse_is_unsupported() {
872 let driver = driver();
873 let err = driver
874 .browse(BrowsePageRequest::root(20))
875 .await
876 .unwrap_err();
877 assert!(matches!(
878 err,
879 DriverError::Unsupported {
880 operation: "browse"
881 }
882 ));
883 }
884
885 #[tokio::test]
893 async fn mrft_engine_completes_a_realistic_relay_test_against_the_simulator_driver() {
894 use bhtune_core::{
895 Action, ControllerDirection, ControllerType, InitialReadings, LoopConfig, MrftEngine,
896 ProcessType, ResponseLevel, Tick, lookup,
897 };
898 use chrono::{TimeZone, Utc};
899
900 let pv_tag = "Loop.PV".to_string();
901 let mv_tag = "Loop.MV".to_string();
902
903 let initial = InitialReadings {
904 pv_ini: 50.0,
905 mv_ini: 50.0,
906 mv_range_low: 0.0,
907 mv_range_high: 100.0,
908 };
909
910 let driver = SimulatorDriver::new(
915 pv_tag.clone(),
916 mv_tag.clone(),
917 FopdtConfig::new(1.0, 2.0, 5.0, 1.0),
918 initial.pv_ini,
919 initial.mv_ini,
920 0,
921 );
922
923 let config = LoopConfig {
924 process_type: ProcessType::Flow,
925 controller_type: ControllerType::Pi,
926 relay_amp_percent: 10.0,
927 num_cycles_skip: 1,
928 num_cycles_count: 2,
929 noise_protection_secs: 0,
930 mrft_delay_secs: 0,
931 };
932 let tc = lookup(
933 config.process_type,
934 config.controller_type,
935 ResponseLevel::Aggressive,
936 );
937 let start_time = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
938 let mut engine = MrftEngine::new(
939 config,
940 ControllerDirection::Reverse,
941 tc.beta,
942 initial,
943 start_time,
944 );
945
946 let mut completion = None;
947 for i in 1..=500 {
948 let read = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
949 let pv: f32 = read[0].value.parse().unwrap();
950 let time = start_time + chrono::Duration::seconds(i);
951
952 for action in engine.step(Tick { time, pv }) {
953 match action {
954 Action::WriteMv(mv) => {
955 driver.write(&mv_tag, TagWrite::Float(mv)).await.unwrap();
956 }
957 Action::Complete {
958 peaks,
959 troughs,
960 switch_times,
961 mv_sign_init,
962 } => {
963 completion = Some((peaks, troughs, switch_times, mv_sign_init));
964 }
965 }
966 }
967 if completion.is_some() {
968 break;
969 }
970 }
971
972 let (peaks, troughs, switch_times, mv_sign_init) =
973 completion.expect("engine should complete within 500 ticks");
974
975 assert!(!peaks.is_empty(), "expected at least one recorded peak");
976 assert!(!troughs.is_empty(), "expected at least one recorded trough");
977 assert!(switch_times.len() >= 2, "expected multiple relay switches");
978 assert!(mv_sign_init == 1 || mv_sign_init == -1);
979 for pv in peaks.iter().chain(troughs.iter()) {
980 assert!(pv.is_finite());
981 }
982 }
983}