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