1use std::sync::Mutex;
19
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22use serde::Deserialize;
23
24use crate::{
25 driver::Driver,
26 error::{DriverError, DriverResult},
27 types::{
28 BrowsePage, BrowsePageRequest, DriverCapabilities, Quality, SearchEvent, SearchRequest,
29 TagId, TagValue, TagWrite, WriteOutcome,
30 },
31};
32
33#[derive(Debug, Clone, Copy, PartialEq)]
45pub struct ReplaySample {
46 pub time: DateTime<Utc>,
47 pub pv: f32,
48}
49
50#[derive(Debug, Clone, PartialEq)]
55pub struct RecordedWrite {
56 pub tag: TagId,
57 pub value: f32,
58}
59
60#[derive(Debug, Deserialize)]
66struct FixtureFile {
67 ticks: Vec<FixtureTick>,
68}
69
70#[derive(Debug, Deserialize)]
71struct FixtureTick {
72 time: DateTime<Utc>,
73 pv: f32,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct ReplayTraceExhausted {
88 pub recorded: usize,
90 pub attempted: usize,
92}
93
94impl std::fmt::Display for ReplayTraceExhausted {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 write!(
97 f,
98 "replay trace exhausted: only {} sample(s) recorded, but PV read attempt #{} was \
99 made -- the driving engine never reported completion within the recorded trace",
100 self.recorded, self.attempted
101 )
102 }
103}
104
105impl std::error::Error for ReplayTraceExhausted {}
106
107#[derive(Debug)]
108struct ReplayState {
109 next_index: usize,
110 last_mv: f32,
111 writes: Vec<RecordedWrite>,
112}
113
114#[derive(Debug)]
144pub struct ReplayDriver {
145 pv_tag: TagId,
146 mv_tag: TagId,
147 samples: Vec<ReplaySample>,
148 state: Mutex<ReplayState>,
149}
150
151impl ReplayDriver {
152 pub fn new(
159 pv_tag: impl Into<TagId>,
160 mv_tag: impl Into<TagId>,
161 samples: Vec<ReplaySample>,
162 initial_mv: f32,
163 ) -> ReplayDriver {
164 ReplayDriver {
165 pv_tag: pv_tag.into(),
166 mv_tag: mv_tag.into(),
167 samples,
168 state: Mutex::new(ReplayState {
169 next_index: 0,
170 last_mv: initial_mv,
171 writes: Vec::new(),
172 }),
173 }
174 }
175
176 pub fn from_fixture_json(
184 pv_tag: impl Into<TagId>,
185 mv_tag: impl Into<TagId>,
186 json: &str,
187 initial_mv: f32,
188 ) -> DriverResult<ReplayDriver> {
189 let file: FixtureFile =
190 serde_json::from_str(json).map_err(|e| DriverError::Operation(Box::new(e)))?;
191 let samples = file
192 .ticks
193 .into_iter()
194 .map(|t| ReplaySample {
195 time: t.time,
196 pv: t.pv,
197 })
198 .collect();
199 Ok(ReplayDriver::new(pv_tag, mv_tag, samples, initial_mv))
200 }
201
202 pub fn writes(&self) -> Vec<RecordedWrite> {
205 self.state.lock().unwrap().writes.clone()
206 }
207
208 pub fn remaining(&self) -> usize {
210 let state = self.state.lock().unwrap();
211 self.samples.len() - state.next_index
212 }
213}
214
215#[async_trait]
216impl Driver for ReplayDriver {
217 async fn read(&self, tags: &[TagId]) -> DriverResult<Vec<TagValue>> {
218 let mut state = self.state.lock().unwrap();
219 tags.iter()
220 .map(|tag| {
221 if *tag == self.pv_tag {
222 let index = state.next_index;
223 let sample = self.samples.get(index).ok_or_else(|| {
224 DriverError::Operation(Box::new(ReplayTraceExhausted {
225 recorded: self.samples.len(),
226 attempted: index + 1,
227 }))
228 })?;
229 state.next_index += 1;
230 Ok(TagValue {
231 tag: tag.clone(),
232 value: sample.pv.to_string(),
233 quality: Quality::Good,
234 timestamp: Some(sample.time),
235 })
236 } else if *tag == self.mv_tag {
237 Ok(TagValue {
238 tag: tag.clone(),
239 value: state.last_mv.to_string(),
240 quality: Quality::Good,
241 timestamp: None,
242 })
243 } else {
244 Err(DriverError::InvalidTagValue {
245 tag: tag.clone(),
246 message: "ReplayDriver only knows its configured PV/MV tags".to_string(),
247 })
248 }
249 })
250 .collect()
251 }
252
253 async fn write(&self, tag: &TagId, value: TagWrite) -> DriverResult<WriteOutcome> {
254 if *tag != self.mv_tag {
255 return Err(DriverError::InvalidTagValue {
256 tag: tag.clone(),
257 message: "ReplayDriver only accepts writes to its configured MV tag".to_string(),
258 });
259 }
260 let mv = match value {
261 TagWrite::Float(f) => f,
262 TagWrite::Raw(s) => match s.parse::<f32>() {
263 Ok(f) => f,
264 Err(_) => {
265 return Ok(WriteOutcome::failure(format!(
266 "'{s}' is not a valid numeric MV value"
267 )));
268 }
269 },
270 };
271 let mut state = self.state.lock().unwrap();
272 state.last_mv = mv;
273 state.writes.push(RecordedWrite {
274 tag: tag.clone(),
275 value: mv,
276 });
277 Ok(WriteOutcome::success())
278 }
279
280 async fn capabilities(&self) -> DriverResult<DriverCapabilities> {
281 Err(DriverError::Unsupported {
282 operation: "capabilities",
283 })
284 }
285
286 async fn browse(&self, _request: BrowsePageRequest) -> DriverResult<BrowsePage> {
287 Err(DriverError::Unsupported {
288 operation: "browse",
289 })
290 }
291
292 async fn close_browse_session(&self, _session_id: &str) -> DriverResult<()> {
293 Err(DriverError::Unsupported {
294 operation: "browse-session close",
295 })
296 }
297
298 async fn search(&self, _request: SearchRequest) -> DriverResult<Vec<SearchEvent>> {
299 Err(DriverError::Unsupported {
300 operation: "search",
301 })
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use chrono::TimeZone;
309
310 fn t(secs: i64) -> DateTime<Utc> {
311 Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() + chrono::Duration::seconds(secs)
312 }
313
314 fn samples() -> Vec<ReplaySample> {
315 vec![
316 ReplaySample {
317 time: t(0),
318 pv: 10.0,
319 },
320 ReplaySample {
321 time: t(1),
322 pv: 11.0,
323 },
324 ReplaySample {
325 time: t(2),
326 pv: 12.0,
327 },
328 ]
329 }
330
331 fn expect_trace_exhaustion(error: DriverError) -> Box<ReplayTraceExhausted> {
332 match error {
333 DriverError::Operation(source) => source.downcast::<ReplayTraceExhausted>().unwrap(),
334 other => panic!("expected DriverError::Operation, got {other:?}"),
335 }
336 }
337
338 #[tokio::test]
339 async fn unsupported_namespace_operations_are_reported() {
340 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
341 assert!(matches!(
342 driver.capabilities().await,
343 Err(DriverError::Unsupported {
344 operation: "capabilities"
345 })
346 ));
347 assert!(matches!(
348 driver.browse(BrowsePageRequest::root(1)).await,
349 Err(DriverError::Unsupported {
350 operation: "browse"
351 })
352 ));
353 assert!(matches!(
354 driver.close_browse_session("s").await,
355 Err(DriverError::Unsupported {
356 operation: "browse-session close"
357 })
358 ));
359 assert!(matches!(
360 driver
361 .search(SearchRequest {
362 query: "PV".into(),
363 match_mode: crate::types::SearchMatchMode::Exact,
364 session_id: None,
365 scope_node_key: None,
366 max_results: 1,
367 include_branches: false,
368 refresh: false,
369 })
370 .await,
371 Err(DriverError::Unsupported {
372 operation: "search"
373 })
374 ));
375 }
376
377 #[tokio::test]
380 async fn reads_pv_samples_in_order_with_their_recorded_timestamps() {
381 let driver = ReplayDriver::new("PV", "MV", samples(), 50.0);
382
383 for (i, expected) in samples().iter().enumerate() {
384 let read = driver.read(&["PV".to_string()]).await.unwrap();
385 assert_eq!(read.len(), 1, "tick {i}");
386 assert_eq!(read[0].tag, "PV");
387 assert_eq!(read[0].value, expected.pv.to_string(), "tick {i}");
388 assert_eq!(read[0].quality, Quality::Good, "tick {i}");
389 assert_eq!(read[0].timestamp, Some(expected.time), "tick {i}");
390 }
391 }
392
393 #[tokio::test]
394 async fn mv_read_before_any_write_returns_the_seeded_initial_value() {
395 let driver = ReplayDriver::new("PV", "MV", samples(), 42.5);
396 let read = driver.read(&["MV".to_string()]).await.unwrap();
397 assert_eq!(read[0].value, "42.5");
398 assert_eq!(read[0].timestamp, None, "MV reads have no recorded time");
399 }
400
401 #[tokio::test]
402 async fn mv_read_reflects_the_most_recent_write() {
403 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
404 driver
405 .write(&"MV".to_string(), TagWrite::Float(37.0))
406 .await
407 .unwrap();
408 let read = driver.read(&["MV".to_string()]).await.unwrap();
409 assert_eq!(read[0].value, "37");
410 }
411
412 #[tokio::test]
413 async fn reading_pv_does_not_advance_a_subsequent_mv_read_and_vice_versa() {
414 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
415 driver.read(&["MV".to_string()]).await.unwrap();
416 driver.read(&["MV".to_string()]).await.unwrap();
417 assert_eq!(
418 driver.remaining(),
419 3,
420 "MV reads must not consume PV samples"
421 );
422 driver.read(&["PV".to_string()]).await.unwrap();
423 assert_eq!(driver.remaining(), 2);
424 }
425
426 #[tokio::test]
427 async fn reading_multiple_tags_in_one_call_resolves_each_independently() {
428 let driver = ReplayDriver::new("PV", "MV", samples(), 5.0);
429 let read = driver
430 .read(&["PV".to_string(), "MV".to_string()])
431 .await
432 .unwrap();
433 assert_eq!(read[0].tag, "PV");
434 assert_eq!(read[0].value, "10");
435 assert_eq!(read[1].tag, "MV");
436 assert_eq!(read[1].value, "5");
437 assert_eq!(
438 driver.remaining(),
439 2,
440 "the one PV tag in the batch consumed one sample"
441 );
442 }
443
444 #[tokio::test]
447 async fn writes_are_recorded_in_call_order() {
448 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
449 driver
450 .write(&"MV".to_string(), TagWrite::Float(1.0))
451 .await
452 .unwrap();
453 driver
454 .write(&"MV".to_string(), TagWrite::Float(2.0))
455 .await
456 .unwrap();
457 driver
458 .write(&"MV".to_string(), TagWrite::Raw("3".to_string()))
459 .await
460 .unwrap();
461 let writes = driver.writes();
462 assert_eq!(
463 writes,
464 vec![
465 RecordedWrite {
466 tag: "MV".to_string(),
467 value: 1.0
468 },
469 RecordedWrite {
470 tag: "MV".to_string(),
471 value: 2.0
472 },
473 RecordedWrite {
474 tag: "MV".to_string(),
475 value: 3.0
476 },
477 ]
478 );
479 }
480
481 #[tokio::test]
482 async fn raw_write_with_unparseable_value_is_a_rejected_outcome_not_an_error() {
483 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
484 let outcome = driver
485 .write(&"MV".to_string(), TagWrite::Raw("not-a-number".to_string()))
486 .await
487 .unwrap();
488 assert!(!outcome.success);
489 assert!(outcome.error_message.unwrap().contains("not-a-number"));
490 assert!(
491 driver.writes().is_empty(),
492 "a rejected write must not be recorded"
493 );
494 }
495
496 #[tokio::test]
499 async fn reading_an_unknown_tag_is_invalid_tag_value() {
500 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
501 let err = driver
502 .read(&["SomeOtherTag".to_string()])
503 .await
504 .unwrap_err();
505 assert!(matches!(
506 err,
507 DriverError::InvalidTagValue { tag, .. } if tag == "SomeOtherTag"
508 ));
509 }
510
511 #[tokio::test]
512 async fn writing_an_unknown_tag_is_invalid_tag_value() {
513 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
514 let err = driver
515 .write(&"SomeOtherTag".to_string(), TagWrite::Float(1.0))
516 .await
517 .unwrap_err();
518 assert!(matches!(err, DriverError::InvalidTagValue { .. }));
519 }
520
521 #[tokio::test]
522 async fn browse_is_unsupported() {
523 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
524 let err = driver
525 .browse(BrowsePageRequest::root(20))
526 .await
527 .unwrap_err();
528 assert!(matches!(
529 err,
530 DriverError::Unsupported {
531 operation: "browse"
532 }
533 ));
534 }
535
536 #[tokio::test]
537 async fn reading_pv_past_the_last_sample_is_operation_error_not_a_panic() {
538 let driver = ReplayDriver::new("PV", "MV", samples(), 0.0);
539 for _ in 0..3 {
540 driver.read(&["PV".to_string()]).await.unwrap();
541 }
542 assert_eq!(driver.remaining(), 0);
543 let err = driver.read(&["PV".to_string()]).await.unwrap_err();
544 let exhausted = expect_trace_exhaustion(err);
545 assert_eq!(exhausted.recorded, 3);
546 assert_eq!(exhausted.attempted, 4);
547 assert!(exhausted.to_string().contains("exhausted"));
548 }
549
550 #[tokio::test]
551 async fn an_empty_trace_reports_exhaustion_on_the_very_first_read() {
552 let driver = ReplayDriver::new("PV", "MV", Vec::new(), 0.0);
553 let err = driver.read(&["PV".to_string()]).await.unwrap_err();
554 let exhausted = expect_trace_exhaustion(err);
555 assert_eq!(exhausted.recorded, 0);
556 assert_eq!(exhausted.attempted, 1);
557 }
558
559 #[test]
560 fn trace_exhaustion_assertion_fails_clearly_for_a_non_operation_error() {
561 let panic = std::panic::catch_unwind(|| {
562 expect_trace_exhaustion(DriverError::Unsupported {
563 operation: "browse",
564 })
565 })
566 .unwrap_err();
567 assert!(
568 panic
569 .downcast_ref::<String>()
570 .is_some_and(|message| message.contains("DriverError::Operation"))
571 );
572 }
573
574 #[test]
577 fn from_fixture_json_parses_ticks_and_ignores_every_other_field() {
578 let json = r#"{
579 "name": "example",
580 "description": "irrelevant prose",
581 "source": { "static_log": "x", "dynamic_log": "y", "captured": "2026-01-01" },
582 "config": { "process_type": "flow", "controller_type": "pi" },
583 "direction": "reverse",
584 "initial": { "pv_ini": 1.0 },
585 "pv_range": { "high": 100.0, "low": 0.0 },
586 "template_name": "Yokogawa CentumVP",
587 "ticks": [
588 { "time": "2024-01-01T00:00:00Z", "pv": 40.0, "expected": { "hysteresis": 0.1 } },
589 { "time": "2024-01-01T00:00:01Z", "pv": 41.5, "expected": { "hysteresis": 0.2 } }
590 ]
591 }"#;
592
593 let driver = ReplayDriver::from_fixture_json("PV", "MV", json, 40.0).unwrap();
594 assert_eq!(driver.remaining(), 2);
595 }
596
597 #[test]
598 fn from_fixture_json_rejects_malformed_json_as_operation_error() {
599 let err = ReplayDriver::from_fixture_json("PV", "MV", "not json", 0.0).unwrap_err();
600 assert!(matches!(err, DriverError::Operation(_)));
601 }
602
603 #[test]
604 fn from_fixture_json_rejects_a_document_with_no_ticks_field() {
605 let err = ReplayDriver::from_fixture_json("PV", "MV", "{}", 0.0).unwrap_err();
606 assert!(matches!(err, DriverError::Operation(_)));
607 }
608
609 #[test]
610 fn trace_exhaustion_error_reports_the_recorded_and_attempted_counts() {
611 let error = ReplayTraceExhausted {
612 recorded: 3,
613 attempted: 4,
614 };
615 assert!(error.to_string().contains("3 sample(s)"));
616 assert!(error.to_string().contains("#4"));
617 let _: Box<dyn std::error::Error> = Box::new(error);
618 }
619
620 #[tokio::test]
623 async fn is_usable_as_a_boxed_dyn_driver() {
624 let driver: Box<dyn Driver> = Box::new(ReplayDriver::new("PV", "MV", samples(), 0.0));
625 let read = driver.read(&["PV".to_string()]).await.unwrap();
626 assert_eq!(read[0].value, "10");
627 }
628
629 #[tokio::test]
640 async fn mrft_engine_replays_the_golden_trace_through_the_real_driver_trait() {
641 use std::{fs, path::Path};
642
643 use bhtune_core::{
644 Action, ControllerDirection, ControllerType, InitialReadings, LoopConfig, MrftCompat,
645 MrftEngine, ProcessType, PvRange, ResponseLevel, Tick, TuningMathCompat,
646 built_in_templates, calculate_all, lookup,
647 };
648
649 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
650 .join("../../tests/golden/fixtures/flow_pi_direct.json");
651 let json = fs::read_to_string(&fixture_path)
652 .unwrap_or_else(|e| panic!("failed to read {}: {e}", fixture_path.display()));
653
654 let pv_tag = "Loop.PV".to_string();
655 let mv_tag = "Loop.MV".to_string();
656 let initial_mv = 40.0;
661 let driver =
662 ReplayDriver::from_fixture_json(pv_tag.clone(), mv_tag.clone(), &json, initial_mv)
663 .expect("flow_pi_direct.json should parse");
664 let total_samples = driver.remaining();
665
666 let config = LoopConfig {
672 process_type: ProcessType::Flow,
673 controller_type: ControllerType::Pi,
674 relay_amp_percent: 2.0,
675 num_cycles_skip: 1,
676 num_cycles_count: 2,
677 noise_protection_secs: 3,
678 mrft_delay_secs: 0,
679 };
680 config.validate().expect("fixture config must be valid");
681 let direction = ControllerDirection::Reverse;
682 let initial = InitialReadings {
683 pv_ini: 40.00012,
684 mv_ini: initial_mv,
685 mv_range_low: 0.0,
686 mv_range_high: 100.0,
687 };
688 let pv_range = PvRange {
689 high: 100.0,
690 low: 0.0,
691 };
692 let beta = lookup(
693 config.process_type,
694 config.controller_type,
695 ResponseLevel::Aggressive,
696 )
697 .beta;
698 let template = built_in_templates()
699 .into_iter()
700 .find(|t| t.name == "Yokogawa CentumVP")
701 .expect("built-in template must exist");
702
703 let first = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
707 let start_time = first[0]
708 .timestamp
709 .expect("ReplayDriver always sets a timestamp on PV reads");
710 let mut pending_tick = Some(Tick {
711 time: start_time,
712 pv: first[0].value.parse().unwrap(),
713 });
714
715 let mut engine = MrftEngine::new(
716 config,
717 direction,
718 beta,
719 initial,
720 start_time,
721 MrftCompat::default(),
722 );
723
724 let mut completion = None;
725 for _ in 0..total_samples {
726 let tick = match pending_tick.take() {
727 Some(tick) => tick,
728 None => {
729 let read = driver.read(std::slice::from_ref(&pv_tag)).await.unwrap();
730 let time = read[0]
731 .timestamp
732 .expect("ReplayDriver always sets a timestamp on PV reads");
733 Tick {
734 time,
735 pv: read[0].value.parse().unwrap(),
736 }
737 }
738 };
739
740 for action in engine.step(tick) {
741 match action {
742 Action::WriteMv(mv) => {
743 driver.write(&mv_tag, TagWrite::Float(mv)).await.unwrap();
744 }
745 Action::Complete {
746 peaks,
747 troughs,
748 switch_times,
749 mv_sign_init,
750 } => {
751 completion = Some((peaks, troughs, switch_times, mv_sign_init));
752 }
753 }
754 }
755 if completion.is_some() {
756 break;
757 }
758 }
759
760 let (peaks, troughs, switch_times, mv_sign_init) =
761 completion.expect("engine should complete within the recorded trace");
762
763 let results = calculate_all(
764 &peaks,
765 &troughs,
766 &switch_times,
767 mv_sign_init,
768 direction,
769 config,
770 pv_range,
771 &template,
772 TuningMathCompat::default(),
773 );
774
775 let expected_aggressive_pb = 157.7088_f32;
783 let (_tuning, pid) = results
784 .iter()
785 .find(|(r, _)| r.response_level == ResponseLevel::Aggressive)
786 .expect("aggressive result must be present");
787 let tolerance = 1e-3 + expected_aggressive_pb.abs() * 1e-2;
788 assert!(
789 (pid.proportional - expected_aggressive_pb).abs() <= tolerance,
790 "aggressive proportional band: expected ~{expected_aggressive_pb}, got {} \
791 (tolerance {tolerance})",
792 pid.proportional
793 );
794
795 assert!(
796 !driver.writes().is_empty(),
797 "the engine should have written at least one relay step through the real \
798 Driver trait"
799 );
800 assert!(
807 driver.remaining() < total_samples,
808 "expected at least one sample to be consumed before completion"
809 );
810 }
811}