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