1use std::collections::{HashMap, HashSet};
13use std::future::Future;
14use std::time::Duration;
15
16use bhtune_core::mrft::clamp_relay_amplitude;
17use bhtune_core::{
18 Action, ControllerDirection, ControllerType, DcsTemplate, InitialReadings, LoopConfig,
19 LoopTags, MrftCompat, MrftEngine, MvRange, PidParameters, ProcessType, PvRange, ResponseLevel,
20 TagOrValue, TagOverrides, Tick, TuningMathCompat, TuningResultStatus, calculate_all_checked,
21 lookup, measure_oscillation, opc_write_values,
22};
23use bhtune_db::SqlitePool;
24use bhtune_db::models::{
25 DcsTemplateRow, EffectiveTuning, MvActuationKind, MvActuationStatus, NewTuneMvActuation,
26 NewTuneWrite, RollbackState, SampleQuality, TimingBasis, TimingMetrics, TuneDriver,
27 TuneMvActuationRow, TuneResultRow, TuneRunInitialReadings, TuneRunRow, TuneSampleRow,
28 TuneWriteRow, WriteKind, WriteReadback,
29};
30use bhtune_driver::{Driver, TagValue, TagWrite};
31use chrono::{DateTime, Utc};
32use tokio::time::Instant;
33
34use crate::args::{DriverKindArg, TuneArgs};
35use crate::cancel::CtrlC;
36use crate::driver::{SIMULATOR_MV_TAG, SIMULATOR_PV_TAG};
37use crate::output::OutputFormat;
38use crate::timing::{PollTimingAccumulator, RunTimeAnchor, TickTimeSource};
39
40pub const MV_ACTUATION_CONFIRMATION_SECS: u64 = 4;
45const MV_ACTUATION_RETRY_INTERVAL: Duration = Duration::from_millis(100);
46const MV_ACTUATION_DEADLINE_READ_MAX: Duration = Duration::from_secs(1);
47const MV_ACTUATION_FALLBACK_HEADROOM: Duration = Duration::from_secs(1);
48const MV_RESTORE_HANDOFF_READ_MAX: Duration = Duration::from_secs(1);
49const MV_SPAN_TOLERANCE_FRACTION: f32 = 0.001;
50const RELAY_STEP_TOLERANCE_FRACTION: f32 = 0.25;
51const MIN_RELAY_STEP: f32 = 0.01;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56struct EffectiveTiming {
57 mrft_delay_secs: u32,
58 poll_interval_ms: u64,
59 timeout_secs: u64,
60 op_timeout_secs: u64,
61 restore_timeout_secs: u64,
62}
63
64impl From<crate::config::EffectiveTuningConfig> for EffectiveTiming {
65 fn from(value: crate::config::EffectiveTuningConfig) -> Self {
66 Self {
67 mrft_delay_secs: value.mrft_delay_secs,
68 poll_interval_ms: value.poll_interval_ms,
69 timeout_secs: value.timeout_secs,
70 op_timeout_secs: value.op_timeout_secs,
71 restore_timeout_secs: value.restore_timeout_secs,
72 }
73 }
74}
75
76impl From<EffectiveTiming> for EffectiveTuning {
77 fn from(value: EffectiveTiming) -> Self {
78 Self {
79 mrft_delay_secs: value.mrft_delay_secs,
80 poll_interval_ms: value.poll_interval_ms,
81 timeout_secs: value.timeout_secs,
82 op_timeout_secs: value.op_timeout_secs,
83 restore_timeout_secs: value.restore_timeout_secs,
84 }
85 }
86}
87
88#[cfg(test)]
89fn test_effective_timing(args: &TuneArgs) -> EffectiveTiming {
90 EffectiveTiming {
91 mrft_delay_secs: args.mrft_delay,
92 poll_interval_ms: args.poll_interval_ms,
93 timeout_secs: args.timeout_secs,
94 op_timeout_secs: args.op_timeout_secs,
95 restore_timeout_secs: args.restore_timeout_secs,
96 }
97}
98
99pub fn validate_restore_timeout_secs(
105 driver: DriverKindArg,
106 restore_timeout_secs: u64,
107) -> anyhow::Result<()> {
108 if restore_timeout_secs == 0 {
109 anyhow::bail!("[tuning].restore_timeout_secs must be greater than zero");
110 }
111 if driver == DriverKindArg::Opcda
112 && restore_timeout_secs < crate::config::MIN_OPC_RESTORE_TIMEOUT_SECS
113 {
114 anyhow::bail!(
115 "[tuning].restore_timeout_secs must be at least {} seconds for OPC DA MV confirmation",
116 crate::config::MIN_OPC_RESTORE_TIMEOUT_SECS
117 );
118 }
119 Ok(())
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum TuneOutcome {
127 Completed,
130 Aborted,
133 TimedOut,
139 PoorQuality,
149 ActuationFailed,
153 WriteBackFailed,
157 RestoreIncomplete,
165}
166
167impl TuneOutcome {
168 pub fn label(self) -> &'static str {
170 match self {
171 TuneOutcome::Completed => "completed",
172 TuneOutcome::Aborted => "aborted",
173 TuneOutcome::TimedOut => "timed_out",
174 TuneOutcome::PoorQuality => "poor_quality",
175 TuneOutcome::ActuationFailed => "actuation_failed",
176 TuneOutcome::WriteBackFailed => "write_back_failed",
177 TuneOutcome::RestoreIncomplete => "restore_incomplete",
178 }
179 }
180}
181
182#[cfg(test)]
195pub async fn run(
196 pool: &SqlitePool,
197 args: TuneArgs,
198 app_config: &crate::config::BhtuneConfig,
199) -> anyhow::Result<TuneOutcome> {
200 run_with_ctrl_c(pool, args, app_config, &mut CtrlC::never()).await
201}
202
203pub(crate) async fn run_with_ctrl_c(
214 pool: &SqlitePool,
215 args: TuneArgs,
216 app_config: &crate::config::BhtuneConfig,
217 ctrl_c: &mut CtrlC,
218) -> anyhow::Result<TuneOutcome> {
219 let prepared = prepare(pool, args, app_config).await?;
220 let PreparedTune {
221 run_id,
222 args,
223 template,
224 tags,
225 driver,
226 config,
227 timing,
228 time_anchor,
229 write_pid,
230 allow_uncertain_quality,
231 } = prepared;
232
233 let outcome = execute_with_timing(
234 pool,
235 run_id,
236 &args,
237 &template,
238 &tags,
239 driver.as_ref(),
240 config,
241 timing,
242 time_anchor,
243 write_pid,
244 allow_uncertain_quality,
245 ctrl_c,
246 &mut std::io::stdin().lock(),
247 )
248 .await;
249
250 match outcome {
251 Ok(run_outcome) => {
252 let tune_outcome = print_summary(run_id, &run_outcome, args.output);
253 let outcome_label = tune_outcome.label();
254 tracing::info!(run_id, outcome = outcome_label, "tune run finished");
255 Ok(tune_outcome)
256 }
257 Err(e) => {
258 tracing::error!(run_id, error = %e, "tune run failed");
259 finalize_pending_for_run_best_effort(
260 pool,
261 run_id,
262 "the run failed before MV confirmation completed",
263 )
264 .await;
265 TuneRunRow::fail(pool, run_id, Utc::now(), &e.to_string())
266 .await
267 .ok();
268 Err(e)
269 }
270 }
271}
272
273pub struct PreparedTune {
292 run_id: i64,
293 args: TuneArgs,
294 template: DcsTemplate,
295 tags: LoopTags,
296 driver: Box<dyn Driver>,
297 config: LoopConfig,
298 timing: EffectiveTiming,
299 time_anchor: RunTimeAnchor,
300 write_pid: Option<ResponseLevel>,
301 allow_uncertain_quality: bool,
302}
303
304pub async fn prepare_owned(
307 pool: &SqlitePool,
308 args: TuneArgs,
309 app_config: &crate::config::BhtuneConfig,
310 demo_session_id: i64,
311) -> anyhow::Result<PreparedTune> {
312 if args.driver != DriverKindArg::Simulator {
313 anyhow::bail!("demo sessions may only start simulator runs");
314 }
315 prepare_internal(pool, args, app_config, Some(demo_session_id)).await
316}
317
318impl PreparedTune {
319 pub fn run_id(&self) -> i64 {
323 self.run_id
324 }
325}
326
327#[derive(serde::Serialize)]
343struct RequestSnapshot<'a> {
344 tagname: &'a str,
345 template: &'a str,
346 process_type: ProcessType,
347 controller_type: ControllerType,
348 relay_amp: f32,
349 cycles_skip: Option<u32>,
350 cycles_count: Option<u32>,
351 noise_protection_secs: Option<u32>,
352 driver: TuneDriver,
353 bridge_host: Option<&'a str>,
354 server: Option<&'a str>,
355 sim_gain: f32,
356 sim_tau: f32,
357 sim_dead_time: f32,
358 sim_noise: f32,
359 sim_seed: u64,
360 sim_initial_pv: f32,
361 sim_initial_mv: f32,
362 pv_range_high: Option<f32>,
363 pv_range_low: Option<f32>,
364 mv_range_high: Option<f32>,
365 mv_range_low: Option<f32>,
366 direction: Option<ControllerDirection>,
367 tag_overrides: Option<&'a TagOverrides>,
368 notes: Option<&'a str>,
369 yes: bool,
370 write_pid: Option<ResponseLevel>,
371}
372
373pub async fn prepare(
384 pool: &SqlitePool,
385 args: TuneArgs,
386 app_config: &crate::config::BhtuneConfig,
387) -> anyhow::Result<PreparedTune> {
388 prepare_internal(pool, args, app_config, None).await
389}
390
391async fn prepare_internal(
392 pool: &SqlitePool,
393 mut args: TuneArgs,
394 app_config: &crate::config::BhtuneConfig,
395 demo_session_id: Option<i64>,
396) -> anyhow::Result<PreparedTune> {
397 if args.write_pid.is_some() && !args.yes {
401 anyhow::bail!(
402 "--write-pid requires --yes: writing PID constants back to the DCS with no \
403 human present to confirm must be an explicit, deliberate choice"
404 );
405 }
406 let timing: EffectiveTiming = crate::config::resolve_and_validate_tuning_config(
407 &app_config.tuning,
408 args.driver == DriverKindArg::Opcda,
409 )?
410 .into();
411 if let Some(tag_overrides) = &args.tag_overrides {
412 tag_overrides.validate()?;
413 }
414 let allow_uncertain_quality = app_config.allow_uncertain_quality;
415
416 let db_driver = match args.driver {
417 DriverKindArg::Opcda => TuneDriver::Opcda,
418 DriverKindArg::Simulator => TuneDriver::Simulator,
419 };
420
421 let request_json = serde_json::to_string(&RequestSnapshot {
426 tagname: &args.tagname,
427 template: &args.template,
428 process_type: args.process_type.into(),
429 controller_type: args.controller_type.into(),
430 relay_amp: args.relay_amp,
431 cycles_skip: args.cycles_skip,
432 cycles_count: args.cycles_count,
433 noise_protection_secs: args.noise_protection_secs,
434 driver: db_driver,
435 bridge_host: args.bridge_host.as_deref(),
436 server: args.server.as_deref(),
437 sim_gain: args.sim_gain,
438 sim_tau: args.sim_tau,
439 sim_dead_time: args.sim_dead_time,
440 sim_noise: args.sim_noise,
441 sim_seed: args.sim_seed,
442 sim_initial_pv: args.sim_initial_pv,
443 sim_initial_mv: args.sim_initial_mv,
444 pv_range_high: args.pv_range_high,
445 pv_range_low: args.pv_range_low,
446 mv_range_high: args.mv_range_high,
447 mv_range_low: args.mv_range_low,
448 direction: args.direction.map(Into::into),
449 tag_overrides: args.tag_overrides.as_ref(),
450 notes: args.notes.as_deref(),
451 yes: args.yes,
452 write_pid: args.write_pid.map(Into::into),
453 })
454 .expect(
455 "RequestSnapshot serialization is infallible: plain enum/scalar fields, no maps and \
456 no floats that JSON can't represent (every f32 here is validated finite before \
457 reaching this call, per safety-validation)",
458 );
459
460 args.bridge_host = Some(crate::config::resolve_bridge_host(
461 args.bridge_host.take(),
462 app_config,
463 ));
464 if matches!(args.driver, DriverKindArg::Opcda) {
465 args.server = Some(crate::config::resolve_server(
466 args.server.take(),
467 app_config,
468 )?);
469 }
470
471 let template_row = DcsTemplateRow::get_by_name(pool, &args.template)
472 .await?
473 .ok_or_else(|| anyhow::anyhow!("no template named '{}'", args.template))?;
474 let template_origin = template_row.origin;
475 let template = template_row.template;
476
477 let config = build_loop_config_with_timing(&args, timing)?;
478 let tags = build_loop_tags(&args, &template)?;
479 let driver = crate::driver::build_with_poll_interval(&args, timing.poll_interval_ms).await?;
480
481 let time_anchor = RunTimeAnchor::now();
482 let started_at = time_anchor.utc();
483 let run = TuneRunRow::start_with_demo_session(
484 pool,
485 demo_session_id,
486 None,
487 &args.tagname,
488 db_driver,
489 config,
490 template_origin,
491 &template,
492 &tags,
493 started_at,
494 )
495 .await?;
496 let metadata_result = async {
497 TuneRunRow::record_effective_tuning(pool, run.id, timing.into()).await?;
498 TuneRunRow::record_allow_uncertain_quality(pool, run.id, allow_uncertain_quality).await?;
499
500 let (opc_server, bridge_host) = if db_driver == TuneDriver::Opcda {
507 (args.server.as_deref(), args.bridge_host.as_deref())
508 } else {
509 (None, None)
510 };
511 TuneRunRow::record_connection(pool, run.id, opc_server, bridge_host, &request_json).await?;
512 let notes = args
513 .notes
514 .as_deref()
515 .map(str::trim)
516 .filter(|notes| !notes.is_empty());
517 TuneRunRow::update_notes(pool, run.id, notes).await?;
518 Ok::<(), anyhow::Error>(())
519 }
520 .await;
521 if let Err(error) = metadata_result {
522 finalize_preparation_failure(pool, run.id, &error.to_string()).await;
523 return Err(error);
524 }
525
526 tracing::info!(
527 run_id = run.id,
528 template = %args.template,
529 process_type = ?config.process_type,
530 controller_type = ?config.controller_type,
531 driver = ?db_driver,
532 allow_uncertain_quality,
533 "starting tune run"
534 );
535
536 let write_pid: Option<ResponseLevel> = args.write_pid.map(Into::into);
537
538 Ok(PreparedTune {
539 run_id: run.id,
540 args,
541 template,
542 tags,
543 driver,
544 config,
545 timing,
546 time_anchor,
547 write_pid,
548 allow_uncertain_quality,
549 })
550}
551
552async fn finalize_preparation_failure(pool: &SqlitePool, run_id: i64, reason: &str) {
553 if let Err(error) = TuneRunRow::fail(pool, run_id, Utc::now(), reason).await {
554 tracing::error!(
555 run_id,
556 error = %error,
557 "could not mark a failed preparation run terminal; attempting to delete it"
558 );
559 if let Err(delete_error) = TuneRunRow::delete(pool, run_id).await {
560 tracing::error!(
561 run_id,
562 error = %delete_error,
563 "could not remove a failed preparation run"
564 );
565 }
566 }
567}
568
569pub async fn drive(
598 pool: &SqlitePool,
599 prepared: PreparedTune,
600 ctrl_c: &mut CtrlC,
601) -> anyhow::Result<TuneOutcome> {
602 let PreparedTune {
603 run_id,
604 args,
605 template,
606 tags,
607 driver,
608 config,
609 timing,
610 time_anchor,
611 write_pid,
612 allow_uncertain_quality,
613 } = prepared;
614
615 let outcome = execute_with_timing(
616 pool,
617 run_id,
618 &args,
619 &template,
620 &tags,
621 driver.as_ref(),
622 config,
623 timing,
624 time_anchor,
625 write_pid,
626 allow_uncertain_quality,
627 ctrl_c,
628 &mut std::io::empty(),
629 )
630 .await;
631
632 match outcome {
633 Ok(run_outcome) => {
634 let tune_outcome = tune_outcome_for_run(&run_outcome);
635 tracing::info!(run_id, outcome = tune_outcome.label(), "tune run finished");
636 Ok(tune_outcome)
637 }
638 Err(e) => {
639 tracing::error!(run_id, error = %e, "tune run failed");
640 finalize_pending_for_run_best_effort(
641 pool,
642 run_id,
643 "the run failed before MV confirmation completed",
644 )
645 .await;
646 TuneRunRow::fail(pool, run_id, Utc::now(), &e.to_string())
647 .await
648 .ok();
649 Err(e)
650 }
651 }
652}
653
654#[derive(Debug)]
655enum RunOutcome {
656 Completed {
657 write_back: WriteBackOutcome,
658 write_back_detail: Option<String>,
665 },
666 Aborted(AbortReason),
667 RestoreIncomplete {
675 reason: String,
676 },
677}
678
679#[derive(Debug, Clone, PartialEq)]
681enum AbortReason {
682 UserInterrupt,
684 Timeout { timeout_secs: u64 },
687 OperationTimedOut { tag: String, op_timeout_secs: u64 },
694 PoorQuality {
706 tag: String,
707 quality: bhtune_driver::Quality,
708 },
709 MvActuationUnconfirmed {
712 tag: String,
713 target: f32,
714 readback: Option<f32>,
715 tolerance: f32,
716 elapsed_ms: u64,
717 deadline_secs: u64,
718 },
719}
720
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726enum WriteBackOutcome {
727 Skipped,
730 Written { response_level: ResponseLevel },
732 Failed,
736}
737
738fn tune_outcome_for_run(outcome: &RunOutcome) -> TuneOutcome {
743 match outcome {
744 RunOutcome::Completed {
745 write_back: WriteBackOutcome::Failed,
746 ..
747 } => TuneOutcome::WriteBackFailed,
748 RunOutcome::Completed { .. } => TuneOutcome::Completed,
749 RunOutcome::Aborted(AbortReason::UserInterrupt) => TuneOutcome::Aborted,
750 RunOutcome::Aborted(AbortReason::Timeout { .. }) => TuneOutcome::TimedOut,
751 RunOutcome::Aborted(AbortReason::OperationTimedOut { .. }) => TuneOutcome::TimedOut,
752 RunOutcome::Aborted(AbortReason::PoorQuality { .. }) => TuneOutcome::PoorQuality,
753 RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. }) => {
754 TuneOutcome::ActuationFailed
755 }
756 RunOutcome::RestoreIncomplete { .. } => TuneOutcome::RestoreIncomplete,
757 }
758}
759
760fn format_mv_actuation_abort_reason(reason: &AbortReason) -> String {
761 let AbortReason::MvActuationUnconfirmed {
762 tag,
763 target,
764 readback,
765 tolerance,
766 elapsed_ms,
767 deadline_secs,
768 } = reason
769 else {
770 return format!("{reason:?}");
771 };
772 let readback = readback
773 .map(|value| value.to_string())
774 .unwrap_or_else(|| "unavailable".to_string());
775 format!(
776 "MV actuation unconfirmed: tag '{tag}', target {target}, readback {readback}, tolerance {tolerance}, elapsed {elapsed_ms} ms, deadline {deadline_secs} s"
777 )
778}
779
780fn print_summary(run_id: i64, outcome: &RunOutcome, output: OutputFormat) -> TuneOutcome {
784 let tune_outcome = tune_outcome_for_run(outcome);
785 match output {
786 OutputFormat::Table => print_table_summary(run_id, outcome),
787 OutputFormat::Json => print_json_summary(run_id, outcome, tune_outcome),
788 }
789 tune_outcome
790}
791
792fn print_table_summary(run_id: i64, outcome: &RunOutcome) {
793 match outcome {
794 RunOutcome::Completed {
795 write_back: WriteBackOutcome::Written { response_level },
796 ..
797 } => {
798 println!(
799 "Tune completed successfully (run id {run_id}); wrote {response_level:?} PID parameters."
800 );
801 }
802 RunOutcome::Completed {
803 write_back: WriteBackOutcome::Skipped,
804 ..
805 } => {
806 println!("Tune completed successfully (run id {run_id}).");
807 }
808 RunOutcome::Completed {
809 write_back: WriteBackOutcome::Failed,
810 ..
811 } => {
812 println!(
813 "Tune completed successfully (run id {run_id}), but PID write-back failed; the loop was left with its previous PID constants."
814 );
815 }
816 RunOutcome::Aborted(AbortReason::UserInterrupt) => {
817 println!("Tune aborted (Ctrl+C received; loop restored).");
818 }
819 RunOutcome::Aborted(AbortReason::Timeout { timeout_secs }) => {
820 println!(
821 "Tune aborted: exceeded the {timeout_secs}s [tuning].timeout_secs limit before completing; loop restored."
822 );
823 }
824 RunOutcome::Aborted(AbortReason::OperationTimedOut {
825 tag,
826 op_timeout_secs,
827 }) => {
828 println!(
829 "Tune aborted: tag '{tag}' did not respond within the {op_timeout_secs}s [tuning].op_timeout_secs limit; loop restored."
830 );
831 }
832 RunOutcome::Aborted(AbortReason::PoorQuality { tag, quality }) => {
833 println!(
834 "Tune aborted: tag '{tag}' reported OPC quality {quality:?} during polling; loop restored."
835 );
836 }
837 RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
838 tag,
839 target,
840 readback,
841 tolerance,
842 elapsed_ms,
843 deadline_secs,
844 }) => {
845 let readback = readback
846 .map(|value| value.to_string())
847 .unwrap_or_else(|| "unavailable".to_string());
848 println!(
849 "Tune aborted: MV tag '{tag}' did not confirm target {target} (readback {readback}, tolerance {tolerance}) after {:.3}s; the confirmation deadline was {deadline_secs}s. Loop restored.",
850 *elapsed_ms as f64 / 1_000.0
851 );
852 }
853 RunOutcome::RestoreIncomplete { reason } => {
854 println!(
855 "Tune ended, but the loop's restore could not be confirmed ({reason}). Check the loop by hand -- see the warning above for the tag and value to check."
856 );
857 }
858 }
859}
860
861fn print_json_summary(run_id: i64, outcome: &RunOutcome, tune_outcome: TuneOutcome) {
862 let (write_back, response_level) = match outcome {
863 RunOutcome::Completed {
864 write_back: WriteBackOutcome::Written { response_level },
865 ..
866 } => ("written", Some(*response_level)),
867 RunOutcome::Completed {
868 write_back: WriteBackOutcome::Skipped,
869 ..
870 } => ("skipped", None),
871 RunOutcome::Completed {
872 write_back: WriteBackOutcome::Failed,
873 ..
874 } => ("failed", None),
875 RunOutcome::Aborted(_) => ("not_attempted", None),
876 RunOutcome::RestoreIncomplete { .. } => ("not_attempted", None),
877 };
878 let write_back_detail = match outcome {
879 RunOutcome::Completed {
880 write_back_detail, ..
881 } => write_back_detail.clone(),
882 _ => None,
883 };
884 let timeout_secs = match outcome {
885 RunOutcome::Aborted(AbortReason::Timeout { timeout_secs }) => Some(*timeout_secs),
886 _ => None,
887 };
888 let (poor_quality_tag, poor_quality) = match outcome {
889 RunOutcome::Aborted(AbortReason::PoorQuality { tag, quality }) => (
890 Some(tag.clone()),
891 Some(format!("{quality:?}").to_lowercase()),
892 ),
893 _ => (None, None),
894 };
895 let (op_timeout_tag, op_timeout_secs) = match outcome {
896 RunOutcome::Aborted(AbortReason::OperationTimedOut {
897 tag,
898 op_timeout_secs,
899 }) => (Some(tag.clone()), Some(*op_timeout_secs)),
900 _ => (None, None),
901 };
902 let restore_incomplete_reason = match outcome {
903 RunOutcome::RestoreIncomplete { reason } => Some(reason.clone()),
904 _ => None,
905 };
906 let actuation = match outcome {
907 RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
908 tag,
909 target,
910 readback,
911 tolerance,
912 elapsed_ms,
913 deadline_secs,
914 }) => Some(serde_json::json!({
915 "tag": tag,
916 "target": target,
917 "readback": readback,
918 "tolerance": tolerance,
919 "elapsed_ms": elapsed_ms,
920 "deadline_secs": deadline_secs,
921 })),
922 _ => None,
923 };
924 let json = serde_json::json!({
925 "run_id": run_id,
926 "outcome": tune_outcome.label(),
927 "write_back": write_back,
928 "write_back_response_level": response_level,
929 "write_back_detail": write_back_detail,
930 "timeout_secs": timeout_secs,
931 "poor_quality_tag": poor_quality_tag,
932 "poor_quality": poor_quality,
933 "op_timeout_tag": op_timeout_tag,
934 "op_timeout_secs": op_timeout_secs,
935 "mv_actuation": actuation,
936 "restore_incomplete_reason": restore_incomplete_reason,
937 });
938 println!(
939 "{}",
940 render_json_summary(&json, serde_json::to_string_pretty)
941 );
942}
943
944fn render_json_summary<E>(
945 json: &serde_json::Value,
946 serialize: impl FnOnce(&serde_json::Value) -> Result<String, E>,
947) -> String
948where
949 E: std::fmt::Display,
950{
951 serialize(json).unwrap_or_else(|error| format!("{{\"error\": \"{error}\"}}"))
952}
953
954fn build_loop_config_with_timing(
955 args: &TuneArgs,
956 timing: EffectiveTiming,
957) -> anyhow::Result<LoopConfig> {
958 let process_type: ProcessType = args.process_type.into();
959 let controller_type: ControllerType = args.controller_type.into();
960
961 if !controller_type.is_allowed_for(process_type) {
962 anyhow::bail!(
963 "{controller_type:?} controller is not valid for {process_type:?} (PID is only offered for the two Temperature process types)"
964 );
965 }
966
967 let config = LoopConfig {
968 process_type,
969 controller_type,
970 relay_amp_percent: args.relay_amp,
971 num_cycles_skip: args
972 .cycles_skip
973 .unwrap_or_else(|| process_type.default_cycles_skip()),
974 num_cycles_count: args
975 .cycles_count
976 .unwrap_or_else(|| process_type.default_cycles_test()),
977 noise_protection_secs: args
978 .noise_protection_secs
979 .unwrap_or_else(|| process_type.default_noise_protection_secs()),
980 mrft_delay_secs: timing.mrft_delay_secs,
981 };
982 config.validate()?;
987 Ok(config)
988}
989
990#[cfg(test)]
991fn build_loop_config(args: &TuneArgs) -> anyhow::Result<LoopConfig> {
992 build_loop_config_with_timing(args, test_effective_timing(args))
993}
994
995fn build_loop_tags(args: &TuneArgs, template: &DcsTemplate) -> anyhow::Result<LoopTags> {
1002 match args.driver {
1003 DriverKindArg::Opcda => {
1004 let mut tags = LoopTags::derive_from_pv_tag(&args.tagname, template);
1005 if let Some(overrides) = &args.tag_overrides {
1006 overrides.apply_to(&mut tags);
1007 }
1008 if let Some(v) = args.pv_range_high {
1011 tags.upper_pv_range = TagOrValue::Value(v);
1012 }
1013 if let Some(v) = args.pv_range_low {
1014 tags.lower_pv_range = TagOrValue::Value(v);
1015 }
1016 if let Some(v) = args.mv_range_high {
1017 tags.upper_mv_range = TagOrValue::Value(v);
1018 }
1019 if let Some(v) = args.mv_range_low {
1020 tags.lower_mv_range = TagOrValue::Value(v);
1021 }
1022 if let Some(d) = args.direction {
1023 tags.controller_direction = TagOrValue::Value(d.into());
1024 }
1025 Ok(tags)
1026 }
1027 DriverKindArg::Simulator => {
1028 let pv_range_high = args.pv_range_high.ok_or_else(|| {
1029 anyhow::anyhow!(
1030 "--pv-range-high is required with --driver simulator (or use `bhtune simulate`)"
1031 )
1032 })?;
1033 let pv_range_low = args.pv_range_low.ok_or_else(|| {
1034 anyhow::anyhow!(
1035 "--pv-range-low is required with --driver simulator (or use `bhtune simulate`)"
1036 )
1037 })?;
1038 let mv_range_high = args.mv_range_high.ok_or_else(|| {
1039 anyhow::anyhow!(
1040 "--mv-range-high is required with --driver simulator (or use `bhtune simulate`)"
1041 )
1042 })?;
1043 let mv_range_low = args.mv_range_low.ok_or_else(|| {
1044 anyhow::anyhow!(
1045 "--mv-range-low is required with --driver simulator (or use `bhtune simulate`)"
1046 )
1047 })?;
1048 let direction = args.direction.ok_or_else(|| {
1049 anyhow::anyhow!(
1050 "--direction is required with --driver simulator (or use `bhtune simulate`)"
1051 )
1052 })?;
1053
1054 Ok(LoopTags {
1055 process_variable: SIMULATOR_PV_TAG.to_string(),
1056 manipulated_variable: SIMULATOR_MV_TAG.to_string(),
1057 setpoint_variable: None,
1058 controller_mode: None,
1059 mode_attribute: None,
1060 upper_pv_range: TagOrValue::Value(pv_range_high),
1061 lower_pv_range: TagOrValue::Value(pv_range_low),
1062 upper_mv_range: TagOrValue::Value(mv_range_high),
1063 lower_mv_range: TagOrValue::Value(mv_range_low),
1064 controller_direction: TagOrValue::Value(direction.into()),
1065 proportional_constant: None,
1066 integral_constant: None,
1067 derivative_constant: None,
1068 })
1069 }
1070 }
1071}
1072
1073#[derive(Debug)]
1076struct InitialState {
1077 pv_ini: f32,
1078 mv_ini: f32,
1079 pv_range_high: f32,
1080 pv_range_low: f32,
1081 mv_range_high: f32,
1082 mv_range_low: f32,
1083 direction: ControllerDirection,
1084 mode_raw: Option<String>,
1085 mode_attribute_raw: Option<String>,
1086 setpoint_ini: Option<f32>,
1099}
1100
1101#[derive(Debug, Default)]
1110struct MutationGuard {
1111 mode_attribute_written: bool,
1113 mode_written: bool,
1115 mv_written: bool,
1122}
1123
1124#[derive(Debug)]
1125struct PendingMvActuation {
1126 id: Option<i64>,
1127 kind: MvActuationKind,
1128 target: f32,
1129 tolerance: f32,
1130 switch_tick: DateTime<Utc>,
1131 switch_instant: Instant,
1132 accepted_instant: Instant,
1133 first_check_at: Instant,
1134 deadline: Instant,
1135 last_readback: Option<f32>,
1136}
1137
1138#[derive(Debug)]
1139struct MvActuationTracker {
1140 next_sequence: i64,
1141 previous_commanded_mv: f32,
1142 confirmed_mv: Option<f32>,
1143 pending: Option<PendingMvActuation>,
1144 mv_span: f32,
1145}
1146
1147impl MvActuationTracker {
1148 fn for_run(args: &TuneArgs, initial: &InitialState) -> Option<Self> {
1149 (args.driver == DriverKindArg::Opcda).then_some(Self {
1150 next_sequence: 0,
1151 previous_commanded_mv: initial.mv_ini,
1152 confirmed_mv: None,
1153 pending: None,
1154 mv_span: initial.mv_range_high - initial.mv_range_low,
1155 })
1156 }
1157
1158 #[cfg(test)]
1159 #[allow(clippy::too_many_arguments)]
1160 async fn record_accepted(
1161 &mut self,
1162 pool: &SqlitePool,
1163 run_id: i64,
1164 kind: MvActuationKind,
1165 target: f32,
1166 first_check_at: Instant,
1167 accepted_at: DateTime<Utc>,
1168 accepted_instant: Instant,
1169 tolerance: f32,
1170 ) -> anyhow::Result<()> {
1171 self.record_accepted_at_switch(
1172 pool,
1173 run_id,
1174 kind,
1175 target,
1176 accepted_at,
1177 accepted_instant,
1178 first_check_at,
1179 accepted_at,
1180 accepted_instant,
1181 tolerance,
1182 )
1183 .await
1184 }
1185
1186 #[allow(clippy::too_many_arguments)]
1187 async fn record_accepted_at_switch(
1188 &mut self,
1189 pool: &SqlitePool,
1190 run_id: i64,
1191 kind: MvActuationKind,
1192 target: f32,
1193 switch_tick: DateTime<Utc>,
1194 switch_instant: Instant,
1195 first_check_at: Instant,
1196 accepted_at: DateTime<Utc>,
1197 accepted_instant: Instant,
1198 tolerance: f32,
1199 ) -> anyhow::Result<()> {
1200 let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
1201 let confirmation_due_at =
1202 accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
1203 let previous_commanded_mv = Some(self.previous_commanded_mv);
1204 let row = TuneMvActuationRow::insert_pending(
1205 pool,
1206 run_id,
1207 NewTuneMvActuation {
1208 sequence: self.next_sequence,
1209 kind,
1210 commanded_at: accepted_at,
1211 target_mv: target,
1212 previous_commanded_mv,
1213 tolerance,
1214 confirmation_due_at,
1215 },
1216 )
1217 .await?;
1218 self.accept_pending(PendingMvActuation {
1219 id: Some(row.id),
1220 kind,
1221 target,
1222 tolerance,
1223 switch_tick,
1224 switch_instant,
1225 accepted_instant,
1226 first_check_at: first_check_at.min(deadline),
1227 deadline,
1228 last_readback: None,
1229 });
1230 Ok(())
1231 }
1232
1233 #[allow(clippy::too_many_arguments)]
1234 async fn record_restore_accepted_best_effort(
1235 &mut self,
1236 pool: &SqlitePool,
1237 run_id: i64,
1238 target: f32,
1239 accepted_at: DateTime<Utc>,
1240 accepted_instant: Instant,
1241 tolerance: f32,
1242 ) {
1243 let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
1244 let confirmation_due_at =
1245 accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
1246 let pending = PendingMvActuation {
1247 id: None,
1248 kind: MvActuationKind::Restore,
1249 target,
1250 tolerance,
1251 switch_tick: accepted_at,
1252 switch_instant: accepted_instant,
1253 accepted_instant,
1254 first_check_at: accepted_instant,
1255 deadline,
1256 last_readback: None,
1257 };
1258 let row = TuneMvActuationRow::insert_pending(
1259 pool,
1260 run_id,
1261 NewTuneMvActuation {
1262 sequence: self.next_sequence,
1263 kind: MvActuationKind::Restore,
1264 commanded_at: accepted_at,
1265 target_mv: target,
1266 previous_commanded_mv: Some(self.previous_commanded_mv),
1267 tolerance,
1268 confirmation_due_at,
1269 },
1270 )
1271 .await;
1272 let mut pending = pending;
1273 match row {
1274 Ok(row) => pending.id = Some(row.id),
1275 Err(error) => {
1276 tracing::error!(
1277 run_id,
1278 error = %error,
1279 "failed to record accepted restore MV command; continuing physical restore"
1280 );
1281 }
1282 }
1283 self.accept_pending(pending);
1284 }
1285
1286 fn accept_pending(&mut self, pending: PendingMvActuation) {
1287 self.next_sequence += 1;
1288 self.previous_commanded_mv = pending.target;
1289 self.confirmed_mv = None;
1290 self.pending = Some(pending);
1291 }
1292
1293 fn next_verification_wakeup(&self) -> Option<Instant> {
1294 self.pending.as_ref().map(|pending| {
1295 if pending.last_readback.is_some() {
1296 pending.deadline
1297 } else {
1298 pending
1299 .first_check_at
1300 .max(pending.deadline - MV_ACTUATION_FALLBACK_HEADROOM)
1301 }
1302 })
1303 }
1304}
1305
1306#[allow(clippy::too_many_arguments)]
1307async fn record_relay_actuation(
1308 tracker: &mut MvActuationTracker,
1309 pool: &SqlitePool,
1310 run_id: i64,
1311 target: f32,
1312 switch_tick: DateTime<Utc>,
1313 switch_instant: Instant,
1314 first_check_at: Instant,
1315 elapsed_since_observation: Duration,
1316 accepted_instant: Instant,
1317 tolerance: f32,
1318) -> anyhow::Result<()> {
1319 let accepted_at = utc_after_elapsed(switch_tick, elapsed_since_observation)?;
1320 tracker
1321 .record_accepted_at_switch(
1322 pool,
1323 run_id,
1324 MvActuationKind::Relay,
1325 target,
1326 switch_tick,
1327 switch_instant,
1328 first_check_at,
1329 accepted_at,
1330 accepted_instant,
1331 tolerance,
1332 )
1333 .await
1334}
1335
1336fn f32_precision_floor(target: f32, previous: f32) -> f32 {
1337 4.0 * f32::EPSILON * target.abs().max(previous.abs()).max(1.0)
1338}
1339
1340fn mv_actuation_tolerance(
1341 kind: MvActuationKind,
1342 target: f32,
1343 previous: f32,
1344 mv_span: f32,
1345) -> anyhow::Result<f32> {
1346 let uncapped = mv_actuation_uncapped_tolerance(target, previous, mv_span);
1347 if kind == MvActuationKind::Restore {
1348 return Ok(uncapped);
1349 }
1350
1351 let step = (target - previous).abs();
1352 let relay_cap = step * RELAY_STEP_TOLERANCE_FRACTION;
1353 if !step.is_finite()
1354 || step < MIN_RELAY_STEP
1355 || relay_cap <= f32_precision_floor(target, previous)
1356 {
1357 let minimum_step = MIN_RELAY_STEP
1358 .max(f32_precision_floor(target, previous) / RELAY_STEP_TOLERANCE_FRACTION);
1359 anyhow::bail!(
1360 "the effective relay step {step} is too small to verify safely (minimum {})",
1361 minimum_step
1362 );
1363 }
1364 Ok(uncapped.min(relay_cap))
1365}
1366
1367fn mv_actuation_uncapped_tolerance(target: f32, previous: f32, mv_span: f32) -> f32 {
1368 let precision_floor = f32_precision_floor(target, previous);
1369 let span_tolerance = mv_span.abs() * MV_SPAN_TOLERANCE_FRACTION;
1370 precision_floor + span_tolerance
1371}
1372
1373fn validate_relay_actuation_step(
1374 args: &TuneArgs,
1375 config: LoopConfig,
1376 initial: &InitialState,
1377) -> anyhow::Result<()> {
1378 if args.driver != DriverKindArg::Opcda {
1379 return Ok(());
1380 }
1381 let relay_step = clamp_relay_amplitude(
1382 config.relay_amp_percent,
1383 initial.mv_ini,
1384 initial.mv_range_low,
1385 initial.mv_range_high,
1386 MrftCompat::default(),
1387 );
1388 mv_actuation_tolerance(
1389 MvActuationKind::Relay,
1390 initial.mv_ini + relay_step,
1391 initial.mv_ini,
1392 initial.mv_range_high - initial.mv_range_low,
1393 )
1394 .map(|_| ())
1395}
1396
1397async fn persist_completed_results(
1402 pool: &SqlitePool,
1403 run_id: i64,
1404 completion: Action,
1405 direction: ControllerDirection,
1406 config: LoopConfig,
1407 pv_range: PvRange,
1408 template: &DcsTemplate,
1409) -> anyhow::Result<()> {
1410 persist_results(
1411 pool, run_id, completion, direction, config, pv_range, template,
1412 )
1413 .await
1414}
1415
1416#[allow(clippy::too_many_arguments)]
1427async fn execute_with_timing<R: std::io::BufRead>(
1428 pool: &SqlitePool,
1429 run_id: i64,
1430 args: &TuneArgs,
1431 template: &DcsTemplate,
1432 tags: &LoopTags,
1433 driver: &dyn Driver,
1434 config: LoopConfig,
1435 effective_timing: EffectiveTiming,
1436 time_anchor: RunTimeAnchor,
1437 write_pid: Option<ResponseLevel>,
1438 allow_uncertain_quality: bool,
1439 ctrl_c: &mut CtrlC,
1440 reader: &mut R,
1441) -> anyhow::Result<RunOutcome> {
1442 let started_at = time_anchor.utc();
1443 let initial = read_initial_values(driver, tags, template, allow_uncertain_quality).await?;
1444 validate_initial_state(&initial)?;
1445 validate_relay_actuation_step(args, config, &initial)?;
1446
1447 TuneRunRow::record_initial_readings(
1452 pool,
1453 run_id,
1454 TuneRunInitialReadings {
1455 pv_ini: initial.pv_ini,
1456 mv_ini: initial.mv_ini,
1457 mv_range_low: initial.mv_range_low,
1458 mv_range_high: initial.mv_range_high,
1459 pv_range_high: initial.pv_range_high,
1460 pv_range_low: initial.pv_range_low,
1461 controller_direction: initial.direction,
1462 mode_raw: initial.mode_raw.clone(),
1463 mode_attribute_raw: initial.mode_attribute_raw.clone(),
1464 setpoint_ini: initial.setpoint_ini,
1465 },
1466 )
1467 .await?;
1468
1469 let mut guard = MutationGuard::default();
1470 let mut mv_actuations = MvActuationTracker::for_run(args, &initial);
1471 if let Err(e) = transition_to_manual(driver, tags, template, &initial, &mut guard).await {
1472 return Err(restore_best_effort_then_propagate_with_timing(
1473 pool,
1474 run_id,
1475 driver,
1476 tags,
1477 template,
1478 &initial,
1479 &guard,
1480 args,
1481 effective_timing,
1482 allow_uncertain_quality,
1483 ctrl_c,
1484 &mut mv_actuations,
1485 e,
1486 )
1487 .await);
1488 }
1489
1490 let beta = lookup(
1491 config.process_type,
1492 config.controller_type,
1493 ResponseLevel::Aggressive,
1494 )
1495 .beta;
1496
1497 let mut engine = MrftEngine::new(
1498 config,
1499 initial.direction,
1500 beta,
1501 InitialReadings {
1502 pv_ini: initial.pv_ini,
1503 mv_ini: initial.mv_ini,
1504 mv_range_low: initial.mv_range_low,
1505 mv_range_high: initial.mv_range_high,
1506 },
1507 started_at,
1508 MrftCompat::default(),
1509 );
1510 let timing_basis = match args.driver {
1511 DriverKindArg::Opcda => TimingBasis::LiveMonotonic,
1512 DriverKindArg::Simulator => TimingBasis::SimulatedFixedStep,
1513 };
1514 let mut timing = PollTimingAccumulator::new(timing_basis, effective_timing.poll_interval_ms);
1515
1516 let poll_result = run_polling_loop_with_timing(
1517 pool,
1518 run_id,
1519 args,
1520 effective_timing,
1521 tags,
1522 driver,
1523 &mut engine,
1524 time_anchor,
1525 ctrl_c,
1526 &mut guard,
1527 allow_uncertain_quality,
1528 &mut timing,
1529 &mut mv_actuations,
1530 config,
1531 )
1532 .await;
1533 let measured_oscillation_period_ms = completed_oscillation_period_ms(
1534 &poll_result,
1535 initial.direction,
1536 config,
1537 PvRange {
1538 high: initial.pv_range_high,
1539 low: initial.pv_range_low,
1540 },
1541 );
1542 let timing_metrics_without_period = timing.finish(None);
1543 if let Some(timing_metrics) = timing_metrics_without_period.as_ref() {
1544 warn_on_missed_poll_opportunities(run_id, timing_metrics);
1545 }
1546
1547 match poll_result {
1548 Ok(PollOutcome::Completed(completion)) => {
1549 finish_completed_run(
1550 pool,
1551 run_id,
1552 args,
1553 effective_timing,
1554 template,
1555 tags,
1556 driver,
1557 config,
1558 &initial,
1559 &guard,
1560 write_pid,
1561 allow_uncertain_quality,
1562 ctrl_c,
1563 reader,
1564 &mut mv_actuations,
1565 completion,
1566 &mut timing,
1567 timing_metrics_without_period,
1568 measured_oscillation_period_ms,
1569 )
1570 .await
1571 }
1572 Ok(PollOutcome::Aborted(reason)) => {
1573 finish_aborted_run(
1574 pool,
1575 run_id,
1576 args,
1577 effective_timing,
1578 template,
1579 tags,
1580 driver,
1581 &initial,
1582 &guard,
1583 allow_uncertain_quality,
1584 ctrl_c,
1585 &mut mv_actuations,
1586 reason,
1587 timing_metrics_without_period,
1588 )
1589 .await
1590 }
1591 Err(error) => {
1592 finish_failed_run(
1593 pool,
1594 run_id,
1595 template,
1596 tags,
1597 driver,
1598 &initial,
1599 &guard,
1600 args,
1601 effective_timing,
1602 allow_uncertain_quality,
1603 ctrl_c,
1604 &mut mv_actuations,
1605 error,
1606 timing_metrics_without_period,
1607 )
1608 .await
1609 }
1610 }
1611}
1612
1613#[cfg(test)]
1614#[allow(clippy::too_many_arguments)]
1615async fn execute<R: std::io::BufRead>(
1616 pool: &SqlitePool,
1617 run_id: i64,
1618 args: &TuneArgs,
1619 template: &DcsTemplate,
1620 tags: &LoopTags,
1621 driver: &dyn Driver,
1622 config: LoopConfig,
1623 time_anchor: RunTimeAnchor,
1624 write_pid: Option<ResponseLevel>,
1625 allow_uncertain_quality: bool,
1626 ctrl_c: &mut CtrlC,
1627 reader: &mut R,
1628) -> anyhow::Result<RunOutcome> {
1629 execute_with_timing(
1630 pool,
1631 run_id,
1632 args,
1633 template,
1634 tags,
1635 driver,
1636 config,
1637 test_effective_timing(args),
1638 time_anchor,
1639 write_pid,
1640 allow_uncertain_quality,
1641 ctrl_c,
1642 reader,
1643 )
1644 .await
1645}
1646
1647#[allow(clippy::too_many_arguments)]
1648async fn attempt_and_record_restore(
1649 pool: &SqlitePool,
1650 run_id: i64,
1651 args: &TuneArgs,
1652 effective_timing: EffectiveTiming,
1653 driver: &dyn Driver,
1654 tags: &LoopTags,
1655 template: &DcsTemplate,
1656 initial: &InitialState,
1657 guard: &MutationGuard,
1658 allow_uncertain_quality: bool,
1659 ctrl_c: &mut CtrlC,
1660 mv_actuations: &mut Option<MvActuationTracker>,
1661) -> RestoreAttempt {
1662 let restore_attempt = attempt_restore_with_actuation_with_timing(
1663 pool,
1664 run_id,
1665 args,
1666 effective_timing,
1667 driver,
1668 tags,
1669 template,
1670 initial,
1671 guard,
1672 allow_uncertain_quality,
1673 ctrl_c,
1674 mv_actuations,
1675 )
1676 .await;
1677 record_restore_status_best_effort(pool, run_id, &restore_attempt).await;
1678 finalize_pending_for_run_best_effort(
1679 pool,
1680 run_id,
1681 "the run ended before MV confirmation completed",
1682 )
1683 .await;
1684 restore_attempt
1685}
1686
1687#[allow(clippy::too_many_arguments)]
1688async fn finish_completed_run<R: std::io::BufRead>(
1689 pool: &SqlitePool,
1690 run_id: i64,
1691 args: &TuneArgs,
1692 effective_timing: EffectiveTiming,
1693 template: &DcsTemplate,
1694 tags: &LoopTags,
1695 driver: &dyn Driver,
1696 config: LoopConfig,
1697 initial: &InitialState,
1698 guard: &MutationGuard,
1699 write_pid: Option<ResponseLevel>,
1700 allow_uncertain_quality: bool,
1701 ctrl_c: &mut CtrlC,
1702 reader: &mut R,
1703 mv_actuations: &mut Option<MvActuationTracker>,
1704 completion: Action,
1705 timing: &mut PollTimingAccumulator,
1706 timing_metrics_without_period: Option<TimingMetrics>,
1707 measured_oscillation_period_ms: Option<f64>,
1708) -> anyhow::Result<RunOutcome> {
1709 let pv_range = PvRange {
1710 high: initial.pv_range_high,
1711 low: initial.pv_range_low,
1712 };
1713 if let Err(error) = persist_completed_results(
1714 pool,
1715 run_id,
1716 completion,
1717 initial.direction,
1718 config,
1719 pv_range,
1720 template,
1721 )
1722 .await
1723 {
1724 let error = restore_best_effort_then_propagate_with_timing(
1725 pool,
1726 run_id,
1727 driver,
1728 tags,
1729 template,
1730 initial,
1731 guard,
1732 args,
1733 effective_timing,
1734 allow_uncertain_quality,
1735 ctrl_c,
1736 mv_actuations,
1737 error,
1738 )
1739 .await;
1740 record_timing_metrics_if_present(pool, run_id, timing_metrics_without_period).await;
1741 return Err(error);
1742 }
1743
1744 let restore_attempt = attempt_and_record_restore(
1745 pool,
1746 run_id,
1747 args,
1748 effective_timing,
1749 driver,
1750 tags,
1751 template,
1752 initial,
1753 guard,
1754 allow_uncertain_quality,
1755 ctrl_c,
1756 mv_actuations,
1757 )
1758 .await;
1759 TuneRunRow::complete_with_timing_metrics(
1760 pool,
1761 run_id,
1762 Utc::now(),
1763 timing.finish(measured_oscillation_period_ms),
1764 )
1765 .await?;
1766 match restore_attempt {
1767 RestoreAttempt::Confirmed => {
1768 let (write_back, write_back_detail) = maybe_write_back(
1769 pool,
1770 run_id,
1771 tags,
1772 template,
1773 driver,
1774 config,
1775 write_pid,
1776 args.output,
1777 allow_uncertain_quality,
1778 reader,
1779 )
1780 .await?;
1781 Ok(RunOutcome::Completed {
1782 write_back,
1783 write_back_detail,
1784 })
1785 }
1786 RestoreAttempt::Incomplete { reason } => Ok(RunOutcome::RestoreIncomplete { reason }),
1787 }
1788}
1789
1790#[allow(clippy::too_many_arguments)]
1791async fn finish_aborted_run(
1792 pool: &SqlitePool,
1793 run_id: i64,
1794 args: &TuneArgs,
1795 effective_timing: EffectiveTiming,
1796 template: &DcsTemplate,
1797 tags: &LoopTags,
1798 driver: &dyn Driver,
1799 initial: &InitialState,
1800 guard: &MutationGuard,
1801 allow_uncertain_quality: bool,
1802 ctrl_c: &mut CtrlC,
1803 mv_actuations: &mut Option<MvActuationTracker>,
1804 reason: AbortReason,
1805 timing_metrics_without_period: Option<TimingMetrics>,
1806) -> anyhow::Result<RunOutcome> {
1807 let restore_attempt = attempt_and_record_restore(
1808 pool,
1809 run_id,
1810 args,
1811 effective_timing,
1812 driver,
1813 tags,
1814 template,
1815 initial,
1816 guard,
1817 allow_uncertain_quality,
1818 ctrl_c,
1819 mv_actuations,
1820 )
1821 .await;
1822 if matches!(reason, AbortReason::MvActuationUnconfirmed { .. }) {
1823 TuneRunRow::abort_with_timing_metrics_and_reason(
1824 pool,
1825 run_id,
1826 Utc::now(),
1827 timing_metrics_without_period,
1828 &format_mv_actuation_abort_reason(&reason),
1829 )
1830 .await?;
1831 } else {
1832 TuneRunRow::abort_with_timing_metrics(
1833 pool,
1834 run_id,
1835 Utc::now(),
1836 timing_metrics_without_period,
1837 )
1838 .await?;
1839 }
1840 match restore_attempt {
1841 RestoreAttempt::Confirmed => Ok(RunOutcome::Aborted(reason)),
1842 RestoreAttempt::Incomplete {
1843 reason: restore_reason,
1844 } => Ok(RunOutcome::RestoreIncomplete {
1845 reason: format!("run aborted ({reason:?}); {restore_reason}"),
1846 }),
1847 }
1848}
1849
1850#[allow(clippy::too_many_arguments)]
1851async fn finish_failed_run(
1852 pool: &SqlitePool,
1853 run_id: i64,
1854 template: &DcsTemplate,
1855 tags: &LoopTags,
1856 driver: &dyn Driver,
1857 initial: &InitialState,
1858 guard: &MutationGuard,
1859 args: &TuneArgs,
1860 effective_timing: EffectiveTiming,
1861 allow_uncertain_quality: bool,
1862 ctrl_c: &mut CtrlC,
1863 mv_actuations: &mut Option<MvActuationTracker>,
1864 error: anyhow::Error,
1865 timing_metrics_without_period: Option<TimingMetrics>,
1866) -> anyhow::Result<RunOutcome> {
1867 let error = restore_best_effort_then_propagate_with_timing(
1872 pool,
1873 run_id,
1874 driver,
1875 tags,
1876 template,
1877 initial,
1878 guard,
1879 args,
1880 effective_timing,
1881 allow_uncertain_quality,
1882 ctrl_c,
1883 mv_actuations,
1884 error,
1885 )
1886 .await;
1887 record_timing_metrics_if_present(pool, run_id, timing_metrics_without_period).await;
1888 Err(error)
1889}
1890
1891fn check_quality(
1898 tag: &str,
1899 quality: bhtune_driver::Quality,
1900 allow_uncertain: bool,
1901) -> anyhow::Result<()> {
1902 match quality {
1903 bhtune_driver::Quality::Good => Ok(()),
1904 bhtune_driver::Quality::Uncertain if allow_uncertain => {
1905 tracing::warn!(
1906 tag,
1907 "accepting Uncertain-quality reading because Config > OPC quality policy \
1908 (allow_uncertain_quality) permits it"
1909 );
1910 Ok(())
1911 }
1912 bhtune_driver::Quality::Uncertain => {
1913 anyhow::bail!(
1914 "tag '{tag}' reported OPC quality Uncertain; refusing to trust it for a \
1915 tuning-critical reading (set Config > OPC quality policy \
1916 `allow_uncertain_quality = true` to accept Uncertain readings; Bad is never \
1917 accepted)"
1918 )
1919 }
1920 bhtune_driver::Quality::Bad => {
1921 anyhow::bail!(
1922 "tag '{tag}' reported OPC quality Bad; refusing to trust it for a \
1923 tuning-critical reading"
1924 )
1925 }
1926 }
1927}
1928
1929pub fn sample_quality_from_driver(quality: bhtune_driver::Quality) -> SampleQuality {
1938 match quality {
1939 bhtune_driver::Quality::Good => SampleQuality::Good,
1940 bhtune_driver::Quality::Uncertain => SampleQuality::Uncertain,
1941 bhtune_driver::Quality::Bad => SampleQuality::Bad,
1942 }
1943}
1944
1945async fn read_raw(driver: &dyn Driver, tag: &str, allow_uncertain: bool) -> anyhow::Result<String> {
1946 let values = driver.read(&[tag.to_string()]).await?;
1947 let value = values
1948 .into_iter()
1949 .next()
1950 .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
1951 check_quality(tag, value.quality, allow_uncertain)?;
1952 Ok(value.value)
1953}
1954
1955async fn read_f32(driver: &dyn Driver, tag: &str, allow_uncertain: bool) -> anyhow::Result<f32> {
1956 let raw = read_raw(driver, tag, allow_uncertain).await?;
1957 parse_f32_value(tag, &raw)
1958}
1959
1960async fn resolve_f32(
1961 driver: &dyn Driver,
1962 tag_or_value: &TagOrValue<f32>,
1963 allow_uncertain: bool,
1964) -> anyhow::Result<f32> {
1965 match tag_or_value {
1966 TagOrValue::Value(v) => {
1967 if !v.is_finite() {
1968 anyhow::bail!("value {v} is not a finite number");
1969 }
1970 Ok(*v)
1971 }
1972 TagOrValue::Tag(tag) => read_f32(driver, tag, allow_uncertain).await,
1973 }
1974}
1975
1976async fn resolve_direction(
1977 driver: &dyn Driver,
1978 tag_or_value: &TagOrValue<ControllerDirection>,
1979 template: &DcsTemplate,
1980 allow_uncertain: bool,
1981) -> anyhow::Result<ControllerDirection> {
1982 match tag_or_value {
1983 TagOrValue::Value(d) => Ok(*d),
1984 TagOrValue::Tag(tag) => {
1985 let raw = read_raw(driver, tag, allow_uncertain).await?;
1986 Ok(ControllerDirection::from_raw_tag_value(
1987 &raw,
1988 &template.controller_action_direct_value,
1989 ))
1990 }
1991 }
1992}
1993
1994fn parse_f32_value(tag: &str, raw: &str) -> anyhow::Result<f32> {
1995 let value: f32 = raw
1996 .trim()
1997 .parse::<f32>()
1998 .map_err(|_| anyhow::anyhow!("tag '{tag}' value '{raw}' is not a number"))?;
1999 if !value.is_finite() {
2000 anyhow::bail!("tag '{tag}' value '{raw}' is not a finite number");
2001 }
2002 Ok(value)
2003}
2004
2005fn read_batch_raw(
2006 values: &HashMap<String, TagValue>,
2007 tag: &str,
2008 allow_uncertain: bool,
2009) -> anyhow::Result<String> {
2010 let value = values
2011 .get(tag)
2012 .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
2013 check_quality(tag, value.quality, allow_uncertain)?;
2014 Ok(value.value.clone())
2015}
2016
2017fn read_batch_f32(
2018 values: &HashMap<String, TagValue>,
2019 tag: &str,
2020 allow_uncertain: bool,
2021) -> anyhow::Result<f32> {
2022 let raw = read_batch_raw(values, tag, allow_uncertain)?;
2023 parse_f32_value(tag, &raw)
2024}
2025
2026async fn resolve_f32_from_batch(
2027 driver: &dyn Driver,
2028 values: &HashMap<String, TagValue>,
2029 tag_or_value: &TagOrValue<f32>,
2030 allow_uncertain: bool,
2031) -> anyhow::Result<f32> {
2032 match tag_or_value {
2033 TagOrValue::Value(_) => resolve_f32(driver, tag_or_value, allow_uncertain).await,
2034 TagOrValue::Tag(tag) => read_batch_f32(values, tag, allow_uncertain),
2035 }
2036}
2037
2038async fn resolve_direction_from_batch(
2039 driver: &dyn Driver,
2040 values: &HashMap<String, TagValue>,
2041 tag_or_value: &TagOrValue<ControllerDirection>,
2042 template: &DcsTemplate,
2043 allow_uncertain: bool,
2044) -> anyhow::Result<ControllerDirection> {
2045 match tag_or_value {
2046 TagOrValue::Value(_) => {
2047 resolve_direction(driver, tag_or_value, template, allow_uncertain).await
2048 }
2049 TagOrValue::Tag(tag) => {
2050 let raw = read_batch_raw(values, tag, allow_uncertain)?;
2051 Ok(ControllerDirection::from_raw_tag_value(
2052 &raw,
2053 &template.controller_action_direct_value,
2054 ))
2055 }
2056 }
2057}
2058
2059#[cfg(test)]
2065async fn read_pv_sample(
2066 driver: &dyn Driver,
2067 tag: &str,
2068) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
2069 read_numeric_sample(driver, tag).await
2070}
2071
2072async fn read_numeric_sample(
2073 driver: &dyn Driver,
2074 tag: &str,
2075) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
2076 let values = driver.read(&[tag.to_string()]).await?;
2077 let value = values
2078 .into_iter()
2079 .next()
2080 .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
2081 let numeric: f32 = value
2082 .value
2083 .trim()
2084 .parse::<f32>()
2085 .map_err(|_| anyhow::anyhow!("tag '{tag}' value '{}' is not a number", value.value))?;
2086 if !numeric.is_finite() {
2087 anyhow::bail!("tag '{tag}' value '{}' is not a finite number", value.value);
2088 }
2089 Ok((numeric, value.quality))
2090}
2091
2092async fn read_poll_batch(
2093 driver: &dyn Driver,
2094 pv_tag: &str,
2095 mv_tag: Option<&str>,
2096) -> anyhow::Result<HashMap<String, TagValue>> {
2097 let mut requested_tags = vec![pv_tag.to_string()];
2098 if let Some(mv_tag) = mv_tag
2099 && mv_tag != pv_tag
2100 {
2101 requested_tags.push(mv_tag.to_string());
2102 }
2103
2104 Ok(driver
2105 .read(&requested_tags)
2106 .await?
2107 .into_iter()
2108 .map(|value| (value.tag.clone(), value))
2109 .collect())
2110}
2111
2112fn read_numeric_from_batch(
2113 values: &HashMap<String, TagValue>,
2114 tag: &str,
2115) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
2116 let value = values
2117 .get(tag)
2118 .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
2119 let numeric = parse_f32_value(tag, &value.value)?;
2120 Ok((numeric, value.quality))
2121}
2122
2123async fn write_raw(driver: &dyn Driver, tag: &str, value: String) -> anyhow::Result<()> {
2124 let outcome = driver.write(&tag.to_string(), TagWrite::Raw(value)).await?;
2125 if outcome.success {
2126 Ok(())
2127 } else {
2128 anyhow::bail!(
2129 "write to '{tag}' was rejected: {}",
2130 outcome
2131 .error_message
2132 .unwrap_or_else(|| "unknown reason".to_string())
2133 )
2134 }
2135}
2136
2137async fn write_value(driver: &dyn Driver, tag: &str, value: f32) -> anyhow::Result<()> {
2138 let outcome = driver
2139 .write(&tag.to_string(), TagWrite::Float(value))
2140 .await?;
2141 if outcome.success {
2142 Ok(())
2143 } else {
2144 anyhow::bail!(
2145 "write to '{tag}' was rejected: {}",
2146 outcome
2147 .error_message
2148 .unwrap_or_else(|| "unknown reason".to_string())
2149 )
2150 }
2151}
2152
2153async fn read_initial_values(
2155 driver: &dyn Driver,
2156 tags: &LoopTags,
2157 template: &DcsTemplate,
2158 allow_uncertain: bool,
2159) -> anyhow::Result<InitialState> {
2160 let mut requested_tags = Vec::new();
2161 let mut seen_tags = HashSet::new();
2162 let mut request_tag = |tag: &str| {
2163 if seen_tags.insert(tag.to_string()) {
2164 requested_tags.push(tag.to_string());
2165 }
2166 };
2167
2168 request_tag(&tags.process_variable);
2169 request_tag(&tags.manipulated_variable);
2170 if let Some(tag) = &tags.controller_mode {
2171 request_tag(tag);
2172 }
2173 if let Some(tag) = &tags.mode_attribute {
2174 request_tag(tag);
2175 }
2176 if let TagOrValue::Tag(tag) = &tags.controller_direction {
2177 request_tag(tag.as_str());
2178 }
2179 for tag_or_value in [
2180 &tags.upper_pv_range,
2181 &tags.lower_pv_range,
2182 &tags.upper_mv_range,
2183 &tags.lower_mv_range,
2184 ] {
2185 if let TagOrValue::Tag(tag) = tag_or_value {
2186 request_tag(tag.as_str());
2187 }
2188 }
2189
2190 let values_by_tag: HashMap<String, TagValue> = driver
2191 .read(&requested_tags)
2192 .await?
2193 .into_iter()
2194 .map(|value| (value.tag.clone(), value))
2195 .collect();
2196
2197 let pv_ini = read_batch_f32(&values_by_tag, &tags.process_variable, allow_uncertain)?;
2198 let mv_ini = read_batch_f32(&values_by_tag, &tags.manipulated_variable, allow_uncertain)?;
2199
2200 let mode_raw = match &tags.controller_mode {
2201 Some(tag) => Some(read_batch_raw(&values_by_tag, tag, allow_uncertain)?),
2202 None => None,
2203 };
2204 let mode_attribute_raw = match &tags.mode_attribute {
2205 Some(tag) => Some(read_batch_raw(&values_by_tag, tag, allow_uncertain)?),
2206 None => None,
2207 };
2208
2209 let setpoint_ini = match (&tags.setpoint_variable, &mode_raw) {
2213 (Some(sv_tag), Some(mode_raw)) if mode_raw == &template.mode_auto_value => {
2214 Some(read_f32(driver, sv_tag, allow_uncertain).await?)
2215 }
2216 _ => None,
2217 };
2218
2219 let direction = resolve_direction_from_batch(
2220 driver,
2221 &values_by_tag,
2222 &tags.controller_direction,
2223 template,
2224 allow_uncertain,
2225 )
2226 .await?;
2227 let pv_range_high = resolve_f32_from_batch(
2228 driver,
2229 &values_by_tag,
2230 &tags.upper_pv_range,
2231 allow_uncertain,
2232 )
2233 .await?;
2234 let pv_range_low = resolve_f32_from_batch(
2235 driver,
2236 &values_by_tag,
2237 &tags.lower_pv_range,
2238 allow_uncertain,
2239 )
2240 .await?;
2241 let mv_range_high = resolve_f32_from_batch(
2242 driver,
2243 &values_by_tag,
2244 &tags.upper_mv_range,
2245 allow_uncertain,
2246 )
2247 .await?;
2248 let mv_range_low = resolve_f32_from_batch(
2249 driver,
2250 &values_by_tag,
2251 &tags.lower_mv_range,
2252 allow_uncertain,
2253 )
2254 .await?;
2255
2256 Ok(InitialState {
2257 pv_ini,
2258 mv_ini,
2259 pv_range_high,
2260 pv_range_low,
2261 mv_range_high,
2262 mv_range_low,
2263 direction,
2264 mode_raw,
2265 mode_attribute_raw,
2266 setpoint_ini,
2267 })
2268}
2269
2270fn validate_initial_state(initial: &InitialState) -> anyhow::Result<()> {
2278 PvRange::new(initial.pv_range_high, initial.pv_range_low)
2279 .map_err(|e| anyhow::anyhow!("invalid PV range: {e}"))?;
2280 let mv_range = MvRange::new(initial.mv_range_high, initial.mv_range_low)
2281 .map_err(|e| anyhow::anyhow!("invalid MV range: {e}"))?;
2282 if !mv_range.contains(initial.mv_ini) {
2283 anyhow::bail!(
2284 "initial MV {} is outside the MV range [{}, {}]",
2285 initial.mv_ini,
2286 initial.mv_range_low,
2287 initial.mv_range_high
2288 );
2289 }
2290 Ok(())
2291}
2292
2293async fn transition_to_manual(
2299 driver: &dyn Driver,
2300 tags: &LoopTags,
2301 template: &DcsTemplate,
2302 initial: &InitialState,
2303 guard: &mut MutationGuard,
2304) -> anyhow::Result<()> {
2305 if let (Some(attr_tag), Some(program_value)) =
2306 (&tags.mode_attribute, &template.mode_attribute_program_value)
2307 {
2308 guard.mode_attribute_written = true;
2309 write_raw(driver, attr_tag, program_value.clone()).await?;
2310 tokio::time::sleep(Duration::from_millis(1000)).await;
2311 }
2312
2313 if let Some(mode_tag) = &tags.controller_mode {
2314 let mode_raw = initial.mode_raw.as_deref().unwrap_or_default();
2315 if mode_raw != template.mode_manual_value {
2316 guard.mode_written = true;
2317 write_raw(driver, mode_tag, template.mode_manual_value.clone()).await?;
2318 }
2319 }
2320
2321 Ok(())
2322}
2323
2324#[derive(Debug, Clone, PartialEq, Default)]
2328enum RestoreStepOutcome {
2329 #[default]
2330 NotNeeded,
2331 Succeeded,
2332 Failed(String),
2333}
2334
2335#[derive(Debug, Clone, Default)]
2340struct RestoreReport {
2341 mv: RestoreStepOutcome,
2342 mode: RestoreStepOutcome,
2343 setpoint: RestoreStepOutcome,
2344 mode_attribute: RestoreStepOutcome,
2345}
2346
2347impl RestoreReport {
2348 fn all_succeeded(&self) -> bool {
2352 [&self.mv, &self.mode, &self.setpoint, &self.mode_attribute]
2353 .into_iter()
2354 .all(|step| !matches!(step, RestoreStepOutcome::Failed(_)))
2355 }
2356
2357 fn failure_summary(&self) -> Option<String> {
2361 let labelled = [
2362 ("MV", &self.mv),
2363 ("mode", &self.mode),
2364 ("setpoint", &self.setpoint),
2365 ("mode attribute", &self.mode_attribute),
2366 ];
2367 let failures: Vec<String> = labelled
2368 .into_iter()
2369 .filter_map(|(label, step)| match step {
2370 RestoreStepOutcome::Failed(e) => Some(format!("{label}: {e}")),
2371 _ => None,
2372 })
2373 .collect();
2374 if failures.is_empty() {
2375 None
2376 } else {
2377 Some(failures.join("; "))
2378 }
2379 }
2380}
2381
2382async fn restore_after_mv(
2393 driver: &dyn Driver,
2394 tags: &LoopTags,
2395 template: &DcsTemplate,
2396 initial: &InitialState,
2397 guard: &MutationGuard,
2398 mv: RestoreStepOutcome,
2399) -> RestoreReport {
2400 let mode = restore_mode_step(driver, tags, template, initial, guard).await;
2401 let setpoint = restore_setpoint_step(driver, tags, template, initial, guard).await;
2402 let mode_attribute = restore_mode_attribute_step(driver, tags, template, initial, guard).await;
2403
2404 RestoreReport {
2405 mv,
2406 mode,
2407 setpoint,
2408 mode_attribute,
2409 }
2410}
2411
2412async fn restore_value_step(driver: &dyn Driver, tag: &str, value: f32) -> RestoreStepOutcome {
2413 match write_value(driver, tag, value).await {
2414 Ok(()) => RestoreStepOutcome::Succeeded,
2415 Err(e) => RestoreStepOutcome::Failed(e.to_string()),
2416 }
2417}
2418
2419#[cfg(test)]
2420async fn restore(
2421 driver: &dyn Driver,
2422 tags: &LoopTags,
2423 template: &DcsTemplate,
2424 initial: &InitialState,
2425 guard: &MutationGuard,
2426) -> RestoreReport {
2427 let mv = restore_value_step(driver, &tags.manipulated_variable, initial.mv_ini).await;
2428 tokio::time::sleep(Duration::from_millis(1000)).await;
2429 restore_after_mv(driver, tags, template, initial, guard, mv).await
2430}
2431
2432async fn restore_raw_step(driver: &dyn Driver, tag: &str, value: &str) -> RestoreStepOutcome {
2433 match write_raw(driver, tag, value.to_string()).await {
2434 Ok(()) => RestoreStepOutcome::Succeeded,
2435 Err(e) => RestoreStepOutcome::Failed(e.to_string()),
2436 }
2437}
2438
2439async fn restore_mode_step(
2440 driver: &dyn Driver,
2441 tags: &LoopTags,
2442 template: &DcsTemplate,
2443 initial: &InitialState,
2444 guard: &MutationGuard,
2445) -> RestoreStepOutcome {
2446 let Some(mode_tag) = &tags.controller_mode else {
2447 return RestoreStepOutcome::NotNeeded;
2448 };
2449 let mode_raw = initial.mode_raw.as_deref().unwrap_or_default();
2450 if !guard.mode_written || !template.revert_mode || mode_raw == template.mode_manual_value {
2451 return RestoreStepOutcome::NotNeeded;
2452 }
2453 restore_raw_step(driver, mode_tag, mode_raw).await
2454}
2455
2456async fn restore_setpoint_step(
2457 driver: &dyn Driver,
2458 tags: &LoopTags,
2459 template: &DcsTemplate,
2460 initial: &InitialState,
2461 guard: &MutationGuard,
2462) -> RestoreStepOutcome {
2463 if !guard.mode_written || !template.revert_mode {
2464 return RestoreStepOutcome::NotNeeded;
2465 }
2466 let (Some(sv_tag), Some(sv_ini)) = (&tags.setpoint_variable, initial.setpoint_ini) else {
2467 return RestoreStepOutcome::NotNeeded;
2468 };
2469 tokio::time::sleep(Duration::from_millis(1000)).await;
2470 restore_value_step(driver, sv_tag, sv_ini).await
2471}
2472
2473async fn restore_mode_attribute_step(
2474 driver: &dyn Driver,
2475 tags: &LoopTags,
2476 template: &DcsTemplate,
2477 initial: &InitialState,
2478 guard: &MutationGuard,
2479) -> RestoreStepOutcome {
2480 let Some(attr_tag) = &tags.mode_attribute else {
2481 return RestoreStepOutcome::NotNeeded;
2482 };
2483 let attr_raw = initial.mode_attribute_raw.as_deref().unwrap_or_default();
2484 let program_value = template
2485 .mode_attribute_program_value
2486 .as_deref()
2487 .unwrap_or_default();
2488 if !guard.mode_attribute_written || attr_raw == program_value {
2489 return RestoreStepOutcome::NotNeeded;
2490 }
2491 restore_raw_step(driver, attr_tag, attr_raw).await
2492}
2493
2494#[derive(Debug)]
2500enum TickOperation<T> {
2501 Completed(T),
2503 Cancelled,
2505 TimedOut,
2507}
2508
2509async fn bounded_driver_call<T>(
2521 op_timeout_secs: u64,
2522 ctrl_c: &mut CtrlC,
2523 fut: impl Future<Output = anyhow::Result<T>>,
2524) -> anyhow::Result<TickOperation<T>> {
2525 tokio::select! {
2526 result = fut => result.map(TickOperation::Completed),
2527 () = ctrl_c.signalled() => Ok(TickOperation::Cancelled),
2528 () = tokio::time::sleep(Duration::from_secs(op_timeout_secs)) => Ok(TickOperation::TimedOut),
2529 }
2530}
2531
2532fn actuation_abort_reason(
2533 tag: &str,
2534 pending: &PendingMvActuation,
2535 readback: Option<f32>,
2536 now: Instant,
2537) -> AbortReason {
2538 let elapsed_ms = now
2539 .saturating_duration_since(pending.accepted_instant)
2540 .as_millis()
2541 .min(u128::from(u64::MAX)) as u64;
2542 AbortReason::MvActuationUnconfirmed {
2543 tag: tag.to_string(),
2544 target: pending.target,
2545 readback,
2546 tolerance: pending.tolerance,
2547 elapsed_ms,
2548 deadline_secs: MV_ACTUATION_CONFIRMATION_SECS,
2549 }
2550}
2551
2552fn actuation_matches(target: f32, readback: f32, tolerance: f32) -> bool {
2553 (target - readback).abs() <= tolerance
2554}
2555
2556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2557enum MvVerificationTrigger {
2558 Scheduled,
2559 Deadline,
2560}
2561
2562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2563enum ActuationAuditPolicy {
2564 Required,
2565 BestEffort,
2566}
2567
2568#[derive(Debug, Clone, Copy)]
2569enum MvVerificationCallLimit {
2570 None,
2571 Restore(Instant),
2572}
2573
2574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2575enum MvVerificationLimitKind {
2576 Confirmation,
2577 Deadline,
2578 Restore,
2579}
2580
2581async fn record_actuation_observation(
2582 pool: &SqlitePool,
2583 pending: &PendingMvActuation,
2584 checked_at: DateTime<Utc>,
2585 readback: Option<f32>,
2586 quality: Option<SampleQuality>,
2587 policy: ActuationAuditPolicy,
2588) -> anyhow::Result<Option<i64>> {
2589 let Some(id) = pending.id else {
2590 return Ok(None);
2591 };
2592 match TuneMvActuationRow::record_observation(pool, id, checked_at, readback, quality).await {
2593 Ok(row) => Ok(Some(row.attempt_count)),
2594 Err(error) if policy == ActuationAuditPolicy::BestEffort => {
2595 tracing::error!(
2596 actuation_id = id,
2597 error = %error,
2598 "failed to record MV verification observation; continuing physical restore"
2599 );
2600 Ok(None)
2601 }
2602 Err(error) => Err(error.into()),
2603 }
2604}
2605
2606#[allow(clippy::too_many_arguments)]
2607async fn record_final_actuation_observation(
2608 pool: &SqlitePool,
2609 pending: &PendingMvActuation,
2610 checked_at: DateTime<Utc>,
2611 readback: Option<f32>,
2612 quality: Option<SampleQuality>,
2613 status: MvActuationStatus,
2614 detail: &str,
2615) -> Option<i64> {
2616 let id = pending.id?;
2617 match TuneMvActuationRow::record_final_observation(
2618 pool,
2619 id,
2620 checked_at,
2621 readback,
2622 quality,
2623 status,
2624 (!detail.is_empty()).then_some(detail),
2625 )
2626 .await
2627 {
2628 Ok(row) => Some(row.attempt_count),
2629 Err(error) => {
2630 tracing::error!(
2631 actuation_id = id,
2632 error = %error,
2633 "failed to record terminal MV verification observation"
2634 );
2635 None
2636 }
2637 }
2638}
2639
2640async fn finalize_actuation_best_effort(
2641 pool: &SqlitePool,
2642 pending: &PendingMvActuation,
2643 status: MvActuationStatus,
2644 detail: &str,
2645) {
2646 let Some(id) = pending.id else {
2647 return;
2648 };
2649 if let Err(error) = TuneMvActuationRow::finalize(pool, id, status, Some(detail)).await {
2650 tracing::error!(
2651 actuation_id = id,
2652 error = %error,
2653 "failed to finalize MV actuation audit row"
2654 );
2655 }
2656}
2657
2658async fn reject_replacement_for_pending_actuation(
2659 pool: &SqlitePool,
2660 tag: &str,
2661 tracker: &mut MvActuationTracker,
2662) -> anyhow::Result<AbortReason> {
2663 let pending = tracker
2664 .pending
2665 .take()
2666 .expect("called only when an MV actuation is pending");
2667 let (status, detail) = if pending.last_readback.is_some() {
2668 (
2669 MvActuationStatus::Failed,
2670 "a replacement relay command was requested while the prior MV readback remained outside tolerance",
2671 )
2672 } else {
2673 (
2674 MvActuationStatus::Unverified,
2675 "a replacement relay command was requested before an acceptable prior MV readback was available",
2676 )
2677 };
2678 finalize_actuation_best_effort(pool, &pending, status, detail).await;
2679 Ok(actuation_abort_reason(
2680 tag,
2681 &pending,
2682 pending.last_readback,
2683 Instant::now(),
2684 ))
2685}
2686
2687fn verification_trigger(
2688 pending: &PendingMvActuation,
2689 now: Instant,
2690) -> Option<MvVerificationTrigger> {
2691 if now >= pending.deadline {
2692 Some(MvVerificationTrigger::Deadline)
2693 } else if pending.last_readback.is_none() && now >= pending.first_check_at {
2694 Some(MvVerificationTrigger::Scheduled)
2695 } else {
2696 None
2697 }
2698}
2699
2700async fn wait_for_mv_verification(wakeup: Option<Instant>) {
2701 match wakeup {
2702 Some(wakeup) => tokio::time::sleep_until(wakeup).await,
2703 None => std::future::pending::<()>().await,
2704 }
2705}
2706
2707fn mv_verification_read_limit(
2708 trigger: MvVerificationTrigger,
2709 pending: &PendingMvActuation,
2710 call_limit: MvVerificationCallLimit,
2711) -> (Instant, MvVerificationLimitKind) {
2712 let external = match call_limit {
2713 MvVerificationCallLimit::None => None,
2714 MvVerificationCallLimit::Restore(deadline) => {
2715 Some((deadline, MvVerificationLimitKind::Restore))
2716 }
2717 };
2718 if trigger == MvVerificationTrigger::Deadline {
2719 let deadline_read_limit = (
2720 Instant::now() + MV_ACTUATION_DEADLINE_READ_MAX,
2721 MvVerificationLimitKind::Deadline,
2722 );
2723 return match external {
2724 Some(external) if external.0 < deadline_read_limit.0 => external,
2725 _ => deadline_read_limit,
2726 };
2727 }
2728 match external {
2729 Some(external @ (deadline, _)) if deadline < pending.deadline => external,
2730 _ => (pending.deadline, MvVerificationLimitKind::Confirmation),
2731 }
2732}
2733
2734#[allow(clippy::too_many_arguments)]
2741async fn verify_pending_mv_actuation_with_timing(
2742 pool: &SqlitePool,
2743 _args: &TuneArgs,
2744 effective_timing: EffectiveTiming,
2745 tag: &str,
2746 driver: &dyn Driver,
2747 ctrl_c: &mut CtrlC,
2748 allow_uncertain_quality: bool,
2749 tracker: &mut MvActuationTracker,
2750 trigger: MvVerificationTrigger,
2751 call_limit: MvVerificationCallLimit,
2752 audit_policy: ActuationAuditPolicy,
2753 mut timing: Option<&mut PollTimingAccumulator>,
2754) -> anyhow::Result<Option<AbortReason>> {
2755 let Some(pending) = tracker.pending.as_ref() else {
2756 return Ok(None);
2757 };
2758 let now = Instant::now();
2759 if trigger == MvVerificationTrigger::Scheduled && now < pending.first_check_at {
2760 return Ok(None);
2761 }
2762
2763 match read_pending_mv_verification_with_timing(
2764 effective_timing,
2765 tag,
2766 driver,
2767 ctrl_c,
2768 tracker,
2769 trigger,
2770 call_limit,
2771 )
2772 .await?
2773 {
2774 PendingMvVerificationRead::DeadlineTimedOut {
2775 pending,
2776 checked_at,
2777 checked_instant,
2778 } => {
2779 record_final_actuation_observation(
2780 pool,
2781 &pending,
2782 checked_at,
2783 pending.last_readback,
2784 None,
2785 MvActuationStatus::Unverified,
2786 "the fresh MV read at the confirmation deadline did not finish within its bounded verification window",
2787 )
2788 .await;
2789 Ok(Some(actuation_abort_reason(
2790 tag,
2791 &pending,
2792 pending.last_readback,
2793 checked_instant,
2794 )))
2795 }
2796 PendingMvVerificationRead::RestoreTimedOut {
2797 pending,
2798 checked_at,
2799 checked_instant,
2800 } => {
2801 record_final_actuation_observation(
2802 pool,
2803 &pending,
2804 checked_at,
2805 pending.last_readback,
2806 None,
2807 MvActuationStatus::Unverified,
2808 "the restore timeout elapsed before MV confirmation completed",
2809 )
2810 .await;
2811 Ok(Some(actuation_abort_reason(
2812 tag,
2813 &pending,
2814 pending.last_readback,
2815 checked_instant,
2816 )))
2817 }
2818 PendingMvVerificationRead::Ready {
2819 operation,
2820 checked_at,
2821 checked_instant,
2822 read_duration,
2823 } => match resolve_pending_mv_read(
2824 pool,
2825 effective_timing,
2826 tag,
2827 tracker,
2828 operation,
2829 checked_at,
2830 checked_instant,
2831 allow_uncertain_quality,
2832 )
2833 .await?
2834 {
2835 PendingMvVerificationResult::Abort(reason) => Ok(Some(reason)),
2836 PendingMvVerificationResult::Value(value) => {
2837 if let Some(timing) = timing.as_mut() {
2838 timing.observe_mv_verification(read_duration);
2839 }
2840 finalize_pending_mv_verification(pool, tag, tracker, value, trigger, audit_policy)
2841 .await
2842 }
2843 },
2844 }
2845}
2846
2847#[cfg(test)]
2848#[allow(clippy::too_many_arguments)]
2849async fn verify_pending_mv_actuation_with(
2850 pool: &SqlitePool,
2851 args: &TuneArgs,
2852 tag: &str,
2853 driver: &dyn Driver,
2854 ctrl_c: &mut CtrlC,
2855 allow_uncertain_quality: bool,
2856 tracker: &mut MvActuationTracker,
2857 trigger: MvVerificationTrigger,
2858 call_limit: MvVerificationCallLimit,
2859 audit_policy: ActuationAuditPolicy,
2860) -> anyhow::Result<Option<AbortReason>> {
2861 verify_pending_mv_actuation_with_timing(
2862 pool,
2863 args,
2864 test_effective_timing(args),
2865 tag,
2866 driver,
2867 ctrl_c,
2868 allow_uncertain_quality,
2869 tracker,
2870 trigger,
2871 call_limit,
2872 audit_policy,
2873 None,
2874 )
2875 .await
2876}
2877
2878enum PendingMvVerificationRead {
2879 Ready {
2880 operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
2881 checked_at: DateTime<Utc>,
2882 checked_instant: Instant,
2883 read_duration: Duration,
2884 },
2885 DeadlineTimedOut {
2886 pending: PendingMvActuation,
2887 checked_at: DateTime<Utc>,
2888 checked_instant: Instant,
2889 },
2890 RestoreTimedOut {
2891 pending: PendingMvActuation,
2892 checked_at: DateTime<Utc>,
2893 checked_instant: Instant,
2894 },
2895}
2896
2897struct PendingMvVerificationValue {
2898 readback: f32,
2899 sample_quality: SampleQuality,
2900 checked_at: DateTime<Utc>,
2901 checked_instant: Instant,
2902}
2903
2904enum PendingMvVerificationResult {
2905 Value(PendingMvVerificationValue),
2906 Abort(AbortReason),
2907}
2908
2909fn pending_verification_ready(
2910 tracker: &MvActuationTracker,
2911 operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
2912 read_duration: Duration,
2913) -> anyhow::Result<PendingMvVerificationRead> {
2914 let checked_instant = Instant::now();
2915 let pending = tracker
2916 .pending
2917 .as_ref()
2918 .expect("pending actuation existed after the bounded read");
2919 Ok(PendingMvVerificationRead::Ready {
2920 operation,
2921 checked_at: checked_at_for_pending(pending, checked_instant)?,
2922 checked_instant,
2923 read_duration,
2924 })
2925}
2926
2927#[allow(clippy::too_many_arguments)]
2928async fn read_pending_mv_verification_with_timing(
2929 effective_timing: EffectiveTiming,
2930 tag: &str,
2931 driver: &dyn Driver,
2932 ctrl_c: &mut CtrlC,
2933 tracker: &mut MvActuationTracker,
2934 mut trigger: MvVerificationTrigger,
2935 call_limit: MvVerificationCallLimit,
2936) -> anyhow::Result<PendingMvVerificationRead> {
2937 loop {
2938 let limit = {
2939 let pending = tracker
2940 .pending
2941 .as_ref()
2942 .expect("pending actuation existed before the bounded read");
2943 mv_verification_read_limit(trigger, pending, call_limit)
2944 };
2945 let read_started = Instant::now();
2946 let read = bounded_driver_call(
2947 effective_timing.op_timeout_secs,
2948 ctrl_c,
2949 read_numeric_sample(driver, tag),
2950 );
2951 let (deadline, limit_kind) = limit;
2952 match tokio::time::timeout_at(deadline, read).await {
2953 Ok(operation) => {
2954 return pending_verification_ready(tracker, operation, read_started.elapsed());
2955 }
2956 Err(_) => match limit_kind {
2957 MvVerificationLimitKind::Confirmation => {
2958 trigger = MvVerificationTrigger::Deadline;
2959 }
2960
2961 MvVerificationLimitKind::Deadline => {
2962 let pending = tracker
2963 .pending
2964 .take()
2965 .expect("pending actuation existed before the deadline read");
2966 let checked_instant = Instant::now();
2967 let checked_at = checked_at_for_pending(&pending, checked_instant)?;
2968 return Ok(PendingMvVerificationRead::DeadlineTimedOut {
2969 pending,
2970 checked_at,
2971 checked_instant,
2972 });
2973 }
2974 MvVerificationLimitKind::Restore => {
2975 let pending = tracker
2976 .pending
2977 .take()
2978 .expect("pending actuation existed before the bounded read");
2979 let checked_instant = Instant::now();
2980 let checked_at = checked_at_for_pending(&pending, checked_instant)?;
2981 return Ok(PendingMvVerificationRead::RestoreTimedOut {
2982 pending,
2983 checked_at,
2984 checked_instant,
2985 });
2986 }
2987 },
2988 }
2989 }
2990}
2991
2992#[allow(clippy::too_many_arguments)]
2993async fn resolve_pending_mv_read(
2994 pool: &SqlitePool,
2995 effective_timing: EffectiveTiming,
2996 tag: &str,
2997 tracker: &mut MvActuationTracker,
2998 operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
2999 checked_at: DateTime<Utc>,
3000 checked_instant: Instant,
3001 allow_uncertain_quality: bool,
3002) -> anyhow::Result<PendingMvVerificationResult> {
3003 let operation = match operation {
3004 Ok(operation) => operation,
3005 Err(error) => {
3006 let pending = tracker
3007 .pending
3008 .take()
3009 .expect("pending actuation existed before the verification read");
3010 let detail = format!("MV verification read failed: {error}");
3011 record_final_actuation_observation(
3012 pool,
3013 &pending,
3014 checked_at,
3015 None,
3016 None,
3017 MvActuationStatus::Unverified,
3018 &detail,
3019 )
3020 .await;
3021 return Err(error);
3022 }
3023 };
3024
3025 let (readback, quality) = match operation {
3026 TickOperation::Completed(value) => value,
3027 TickOperation::Cancelled => {
3028 let pending = tracker
3029 .pending
3030 .take()
3031 .expect("pending actuation existed before the verification read");
3032 finalize_actuation_best_effort(
3033 pool,
3034 &pending,
3035 MvActuationStatus::Unverified,
3036 "MV verification was interrupted before confirmation completed",
3037 )
3038 .await;
3039 return Ok(PendingMvVerificationResult::Abort(
3040 AbortReason::UserInterrupt,
3041 ));
3042 }
3043 TickOperation::TimedOut => {
3044 let pending = tracker
3045 .pending
3046 .take()
3047 .expect("pending actuation existed before the verification read");
3048 let detail = format!(
3049 "MV verification read did not complete within {} seconds",
3050 effective_timing.op_timeout_secs
3051 );
3052 record_final_actuation_observation(
3053 pool,
3054 &pending,
3055 checked_at,
3056 None,
3057 None,
3058 MvActuationStatus::Unverified,
3059 &detail,
3060 )
3061 .await;
3062 return Ok(PendingMvVerificationResult::Abort(
3063 AbortReason::OperationTimedOut {
3064 tag: tag.to_string(),
3065 op_timeout_secs: effective_timing.op_timeout_secs,
3066 },
3067 ));
3068 }
3069 };
3070 let sample_quality = sample_quality_from_driver(quality);
3071 if check_quality(tag, quality, allow_uncertain_quality).is_err() {
3072 let pending = tracker
3073 .pending
3074 .take()
3075 .expect("pending actuation existed before the verification read");
3076 let detail = format!("MV verification read reported OPC quality {quality:?}");
3077 record_final_actuation_observation(
3078 pool,
3079 &pending,
3080 checked_at,
3081 Some(readback),
3082 Some(sample_quality),
3083 MvActuationStatus::Unverified,
3084 &detail,
3085 )
3086 .await;
3087 return Ok(PendingMvVerificationResult::Abort(
3088 AbortReason::PoorQuality {
3089 tag: tag.to_string(),
3090 quality,
3091 },
3092 ));
3093 }
3094
3095 Ok(PendingMvVerificationResult::Value(
3096 PendingMvVerificationValue {
3097 readback,
3098 sample_quality,
3099 checked_at,
3100 checked_instant,
3101 },
3102 ))
3103}
3104
3105#[allow(clippy::too_many_arguments)]
3106async fn resolve_pending_mv_poll(
3107 pool: &SqlitePool,
3108 effective_timing: EffectiveTiming,
3109 pv_mv_values: TickOperation<HashMap<String, TagValue>>,
3110 mv_tag: &str,
3111 checked_at: DateTime<Utc>,
3112 checked_instant: Instant,
3113 read_duration: Duration,
3114 allow_uncertain_quality: bool,
3115 tracker: &mut MvActuationTracker,
3116 timing: &mut PollTimingAccumulator,
3117) -> anyhow::Result<(Option<AbortReason>, bool)> {
3118 let operation = match pv_mv_values {
3119 TickOperation::Completed(values) => match read_numeric_from_batch(&values, mv_tag) {
3120 Ok(value) => Ok(TickOperation::Completed(value)),
3121 Err(error) => Err(error),
3122 },
3123 TickOperation::Cancelled => Ok(TickOperation::Cancelled),
3124 TickOperation::TimedOut => Ok(TickOperation::TimedOut),
3125 };
3126
3127 match resolve_pending_mv_read(
3128 pool,
3129 effective_timing,
3130 mv_tag,
3131 tracker,
3132 operation,
3133 checked_at,
3134 checked_instant,
3135 allow_uncertain_quality,
3136 )
3137 .await?
3138 {
3139 PendingMvVerificationResult::Abort(reason) => Ok((Some(reason), false)),
3140 PendingMvVerificationResult::Value(value) => {
3141 timing.observe_mv_verification(read_duration);
3142 let outcome = finalize_pending_mv_verification(
3143 pool,
3144 mv_tag,
3145 tracker,
3146 value,
3147 MvVerificationTrigger::Scheduled,
3148 ActuationAuditPolicy::Required,
3149 )
3150 .await?;
3151 Ok((outcome, true))
3152 }
3153 }
3154}
3155
3156#[allow(clippy::too_many_arguments)]
3157async fn confirm_pending_mv_actuation(
3158 pool: &SqlitePool,
3159 tracker: &mut MvActuationTracker,
3160 pending: PendingMvActuation,
3161 checked_at: DateTime<Utc>,
3162 readback: f32,
3163 sample_quality: SampleQuality,
3164 audit_policy: ActuationAuditPolicy,
3165) -> anyhow::Result<()> {
3166 let recorded_attempts = match pending.id {
3167 Some(id) => {
3168 let result = TuneMvActuationRow::record_final_observation(
3169 pool,
3170 id,
3171 checked_at,
3172 Some(readback),
3173 Some(sample_quality),
3174 MvActuationStatus::Confirmed,
3175 None,
3176 )
3177 .await;
3178 match result {
3179 Ok(row) => Some(row.attempt_count),
3180 Err(error) if audit_policy == ActuationAuditPolicy::BestEffort => {
3181 tracing::error!(
3182 actuation_id = id,
3183 error = %error,
3184 "failed to record confirmed MV restore observation"
3185 );
3186 None
3187 }
3188 Err(error) => {
3189 tracker.pending = Some(pending);
3190 return Err(error.into());
3191 }
3192 }
3193 }
3194 None => None,
3195 };
3196 if let Some(attempt_count) = recorded_attempts.filter(|attempt_count| *attempt_count > 1) {
3197 tracing::warn!(
3198 actuation_id = ?pending.id,
3199 attempt_count,
3200 target = pending.target,
3201 readback,
3202 tolerance = pending.tolerance,
3203 "MV actuation confirmed after earlier unsuccessful observations"
3204 );
3205 }
3206 tracker.confirmed_mv = Some(pending.target);
3207 Ok(())
3208}
3209
3210async fn finalize_pending_mv_verification(
3211 pool: &SqlitePool,
3212 tag: &str,
3213 tracker: &mut MvActuationTracker,
3214 value: PendingMvVerificationValue,
3215 trigger: MvVerificationTrigger,
3216 audit_policy: ActuationAuditPolicy,
3217) -> anyhow::Result<Option<AbortReason>> {
3218 let pending = tracker
3219 .pending
3220 .take()
3221 .expect("pending actuation existed before the verification result");
3222 if value.checked_instant > pending.deadline {
3223 let detail = if actuation_matches(pending.target, value.readback, pending.tolerance) {
3224 "MV readback matched the target only after the confirmation deadline"
3225 } else {
3226 "MV readback remained outside tolerance after the confirmation deadline"
3227 };
3228 record_final_actuation_observation(
3229 pool,
3230 &pending,
3231 value.checked_at,
3232 Some(value.readback),
3233 Some(value.sample_quality),
3234 MvActuationStatus::Failed,
3235 detail,
3236 )
3237 .await;
3238 return Ok(Some(actuation_abort_reason(
3239 tag,
3240 &pending,
3241 Some(value.readback),
3242 value.checked_instant,
3243 )));
3244 }
3245 if actuation_matches(pending.target, value.readback, pending.tolerance) {
3246 confirm_pending_mv_actuation(
3247 pool,
3248 tracker,
3249 pending,
3250 value.checked_at,
3251 value.readback,
3252 value.sample_quality,
3253 audit_policy,
3254 )
3255 .await?;
3256 return Ok(None);
3257 }
3258
3259 let deadline_reached =
3260 trigger == MvVerificationTrigger::Deadline || value.checked_instant >= pending.deadline;
3261 if deadline_reached {
3262 record_final_actuation_observation(
3263 pool,
3264 &pending,
3265 value.checked_at,
3266 Some(value.readback),
3267 Some(value.sample_quality),
3268 MvActuationStatus::Failed,
3269 "MV readback remained outside tolerance at the confirmation deadline",
3270 )
3271 .await;
3272 return Ok(Some(actuation_abort_reason(
3273 tag,
3274 &pending,
3275 Some(value.readback),
3276 value.checked_instant,
3277 )));
3278 }
3279
3280 let attempt_count = record_actuation_observation(
3281 pool,
3282 &pending,
3283 value.checked_at,
3284 Some(value.readback),
3285 Some(value.sample_quality),
3286 audit_policy,
3287 )
3288 .await?;
3289 tracing::warn!(
3290 actuation_id = ?pending.id,
3291 attempt_count,
3292 target = pending.target,
3293 readback = value.readback,
3294 tolerance = pending.tolerance,
3295 "MV readback is outside tolerance; confirmation remains pending"
3296 );
3297 let mut pending = pending;
3298 pending.last_readback = Some(value.readback);
3299 tracker.pending = Some(pending);
3300 Ok(None)
3301}
3302
3303#[cfg(test)]
3304#[allow(clippy::too_many_arguments)]
3305async fn verify_pending_mv_actuation(
3306 pool: &SqlitePool,
3307 args: &TuneArgs,
3308 tag: &str,
3309 driver: &dyn Driver,
3310 ctrl_c: &mut CtrlC,
3311 allow_uncertain_quality: bool,
3312 tracker: &mut MvActuationTracker,
3313 call_deadline: Option<Instant>,
3314) -> anyhow::Result<Option<AbortReason>> {
3315 let trigger = tracker
3316 .pending
3317 .as_ref()
3318 .and_then(|pending| verification_trigger(pending, Instant::now()))
3319 .unwrap_or(MvVerificationTrigger::Scheduled);
3320 verify_pending_mv_actuation_with_timing(
3321 pool,
3322 args,
3323 test_effective_timing(args),
3324 tag,
3325 driver,
3326 ctrl_c,
3327 allow_uncertain_quality,
3328 tracker,
3329 trigger,
3330 call_deadline.map_or(
3331 MvVerificationCallLimit::None,
3332 MvVerificationCallLimit::Restore,
3333 ),
3334 ActuationAuditPolicy::Required,
3335 None,
3336 )
3337 .await
3338}
3339
3340async fn finalize_pending_for_run_best_effort(pool: &SqlitePool, run_id: i64, detail: &str) {
3341 if let Err(error) = TuneMvActuationRow::finalize_pending_for_run(
3342 pool,
3343 run_id,
3344 MvActuationStatus::Unverified,
3345 Some(detail),
3346 )
3347 .await
3348 {
3349 tracing::error!(
3350 run_id,
3351 error = %error,
3352 "failed to finalize pending MV actuation rows"
3353 );
3354 }
3355}
3356
3357async fn supersede_pending_actuation_best_effort(
3358 pool: &SqlitePool,
3359 tracker: &mut MvActuationTracker,
3360 detail: &str,
3361) {
3362 let Some(pending) = tracker.pending.take() else {
3363 return;
3364 };
3365 finalize_actuation_best_effort(pool, &pending, MvActuationStatus::Superseded, detail).await;
3366}
3367
3368async fn record_handoff_observation_best_effort(
3369 pool: &SqlitePool,
3370 pending: &PendingMvActuation,
3371 checked_at: DateTime<Utc>,
3372 readback: Option<f32>,
3373 quality: Option<SampleQuality>,
3374 detail: &str,
3375) {
3376 record_final_actuation_observation(
3377 pool,
3378 pending,
3379 checked_at,
3380 readback,
3381 quality,
3382 MvActuationStatus::Superseded,
3383 detail,
3384 )
3385 .await;
3386}
3387
3388fn checked_at_for_pending(
3389 pending: &PendingMvActuation,
3390 checked_instant: Instant,
3391) -> anyhow::Result<DateTime<Utc>> {
3392 Ok(pending.switch_tick
3393 + chrono::Duration::from_std(
3394 checked_instant.saturating_duration_since(pending.switch_instant),
3395 )
3396 .map_err(|_| anyhow::anyhow!("MV actuation observation time exceeded chrono's range"))?)
3397}
3398
3399fn utc_after_elapsed(now: DateTime<Utc>, elapsed: Duration) -> anyhow::Result<DateTime<Utc>> {
3400 Ok(now
3401 + chrono::Duration::from_std(elapsed)
3402 .map_err(|_| anyhow::anyhow!("MV command time exceeded chrono's range"))?)
3403}
3404
3405enum RestoreAttempt {
3410 Confirmed,
3415 Incomplete { reason: String },
3421}
3422
3423enum RestoreMvOutcome {
3424 Continue(RestoreStepOutcome),
3425 Interrupted(String),
3426}
3427
3428fn restore_mv_outcome_or_failed(result: anyhow::Result<RestoreMvOutcome>) -> RestoreMvOutcome {
3429 match result {
3430 Ok(outcome) => outcome,
3431 Err(error) => RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(error.to_string())),
3432 }
3433}
3434
3435enum RestoreHandoffOutcome {
3436 Confirmed,
3437 Rewrite,
3438 Interrupted(String),
3439}
3440
3441#[allow(clippy::too_many_arguments)]
3442async fn try_confirm_final_snapback_handoff_with_timing(
3443 pool: &SqlitePool,
3444 _args: &TuneArgs,
3445 effective_timing: EffectiveTiming,
3446 driver: &dyn Driver,
3447 tag: &str,
3448 initial_mv: f32,
3449 allow_uncertain_quality: bool,
3450 ctrl_c: &mut CtrlC,
3451 tracker: &mut MvActuationTracker,
3452 restore_deadline: Instant,
3453) -> anyhow::Result<Option<RestoreHandoffOutcome>> {
3454 let is_final_snapback = tracker.pending.as_ref().is_some_and(|pending| {
3455 pending.kind == MvActuationKind::Relay && pending.target == initial_mv
3456 });
3457 if !is_final_snapback {
3458 return Ok(None);
3459 }
3460
3461 let pending = tracker
3462 .pending
3463 .take()
3464 .expect("the final-snapback predicate required a pending actuation");
3465 let now = Instant::now();
3466 let reserved_restore_window = Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
3467 let latest_handoff_finish = restore_deadline
3468 .checked_sub(reserved_restore_window)
3469 .unwrap_or(now);
3470 if now >= latest_handoff_finish {
3471 finalize_actuation_best_effort(
3472 pool,
3473 &pending,
3474 MvActuationStatus::Superseded,
3475 "the authoritative restore skipped the final-snapback handoff read to preserve its full MV confirmation budget",
3476 )
3477 .await;
3478 return Ok(Some(RestoreHandoffOutcome::Rewrite));
3479 }
3480 let handoff_deadline = (now + MV_RESTORE_HANDOFF_READ_MAX).min(latest_handoff_finish);
3481 let read = tokio::time::timeout_at(
3482 handoff_deadline,
3483 bounded_driver_call(
3484 effective_timing.op_timeout_secs,
3485 ctrl_c,
3486 read_numeric_sample(driver, tag),
3487 ),
3488 )
3489 .await;
3490 let checked_instant = Instant::now();
3491 let checked_at = checked_at_for_pending(&pending, checked_instant)?;
3492
3493 let operation = match read {
3494 Err(_) => {
3495 finalize_actuation_best_effort(
3496 pool,
3497 &pending,
3498 MvActuationStatus::Superseded,
3499 "the authoritative restore superseded the final MRFT snapback when its tightly bounded handoff read did not finish promptly",
3500 )
3501 .await;
3502 return Ok(Some(RestoreHandoffOutcome::Rewrite));
3503 }
3504 Ok(Ok(operation)) => operation,
3505 Ok(Err(error)) => {
3506 let detail = format!(
3507 "the authoritative restore superseded the final MRFT snapback after its handoff read failed: {error}"
3508 );
3509 record_handoff_observation_best_effort(pool, &pending, checked_at, None, None, &detail)
3510 .await;
3511 tracing::warn!(error = %error, "final MRFT snapback handoff read failed; issuing authoritative restore write");
3512 return Ok(Some(RestoreHandoffOutcome::Rewrite));
3513 }
3514 };
3515
3516 let (readback, quality) = match operation {
3517 TickOperation::Completed(value) => value,
3518 TickOperation::Cancelled => {
3519 tracker.pending = Some(pending);
3520 return Ok(Some(RestoreHandoffOutcome::Interrupted(
3521 "a second Ctrl+C was received while confirming the final MRFT snapback".to_string(),
3522 )));
3523 }
3524 TickOperation::TimedOut => {
3525 let detail = format!(
3526 "the authoritative restore superseded the final MRFT snapback after its handoff read exceeded the {}s operation timeout",
3527 effective_timing.op_timeout_secs
3528 );
3529 record_handoff_observation_best_effort(pool, &pending, checked_at, None, None, &detail)
3530 .await;
3531 return Ok(Some(RestoreHandoffOutcome::Rewrite));
3532 }
3533 };
3534 let sample_quality = sample_quality_from_driver(quality);
3535 if check_quality(tag, quality, allow_uncertain_quality).is_err() {
3536 let detail = format!(
3537 "the authoritative restore superseded the final MRFT snapback after its handoff read reported OPC quality {quality:?}"
3538 );
3539 record_handoff_observation_best_effort(
3540 pool,
3541 &pending,
3542 checked_at,
3543 Some(readback),
3544 Some(sample_quality),
3545 &detail,
3546 )
3547 .await;
3548 return Ok(Some(RestoreHandoffOutcome::Rewrite));
3549 }
3550
3551 if actuation_matches(pending.target, readback, pending.tolerance) {
3552 record_handoff_observation_best_effort(
3553 pool,
3554 &pending,
3555 checked_at,
3556 Some(readback),
3557 Some(sample_quality),
3558 "the authoritative restore adopted and confirmed the final MRFT snapback; no duplicate MV write was issued",
3559 )
3560 .await;
3561 tracker.confirmed_mv = Some(initial_mv);
3562 return Ok(Some(RestoreHandoffOutcome::Confirmed));
3563 }
3564
3565 record_handoff_observation_best_effort(
3566 pool,
3567 &pending,
3568 checked_at,
3569 Some(readback),
3570 Some(sample_quality),
3571 "the authoritative restore superseded an unconfirmed final MRFT snapback and issued a replacement MV write",
3572 )
3573 .await;
3574 Ok(Some(RestoreHandoffOutcome::Rewrite))
3575}
3576
3577#[allow(clippy::too_many_arguments)]
3578async fn restore_mv_with_verification_with_timing(
3579 pool: &SqlitePool,
3580 run_id: i64,
3581 args: &TuneArgs,
3582 effective_timing: EffectiveTiming,
3583 driver: &dyn Driver,
3584 tag: &str,
3585 initial_mv: f32,
3586 allow_uncertain_quality: bool,
3587 ctrl_c: &mut CtrlC,
3588 tracker: &mut Option<MvActuationTracker>,
3589 restore_deadline: &mut Instant,
3590) -> anyhow::Result<RestoreMvOutcome> {
3591 let Some(tracker) = tracker.as_mut() else {
3592 let write = tokio::time::timeout_at(
3593 *restore_deadline,
3594 bounded_driver_call(
3595 effective_timing.op_timeout_secs,
3596 ctrl_c,
3597 write_value(driver, tag, initial_mv),
3598 ),
3599 )
3600 .await;
3601 return Ok(match write {
3602 Err(_) => RestoreMvOutcome::Interrupted(format!(
3603 "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3604 effective_timing.restore_timeout_secs
3605 )),
3606 Ok(Err(error)) => {
3607 RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(error.to_string()))
3608 }
3609 Ok(Ok(operation)) => RestoreMvOutcome::Continue(match operation {
3610 TickOperation::Completed(()) => RestoreStepOutcome::Succeeded,
3611 TickOperation::Cancelled => {
3612 return Ok(RestoreMvOutcome::Interrupted(
3613 "a second Ctrl+C was received while restoring the MV".to_string(),
3614 ));
3615 }
3616 TickOperation::TimedOut => RestoreStepOutcome::Failed(format!(
3617 "MV restore write did not complete within {}s",
3618 effective_timing.op_timeout_secs
3619 )),
3620 }),
3621 });
3622 };
3623
3624 if tracker.pending.is_none() && tracker.confirmed_mv == Some(initial_mv) {
3625 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded));
3626 }
3627
3628 match try_confirm_final_snapback_handoff_with_timing(
3629 pool,
3630 args,
3631 effective_timing,
3632 driver,
3633 tag,
3634 initial_mv,
3635 allow_uncertain_quality,
3636 ctrl_c,
3637 tracker,
3638 *restore_deadline,
3639 )
3640 .await?
3641 {
3642 Some(RestoreHandoffOutcome::Confirmed) => {
3643 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded));
3644 }
3645 Some(RestoreHandoffOutcome::Interrupted(reason)) => {
3646 return Ok(RestoreMvOutcome::Interrupted(reason));
3647 }
3648 Some(RestoreHandoffOutcome::Rewrite) | None => {}
3649 }
3650
3651 let tolerance =
3652 mv_actuation_uncapped_tolerance(initial_mv, tracker.previous_commanded_mv, tracker.mv_span);
3653 let write = tokio::time::timeout_at(
3654 *restore_deadline,
3655 bounded_driver_call(
3656 effective_timing.op_timeout_secs,
3657 ctrl_c,
3658 write_value(driver, tag, initial_mv),
3659 ),
3660 )
3661 .await;
3662 match write {
3663 Err(_) => {
3664 return Ok(RestoreMvOutcome::Interrupted(format!(
3665 "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3666 effective_timing.restore_timeout_secs
3667 )));
3668 }
3669 Ok(Ok(TickOperation::Completed(()))) => {}
3670 Ok(Ok(TickOperation::Cancelled)) => {
3671 return Ok(RestoreMvOutcome::Interrupted(
3672 "a second Ctrl+C was received while restoring the MV".to_string(),
3673 ));
3674 }
3675 Ok(Ok(TickOperation::TimedOut)) => {
3676 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3677 format!(
3678 "MV restore write did not complete within {}s",
3679 effective_timing.op_timeout_secs
3680 ),
3681 )));
3682 }
3683 Ok(Err(error)) => {
3684 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3685 error.to_string(),
3686 )));
3687 }
3688 }
3689
3690 if tracker.pending.is_some() {
3691 supersede_pending_actuation_best_effort(
3692 pool,
3693 tracker,
3694 "the authoritative restore write replaced this pending relay command",
3695 )
3696 .await;
3697 }
3698 let accepted_instant = Instant::now();
3699 let accepted_at = Utc::now();
3700 *restore_deadline = (*restore_deadline)
3701 .max(accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS));
3702 tracker
3703 .record_restore_accepted_best_effort(
3704 pool,
3705 run_id,
3706 initial_mv,
3707 accepted_at,
3708 accepted_instant,
3709 tolerance,
3710 )
3711 .await;
3712
3713 loop {
3714 let trigger = tracker
3715 .pending
3716 .as_ref()
3717 .and_then(|pending| verification_trigger(pending, Instant::now()))
3718 .unwrap_or(MvVerificationTrigger::Scheduled);
3719 let verification = verify_pending_mv_actuation_with_timing(
3720 pool,
3721 args,
3722 effective_timing,
3723 tag,
3724 driver,
3725 ctrl_c,
3726 allow_uncertain_quality,
3727 tracker,
3728 trigger,
3729 MvVerificationCallLimit::Restore(*restore_deadline),
3730 ActuationAuditPolicy::BestEffort,
3731 None,
3732 )
3733 .await;
3734 match verification {
3735 Ok(None) if tracker.pending.is_none() => {
3736 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded));
3737 }
3738 Ok(None) => {
3739 let pending = tracker
3740 .pending
3741 .as_ref()
3742 .expect("pending state was checked above");
3743 let remaining_confirmation =
3744 pending.deadline.saturating_duration_since(Instant::now());
3745 let remaining_restore =
3746 (*restore_deadline).saturating_duration_since(Instant::now());
3747 tokio::time::sleep(
3748 MV_ACTUATION_RETRY_INTERVAL
3749 .min(remaining_confirmation)
3750 .min(remaining_restore),
3751 )
3752 .await;
3753 }
3754 Ok(Some(AbortReason::UserInterrupt)) => {
3755 return Ok(RestoreMvOutcome::Interrupted(
3756 "a second Ctrl+C was received while confirming the restored MV".to_string(),
3757 ));
3758 }
3759 Ok(Some(_)) if Instant::now() >= *restore_deadline => {
3760 return Ok(RestoreMvOutcome::Interrupted(format!(
3761 "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3762 effective_timing.restore_timeout_secs
3763 )));
3764 }
3765 Ok(Some(reason)) => {
3766 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3767 format!("MV restore could not be confirmed: {reason:?}"),
3768 )));
3769 }
3770 Err(error) => {
3771 return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3772 format!("MV restore verification failed: {error}"),
3773 )));
3774 }
3775 }
3776 }
3777}
3778
3779#[cfg(test)]
3780#[allow(clippy::too_many_arguments)]
3781async fn try_confirm_final_snapback_handoff(
3782 pool: &SqlitePool,
3783 args: &TuneArgs,
3784 driver: &dyn Driver,
3785 tag: &str,
3786 initial_mv: f32,
3787 allow_uncertain_quality: bool,
3788 ctrl_c: &mut CtrlC,
3789 tracker: &mut MvActuationTracker,
3790 restore_deadline: Instant,
3791) -> anyhow::Result<Option<RestoreHandoffOutcome>> {
3792 try_confirm_final_snapback_handoff_with_timing(
3793 pool,
3794 args,
3795 test_effective_timing(args),
3796 driver,
3797 tag,
3798 initial_mv,
3799 allow_uncertain_quality,
3800 ctrl_c,
3801 tracker,
3802 restore_deadline,
3803 )
3804 .await
3805}
3806
3807#[cfg(test)]
3808#[allow(clippy::too_many_arguments)]
3809async fn restore_mv_with_verification(
3810 pool: &SqlitePool,
3811 run_id: i64,
3812 args: &TuneArgs,
3813 driver: &dyn Driver,
3814 tag: &str,
3815 initial_mv: f32,
3816 allow_uncertain_quality: bool,
3817 ctrl_c: &mut CtrlC,
3818 tracker: &mut Option<MvActuationTracker>,
3819 restore_deadline: Instant,
3820) -> anyhow::Result<RestoreMvOutcome> {
3821 let mut restore_deadline = restore_deadline;
3822 restore_mv_with_verification_with_timing(
3823 pool,
3824 run_id,
3825 args,
3826 test_effective_timing(args),
3827 driver,
3828 tag,
3829 initial_mv,
3830 allow_uncertain_quality,
3831 ctrl_c,
3832 tracker,
3833 &mut restore_deadline,
3834 )
3835 .await
3836}
3837
3838#[allow(clippy::too_many_arguments)]
3854async fn attempt_restore_with_actuation_with_timing(
3855 pool: &SqlitePool,
3856 run_id: i64,
3857 args: &TuneArgs,
3858 effective_timing: EffectiveTiming,
3859 driver: &dyn Driver,
3860 tags: &LoopTags,
3861 template: &DcsTemplate,
3862 initial: &InitialState,
3863 guard: &MutationGuard,
3864 allow_uncertain_quality: bool,
3865 ctrl_c: &mut CtrlC,
3866 mv_actuations: &mut Option<MvActuationTracker>,
3867) -> RestoreAttempt {
3868 let mut restore_deadline =
3869 Instant::now() + Duration::from_secs(effective_timing.restore_timeout_secs);
3870 let mv = match restore_mv_outcome_or_failed(
3871 restore_mv_with_verification_with_timing(
3872 pool,
3873 run_id,
3874 args,
3875 effective_timing,
3876 driver,
3877 &tags.manipulated_variable,
3878 initial.mv_ini,
3879 allow_uncertain_quality,
3880 ctrl_c,
3881 mv_actuations,
3882 &mut restore_deadline,
3883 )
3884 .await,
3885 ) {
3886 RestoreMvOutcome::Continue(outcome) => outcome,
3887 RestoreMvOutcome::Interrupted(reason) => {
3888 let _ = warn_restore_incomplete(tags, initial, &reason);
3889 return RestoreAttempt::Incomplete { reason };
3890 }
3891 };
3892
3893 tokio::select! {
3894 report = restore_after_mv(driver, tags, template, initial, guard, mv) => {
3895 if report.all_succeeded() {
3896 RestoreAttempt::Confirmed
3897 } else {
3898 let reason = report
3899 .failure_summary()
3900 .unwrap_or_else(|| "one or more restore steps failed".to_string());
3901 let _ = warn_restore_incomplete(tags, initial, &reason);
3902 RestoreAttempt::Incomplete { reason }
3903 }
3904 }
3905 () = ctrl_c.signalled() => {
3906 let reason = "a second Ctrl+C was received while restoring the loop".to_string();
3907 let _ = warn_restore_incomplete(tags, initial, &reason);
3908 RestoreAttempt::Incomplete { reason }
3909 }
3910 () = tokio::time::sleep_until(restore_deadline) => {
3911 let reason = format!(
3912 "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3913 effective_timing.restore_timeout_secs
3914 );
3915 let _ = warn_restore_incomplete(tags, initial, &reason);
3916 RestoreAttempt::Incomplete { reason }
3917 }
3918 }
3919}
3920
3921#[cfg(test)]
3922#[allow(clippy::too_many_arguments)]
3923async fn attempt_restore_with_actuation(
3924 pool: &SqlitePool,
3925 run_id: i64,
3926 args: &TuneArgs,
3927 driver: &dyn Driver,
3928 tags: &LoopTags,
3929 template: &DcsTemplate,
3930 initial: &InitialState,
3931 guard: &MutationGuard,
3932 allow_uncertain_quality: bool,
3933 ctrl_c: &mut CtrlC,
3934 mv_actuations: &mut Option<MvActuationTracker>,
3935) -> RestoreAttempt {
3936 attempt_restore_with_actuation_with_timing(
3937 pool,
3938 run_id,
3939 args,
3940 test_effective_timing(args),
3941 driver,
3942 tags,
3943 template,
3944 initial,
3945 guard,
3946 allow_uncertain_quality,
3947 ctrl_c,
3948 mv_actuations,
3949 )
3950 .await
3951}
3952
3953fn restore_incomplete_warning_message(
3961 tags: &LoopTags,
3962 initial: &InitialState,
3963 reason: &str,
3964) -> String {
3965 format!(
3966 "WARNING: could not confirm the loop was fully restored ({reason}). Tag '{}' may still be at its last relay-test value instead of its pre-test value {}. Check it -- and the loop's mode -- by hand.",
3967 tags.manipulated_variable, initial.mv_ini
3968 )
3969}
3970
3971fn warn_restore_incomplete(tags: &LoopTags, initial: &InitialState, reason: &str) -> String {
3972 let message = restore_incomplete_warning_message(tags, initial, reason);
3973 eprintln!("{message}");
3974 tracing::error!(
3975 mv_tag = %tags.manipulated_variable,
3976 mv_ini = initial.mv_ini,
3977 reason,
3978 "loop restore could not be confirmed"
3979 );
3980 message
3981}
3982
3983async fn record_restore_status_best_effort(
3988 pool: &SqlitePool,
3989 run_id: i64,
3990 attempt: &RestoreAttempt,
3991) {
3992 let (status, detail) = match attempt {
3993 RestoreAttempt::Confirmed => (bhtune_db::models::RestoreStatus::Confirmed, None),
3994 RestoreAttempt::Incomplete { reason } => (
3995 bhtune_db::models::RestoreStatus::Incomplete,
3996 Some(reason.as_str()),
3997 ),
3998 };
3999 if let Err(e) = TuneRunRow::record_restore_status(pool, run_id, status, detail).await {
4000 tracing::error!(run_id, error = %e, "failed to record restore status");
4001 }
4002}
4003
4004fn completed_oscillation_period_ms(
4005 poll_result: &anyhow::Result<PollOutcome>,
4006 direction: ControllerDirection,
4007 config: LoopConfig,
4008 pv_range: PvRange,
4009) -> Option<f64> {
4010 let Ok(PollOutcome::Completed(Action::Complete {
4011 peaks,
4012 troughs,
4013 switch_times,
4014 mv_sign_init,
4015 })) = poll_result
4016 else {
4017 return None;
4018 };
4019
4020 let oscillation = measure_oscillation(
4021 peaks,
4022 troughs,
4023 switch_times,
4024 *mv_sign_init,
4025 direction,
4026 config,
4027 pv_range,
4028 TuningMathCompat::default(),
4029 );
4030 Some(f64::from(oscillation.period_minutes) * 60_000.0)
4031}
4032
4033fn warn_on_missed_poll_opportunities(run_id: i64, metrics: &TimingMetrics) {
4034 if metrics.basis != TimingBasis::LiveMonotonic || metrics.missed_poll_opportunity_count == 0 {
4035 return;
4036 }
4037
4038 tracing::warn!(
4039 run_id,
4040 requested_interval_ms = metrics.requested_interval_ms,
4041 sample_gap_count = metrics.sample_gap_count,
4042 mean_sample_gap_ms = metrics.mean_sample_gap_ms,
4043 max_sample_gap_ms = metrics.max_sample_gap_ms,
4044 missed_poll_opportunity_count = metrics.missed_poll_opportunity_count,
4045 "live tune missed at least one complete polling opportunity"
4046 );
4047}
4048
4049async fn record_timing_metrics_best_effort(pool: &SqlitePool, run_id: i64, metrics: TimingMetrics) {
4053 if let Err(e) = TuneRunRow::record_timing_metrics(pool, run_id, metrics).await {
4054 tracing::error!(run_id, error = %e, "failed to record tune timing metrics");
4055 }
4056}
4057
4058async fn record_timing_metrics_if_present(
4059 pool: &SqlitePool,
4060 run_id: i64,
4061 metrics: Option<TimingMetrics>,
4062) {
4063 let Some(metrics) = metrics else { return };
4064 record_timing_metrics_best_effort(pool, run_id, metrics).await;
4065}
4066
4067#[allow(clippy::too_many_arguments)]
4077async fn restore_best_effort_then_propagate_with_timing(
4078 pool: &SqlitePool,
4079 run_id: i64,
4080 driver: &dyn Driver,
4081 tags: &LoopTags,
4082 template: &DcsTemplate,
4083 initial: &InitialState,
4084 guard: &MutationGuard,
4085 args: &TuneArgs,
4086 effective_timing: EffectiveTiming,
4087 allow_uncertain_quality: bool,
4088 ctrl_c: &mut CtrlC,
4089 mv_actuations: &mut Option<MvActuationTracker>,
4090 err: anyhow::Error,
4091) -> anyhow::Error {
4092 let attempt = attempt_restore_with_actuation_with_timing(
4093 pool,
4094 run_id,
4095 args,
4096 effective_timing,
4097 driver,
4098 tags,
4099 template,
4100 initial,
4101 guard,
4102 allow_uncertain_quality,
4103 ctrl_c,
4104 mv_actuations,
4105 )
4106 .await;
4107 record_restore_status_best_effort(pool, run_id, &attempt).await;
4108 if let Err(finalize_error) = TuneMvActuationRow::finalize_pending_for_run(
4109 pool,
4110 run_id,
4111 MvActuationStatus::Unverified,
4112 Some("the run failed before MV confirmation completed"),
4113 )
4114 .await
4115 {
4116 tracing::error!(
4117 run_id,
4118 error = %finalize_error,
4119 "failed to finalize pending MV actuation rows"
4120 );
4121 }
4122 err
4123}
4124
4125#[cfg(test)]
4126#[allow(clippy::too_many_arguments)]
4127async fn restore_best_effort_then_propagate(
4128 pool: &SqlitePool,
4129 run_id: i64,
4130 driver: &dyn Driver,
4131 tags: &LoopTags,
4132 template: &DcsTemplate,
4133 initial: &InitialState,
4134 guard: &MutationGuard,
4135 args: &TuneArgs,
4136 allow_uncertain_quality: bool,
4137 ctrl_c: &mut CtrlC,
4138 mv_actuations: &mut Option<MvActuationTracker>,
4139 err: anyhow::Error,
4140) -> anyhow::Error {
4141 restore_best_effort_then_propagate_with_timing(
4142 pool,
4143 run_id,
4144 driver,
4145 tags,
4146 template,
4147 initial,
4148 guard,
4149 args,
4150 test_effective_timing(args),
4151 allow_uncertain_quality,
4152 ctrl_c,
4153 mv_actuations,
4154 err,
4155 )
4156 .await
4157}
4158
4159enum PollOutcome {
4162 Completed(Action),
4166 Aborted(AbortReason),
4169}
4170
4171async fn insert_tune_sample_with_timing(
4172 pool: &SqlitePool,
4173 run_id: i64,
4174 tick_index: i64,
4175 tick: Tick,
4176 state: bhtune_core::MrftState,
4177 sample_quality: SampleQuality,
4178 timing: &mut PollTimingAccumulator,
4179) -> anyhow::Result<()> {
4180 let started = Instant::now();
4181 TuneSampleRow::insert(pool, run_id, tick_index, tick, state, sample_quality).await?;
4182 timing.observe_sample_persist(started.elapsed());
4183 Ok(())
4184}
4185
4186#[allow(clippy::too_many_arguments)]
4199async fn run_polling_loop_with_timing(
4200 pool: &SqlitePool,
4201 run_id: i64,
4202 args: &TuneArgs,
4203 effective_timing: EffectiveTiming,
4204 tags: &LoopTags,
4205 driver: &dyn Driver,
4206 engine: &mut MrftEngine,
4207 time_anchor: RunTimeAnchor,
4208 ctrl_c: &mut CtrlC,
4209 guard: &mut MutationGuard,
4210 allow_uncertain_quality: bool,
4211 timing: &mut PollTimingAccumulator,
4212 mv_actuations: &mut Option<MvActuationTracker>,
4213 config: LoopConfig,
4214) -> anyhow::Result<PollOutcome> {
4215 let start_time = time_anchor.utc();
4216 let mut tick_time =
4217 TickTimeSource::for_driver(args.driver, time_anchor, effective_timing.poll_interval_ms)?;
4218 let poll_interval = Duration::from_millis(effective_timing.poll_interval_ms);
4219 let mut next_poll_at = Instant::now();
4220
4221 let pre_delay_end =
4222 start_time + chrono::Duration::seconds(i64::from(effective_timing.mrft_delay_secs));
4223 let mut tick_index: i64 = 0;
4224 let mut completion: Option<Action> = None;
4225 let mut post_delay_end: Option<DateTime<Utc>> = None;
4226
4227 let timeout = tokio::time::sleep(Duration::from_secs(effective_timing.timeout_secs));
4233 tokio::pin!(timeout);
4234
4235 loop {
4236 let verification_wakeup = mv_actuations
4237 .as_ref()
4238 .and_then(MvActuationTracker::next_verification_wakeup);
4239 tokio::select! {
4240 biased;
4241 _ = wait_for_mv_verification(verification_wakeup) => {
4242 let trigger = mv_actuations
4243 .as_ref()
4244 .and_then(|tracker| tracker.pending.as_ref())
4245 .and_then(|pending| verification_trigger(pending, Instant::now()))
4246 .expect("a verification wakeup requires a due pending actuation");
4247 let tracker = mv_actuations
4248 .as_mut()
4249 .expect("a verification trigger requires an OPC DA tracker");
4250 let reason = verify_pending_mv_actuation_with_timing(
4251 pool,
4252 args,
4253 effective_timing,
4254 &tags.manipulated_variable,
4255 driver,
4256 ctrl_c,
4257 allow_uncertain_quality,
4258 tracker,
4259 trigger,
4260 MvVerificationCallLimit::None,
4261 ActuationAuditPolicy::Required,
4262 Some(timing),
4263 )
4264 .await?;
4265 return Ok(match reason {
4266 Some(reason) => PollOutcome::Aborted(reason),
4267 None => continue,
4268 });
4269 }
4270 _ = tokio::time::sleep_until(next_poll_at) => {
4271 let tick_started = Instant::now();
4272 next_poll_at = Instant::now() + poll_interval;
4273 let pending_actuation = mv_actuations
4274 .as_ref()
4275 .is_some_and(|tracker| tracker.pending.is_some());
4276 let pv_read_started = Instant::now();
4277 let (pv, quality, poll_provided_mv_evidence, batched_mv_abort_reason) = match bounded_driver_call(
4278 effective_timing.op_timeout_secs,
4279 ctrl_c,
4280 read_poll_batch(
4281 driver,
4282 &tags.process_variable,
4283 pending_actuation.then_some(tags.manipulated_variable.as_str()),
4284 ),
4285 )
4286 .await?
4287 {
4288 TickOperation::Completed(values) => {
4289 timing.observe_pv_read(pv_read_started.elapsed());
4290 let completed_at = Instant::now();
4291 let (batched_mv_abort_reason, poll_provided_mv_evidence) =
4292 if pending_actuation {
4293 let pending = mv_actuations
4294 .as_ref()
4295 .and_then(|tracker| tracker.pending.as_ref())
4296 .expect("pending actuation existed for the batched poll");
4297 let checked_at = checked_at_for_pending(pending, completed_at)?;
4298 let tracker = mv_actuations
4299 .as_mut()
4300 .expect("pending actuation requires an OPC DA tracker");
4301 resolve_pending_mv_poll(
4302 pool,
4303 effective_timing,
4304 TickOperation::Completed(values.clone()),
4305 &tags.manipulated_variable,
4306 checked_at,
4307 completed_at,
4308 pv_read_started.elapsed(),
4309 allow_uncertain_quality,
4310 tracker,
4311 timing,
4312 )
4313 .await?
4314 } else {
4315 (None, false)
4316 };
4317 let (pv, quality) = read_numeric_from_batch(&values, &tags.process_variable)?;
4318 (
4319 pv,
4320 quality,
4321 poll_provided_mv_evidence,
4322 batched_mv_abort_reason,
4323 )
4324 }
4325 TickOperation::Cancelled => {
4326 if let Some(tracker) = mv_actuations.as_mut()
4327 && tracker.pending.is_some()
4328 {
4329 let completed_at = Instant::now();
4330 let pending = tracker
4331 .pending
4332 .as_ref()
4333 .expect("pending actuation existed for the cancelled poll");
4334 let checked_at = checked_at_for_pending(pending, completed_at)?;
4335 let (reason, _) = resolve_pending_mv_poll(
4336 pool,
4337 effective_timing,
4338 TickOperation::Cancelled,
4339 &tags.manipulated_variable,
4340 checked_at,
4341 completed_at,
4342 pv_read_started.elapsed(),
4343 allow_uncertain_quality,
4344 tracker,
4345 timing,
4346 )
4347 .await?;
4348 let reason =
4349 reason.expect("a cancelled pending MV poll must abort the run");
4350 return Ok(PollOutcome::Aborted(reason));
4351 }
4352 tracing::warn!(run_id, tick_index, "Ctrl+C received while reading the PV; aborting run");
4353 return Ok(PollOutcome::Aborted(AbortReason::UserInterrupt));
4354 }
4355 TickOperation::TimedOut => {
4356 if let Some(tracker) = mv_actuations.as_mut()
4357 && tracker.pending.is_some()
4358 {
4359 let completed_at = Instant::now();
4360 let pending = tracker
4361 .pending
4362 .as_ref()
4363 .expect("pending actuation existed for the timed-out poll");
4364 let checked_at = checked_at_for_pending(pending, completed_at)?;
4365 let (reason, _) = resolve_pending_mv_poll(
4366 pool,
4367 effective_timing,
4368 TickOperation::TimedOut,
4369 &tags.manipulated_variable,
4370 checked_at,
4371 completed_at,
4372 pv_read_started.elapsed(),
4373 allow_uncertain_quality,
4374 tracker,
4375 timing,
4376 )
4377 .await?;
4378 let reason =
4379 reason.expect("a timed-out pending MV poll must abort the run");
4380 return Ok(PollOutcome::Aborted(reason));
4381 }
4382 tracing::warn!(
4383 run_id,
4384 tick_index,
4385 op_timeout_secs = effective_timing.op_timeout_secs,
4386 tag = %tags.process_variable,
4387 "[tuning].op_timeout_secs elapsed reading the PV; aborting run"
4388 );
4389 return Ok(PollOutcome::Aborted(AbortReason::OperationTimedOut {
4390 tag: tags.process_variable.clone(),
4391 op_timeout_secs: effective_timing.op_timeout_secs,
4392 }));
4393 }
4394 };
4395 let tick_observed_instant = Instant::now();
4399 let now = tick_time.next_timestamp()?;
4400 timing.observe(now)?;
4401 let tick = Tick { time: now, pv };
4402 let sample_quality = sample_quality_from_driver(quality);
4403
4404 if let Err(e) = check_quality(&tags.process_variable, quality, allow_uncertain_quality) {
4405 tracing::warn!(
4406 run_id,
4407 tick_index,
4408 tag = %tags.process_variable,
4409 quality = ?quality,
4410 error = %e,
4411 "PV quality check failed; aborting run"
4412 );
4413 insert_tune_sample_with_timing(
4416 pool,
4417 run_id,
4418 tick_index,
4419 tick,
4420 engine.state(),
4421 sample_quality,
4422 timing,
4423 )
4424 .await?;
4425 timing.observe_tick_work(tick_started.elapsed());
4426 return Ok(PollOutcome::Aborted(AbortReason::PoorQuality {
4427 tag: tags.process_variable.clone(),
4428 quality,
4429 }));
4430 }
4431
4432 if let Some(reason) = batched_mv_abort_reason {
4433 insert_tune_sample_with_timing(
4434 pool,
4435 run_id,
4436 tick_index,
4437 tick,
4438 engine.state(),
4439 sample_quality,
4440 timing,
4441 )
4442 .await?;
4443 timing.observe_tick_work(tick_started.elapsed());
4444 return Ok(PollOutcome::Aborted(reason));
4445 }
4446
4447 if completion.is_none() && now < pre_delay_end {
4448 insert_tune_sample_with_timing(
4449 pool,
4450 run_id,
4451 tick_index,
4452 tick,
4453 engine.state(),
4454 sample_quality,
4455 timing,
4456 )
4457 .await?;
4458 timing.observe_tick_work(tick_started.elapsed());
4459 tick_index += 1;
4460 continue;
4461 }
4462
4463 let state_before_step = engine.state();
4468 let actions = if mv_actuations
4469 .as_ref()
4470 .is_some_and(|tracker| tracker.pending.is_some())
4471 {
4472 let mut preview = engine.clone();
4473 let actions = preview.step(tick);
4474 if actions.iter().any(|action| matches!(action, Action::WriteMv(_))) {
4475 let tracker = mv_actuations
4476 .as_mut()
4477 .expect("a pending actuation requires an OPC DA tracker");
4478 assert!(
4479 poll_provided_mv_evidence,
4480 "a pending batched poll must provide MV evidence before replacement preview"
4481 );
4482 let reason = reject_replacement_for_pending_actuation(
4483 pool,
4484 &tags.manipulated_variable,
4485 tracker,
4486 )
4487 .await?;
4488 insert_tune_sample_with_timing(
4489 pool,
4490 run_id,
4491 tick_index,
4492 tick,
4493 state_before_step,
4494 sample_quality,
4495 timing,
4496 )
4497 .await?;
4498 timing.observe_tick_work(tick_started.elapsed());
4499 return Ok(PollOutcome::Aborted(reason));
4500 }
4501 *engine = preview;
4502 actions
4503 } else {
4504 engine.step(tick)
4505 };
4506 for action in actions {
4507 match action {
4508 Action::WriteMv(v) => {
4509 let tolerance = mv_actuations
4510 .as_ref()
4511 .map(|tracker| {
4512 mv_actuation_tolerance(
4513 MvActuationKind::Relay,
4514 v,
4515 tracker.previous_commanded_mv,
4516 tracker.mv_span,
4517 )
4518 })
4519 .transpose()?;
4520 guard.mv_written = true;
4521 let mv_write_started = Instant::now();
4522 match bounded_driver_call(
4523 effective_timing.op_timeout_secs,
4524 ctrl_c,
4525 write_value(driver, &tags.manipulated_variable, v),
4526 )
4527 .await?
4528 {
4529 TickOperation::Completed(()) => {
4530 timing.observe_mv_write(mv_write_started.elapsed());
4531 if let (Some(tracker), Some(tolerance)) =
4532 (mv_actuations.as_mut(), tolerance)
4533 {
4534 let commanded_instant = Instant::now();
4535 record_relay_actuation(
4536 tracker,
4537 pool,
4538 run_id,
4539 v,
4540 now,
4541 tick_observed_instant,
4542 tick_observed_instant
4543 + Duration::from_secs(u64::from(
4544 config.noise_protection_secs,
4545 )),
4546 commanded_instant
4547 .saturating_duration_since(tick_observed_instant),
4548 commanded_instant,
4549 tolerance,
4550 )
4551 .await?;
4552 }
4553 }
4554 TickOperation::Cancelled => {
4555 insert_tune_sample_with_timing(
4560 pool,
4561 run_id,
4562 tick_index,
4563 tick,
4564 state_before_step,
4565 sample_quality,
4566 timing,
4567 )
4568 .await?;
4569 timing.observe_tick_work(tick_started.elapsed());
4570 tracing::warn!(run_id, tick_index, "Ctrl+C received while writing the MV; aborting run");
4571 return Ok(PollOutcome::Aborted(AbortReason::UserInterrupt));
4572 }
4573 TickOperation::TimedOut => {
4574 insert_tune_sample_with_timing(
4575 pool,
4576 run_id,
4577 tick_index,
4578 tick,
4579 engine.state(),
4580 sample_quality,
4581 timing,
4582 )
4583 .await?;
4584 timing.observe_tick_work(tick_started.elapsed());
4585 tracing::warn!(
4586 run_id,
4587 tick_index,
4588 op_timeout_secs = effective_timing.op_timeout_secs,
4589 tag = %tags.manipulated_variable,
4590 "[tuning].op_timeout_secs elapsed writing the MV; aborting run"
4591 );
4592 return Ok(PollOutcome::Aborted(AbortReason::OperationTimedOut {
4593 tag: tags.manipulated_variable.clone(),
4594 op_timeout_secs: effective_timing.op_timeout_secs,
4595 }));
4596 }
4597 }
4598 }
4599 Action::Complete { .. } => {
4600 tracing::info!(
4601 run_id,
4602 tick_index,
4603 "MRFT engine reported completion; recording post-test padding"
4604 );
4605 completion = Some(action);
4606 post_delay_end = Some(
4607 now + chrono::Duration::seconds(i64::from(
4608 effective_timing.mrft_delay_secs,
4609 )),
4610 );
4611 }
4612 }
4613 }
4614 tracing::trace!(run_id, tick_index, pv, "recorded tune sample");
4615 insert_tune_sample_with_timing(
4616 pool,
4617 run_id,
4618 tick_index,
4619 tick,
4620 engine.state(),
4621 sample_quality,
4622 timing,
4623 )
4624 .await?;
4625 timing.observe_tick_work(tick_started.elapsed());
4626 tick_index += 1;
4627
4628 if let Some(end) = post_delay_end
4629 && now >= end
4630 {
4631 break;
4632 }
4633 }
4634 () = ctrl_c.signalled() => {
4635 tracing::warn!(run_id, tick_index, "Ctrl+C received; aborting run");
4636 return Ok(PollOutcome::Aborted(AbortReason::UserInterrupt));
4637 }
4638 _ = &mut timeout => {
4639 tracing::warn!(
4640 run_id,
4641 tick_index,
4642 timeout_secs = effective_timing.timeout_secs,
4643 "[tuning].timeout_secs elapsed before completion; aborting run"
4644 );
4645 return Ok(PollOutcome::Aborted(AbortReason::Timeout {
4646 timeout_secs: effective_timing.timeout_secs,
4647 }));
4648 }
4649 }
4650 }
4651
4652 Ok(PollOutcome::Completed(completion.expect(
4653 "the loop only `break`s after `completion` is set",
4654 )))
4655}
4656
4657#[cfg(test)]
4658#[allow(clippy::too_many_arguments)]
4659async fn run_polling_loop(
4660 pool: &SqlitePool,
4661 run_id: i64,
4662 args: &TuneArgs,
4663 tags: &LoopTags,
4664 driver: &dyn Driver,
4665 engine: &mut MrftEngine,
4666 time_anchor: RunTimeAnchor,
4667 ctrl_c: &mut CtrlC,
4668 guard: &mut MutationGuard,
4669 allow_uncertain_quality: bool,
4670 timing: &mut PollTimingAccumulator,
4671 mv_actuations: &mut Option<MvActuationTracker>,
4672 config: LoopConfig,
4673) -> anyhow::Result<PollOutcome> {
4674 run_polling_loop_with_timing(
4675 pool,
4676 run_id,
4677 args,
4678 test_effective_timing(args),
4679 tags,
4680 driver,
4681 engine,
4682 time_anchor,
4683 ctrl_c,
4684 guard,
4685 allow_uncertain_quality,
4686 timing,
4687 mv_actuations,
4688 config,
4689 )
4690 .await
4691}
4692
4693async fn persist_results(
4694 pool: &SqlitePool,
4695 run_id: i64,
4696 action: Action,
4697 direction: ControllerDirection,
4698 config: LoopConfig,
4699 pv_range: PvRange,
4700 template: &DcsTemplate,
4701) -> anyhow::Result<()> {
4702 let Action::Complete {
4703 peaks,
4704 troughs,
4705 switch_times,
4706 mv_sign_init,
4707 } = action
4708 else {
4709 anyhow::bail!("internal error: persist_results called with a non-Complete action");
4710 };
4711
4712 let results = calculate_all_checked(
4713 &peaks,
4714 &troughs,
4715 &switch_times,
4716 mv_sign_init,
4717 direction,
4718 config,
4719 pv_range,
4720 template,
4721 TuningMathCompat::default(),
4722 );
4723
4724 for result in results {
4725 let row = TuneResultRow::from_checked(run_id, result);
4726 TuneResultRow::insert(pool, &row).await?;
4727 }
4728
4729 Ok(())
4730}
4731
4732pub(crate) async fn read_previous_pid_values(
4740 driver: &dyn Driver,
4741 p_tag: &str,
4742 i_tag: &str,
4743 d_tag: &str,
4744 allow_uncertain: bool,
4745) -> anyhow::Result<WriteReadback> {
4746 let proportional = read_f32(driver, p_tag, allow_uncertain)
4747 .await
4748 .map_err(|e| anyhow::anyhow!("pre-read of Proportional tag '{p_tag}' failed: {e}"))?;
4749 let integral = read_f32(driver, i_tag, allow_uncertain)
4750 .await
4751 .map_err(|e| anyhow::anyhow!("pre-read of Integral tag '{i_tag}' failed: {e}"))?;
4752 let derivative = read_f32(driver, d_tag, allow_uncertain)
4753 .await
4754 .map_err(|e| anyhow::anyhow!("pre-read of Derivative tag '{d_tag}' failed: {e}"))?;
4755 Ok(WriteReadback {
4756 proportional,
4757 integral,
4758 derivative,
4759 })
4760}
4761
4762fn pid_value_within_tolerance(requested: f32, actual: f32) -> bool {
4769 let tolerance = (1e-3_f32).max(0.01 * requested.abs());
4770 (actual - requested).abs() <= tolerance
4771}
4772
4773pub(crate) async fn write_and_verify_pid_value(
4781 driver: &dyn Driver,
4782 label: &str,
4783 tag: &str,
4784 value: f32,
4785 allow_uncertain: bool,
4786) -> Result<f32, String> {
4787 write_value(driver, tag, value)
4788 .await
4789 .map_err(|e| format!("{label} write to '{tag}' failed: {e}"))?;
4790 let readback = read_f32(driver, tag, allow_uncertain)
4791 .await
4792 .map_err(|e| format!("{label} readback from '{tag}' failed: {e}"))?;
4793 if pid_value_within_tolerance(value, readback) {
4794 Ok(readback)
4795 } else {
4796 Err(format!(
4797 "{label} readback {readback} from '{tag}' is outside tolerance of requested {value}"
4798 ))
4799 }
4800}
4801
4802async fn rollback_pid_writes(
4808 driver: &dyn Driver,
4809 targets: &[(&str, &str, f32)],
4810) -> Result<(), String> {
4811 let mut failures = Vec::new();
4812 for (label, tag, previous_value) in targets {
4813 if let Err(e) = write_value(driver, tag, *previous_value).await {
4814 failures.push(format!("{label} rollback write to '{tag}' failed: {e}"));
4815 }
4816 }
4817 if failures.is_empty() {
4818 Ok(())
4819 } else {
4820 Err(failures.join("; "))
4821 }
4822}
4823
4824#[derive(Debug, Clone, PartialEq)]
4832pub enum PidWriteOutcome {
4833 Written,
4835 Failed { detail: String },
4841}
4842
4843#[allow(clippy::too_many_arguments)]
4868pub async fn write_pid_values(
4869 pool: &SqlitePool,
4870 run_id: i64,
4871 driver: &dyn Driver,
4872 p_tag: &str,
4873 i_tag: &str,
4874 d_tag: &str,
4875 response_level: ResponseLevel,
4876 target: WriteReadback,
4877 kind: WriteKind,
4878 allow_uncertain: bool,
4879) -> anyhow::Result<PidWriteOutcome> {
4880 let written_at = Utc::now();
4881 let mut new_write = NewTuneWrite::new(response_level, written_at);
4882 new_write.kind = kind;
4883 new_write.allow_uncertain_quality = allow_uncertain;
4884
4885 let previous =
4886 match read_previous_pid_values(driver, p_tag, i_tag, d_tag, allow_uncertain).await {
4887 Ok(previous) => previous,
4888 Err(e) => {
4889 let message = e.to_string();
4890 new_write.error_message = Some(message.clone());
4891 TuneWriteRow::insert(pool, run_id, new_write).await?;
4892 tracing::error!(run_id, ?response_level, ?kind, %message, "PID pre-read failed");
4893 return Ok(PidWriteOutcome::Failed {
4894 detail: format!("pre-read failed: {message}"),
4895 });
4896 }
4897 };
4898 new_write.previous = Some(previous);
4899
4900 let steps: [(&str, &str, f32, f32); 3] = [
4905 (
4906 "Proportional",
4907 p_tag,
4908 target.proportional,
4909 previous.proportional,
4910 ),
4911 ("Integral", i_tag, target.integral, previous.integral),
4912 ("Derivative", d_tag, target.derivative, previous.derivative),
4913 ];
4914 let mut written_vals: [Option<f32>; 3] = [None; 3];
4915 let mut readback_vals: [Option<f32>; 3] = [None; 3];
4916 let mut rollback_targets: Vec<(&str, &str, f32)> = Vec::new();
4917 let mut failure: Option<String> = None;
4918
4919 for (i, (label, tag, value, previous_value)) in steps.into_iter().enumerate() {
4920 written_vals[i] = Some(value);
4921 match write_and_verify_pid_value(driver, label, tag, value, allow_uncertain).await {
4922 Ok(readback) => {
4923 readback_vals[i] = Some(readback);
4924 rollback_targets.push((label, tag, previous_value));
4925 }
4926 Err(e) => {
4927 failure = Some(e);
4928 break;
4929 }
4930 }
4931 }
4932
4933 new_write.proportional_written = written_vals[0];
4934 new_write.integral_written = written_vals[1];
4935 new_write.derivative_written = written_vals[2];
4936 new_write.proportional_readback = readback_vals[0];
4937 new_write.integral_readback = readback_vals[1];
4938 new_write.derivative_readback = readback_vals[2];
4939
4940 let Some(error_message) = failure else {
4941 new_write.success = true;
4942 TuneWriteRow::insert(pool, run_id, new_write).await?;
4943 tracing::info!(run_id, ?response_level, ?kind, "PID write succeeded");
4944 return Ok(PidWriteOutcome::Written);
4945 };
4946
4947 new_write.success = false;
4948 new_write.error_message = Some(error_message.clone());
4949
4950 if kind != WriteKind::Write || rollback_targets.is_empty() {
4954 TuneWriteRow::insert(pool, run_id, new_write).await?;
4955 tracing::error!(run_id, ?response_level, ?kind, %error_message, "PID write failed");
4956 return Ok(PidWriteOutcome::Failed {
4957 detail: error_message,
4958 });
4959 }
4960
4961 match rollback_pid_writes(driver, &rollback_targets).await {
4962 Ok(()) => {
4963 new_write.rollback_state = Some(RollbackState::Succeeded);
4964 TuneWriteRow::insert(pool, run_id, new_write).await?;
4965 tracing::error!(run_id, ?response_level, %error_message, "PID write failed partway through; rollback succeeded");
4966 Ok(PidWriteOutcome::Failed {
4967 detail: format!("{error_message} (rolled back)"),
4968 })
4969 }
4970 Err(rollback_error) => {
4971 new_write.rollback_state = Some(RollbackState::Failed);
4972 new_write.rollback_error = Some(rollback_error.clone());
4973 TuneWriteRow::insert(pool, run_id, new_write).await?;
4974 tracing::error!(
4975 run_id,
4976 ?response_level,
4977 %error_message,
4978 %rollback_error,
4979 "PID write failed partway through; rollback also failed"
4980 );
4981 Ok(PidWriteOutcome::Failed {
4982 detail: format!(
4983 "{error_message}; rollback also failed: {rollback_error} -- the loop may \
4984 hold a mismatched set of PID constants, see \
4985 `bhtune history revert {run_id}`"
4986 ),
4987 })
4988 }
4989 }
4990}
4991
4992enum WriteBackSelection<'a> {
4996 Selected(&'a TuneResultRow),
4997 Skipped(String),
4998 Failed(String),
4999}
5000
5001pub fn pid_parameters_for_result(result: &TuneResultRow) -> anyhow::Result<PidParameters> {
5007 if result.status != TuningResultStatus::Valid {
5008 let reason = result
5009 .invalid_reason
5010 .map(|reason| reason.to_string())
5011 .unwrap_or_else(|| "no invalid reason was recorded".to_string());
5012 anyhow::bail!(
5013 "{:?} calculated result is invalid: {reason}",
5014 result.response_level
5015 );
5016 }
5017 if result.invalid_reason.is_some() {
5018 anyhow::bail!(
5019 "{:?} calculated result has an invalid reason despite being marked valid",
5020 result.response_level
5021 );
5022 }
5023
5024 let proportional = result.proportional.ok_or_else(|| {
5025 anyhow::anyhow!(
5026 "{:?} calculated result is missing its proportional value",
5027 result.response_level
5028 )
5029 })?;
5030 let integral = result.integral.ok_or_else(|| {
5031 anyhow::anyhow!(
5032 "{:?} calculated result is missing its integral value",
5033 result.response_level
5034 )
5035 })?;
5036 let derivative = result.derivative.ok_or_else(|| {
5037 anyhow::anyhow!(
5038 "{:?} calculated result is missing its derivative value",
5039 result.response_level
5040 )
5041 })?;
5042 if !proportional.is_finite() || !integral.is_finite() || !derivative.is_finite() {
5043 anyhow::bail!(
5044 "{:?} calculated result contains a non-finite PID value",
5045 result.response_level
5046 );
5047 }
5048
5049 Ok(PidParameters {
5050 response_level: result.response_level,
5051 proportional,
5052 integral,
5053 derivative,
5054 })
5055}
5056
5057fn result_write_back_error(result: &TuneResultRow) -> Option<String> {
5058 pid_parameters_for_result(result)
5059 .err()
5060 .map(|error| error.to_string())
5061}
5062
5063fn select_named_write_back_result<'a>(
5064 results: &'a [TuneResultRow],
5065 level: ResponseLevel,
5066 output: OutputFormat,
5067) -> WriteBackSelection<'a> {
5068 match results.iter().find(|r| r.response_level == level) {
5069 Some(result) => {
5070 if let Some(detail) = result_write_back_error(result) {
5071 if prints_table_output(output) {
5072 println!(
5073 "Calculated {level:?} result is invalid; skipping write-back: {detail}"
5074 );
5075 }
5076 return WriteBackSelection::Failed(detail);
5077 }
5078 if prints_table_output(output) {
5079 println!(
5080 "Non-interactively writing {level:?} PID parameters back to the DCS (--write-pid)."
5081 );
5082 }
5083 WriteBackSelection::Selected(result)
5084 }
5085 None => {
5086 let detail = format!("no calculated result recorded for response level {level:?}");
5087 if prints_table_output(output) {
5088 println!(
5089 "No calculated result recorded for response level {level:?}; skipping write-back."
5090 );
5091 }
5092 WriteBackSelection::Failed(detail)
5093 }
5094 }
5095}
5096
5097fn select_interactive_write_back_result<'a>(
5098 results: &'a [TuneResultRow],
5099 reader: &mut impl std::io::BufRead,
5100) -> WriteBackSelection<'a> {
5101 eprintln!("\nCalculated PID parameters:");
5102 for (i, result) in results.iter().enumerate() {
5103 match pid_parameters_for_result(result) {
5104 Ok(pid) => eprintln!(
5105 " {}. {:?}: P={:.4} I={:.4} D={:.4}",
5106 i + 1,
5107 result.response_level,
5108 pid.proportional,
5109 pid.integral,
5110 pid.derivative
5111 ),
5112 Err(error) => eprintln!(
5113 " {}. {:?}: INVALID ({error})",
5114 i + 1,
5115 result.response_level
5116 ),
5117 }
5118 }
5119 eprintln!(
5120 "Write which response level's PID parameters back to the DCS? [1-{}, or Enter/n to skip]:",
5121 results.len()
5122 );
5123
5124 let mut input = String::new();
5125 let bytes_read = reader.read_line(&mut input).unwrap_or(0);
5126 let input = input.trim();
5127 if bytes_read == 0 || input.is_empty() || input.eq_ignore_ascii_case("n") {
5128 eprintln!("Skipping PID write-back.");
5129 return WriteBackSelection::Skipped(
5130 "skipped interactively (no selection made)".to_string(),
5131 );
5132 }
5133
5134 match input.parse::<usize>() {
5135 Ok(n) if n >= 1 && n <= results.len() => {
5136 let result = &results[n - 1];
5137 match result_write_back_error(result) {
5138 Some(detail) => {
5139 eprintln!("Selected result is invalid; skipping PID write-back: {detail}");
5140 WriteBackSelection::Failed(detail)
5141 }
5142 None => WriteBackSelection::Selected(result),
5143 }
5144 }
5145 _ => {
5146 eprintln!("Invalid selection; skipping PID write-back.");
5147 WriteBackSelection::Skipped("invalid response level selection".to_string())
5148 }
5149 }
5150}
5151
5152fn select_write_back_result<'a>(
5153 results: &'a [TuneResultRow],
5154 write_pid: Option<ResponseLevel>,
5155 output: OutputFormat,
5156 reader: &mut impl std::io::BufRead,
5157) -> WriteBackSelection<'a> {
5158 match write_pid {
5159 Some(level) => select_named_write_back_result(results, level, output),
5160 None if skips_interactive_prompt(write_pid, output) => WriteBackSelection::Skipped(
5161 "--output json was set without --write-pid; skipped the interactive \
5162 write-back prompt since there is no human present to answer it"
5163 .to_string(),
5164 ),
5165 None => select_interactive_write_back_result(results, reader),
5166 }
5167}
5168
5169fn finish_write_back(
5170 output: OutputFormat,
5171 response_level: ResponseLevel,
5172 outcome: PidWriteOutcome,
5173) -> (WriteBackOutcome, Option<String>) {
5174 match outcome {
5175 PidWriteOutcome::Written => {
5176 if prints_table_output(output) {
5177 println!("Wrote and confirmed {response_level:?} PID parameters.");
5178 }
5179 (WriteBackOutcome::Written { response_level }, None)
5180 }
5181 PidWriteOutcome::Failed { detail } => {
5182 if prints_table_output(output) {
5183 println!("PID write-back failed: {detail}");
5184 }
5185 (WriteBackOutcome::Failed, Some(detail))
5186 }
5187 }
5188}
5189
5190#[allow(clippy::too_many_arguments)]
5224async fn maybe_write_back(
5225 pool: &SqlitePool,
5226 run_id: i64,
5227 tags: &LoopTags,
5228 template: &DcsTemplate,
5229 driver: &dyn Driver,
5230 config: LoopConfig,
5231 write_pid: Option<ResponseLevel>,
5232 output: OutputFormat,
5233 allow_uncertain: bool,
5234 reader: &mut impl std::io::BufRead,
5235) -> anyhow::Result<(WriteBackOutcome, Option<String>)> {
5236 let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
5237 &tags.proportional_constant,
5238 &tags.integral_constant,
5239 &tags.derivative_constant,
5240 ) else {
5241 let detail = "no PID constant tags configured for this run's driver/template";
5242 if prints_table_output(output) {
5243 println!(
5244 "No PID constant tags configured for this run's driver/template; skipping write-back."
5245 );
5246 }
5247 return Ok((WriteBackOutcome::Skipped, Some(detail.to_string())));
5248 };
5249
5250 let results = TuneResultRow::list_for_run(pool, run_id).await?;
5251 if results.is_empty() {
5252 return Ok((
5253 WriteBackOutcome::Skipped,
5254 Some("no calculated results were recorded for this run".to_string()),
5255 ));
5256 }
5257
5258 let selected = match select_write_back_result(&results, write_pid, output, reader) {
5259 WriteBackSelection::Selected(result) => result,
5260 WriteBackSelection::Skipped(detail) => {
5261 return Ok((WriteBackOutcome::Skipped, Some(detail)));
5262 }
5263 WriteBackSelection::Failed(detail) => {
5264 return Ok((WriteBackOutcome::Failed, Some(detail)));
5265 }
5266 };
5267
5268 let pid = pid_parameters_for_result(selected)?;
5269 let response_level = pid.response_level;
5270 let written = opc_write_values(pid, config.controller_type, template.integral_type);
5271 let target = WriteReadback {
5272 proportional: written.proportional,
5273 integral: written.integral,
5274 derivative: written.derivative,
5275 };
5276
5277 let outcome = write_pid_values(
5278 pool,
5279 run_id,
5280 driver,
5281 p_tag,
5282 i_tag,
5283 d_tag,
5284 response_level,
5285 target,
5286 WriteKind::Write,
5287 allow_uncertain,
5288 )
5289 .await?;
5290
5291 Ok(finish_write_back(output, response_level, outcome))
5292}
5293
5294fn prints_table_output(output: OutputFormat) -> bool {
5295 matches!(output, OutputFormat::Table)
5296}
5297
5298fn skips_interactive_prompt(write_pid: Option<ResponseLevel>, output: OutputFormat) -> bool {
5299 write_pid.is_none() && matches!(output, OutputFormat::Json)
5300}
5301
5302#[cfg(test)]
5303mod tests {
5304 use super::*;
5305 use crate::args::{ControllerTypeArg, DirectionArg, ProcessTypeArg};
5306 use bhtune_db::models::{SamplingAdequacy, TemplateOrigin};
5307
5308 async fn seeded_pool() -> SqlitePool {
5309 let pool = bhtune_db::connect_in_memory().await.unwrap();
5310 bhtune_db::seed_builtin_templates(&pool, Utc::now())
5311 .await
5312 .unwrap();
5313 pool
5314 }
5315
5316 async fn start_opc_test_run(
5317 pool: &SqlitePool,
5318 name: &str,
5319 ) -> (i64, LoopConfig, DcsTemplate, LoopTags) {
5320 let mut args = fast_simulator_args();
5321 args.driver = DriverKindArg::Opcda;
5322 let config = build_loop_config(&args).unwrap();
5323 let template = honeywell_template();
5324 let tags = honeywell_tags();
5325 let run = TuneRunRow::start(
5326 pool,
5327 None,
5328 name,
5329 TuneDriver::Opcda,
5330 config,
5331 TemplateOrigin::Builtin,
5332 &template,
5333 &tags,
5334 Utc::now(),
5335 )
5336 .await
5337 .unwrap();
5338 (run.id, config, template, tags)
5339 }
5340
5341 fn test_config() -> crate::config::BhtuneConfig {
5344 crate::config::BhtuneConfig {
5345 tuning: crate::config::TuningConfig {
5346 mrft_delay_secs: Some(0),
5347 poll_interval_ms: Some(5),
5348 timeout_secs: Some(5),
5349 op_timeout_secs: Some(30),
5350 restore_timeout_secs: Some(30),
5351 },
5352 ..crate::config::BhtuneConfig::default()
5353 }
5354 }
5355
5356 fn time_anchor_at(utc: DateTime<Utc>) -> RunTimeAnchor {
5357 RunTimeAnchor::from_parts(utc, tokio::time::Instant::now())
5358 }
5359
5360 fn timing_for_args(args: &TuneArgs) -> PollTimingAccumulator {
5361 let basis = match args.driver {
5362 DriverKindArg::Opcda => TimingBasis::LiveMonotonic,
5363 DriverKindArg::Simulator => TimingBasis::SimulatedFixedStep,
5364 };
5365 PollTimingAccumulator::new(basis, args.poll_interval_ms)
5366 }
5367
5368 #[test]
5369 fn simulator_driver_requires_every_fixed_range_and_direction_value() {
5370 let template = bhtune_core::built_in_templates().remove(0);
5371 for (field, clear) in [
5372 (
5373 "pv_range_high",
5374 (|args: &mut TuneArgs| args.pv_range_high = None) as fn(&mut TuneArgs),
5375 ),
5376 ("pv_range_low", |args: &mut TuneArgs| {
5377 args.pv_range_low = None
5378 }),
5379 ("mv_range_high", |args: &mut TuneArgs| {
5380 args.mv_range_high = None
5381 }),
5382 ("mv_range_low", |args: &mut TuneArgs| {
5383 args.mv_range_low = None
5384 }),
5385 ("direction", |args: &mut TuneArgs| args.direction = None),
5386 ] {
5387 let mut args = fast_simulator_args();
5388 clear(&mut args);
5389 let error = build_loop_tags(&args, &template).unwrap_err();
5390 assert!(error.to_string().contains(&field.replace('_', "-")));
5391 }
5392 }
5393
5394 #[test]
5395 fn restore_mv_errors_become_failed_restore_steps() {
5396 let outcome = restore_mv_outcome_or_failed(Err(anyhow::anyhow!("restore failed")));
5397
5398 assert!(matches!(
5399 outcome,
5400 RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(detail))
5401 if detail == "restore failed"
5402 ));
5403 }
5404
5405 #[test]
5406 fn json_summary_renderer_has_a_displayable_fallback_for_an_encoding_failure() {
5407 let rendered = render_json_summary(&serde_json::json!({"run_id": 1}), |_| {
5408 Err::<String, _>("injected encoding failure")
5409 });
5410 assert_eq!(rendered, r#"{"error": "injected encoding failure"}"#);
5411 }
5412
5413 fn valid_pid_result_row() -> TuneResultRow {
5414 TuneResultRow {
5415 id: 0,
5416 run_id: 1,
5417 response_level: ResponseLevel::Moderate,
5418 kp: Some(2.0),
5419 ti_minutes: Some(4.0),
5420 td_minutes: Some(0.5),
5421 proportional: Some(2.0),
5422 integral: Some(4.0),
5423 derivative: Some(0.5),
5424 status: TuningResultStatus::Valid,
5425 invalid_reason: None,
5426 }
5427 }
5428
5429 #[test]
5430 fn pid_result_validation_rejects_every_malformed_shape() {
5431 let mut result = valid_pid_result_row();
5432 result.status = TuningResultStatus::Invalid;
5433 result.invalid_reason =
5434 Some(bhtune_core::TuningResultInvalidReason::NonPositivePvAmplitude);
5435 let error = pid_parameters_for_result(&result).unwrap_err();
5436 assert!(error.to_string().contains("PV amplitude is not positive"));
5437
5438 let mut result = valid_pid_result_row();
5439 result.invalid_reason = Some(bhtune_core::TuningResultInvalidReason::NonFiniteKp);
5440 let error = pid_parameters_for_result(&result).unwrap_err();
5441 assert!(
5442 error
5443 .to_string()
5444 .contains("has an invalid reason despite being marked valid")
5445 );
5446
5447 let mut proportional_missing = valid_pid_result_row();
5448 proportional_missing.proportional = None;
5449 let mut integral_missing = valid_pid_result_row();
5450 integral_missing.integral = None;
5451 let mut derivative_missing = valid_pid_result_row();
5452 derivative_missing.derivative = None;
5453 for (result, expected) in [
5454 (proportional_missing, "missing its proportional value"),
5455 (integral_missing, "missing its integral value"),
5456 (derivative_missing, "missing its derivative value"),
5457 ] {
5458 assert!(
5459 pid_parameters_for_result(&result)
5460 .unwrap_err()
5461 .to_string()
5462 .contains(expected)
5463 );
5464 }
5465
5466 let mut result = valid_pid_result_row();
5467 result.integral = Some(f32::NAN);
5468 let error = pid_parameters_for_result(&result).unwrap_err();
5469 assert!(error.to_string().contains("non-finite PID value"));
5470
5471 let valid = pid_parameters_for_result(&valid_pid_result_row()).unwrap();
5472 assert_eq!(valid.response_level, ResponseLevel::Moderate);
5473 assert_eq!(valid.proportional, 2.0);
5474 }
5475
5476 #[test]
5477 fn write_back_selection_rejects_invalid_results_in_named_and_interactive_paths() {
5478 let mut invalid = valid_pid_result_row();
5479 invalid.status = TuningResultStatus::Invalid;
5480 invalid.invalid_reason =
5481 Some(bhtune_core::TuningResultInvalidReason::NonPositivePvAmplitude);
5482 let results = vec![invalid];
5483
5484 assert!(matches!(
5485 select_named_write_back_result(&results, ResponseLevel::Moderate, OutputFormat::Table),
5486 WriteBackSelection::Failed(detail) if detail.contains("PV amplitude is not positive")
5487 ));
5488 assert!(matches!(
5489 select_named_write_back_result(&results, ResponseLevel::Moderate, OutputFormat::Json),
5490 WriteBackSelection::Failed(detail) if detail.contains("PV amplitude is not positive")
5491 ));
5492
5493 let mut reader = std::io::Cursor::new(b"1\n");
5494 assert!(matches!(
5495 select_interactive_write_back_result(&results, &mut reader),
5496 WriteBackSelection::Failed(detail) if detail.contains("PV amplitude is not positive")
5497 ));
5498 }
5499
5500 #[test]
5501 fn utc_after_elapsed_rejects_a_duration_outside_chrono_range() {
5502 let error = utc_after_elapsed(Utc::now(), Duration::MAX).unwrap_err();
5503
5504 assert!(
5505 error
5506 .to_string()
5507 .contains("MV command time exceeded chrono's range")
5508 );
5509 }
5510
5511 #[tokio::test]
5512 async fn relay_actuation_timestamp_conversion_error_is_propagated() {
5513 let pool = seeded_pool().await;
5514 let mut args = fast_simulator_args();
5515 args.driver = DriverKindArg::Opcda;
5516 let initial = sample_initial_state();
5517 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
5518 let now = Utc::now();
5519 let instant = Instant::now();
5520
5521 let error = record_relay_actuation(
5522 &mut tracker,
5523 &pool,
5524 0,
5525 55.0,
5526 now,
5527 instant,
5528 instant,
5529 Duration::MAX,
5530 instant,
5531 1.0,
5532 )
5533 .await
5534 .unwrap_err();
5535
5536 assert!(
5537 error
5538 .to_string()
5539 .contains("MV command time exceeded chrono's range")
5540 );
5541 assert!(tracker.pending.is_none());
5542 }
5543
5544 fn delayed_live_timing_metrics() -> TimingMetrics {
5545 TimingMetrics {
5546 basis: TimingBasis::LiveMonotonic,
5547 requested_interval_ms: 800,
5548 sample_gap_count: 2,
5549 mean_sample_gap_ms: Some(1_200.0),
5550 max_sample_gap_ms: Some(1_600.0),
5551 missed_poll_opportunity_count: 1,
5552 measured_oscillation_period_ms: None,
5553 approximate_samples_per_period: None,
5554 sampling_adequacy: SamplingAdequacy::NotAssessed,
5555 poll_latency: None,
5556 }
5557 }
5558
5559 fn fast_simulator_args() -> TuneArgs {
5565 TuneArgs {
5566 tagname: "ignored-for-simulator".to_string(),
5567 template: "Yokogawa CentumVP".to_string(),
5568 process_type: ProcessTypeArg::Flow,
5569 controller_type: ControllerTypeArg::Pi,
5570 relay_amp: 10.0,
5571 cycles_skip: Some(1),
5572 cycles_count: Some(2),
5573 noise_protection_secs: Some(0),
5574 mrft_delay: 0,
5575 driver: DriverKindArg::Simulator,
5576 bridge_host: None,
5577 server: None,
5578 sim_gain: 1.0,
5579 sim_tau: 0.01,
5580 sim_dead_time: 0.025,
5581 sim_noise: 0.0,
5582 sim_seed: 0,
5583 sim_initial_pv: 50.0,
5584 sim_initial_mv: 50.0,
5585 pv_range_high: Some(100.0),
5586 pv_range_low: Some(0.0),
5587 mv_range_high: Some(100.0),
5588 mv_range_low: Some(0.0),
5589 direction: Some(DirectionArg::Reverse),
5590 tag_overrides: None,
5591 poll_interval_ms: 5,
5592 timeout_secs: 5,
5595 op_timeout_secs: 30,
5596 restore_timeout_secs: 30,
5597 notes: Some("test note".to_string()),
5598 yes: false,
5599 write_pid: None,
5600 output: OutputFormat::Table,
5601 }
5602 }
5603
5604 #[tokio::test]
5605 async fn a_full_simulator_tune_completes_and_persists_results() {
5606 let pool = seeded_pool().await;
5607 run(&pool, fast_simulator_args(), &test_config())
5608 .await
5609 .unwrap();
5610
5611 let runs = TuneRunRow::list(
5612 &pool,
5613 &bhtune_db::models::TuneRunFilter::default(),
5614 bhtune_db::models::Pagination::first(10),
5615 )
5616 .await
5617 .unwrap();
5618 assert_eq!(runs.len(), 1);
5619 assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Completed);
5620 assert_eq!(runs[0].loop_name, "ignored-for-simulator");
5621 assert_eq!(runs[0].notes.as_deref(), Some("test note"));
5622 assert!(runs[0].initial_readings.is_some());
5623 let timing = runs[0]
5624 .timing_metrics
5625 .expect("completed simulator run should record timing diagnostics");
5626 assert_eq!(timing.basis, TimingBasis::SimulatedFixedStep);
5627 assert_eq!(timing.requested_interval_ms, 5);
5628 assert!(timing.sample_gap_count > 0);
5629 assert_eq!(timing.mean_sample_gap_ms, Some(5.0));
5630 assert_eq!(timing.max_sample_gap_ms, Some(5.0));
5631 assert_eq!(timing.missed_poll_opportunity_count, 0);
5632 assert!(
5633 timing
5634 .measured_oscillation_period_ms
5635 .is_some_and(|period| period > 0.0)
5636 );
5637 assert!(
5638 timing
5639 .approximate_samples_per_period
5640 .is_some_and(|samples| samples > 1.0)
5641 );
5642
5643 assert_eq!(runs[0].opc_server, None);
5648 assert_eq!(runs[0].bridge_host, None);
5649
5650 let request: serde_json::Value = serde_json::from_str(&runs[0].request_json).unwrap();
5654 assert_eq!(request["tagname"], "ignored-for-simulator");
5655 assert_eq!(request["driver"], "simulator");
5656 assert_eq!(request["server"], serde_json::Value::Null);
5657 assert_eq!(request["notes"], "test note");
5658 for timing_field in [
5659 "mrft_delay",
5660 "mrft_delay_secs",
5661 "poll_interval_ms",
5662 "timeout_secs",
5663 "op_timeout_secs",
5664 "restore_timeout_secs",
5665 ] {
5666 assert!(
5667 request.get(timing_field).is_none(),
5668 "{timing_field} must not be part of the per-run request snapshot"
5669 );
5670 }
5671 assert_eq!(
5672 runs[0].effective_tuning,
5673 Some(EffectiveTuning {
5674 mrft_delay_secs: 0,
5675 poll_interval_ms: 5,
5676 timeout_secs: 5,
5677 op_timeout_secs: 30,
5678 restore_timeout_secs: 30,
5679 })
5680 );
5681 assert!(
5682 TuneMvActuationRow::list_for_run(&pool, runs[0].id)
5683 .await
5684 .unwrap()
5685 .is_empty(),
5686 "simulator runs must not create OPC DA MV actuation audit rows"
5687 );
5688
5689 let results = TuneResultRow::list_for_run(&pool, runs[0].id)
5690 .await
5691 .unwrap();
5692 assert_eq!(results.len(), 3);
5693
5694 let samples = TuneSampleRow::list_for_run(&pool, runs[0].id)
5695 .await
5696 .unwrap();
5697 assert!(!samples.is_empty());
5698
5699 let writes = TuneWriteRow::list_for_run(&pool, runs[0].id).await.unwrap();
5702 assert!(writes.is_empty());
5703 }
5704
5705 #[tokio::test]
5706 async fn prepared_tune_run_id_matches_the_persisted_run_id() {
5707 let pool = seeded_pool().await;
5708 let first = prepare(&pool, fast_simulator_args(), &test_config())
5709 .await
5710 .unwrap();
5711 let second = prepare(&pool, fast_simulator_args(), &test_config())
5712 .await
5713 .unwrap();
5714
5715 assert!(first.run_id() > 0);
5716 assert_eq!(second.run_id(), first.run_id() + 1);
5717 }
5718
5719 #[tokio::test]
5720 async fn prepare_rejects_invalid_tag_overrides_before_creating_a_run() {
5721 let pool = seeded_pool().await;
5722 let mut args = fast_simulator_args();
5723 args.tag_overrides = Some(TagOverrides {
5724 process_variable: Some("bad\0tag".to_string()),
5725 ..TagOverrides::default()
5726 });
5727
5728 let result = prepare(&pool, args, &test_config()).await;
5729 assert!(result.is_err());
5730 let err = result.err().unwrap();
5731 assert!(err.to_string().contains("process_variable"));
5732 assert!(
5733 TuneRunRow::list(
5734 &pool,
5735 &bhtune_db::models::TuneRunFilter::default(),
5736 bhtune_db::models::Pagination::first(10),
5737 )
5738 .await
5739 .unwrap()
5740 .is_empty()
5741 );
5742 }
5743
5744 #[tokio::test]
5745 async fn prepare_owned_rejects_a_non_simulator_before_creating_a_run() {
5746 let pool = seeded_pool().await;
5747 let mut args = fast_simulator_args();
5748 args.driver = DriverKindArg::Opcda;
5749
5750 let error = prepare_owned(&pool, args, &test_config(), 1)
5751 .await
5752 .err()
5753 .expect("demo preparation must reject a non-simulator driver");
5754
5755 assert!(
5756 error
5757 .to_string()
5758 .contains("demo sessions may only start simulator runs")
5759 );
5760 assert!(
5761 TuneRunRow::list(
5762 &pool,
5763 &bhtune_db::models::TuneRunFilter::default(),
5764 bhtune_db::models::Pagination::first(10),
5765 )
5766 .await
5767 .unwrap()
5768 .is_empty(),
5769 "the demo driver guard must run before the tune_runs insert"
5770 );
5771 }
5772
5773 #[tokio::test]
5774 async fn drive_completes_a_prepared_simulator_run() {
5775 let pool = seeded_pool().await;
5776 let prepared = prepare(&pool, fast_simulator_args(), &test_config())
5777 .await
5778 .unwrap();
5779 let run_id = prepared.run_id();
5780
5781 let outcome = drive(&pool, prepared, &mut CtrlC::never()).await.unwrap();
5782
5783 assert_eq!(outcome, TuneOutcome::Completed);
5784 assert_eq!(
5785 TuneRunRow::get(&pool, run_id)
5786 .await
5787 .unwrap()
5788 .unwrap()
5789 .outcome,
5790 bhtune_db::models::TuneOutcome::Completed
5791 );
5792 }
5793
5794 #[tokio::test]
5795 async fn drive_marks_a_prepared_run_failed_when_execution_errors() {
5796 let pool = seeded_pool().await;
5797 let mut prepared = prepare(&pool, fast_simulator_args(), &test_config())
5798 .await
5799 .unwrap();
5800 let run_id = prepared.run_id();
5801 prepared.driver = Box::new(MockDriver::default().empty_read(SIMULATOR_PV_TAG));
5802
5803 let err = drive(&pool, prepared, &mut CtrlC::never())
5804 .await
5805 .unwrap_err();
5806
5807 assert!(err.to_string().contains("no value"));
5808 let run = TuneRunRow::get(&pool, run_id).await.unwrap().unwrap();
5809 assert_eq!(run.outcome, bhtune_db::models::TuneOutcome::Failed);
5810 assert!(run.failure_reason.is_some());
5811 }
5812
5813 #[tokio::test]
5823 async fn run_with_opcda_driver_fails_mid_poll_and_marks_the_run_failed() {
5824 use crate::test_support::{MockBridgeService, start_mock_server};
5825 use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
5826
5827 let (host, server) = start_mock_server(
5828 MockBridgeService {
5829 read_response: ReadResponse {
5830 values: vec![ProtoTagValue {
5831 tag_id: "ignored".to_string(),
5832 value: "50".to_string(),
5833 quality: "Good".to_string(),
5834 timestamp: "2024-01-15 10:23:45".to_string(),
5835 }],
5836 },
5837 write_response: WriteResponse {
5838 tag_id: "ignored".to_string(),
5839 success: true,
5840 error: None,
5841 },
5842 ..Default::default()
5843 }
5844 .failing_read_from_call(2),
5845 )
5846 .await;
5847
5848 let pool = seeded_pool().await;
5849 let mut args = fast_simulator_args();
5850 args.driver = DriverKindArg::Opcda;
5851 args.tagname = "Unit1.LIC101.PV".to_string();
5852 args.bridge_host = Some(host.clone());
5853 args.server = Some("MockServer".to_string());
5854
5855 let err = run(&pool, args, &test_config()).await.unwrap_err();
5856 assert!(err.to_string().contains("driver operation failed"));
5857
5858 let runs = TuneRunRow::list(
5859 &pool,
5860 &bhtune_db::models::TuneRunFilter::default(),
5861 bhtune_db::models::Pagination::first(10),
5862 )
5863 .await
5864 .unwrap();
5865 assert_eq!(runs.len(), 1);
5866 assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Failed);
5867 assert_eq!(runs[0].driver, bhtune_db::models::TuneDriver::Opcda);
5868 assert!(
5869 runs[0]
5870 .failure_reason
5871 .as_deref()
5872 .unwrap()
5873 .contains("driver operation failed")
5874 );
5875
5876 assert_eq!(runs[0].opc_server.as_deref(), Some("MockServer"));
5880 assert_eq!(runs[0].bridge_host.as_deref(), Some(host.as_str()));
5881 let request: serde_json::Value = serde_json::from_str(&runs[0].request_json).unwrap();
5882 assert_eq!(request["tagname"], "Unit1.LIC101.PV");
5883 assert_eq!(request["driver"], "opcda");
5884 assert_eq!(request["server"], "MockServer");
5885
5886 server.shutdown().await;
5887 }
5888
5889 #[tokio::test]
5901 async fn run_resolves_bridge_host_and_server_from_config_when_cli_flags_are_unset() {
5902 use crate::test_support::{MockBridgeService, start_mock_server};
5903 use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue};
5904
5905 let (host, server) = start_mock_server(
5906 MockBridgeService {
5907 read_response: ReadResponse {
5908 values: vec![ProtoTagValue {
5909 tag_id: "ignored".to_string(),
5910 value: "50".to_string(),
5911 quality: "Good".to_string(),
5912 timestamp: "2024-01-15 10:23:45".to_string(),
5913 }],
5914 },
5915 ..Default::default()
5916 }
5917 .failing_read_from_call(1),
5918 )
5919 .await;
5920
5921 let pool = seeded_pool().await;
5922 let mut args = fast_simulator_args();
5923 args.driver = DriverKindArg::Opcda;
5924 args.tagname = "Unit1.LIC101.PV".to_string();
5925 args.bridge_host = None;
5926 args.server = None;
5927
5928 let app_config = crate::config::BhtuneConfig {
5929 bridge_host: Some(host),
5930 server: Some("MockServer".to_string()),
5931 ..Default::default()
5932 };
5933
5934 let err = run(&pool, args, &app_config).await.unwrap_err();
5938 assert!(err.to_string().contains("driver operation failed"));
5939
5940 server.shutdown().await;
5941 }
5942
5943 #[tokio::test]
5944 async fn run_errors_when_opcda_server_is_unset_in_both_cli_and_config() {
5945 let pool = seeded_pool().await;
5946 let mut args = fast_simulator_args();
5947 args.driver = DriverKindArg::Opcda;
5948 args.server = None;
5949
5950 let err = run(&pool, args, &test_config()).await.unwrap_err();
5951 assert!(err.to_string().contains("no OPC server specified"));
5952 }
5953
5954 #[tokio::test]
5960 async fn mrft_delay_pads_the_run_with_extra_recorded_samples() {
5961 let pool = seeded_pool().await;
5962 let args = fast_simulator_args();
5963 let mut config = test_config();
5966 config.tuning.mrft_delay_secs = Some(1);
5967 config.tuning.timeout_secs = Some(30);
5968 run(&pool, args, &config).await.unwrap();
5969
5970 let runs = TuneRunRow::list(
5971 &pool,
5972 &bhtune_db::models::TuneRunFilter::default(),
5973 bhtune_db::models::Pagination::first(10),
5974 )
5975 .await
5976 .unwrap();
5977 assert_eq!(runs.len(), 1);
5978 assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Completed);
5979
5980 let samples = TuneSampleRow::list_for_run(&pool, runs[0].id)
5985 .await
5986 .unwrap();
5987 assert!(samples.len() > 100);
5988 }
5989
5990 #[tokio::test]
5991 async fn mrft_delay_keeps_the_engine_idle_during_pre_test_padding() {
5992 let pool = seeded_pool().await;
5993 let template = honeywell_template();
5994 let tags = honeywell_tags();
5995 let mut args = fast_simulator_args();
5996 args.mrft_delay = 1;
5997 args.cycles_count = Some(1_000);
5998 let config = build_loop_config(&args).unwrap();
5999 let started_at = Utc::now();
6000 let run = TuneRunRow::start(
6001 &pool,
6002 None,
6003 "pre-delay",
6004 TuneDriver::Opcda,
6005 config,
6006 TemplateOrigin::Builtin,
6007 &template,
6008 &tags,
6009 started_at,
6010 )
6011 .await
6012 .unwrap();
6013 let driver = honeywell_driver_auto();
6014 let initial = sample_initial_state();
6015 let mut engine = MrftEngine::new(
6016 config,
6017 initial.direction,
6018 lookup(
6019 config.process_type,
6020 config.controller_type,
6021 ResponseLevel::Aggressive,
6022 )
6023 .beta,
6024 InitialReadings {
6025 pv_ini: initial.pv_ini,
6026 mv_ini: initial.mv_ini,
6027 mv_range_low: initial.mv_range_low,
6028 mv_range_high: initial.mv_range_high,
6029 },
6030 started_at,
6031 MrftCompat::default(),
6032 );
6033 let (mut ctrl_c, tx) = CtrlC::test_pair();
6034 tokio::spawn(async move {
6035 tokio::time::sleep(Duration::from_millis(50)).await;
6036 let _ = tx.send(1);
6037 });
6038
6039 let mut timing = timing_for_args(&args);
6040 let outcome = run_polling_loop(
6041 &pool,
6042 run.id,
6043 &args,
6044 &tags,
6045 &driver,
6046 &mut engine,
6047 time_anchor_at(started_at),
6048 &mut ctrl_c,
6049 &mut MutationGuard::default(),
6050 true,
6051 &mut timing,
6052 &mut None,
6053 build_loop_config(&args).unwrap(),
6054 )
6055 .await
6056 .unwrap();
6057
6058 assert!(matches!(
6059 outcome,
6060 PollOutcome::Aborted(AbortReason::UserInterrupt)
6061 ));
6062 assert!(
6063 !TuneSampleRow::list_for_run(&pool, run.id)
6064 .await
6065 .unwrap()
6066 .is_empty()
6067 );
6068 assert!(
6069 driver.write_log().is_empty(),
6070 "MRFT writes must not occur during pre-test padding"
6071 );
6072 }
6073
6074 #[tokio::test]
6075 async fn unknown_template_is_a_clean_error() {
6076 let pool = seeded_pool().await;
6077 let mut args = fast_simulator_args();
6078 args.template = "Does Not Exist".to_string();
6079 let err = run(&pool, args, &test_config()).await.unwrap_err();
6080 assert!(err.to_string().contains("Does Not Exist"));
6081 }
6082
6083 #[test]
6084 fn build_loop_config_rejects_pid_for_a_non_temperature_process_type() {
6085 let mut args = fast_simulator_args();
6086 args.controller_type = ControllerTypeArg::Pid;
6087 args.process_type = ProcessTypeArg::Flow;
6088 let err = build_loop_config(&args).unwrap_err();
6089 assert!(err.to_string().contains("Pid"));
6090 }
6091
6092 #[test]
6093 fn build_loop_config_uses_process_type_defaults_when_unset() {
6094 let mut args = fast_simulator_args();
6095 args.cycles_skip = None;
6096 args.cycles_count = None;
6097 args.noise_protection_secs = None;
6098 let config = build_loop_config(&args).unwrap();
6099 assert_eq!(
6100 config.num_cycles_skip,
6101 ProcessType::Flow.default_cycles_skip()
6102 );
6103 assert_eq!(
6104 config.num_cycles_count,
6105 ProcessType::Flow.default_cycles_test()
6106 );
6107 assert_eq!(
6108 config.noise_protection_secs,
6109 ProcessType::Flow.default_noise_protection_secs()
6110 );
6111 }
6112
6113 #[test]
6114 fn build_loop_config_rejects_an_out_of_range_relay_amp_before_any_driver_or_db_io() {
6115 let mut args = fast_simulator_args();
6120 args.relay_amp = 2014.0;
6121 let err = build_loop_config(&args).unwrap_err();
6122 assert!(err.to_string().contains("relay amplitude 2014"));
6123 assert!(err.to_string().contains("out of range"));
6124 }
6125
6126 #[test]
6127 fn build_loop_config_rejects_a_relay_amp_below_the_minimum() {
6128 let mut args = fast_simulator_args();
6129 args.relay_amp = 0.0;
6130 let err = build_loop_config(&args).unwrap_err();
6131 assert!(err.to_string().contains("out of range"));
6132 }
6133
6134 #[test]
6142 fn build_loop_config_rejects_zero_cycles_count_before_any_driver_or_db_io() {
6143 let mut args = fast_simulator_args();
6144 args.cycles_count = Some(0);
6145 let err = build_loop_config(&args).unwrap_err();
6146 assert!(err.to_string().contains("at least 1"));
6147 }
6148
6149 #[test]
6150 fn build_loop_tags_simulator_requires_all_overrides() {
6151 let template = bhtune_core::built_in_templates().remove(0);
6152 let mut args = fast_simulator_args();
6153 args.pv_range_high = None;
6154 let err = build_loop_tags(&args, &template).unwrap_err();
6155 assert!(err.to_string().contains("--pv-range-high"));
6156 }
6157
6158 #[test]
6159 fn build_loop_tags_simulator_requires_every_override_individually() {
6160 type ClearFn = fn(&mut TuneArgs);
6164 let template = bhtune_core::built_in_templates().remove(0);
6165 let cases: &[(&str, ClearFn)] = &[
6166 ("--pv-range-low", |a| a.pv_range_low = None),
6167 ("--mv-range-high", |a| a.mv_range_high = None),
6168 ("--mv-range-low", |a| a.mv_range_low = None),
6169 ("--direction", |a| a.direction = None),
6170 ];
6171 for (flag, clear) in cases {
6172 let mut args = fast_simulator_args();
6173 clear(&mut args);
6174 let err = build_loop_tags(&args, &template).unwrap_err();
6175 assert!(
6176 err.to_string().contains(flag),
6177 "expected error for missing {flag}, got: {err}"
6178 );
6179 }
6180 }
6181
6182 #[test]
6183 fn build_loop_tags_simulator_uses_fixed_tag_names() {
6184 let template = bhtune_core::built_in_templates().remove(0);
6185 let args = fast_simulator_args();
6186 let tags = build_loop_tags(&args, &template).unwrap();
6187 assert_eq!(tags.process_variable, SIMULATOR_PV_TAG);
6188 assert_eq!(tags.manipulated_variable, SIMULATOR_MV_TAG);
6189 assert!(tags.controller_mode.is_none());
6190 assert!(tags.proportional_constant.is_none());
6191 }
6192
6193 #[test]
6194 fn build_loop_tags_opcda_derives_and_applies_overrides() {
6195 let template = bhtune_core::built_in_templates().remove(0);
6196 let mut args = fast_simulator_args();
6197 args.driver = DriverKindArg::Opcda;
6198 args.tagname = "Unit1.LIC101.PV".to_string();
6199 args.direction = Some(DirectionArg::Direct);
6200 args.tag_overrides = Some(TagOverrides {
6201 manipulated_variable: Some("Unit1.LIC101.PY".to_string()),
6202 proportional_constant: Some("Unit1.LIC101.PB".to_string()),
6203 ..TagOverrides::default()
6204 });
6205 let tags = build_loop_tags(&args, &template).unwrap();
6206 assert!(tags.process_variable.starts_with("Unit1.LIC101"));
6207 assert_eq!(tags.manipulated_variable, "Unit1.LIC101.PY");
6208 assert_eq!(
6209 tags.proportional_constant,
6210 Some("Unit1.LIC101.PB".to_string())
6211 );
6212 assert_eq!(
6213 tags.controller_direction,
6214 TagOrValue::Value(ControllerDirection::Direct)
6215 );
6216 assert_eq!(tags.upper_pv_range, TagOrValue::Value(100.0));
6217 }
6218
6219 #[tokio::test]
6220 async fn a_ctrl_c_style_abort_restores_and_records_aborted() {
6221 let pool = seeded_pool().await;
6230 let template = bhtune_core::built_in_templates().remove(0);
6231 let args = fast_simulator_args();
6232 let config = build_loop_config(&args).unwrap();
6233 let tags = build_loop_tags(&args, &template).unwrap();
6234 let driver = crate::driver::build(&args).await.unwrap();
6235
6236 let started_at = Utc::now();
6237 let run = TuneRunRow::start(
6238 &pool,
6239 None,
6240 "abort-test",
6241 TuneDriver::Simulator,
6242 config,
6243 TemplateOrigin::Builtin,
6244 &template,
6245 &tags,
6246 started_at,
6247 )
6248 .await
6249 .unwrap();
6250
6251 let initial = read_initial_values(driver.as_ref(), &tags, &template, false)
6252 .await
6253 .unwrap();
6254 let mut guard = MutationGuard::default();
6255 transition_to_manual(driver.as_ref(), &tags, &template, &initial, &mut guard)
6256 .await
6257 .unwrap();
6258 let report = restore(driver.as_ref(), &tags, &template, &initial, &guard).await;
6259 assert!(report.all_succeeded());
6260 TuneRunRow::abort(&pool, run.id, Utc::now()).await.unwrap();
6261
6262 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
6263 assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
6264 }
6265
6266 #[tokio::test]
6273 async fn poor_quality_pv_during_polling_aborts_records_the_sample_and_restores() {
6274 let pool = seeded_pool().await;
6275 let template = honeywell_template();
6276 let tags = honeywell_tags();
6277 let driver = honeywell_driver_auto()
6278 .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
6279 let args = fast_simulator_args();
6280 let config = build_loop_config(&args).unwrap();
6281
6282 let started_at = Utc::now();
6283 let run = TuneRunRow::start(
6284 &pool,
6285 None,
6286 "poor-quality-poll",
6287 TuneDriver::Opcda,
6288 config,
6289 TemplateOrigin::Builtin,
6290 &template,
6291 &tags,
6292 started_at,
6293 )
6294 .await
6295 .unwrap();
6296
6297 let initial = sample_initial_state();
6302 let beta = lookup(
6303 config.process_type,
6304 config.controller_type,
6305 ResponseLevel::Aggressive,
6306 )
6307 .beta;
6308 let mut engine = MrftEngine::new(
6309 config,
6310 initial.direction,
6311 beta,
6312 InitialReadings {
6313 pv_ini: initial.pv_ini,
6314 mv_ini: initial.mv_ini,
6315 mv_range_low: initial.mv_range_low,
6316 mv_range_high: initial.mv_range_high,
6317 },
6318 started_at,
6319 MrftCompat::default(),
6320 );
6321
6322 let mut timing = timing_for_args(&args);
6323 let outcome = run_polling_loop(
6324 &pool,
6325 run.id,
6326 &args,
6327 &tags,
6328 &driver,
6329 &mut engine,
6330 time_anchor_at(started_at),
6331 &mut CtrlC::never(),
6332 &mut MutationGuard::default(),
6333 false,
6334 &mut timing,
6335 &mut None,
6336 build_loop_config(&args).unwrap(),
6337 )
6338 .await
6339 .unwrap();
6340
6341 assert!(matches!(
6342 outcome,
6343 PollOutcome::Aborted(AbortReason::PoorQuality { ref tag, quality })
6344 if tag == &tags.process_variable && quality == bhtune_driver::Quality::Bad
6345 ));
6346
6347 let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6351 assert_eq!(samples.len(), 1);
6352 assert_eq!(samples[0].pv_quality, SampleQuality::Bad);
6353
6354 let guard = MutationGuard::default();
6362 let report = restore(&driver, &tags, &template, &initial, &guard).await;
6363 assert_eq!(report.mv, RestoreStepOutcome::Succeeded);
6364 assert_eq!(report.mode, RestoreStepOutcome::NotNeeded);
6365 assert_eq!(report.setpoint, RestoreStepOutcome::NotNeeded);
6366 assert_eq!(report.mode_attribute, RestoreStepOutcome::NotNeeded);
6367 TuneRunRow::abort(&pool, run.id, Utc::now()).await.unwrap();
6368
6369 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
6370 assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
6371
6372 assert!(
6374 driver
6375 .write_log()
6376 .iter()
6377 .any(|(tag, value)| tag == &tags.manipulated_variable && value == "45")
6378 );
6379 }
6380
6381 #[tokio::test]
6382 async fn live_sample_timestamp_includes_driver_read_delay() {
6383 let pool = seeded_pool().await;
6384 let template = honeywell_template();
6385 let tags = honeywell_tags();
6386 let driver = honeywell_driver_auto()
6387 .delaying_read(&tags.process_variable, Duration::from_millis(50))
6388 .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
6389 let mut args = fast_simulator_args();
6390 args.driver = DriverKindArg::Opcda;
6391 let config = build_loop_config(&args).unwrap();
6392 let started_at = DateTime::UNIX_EPOCH;
6393 let time_anchor = time_anchor_at(started_at);
6394 let run = TuneRunRow::start(
6395 &pool,
6396 None,
6397 "delayed-live-read",
6398 TuneDriver::Opcda,
6399 config,
6400 TemplateOrigin::Builtin,
6401 &template,
6402 &tags,
6403 started_at,
6404 )
6405 .await
6406 .unwrap();
6407 let initial = sample_initial_state();
6408 let mut engine = MrftEngine::new(
6409 config,
6410 initial.direction,
6411 lookup(
6412 config.process_type,
6413 config.controller_type,
6414 ResponseLevel::Aggressive,
6415 )
6416 .beta,
6417 InitialReadings {
6418 pv_ini: initial.pv_ini,
6419 mv_ini: initial.mv_ini,
6420 mv_range_low: initial.mv_range_low,
6421 mv_range_high: initial.mv_range_high,
6422 },
6423 started_at,
6424 MrftCompat::default(),
6425 );
6426
6427 let mut timing = timing_for_args(&args);
6428 let outcome = run_polling_loop(
6429 &pool,
6430 run.id,
6431 &args,
6432 &tags,
6433 &driver,
6434 &mut engine,
6435 time_anchor,
6436 &mut CtrlC::never(),
6437 &mut MutationGuard::default(),
6438 false,
6439 &mut timing,
6440 &mut None,
6441 build_loop_config(&args).unwrap(),
6442 )
6443 .await
6444 .unwrap();
6445
6446 assert!(matches!(
6447 outcome,
6448 PollOutcome::Aborted(AbortReason::PoorQuality { .. })
6449 ));
6450 let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6451 assert_eq!(samples.len(), 1);
6452 assert!(
6453 samples[0].sample.time - started_at >= chrono::Duration::milliseconds(45),
6454 "live sample timestamp should include the driver's 50 ms read delay"
6455 );
6456 }
6457
6458 #[tokio::test]
6471 async fn a_stalled_pv_read_aborts_the_poll_loop_via_op_timeout_secs() {
6472 let pool = seeded_pool().await;
6473 let template = honeywell_template();
6474 let tags = honeywell_tags();
6475 let driver = honeywell_driver_auto().hanging_read(&tags.process_variable);
6476 let mut args = fast_simulator_args();
6477 args.op_timeout_secs = 1;
6478 let config = build_loop_config(&args).unwrap();
6479
6480 let started_at = Utc::now();
6481 let run = TuneRunRow::start(
6482 &pool,
6483 None,
6484 "stalled-pv-read",
6485 TuneDriver::Opcda,
6486 config,
6487 TemplateOrigin::Builtin,
6488 &template,
6489 &tags,
6490 started_at,
6491 )
6492 .await
6493 .unwrap();
6494
6495 let initial = sample_initial_state();
6496 let beta = lookup(
6497 config.process_type,
6498 config.controller_type,
6499 ResponseLevel::Aggressive,
6500 )
6501 .beta;
6502 let mut engine = MrftEngine::new(
6503 config,
6504 initial.direction,
6505 beta,
6506 InitialReadings {
6507 pv_ini: initial.pv_ini,
6508 mv_ini: initial.mv_ini,
6509 mv_range_low: initial.mv_range_low,
6510 mv_range_high: initial.mv_range_high,
6511 },
6512 started_at,
6513 MrftCompat::default(),
6514 );
6515
6516 let mut timing = timing_for_args(&args);
6517 let outcome = run_polling_loop(
6518 &pool,
6519 run.id,
6520 &args,
6521 &tags,
6522 &driver,
6523 &mut engine,
6524 time_anchor_at(started_at),
6525 &mut CtrlC::never(),
6526 &mut MutationGuard::default(),
6527 false,
6528 &mut timing,
6529 &mut None,
6530 build_loop_config(&args).unwrap(),
6531 )
6532 .await
6533 .unwrap();
6534
6535 assert!(matches!(
6536 outcome,
6537 PollOutcome::Aborted(AbortReason::OperationTimedOut {
6538 ref tag,
6539 op_timeout_secs,
6540 }) if tag == &tags.process_variable && op_timeout_secs == 1
6541 ));
6542
6543 let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6546 assert!(samples.is_empty());
6547 }
6548
6549 #[tokio::test]
6563 async fn a_stalled_mv_write_during_a_tick_is_cancelled_and_still_records_the_sample() {
6564 let pool = seeded_pool().await;
6565 let template = honeywell_template();
6566 let tags = honeywell_tags();
6567 let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
6568 let mut args = fast_simulator_args();
6569 args.op_timeout_secs = 30;
6572 let config = build_loop_config(&args).unwrap();
6573
6574 let started_at = Utc::now();
6575 let run = TuneRunRow::start(
6576 &pool,
6577 None,
6578 "stalled-mv-write",
6579 TuneDriver::Opcda,
6580 config,
6581 TemplateOrigin::Builtin,
6582 &template,
6583 &tags,
6584 started_at,
6585 )
6586 .await
6587 .unwrap();
6588
6589 let initial = sample_initial_state();
6590 let beta = lookup(
6591 config.process_type,
6592 config.controller_type,
6593 ResponseLevel::Aggressive,
6594 )
6595 .beta;
6596 let mut engine = MrftEngine::new(
6597 config,
6598 initial.direction,
6599 beta,
6600 InitialReadings {
6601 pv_ini: initial.pv_ini,
6602 mv_ini: initial.mv_ini,
6603 mv_range_low: initial.mv_range_low,
6604 mv_range_high: initial.mv_range_high,
6605 },
6606 started_at,
6607 MrftCompat::default(),
6608 );
6609
6610 let (mut ctrl_c, tx) = CtrlC::test_pair();
6611 tokio::spawn(async move {
6612 tokio::time::sleep(Duration::from_millis(50)).await;
6613 let _ = tx.send(1);
6614 });
6615
6616 let mut timing = timing_for_args(&args);
6617 let outcome = run_polling_loop(
6618 &pool,
6619 run.id,
6620 &args,
6621 &tags,
6622 &driver,
6623 &mut engine,
6624 time_anchor_at(started_at),
6625 &mut ctrl_c,
6626 &mut MutationGuard::default(),
6627 true,
6628 &mut timing,
6629 &mut None,
6630 build_loop_config(&args).unwrap(),
6631 )
6632 .await
6633 .unwrap();
6634
6635 assert!(matches!(
6636 outcome,
6637 PollOutcome::Aborted(AbortReason::UserInterrupt)
6638 ));
6639
6640 let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6643 assert_eq!(samples.len(), 1);
6644
6645 assert!(driver.write_log().is_empty());
6649 }
6650
6651 #[derive(Default)]
6665 struct MockDriver {
6666 values: std::sync::Mutex<std::collections::HashMap<String, String>>,
6667 read_sequences:
6668 std::sync::Mutex<std::collections::HashMap<String, std::collections::VecDeque<String>>>,
6669 read_batches: std::sync::Mutex<Vec<Vec<String>>>,
6670 reverse_read_results: bool,
6671 writes: std::sync::Mutex<Vec<(String, String)>>,
6672 reject_writes: std::collections::HashSet<String>,
6673 error_reads: std::collections::HashSet<String>,
6674 error_writes: std::collections::HashSet<String>,
6675 empty_reads: std::collections::HashSet<String>,
6676 hang_reads: std::collections::HashSet<String>,
6682 hang_writes: std::collections::HashSet<String>,
6683 read_delays: std::collections::HashMap<String, Duration>,
6686 cancelled_delayed_reads: std::sync::Mutex<std::collections::HashSet<String>>,
6688 write_delays: std::collections::HashMap<String, Duration>,
6691 cancelled_delayed_writes: std::sync::Mutex<std::collections::HashSet<String>>,
6693 qualities: std::sync::Mutex<std::collections::HashMap<String, bhtune_driver::Quality>>,
6697 degrade_quality_after: std::collections::HashMap<String, (usize, bhtune_driver::Quality)>,
6707 read_counts: std::sync::Mutex<std::collections::HashMap<String, usize>>,
6710 error_reads_after: std::collections::HashMap<String, usize>,
6717 write_offsets: std::collections::HashMap<String, f32>,
6722 prefix_write_offsets: std::collections::HashMap<String, (usize, f32)>,
6725 reject_writes_after: std::collections::HashMap<String, usize>,
6731 write_counts: std::sync::Mutex<std::collections::HashMap<String, usize>>,
6734 }
6735
6736 impl MockDriver {
6737 fn new(values: &[(&str, &str)]) -> MockDriver {
6738 MockDriver {
6739 values: std::sync::Mutex::new(
6740 values
6741 .iter()
6742 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
6743 .collect(),
6744 ),
6745 ..Default::default()
6746 }
6747 }
6748
6749 fn rejecting_write(mut self, tag: &str) -> MockDriver {
6750 self.reject_writes.insert(tag.to_string());
6751 self
6752 }
6753
6754 fn erroring_read(mut self, tag: &str) -> MockDriver {
6755 self.error_reads.insert(tag.to_string());
6756 self
6757 }
6758
6759 fn erroring_write(mut self, tag: &str) -> MockDriver {
6760 self.error_writes.insert(tag.to_string());
6761 self
6762 }
6763
6764 fn empty_read(mut self, tag: &str) -> MockDriver {
6765 self.empty_reads.insert(tag.to_string());
6766 self
6767 }
6768
6769 fn hanging_read(mut self, tag: &str) -> MockDriver {
6771 self.hang_reads.insert(tag.to_string());
6772 self
6773 }
6774
6775 fn hanging_write(mut self, tag: &str) -> MockDriver {
6777 self.hang_writes.insert(tag.to_string());
6778 self
6779 }
6780
6781 fn delaying_read(mut self, tag: &str, delay: Duration) -> MockDriver {
6782 self.read_delays.insert(tag.to_string(), delay);
6783 self
6784 }
6785
6786 fn delaying_write(mut self, tag: &str, delay: Duration) -> MockDriver {
6787 self.write_delays.insert(tag.to_string(), delay);
6788 self
6789 }
6790
6791 fn reversing_read_results(mut self) -> MockDriver {
6794 self.reverse_read_results = true;
6795 self
6796 }
6797
6798 async fn apply_read_delay(&self, tags: &[String]) {
6799 if let Some(delay) = tags
6800 .iter()
6801 .filter_map(|tag| self.read_delays.get(tag))
6802 .max()
6803 {
6804 let delayed_tags = tags
6805 .iter()
6806 .filter(|tag| self.read_delays.contains_key(*tag))
6807 .cloned()
6808 .collect();
6809 let mut observer = DelayedReadObserver {
6810 driver: self,
6811 tags: delayed_tags,
6812 completed: false,
6813 };
6814 tokio::time::sleep(*delay).await;
6815 observer.completed = true;
6816 }
6817 }
6818
6819 async fn apply_write_delay(&self, tag: &str) {
6820 let Some(delay) = self.write_delays.get(tag).copied() else {
6821 return;
6822 };
6823 let mut observer = DelayedWriteObserver {
6824 driver: self,
6825 tag: tag.to_string(),
6826 completed: false,
6827 };
6828 tokio::time::sleep(delay).await;
6829 observer.completed = true;
6830 }
6831
6832 fn with_value(self, tag: &str, value: &str) -> MockDriver {
6835 self.values
6836 .lock()
6837 .unwrap()
6838 .insert(tag.to_string(), value.to_string());
6839 self
6840 }
6841
6842 fn with_read_sequence(self, tag: &str, values: &[&str]) -> MockDriver {
6843 self.read_sequences.lock().unwrap().insert(
6844 tag.to_string(),
6845 values.iter().map(|value| (*value).to_string()).collect(),
6846 );
6847 self
6848 }
6849
6850 fn with_quality(self, tag: &str, quality: bhtune_driver::Quality) -> MockDriver {
6853 self.qualities
6854 .lock()
6855 .unwrap()
6856 .insert(tag.to_string(), quality);
6857 self
6858 }
6859
6860 fn degrade_quality_after(
6864 mut self,
6865 tag: &str,
6866 good_reads: usize,
6867 degraded: bhtune_driver::Quality,
6868 ) -> MockDriver {
6869 self.degrade_quality_after
6870 .insert(tag.to_string(), (good_reads, degraded));
6871 self
6872 }
6873
6874 fn erroring_read_after(mut self, tag: &str, good_reads: usize) -> MockDriver {
6877 self.error_reads_after.insert(tag.to_string(), good_reads);
6878 self
6879 }
6880
6881 fn distorting_write(mut self, tag: &str, offset: f32) -> MockDriver {
6885 self.write_offsets.insert(tag.to_string(), offset);
6886 self
6887 }
6888
6889 fn distorting_first_writes(
6890 mut self,
6891 tag: &str,
6892 write_count: usize,
6893 offset: f32,
6894 ) -> MockDriver {
6895 self.prefix_write_offsets
6896 .insert(tag.to_string(), (write_count, offset));
6897 self
6898 }
6899
6900 fn rejecting_write_after(mut self, tag: &str, good_writes: usize) -> MockDriver {
6903 self.reject_writes_after
6904 .insert(tag.to_string(), good_writes);
6905 self
6906 }
6907
6908 fn value_of(&self, tag: &str) -> Option<String> {
6909 self.values.lock().unwrap().get(tag).cloned()
6910 }
6911
6912 fn write_log(&self) -> Vec<(String, String)> {
6913 self.writes.lock().unwrap().clone()
6914 }
6915
6916 fn read_batches(&self) -> Vec<Vec<String>> {
6917 self.read_batches.lock().unwrap().clone()
6918 }
6919
6920 fn next_read_count(&self, tag: &str) -> usize {
6921 let mut counts = self.read_counts.lock().unwrap();
6922 let count = counts.entry(tag.to_string()).or_insert(0);
6923 *count += 1;
6924 *count
6925 }
6926
6927 fn quality_for_read(&self, tag: &str, count: usize) -> bhtune_driver::Quality {
6928 let baseline_quality = self
6929 .qualities
6930 .lock()
6931 .unwrap()
6932 .get(tag)
6933 .copied()
6934 .unwrap_or(bhtune_driver::Quality::Good);
6935 self.degrade_quality_after.get(tag).map_or(
6936 baseline_quality,
6937 |(good_reads, degraded)| {
6938 if count > *good_reads {
6939 *degraded
6940 } else {
6941 baseline_quality
6942 }
6943 },
6944 )
6945 }
6946
6947 fn read_tag(
6948 &self,
6949 tag: &str,
6950 store: &std::collections::HashMap<String, String>,
6951 sequences: &mut std::collections::HashMap<String, std::collections::VecDeque<String>>,
6952 ) -> bhtune_driver::DriverResult<Option<bhtune_driver::TagValue>> {
6953 if self.error_reads.contains(tag) {
6954 return Err(bhtune_driver::DriverError::Operation(Box::new(
6955 std::io::Error::other("mock read error"),
6956 )));
6957 }
6958 if self.empty_reads.contains(tag) {
6959 return Ok(None);
6960 }
6961
6962 let count = self.next_read_count(tag);
6963 if self
6964 .error_reads_after
6965 .get(tag)
6966 .is_some_and(|good_reads| count > *good_reads)
6967 {
6968 return Err(bhtune_driver::DriverError::Operation(Box::new(
6969 std::io::Error::other("mock read error after good reads"),
6970 )));
6971 }
6972
6973 let quality = self.quality_for_read(tag, count);
6974 let value = sequences
6975 .get_mut(tag)
6976 .and_then(std::collections::VecDeque::pop_front)
6977 .or_else(|| store.get(tag).cloned())
6978 .unwrap_or_default();
6979 Ok(Some(bhtune_driver::TagValue {
6980 tag: tag.to_string(),
6981 value,
6982 quality,
6983 timestamp: None,
6984 }))
6985 }
6986
6987 fn delayed_read_was_cancelled(&self, tag: &str) -> bool {
6988 self.cancelled_delayed_reads.lock().unwrap().contains(tag)
6989 }
6990
6991 fn delayed_write_was_cancelled(&self, tag: &str) -> bool {
6992 self.cancelled_delayed_writes.lock().unwrap().contains(tag)
6993 }
6994 }
6995
6996 struct DelayedReadObserver<'a> {
6997 driver: &'a MockDriver,
6998 tags: Vec<String>,
6999 completed: bool,
7000 }
7001
7002 impl Drop for DelayedReadObserver<'_> {
7003 fn drop(&mut self) {
7004 if !self.completed {
7005 self.driver
7006 .cancelled_delayed_reads
7007 .lock()
7008 .unwrap()
7009 .extend(self.tags.iter().cloned());
7010 }
7011 }
7012 }
7013
7014 struct DelayedWriteObserver<'a> {
7015 driver: &'a MockDriver,
7016 tag: String,
7017 completed: bool,
7018 }
7019
7020 impl Drop for DelayedWriteObserver<'_> {
7021 fn drop(&mut self) {
7022 if !self.completed {
7023 self.driver
7024 .cancelled_delayed_writes
7025 .lock()
7026 .unwrap()
7027 .insert(self.tag.clone());
7028 }
7029 }
7030 }
7031
7032 #[test]
7033 fn delayed_write_observer_records_cancellation_only_when_incomplete() {
7034 let driver = MockDriver::default();
7035 {
7036 let _observer = DelayedWriteObserver {
7037 driver: &driver,
7038 tag: "MV".into(),
7039 completed: false,
7040 };
7041 }
7042 assert!(driver.delayed_write_was_cancelled("MV"));
7043
7044 {
7045 let _observer = DelayedWriteObserver {
7046 driver: &driver,
7047 tag: "completed".into(),
7048 completed: true,
7049 };
7050 }
7051 assert!(!driver.delayed_write_was_cancelled("completed"));
7052 }
7053
7054 #[async_trait::async_trait]
7055 impl Driver for MockDriver {
7056 async fn read(
7057 &self,
7058 tags: &[String],
7059 ) -> bhtune_driver::DriverResult<Vec<bhtune_driver::TagValue>> {
7060 self.read_batches.lock().unwrap().push(tags.to_vec());
7061 if tags.iter().any(|tag| self.hang_reads.contains(tag)) {
7062 std::future::pending::<()>().await;
7063 }
7064 self.apply_read_delay(tags).await;
7065 let store = self.values.lock().unwrap();
7066 let mut sequences = self.read_sequences.lock().unwrap();
7067 let mut out = Vec::new();
7068 for tag in tags {
7069 if let Some(value) = self.read_tag(tag, &store, &mut sequences)? {
7070 out.push(value);
7071 }
7072 }
7073 if self.reverse_read_results {
7074 out.reverse();
7075 }
7076 Ok(out)
7077 }
7078
7079 async fn write(
7080 &self,
7081 tag: &String,
7082 value: TagWrite,
7083 ) -> bhtune_driver::DriverResult<bhtune_driver::WriteOutcome> {
7084 if self.hang_writes.contains(tag) {
7085 std::future::pending::<()>().await;
7086 }
7087 self.apply_write_delay(tag).await;
7088 if self.error_writes.contains(tag) {
7089 return Err(bhtune_driver::DriverError::Operation(Box::new(
7090 std::io::Error::other("mock write error"),
7091 )));
7092 }
7093 let text = match &value {
7094 TagWrite::Float(f) => f.to_string(),
7095 TagWrite::Raw(s) => s.clone(),
7096 };
7097 self.writes
7098 .lock()
7099 .unwrap()
7100 .push((tag.clone(), text.clone()));
7101 let write_count = {
7102 let mut counts = self.write_counts.lock().unwrap();
7103 let count = counts.entry(tag.clone()).or_insert(0);
7104 *count += 1;
7105 *count
7106 };
7107 if self.reject_writes.contains(tag) {
7108 return Ok(bhtune_driver::WriteOutcome::failure("mock rejected write"));
7109 }
7110 if let Some(good_writes) = self.reject_writes_after.get(tag)
7111 && write_count > *good_writes
7112 {
7113 return Ok(bhtune_driver::WriteOutcome::failure(
7114 "mock rejected write after good writes",
7115 ));
7116 }
7117 let stored = if let TagWrite::Float(f) = value {
7121 let prefix_offset = self
7122 .prefix_write_offsets
7123 .get(tag)
7124 .filter(|(prefix, _)| write_count <= *prefix)
7125 .map_or(0.0, |(_, offset)| *offset);
7126 (f + self
7127 .write_offsets
7128 .get(tag)
7129 .copied()
7130 .unwrap_or(prefix_offset))
7131 .to_string()
7132 } else {
7133 text
7134 };
7135 self.values.lock().unwrap().insert(tag.clone(), stored);
7136 Ok(bhtune_driver::WriteOutcome::success())
7137 }
7138
7139 async fn browse(
7140 &self,
7141 _request: bhtune_driver::BrowsePageRequest,
7142 ) -> bhtune_driver::DriverResult<bhtune_driver::BrowsePage> {
7143 Err(bhtune_driver::DriverError::Unsupported {
7144 operation: "browse",
7145 })
7146 }
7147 }
7148
7149 #[tokio::test]
7150 async fn mock_driver_browse_is_unsupported() {
7151 let err = MockDriver::new(&[])
7155 .browse(bhtune_driver::BrowsePageRequest::root(20))
7156 .await
7157 .unwrap_err();
7158 assert!(matches!(
7159 err,
7160 bhtune_driver::DriverError::Unsupported {
7161 operation: "browse"
7162 }
7163 ));
7164 }
7165
7166 #[test]
7167 fn relay_actuation_tolerance_uses_span_allowance_and_step_cap() {
7168 let uncapped = mv_actuation_tolerance(MvActuationKind::Relay, 60.0, 50.0, 100.0).unwrap();
7169 assert!(uncapped > 0.1);
7170 assert!(uncapped < 0.101);
7171
7172 let capped = mv_actuation_tolerance(MvActuationKind::Relay, 50.2, 50.0, 100.0).unwrap();
7173 assert!((capped - 0.05).abs() < 1e-6);
7174
7175 let restore = mv_actuation_tolerance(MvActuationKind::Restore, 50.0, 50.2, 100.0).unwrap();
7176 assert!(restore > capped);
7177 }
7178
7179 #[test]
7180 fn relay_actuation_tolerance_rejects_a_step_below_the_f32_floor() {
7181 let error =
7182 mv_actuation_tolerance(MvActuationKind::Relay, 50.0 + f32::EPSILON, 50.0, 100.0)
7183 .unwrap_err();
7184 assert!(error.to_string().contains("too small to verify safely"));
7185 assert!(mv_actuation_tolerance(MvActuationKind::Relay, 50.005, 50.0, 100.0).is_err());
7186 }
7187
7188 #[test]
7189 fn relay_actuation_tolerance_rejects_large_magnitude_step_below_precision_floor() {
7190 let previous = 100_000_000.0_f32;
7191 let target = previous + 8.0;
7192 assert_eq!(target - previous, 8.0);
7193 let error =
7194 mv_actuation_tolerance(MvActuationKind::Relay, target, previous, 100.0).unwrap_err();
7195 assert!(error.to_string().contains("too small to verify safely"));
7196 }
7197
7198 fn pending_actuation(
7199 id: Option<i64>,
7200 kind: MvActuationKind,
7201 target: f32,
7202 first_check_at: Instant,
7203 deadline: Instant,
7204 last_readback: Option<f32>,
7205 ) -> PendingMvActuation {
7206 let now = Instant::now();
7207 PendingMvActuation {
7208 id,
7209 kind,
7210 target,
7211 tolerance: 0.1,
7212 switch_tick: Utc::now(),
7213 switch_instant: now,
7214 accepted_instant: now,
7215 first_check_at,
7216 deadline,
7217 last_readback,
7218 }
7219 }
7220
7221 fn tracker_with_pending(pending: PendingMvActuation) -> MvActuationTracker {
7222 MvActuationTracker {
7223 next_sequence: 1,
7224 previous_commanded_mv: 55.0,
7225 confirmed_mv: None,
7226 pending: Some(pending),
7227 mv_span: 100.0,
7228 }
7229 }
7230
7231 fn batched_mv_value(tag: &str, value: &str, quality: bhtune_driver::Quality) -> TagValue {
7232 TagValue {
7233 tag: tag.to_string(),
7234 value: value.to_string(),
7235 quality,
7236 timestamp: None,
7237 }
7238 }
7239
7240 fn pending_poll_test_state() -> (
7241 SqlitePool,
7242 EffectiveTiming,
7243 MvActuationTracker,
7244 PollTimingAccumulator,
7245 ) {
7246 let args = {
7247 let mut args = fast_simulator_args();
7248 args.driver = DriverKindArg::Opcda;
7249 args
7250 };
7251 let now = Instant::now();
7252 let pending = pending_actuation(
7253 None,
7254 MvActuationKind::Relay,
7255 55.0,
7256 now,
7257 now + Duration::from_secs(10),
7258 None,
7259 );
7260 (
7261 SqlitePool::connect_lazy("sqlite::memory:").unwrap(),
7262 test_effective_timing(&args),
7263 tracker_with_pending(pending),
7264 timing_for_args(&args),
7265 )
7266 }
7267
7268 #[tokio::test]
7269 async fn resolve_pending_mv_poll_reports_missing_mv_data() {
7270 let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7271 let error = resolve_pending_mv_poll(
7272 &pool,
7273 effective_timing,
7274 TickOperation::Completed(HashMap::new()),
7275 "Unit1.LIC101.OP",
7276 Utc::now(),
7277 Instant::now(),
7278 Duration::from_millis(1),
7279 false,
7280 &mut tracker,
7281 &mut timing,
7282 )
7283 .await
7284 .unwrap_err();
7285
7286 assert!(error.to_string().contains("no value for tag"));
7287 assert!(tracker.pending.is_none());
7288 }
7289
7290 #[tokio::test]
7291 async fn resolve_pending_mv_poll_reports_malformed_mv_data() {
7292 let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7293 let values = HashMap::from([(
7294 "Unit1.LIC101.OP".to_string(),
7295 batched_mv_value(
7296 "Unit1.LIC101.OP",
7297 "not-a-number",
7298 bhtune_driver::Quality::Good,
7299 ),
7300 )]);
7301 let error = resolve_pending_mv_poll(
7302 &pool,
7303 effective_timing,
7304 TickOperation::Completed(values),
7305 "Unit1.LIC101.OP",
7306 Utc::now(),
7307 Instant::now(),
7308 Duration::from_millis(1),
7309 false,
7310 &mut tracker,
7311 &mut timing,
7312 )
7313 .await
7314 .unwrap_err();
7315
7316 assert!(error.to_string().contains("not a number"));
7317 assert!(tracker.pending.is_none());
7318 }
7319
7320 #[tokio::test]
7321 async fn resolve_pending_mv_poll_preserves_a_cancelled_operation() {
7322 let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7323 let (reason, provided_evidence) = resolve_pending_mv_poll(
7324 &pool,
7325 effective_timing,
7326 TickOperation::Cancelled,
7327 "Unit1.LIC101.OP",
7328 Utc::now(),
7329 Instant::now(),
7330 Duration::from_millis(1),
7331 false,
7332 &mut tracker,
7333 &mut timing,
7334 )
7335 .await
7336 .unwrap();
7337
7338 assert_eq!(reason, Some(AbortReason::UserInterrupt));
7339 assert!(!provided_evidence);
7340 assert!(tracker.pending.is_none());
7341 }
7342
7343 #[tokio::test]
7344 async fn resolve_pending_mv_poll_preserves_a_timed_out_operation() {
7345 let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7346 let (reason, provided_evidence) = resolve_pending_mv_poll(
7347 &pool,
7348 effective_timing,
7349 TickOperation::TimedOut,
7350 "Unit1.LIC101.OP",
7351 Utc::now(),
7352 Instant::now(),
7353 Duration::from_millis(1),
7354 false,
7355 &mut tracker,
7356 &mut timing,
7357 )
7358 .await
7359 .unwrap();
7360
7361 assert!(matches!(
7362 reason,
7363 Some(AbortReason::OperationTimedOut {
7364 tag,
7365 op_timeout_secs: 30,
7366 }) if tag == "Unit1.LIC101.OP"
7367 ));
7368 assert!(!provided_evidence);
7369 assert!(tracker.pending.is_none());
7370 }
7371
7372 #[test]
7373 fn mv_actuation_abort_format_falls_back_for_other_abort_reasons() {
7374 assert_eq!(
7375 format_mv_actuation_abort_reason(&AbortReason::UserInterrupt),
7376 "UserInterrupt"
7377 );
7378 }
7379
7380 #[tokio::test]
7381 async fn audit_helpers_skip_rows_without_an_audit_id_or_pending_actuation() {
7382 let pool = seeded_pool().await;
7383 let now = Instant::now();
7384 let pending = pending_actuation(
7385 None,
7386 MvActuationKind::Restore,
7387 45.0,
7388 now,
7389 now + Duration::from_secs(1),
7390 None,
7391 );
7392
7393 assert_eq!(
7394 record_actuation_observation(
7395 &pool,
7396 &pending,
7397 Utc::now(),
7398 Some(45.0),
7399 Some(SampleQuality::Good),
7400 ActuationAuditPolicy::Required,
7401 )
7402 .await
7403 .unwrap(),
7404 None
7405 );
7406 assert_eq!(
7407 record_final_actuation_observation(
7408 &pool,
7409 &pending,
7410 Utc::now(),
7411 Some(45.0),
7412 Some(SampleQuality::Good),
7413 MvActuationStatus::Confirmed,
7414 "",
7415 )
7416 .await,
7417 None
7418 );
7419 finalize_actuation_best_effort(
7420 &pool,
7421 &pending,
7422 MvActuationStatus::Superseded,
7423 "no audit row",
7424 )
7425 .await;
7426
7427 let mut args = fast_simulator_args();
7428 args.driver = DriverKindArg::Opcda;
7429 let mut tracker = MvActuationTracker::for_run(&args, &sample_initial_state()).unwrap();
7430 supersede_pending_actuation_best_effort(&pool, &mut tracker, "nothing pending").await;
7431 assert!(tracker.pending.is_none());
7432 }
7433
7434 #[tokio::test]
7435 async fn audit_helpers_apply_required_and_best_effort_failure_policies() {
7436 let pool = seeded_pool().await;
7437 let now = Instant::now();
7438 let pending = pending_actuation(
7439 Some(i64::MAX),
7440 MvActuationKind::Restore,
7441 45.0,
7442 now,
7443 now + Duration::from_secs(1),
7444 None,
7445 );
7446 pool.close().await;
7447
7448 assert_eq!(
7449 record_actuation_observation(
7450 &pool,
7451 &pending,
7452 Utc::now(),
7453 Some(45.0),
7454 Some(SampleQuality::Good),
7455 ActuationAuditPolicy::BestEffort,
7456 )
7457 .await
7458 .unwrap(),
7459 None
7460 );
7461 assert!(
7462 record_actuation_observation(
7463 &pool,
7464 &pending,
7465 Utc::now(),
7466 Some(45.0),
7467 Some(SampleQuality::Good),
7468 ActuationAuditPolicy::Required,
7469 )
7470 .await
7471 .is_err()
7472 );
7473 assert_eq!(
7474 record_final_actuation_observation(
7475 &pool,
7476 &pending,
7477 Utc::now(),
7478 Some(45.0),
7479 Some(SampleQuality::Good),
7480 MvActuationStatus::Confirmed,
7481 "closed pool",
7482 )
7483 .await,
7484 None
7485 );
7486 finalize_actuation_best_effort(
7487 &pool,
7488 &pending,
7489 MvActuationStatus::Superseded,
7490 "closed pool",
7491 )
7492 .await;
7493 }
7494
7495 #[tokio::test]
7496 async fn replacement_before_any_readback_is_finalized_as_unverified() {
7497 let pool = seeded_pool().await;
7498 let now = Instant::now();
7499 let pending = pending_actuation(
7500 None,
7501 MvActuationKind::Relay,
7502 55.0,
7503 now,
7504 now + Duration::from_secs(1),
7505 None,
7506 );
7507 let mut tracker = tracker_with_pending(pending);
7508
7509 let reason =
7510 reject_replacement_for_pending_actuation(&pool, "Unit1.LIC101.OP", &mut tracker)
7511 .await
7512 .unwrap();
7513
7514 assert!(matches!(
7515 reason,
7516 AbortReason::MvActuationUnconfirmed { readback: None, .. }
7517 ));
7518 assert!(tracker.pending.is_none());
7519 }
7520
7521 #[tokio::test]
7522 async fn verification_without_pending_work_or_before_first_check_is_a_noop() {
7523 let pool = seeded_pool().await;
7524 let driver = honeywell_driver_auto();
7525 let mut args = fast_simulator_args();
7526 args.driver = DriverKindArg::Opcda;
7527 let mut tracker = MvActuationTracker::for_run(&args, &sample_initial_state()).unwrap();
7528
7529 assert_eq!(
7530 verify_pending_mv_actuation_with(
7531 &pool,
7532 &args,
7533 "Unit1.LIC101.OP",
7534 &driver,
7535 &mut CtrlC::never(),
7536 false,
7537 &mut tracker,
7538 MvVerificationTrigger::Scheduled,
7539 MvVerificationCallLimit::None,
7540 ActuationAuditPolicy::Required,
7541 )
7542 .await
7543 .unwrap(),
7544 None
7545 );
7546
7547 let now = Instant::now();
7548 tracker.pending = Some(pending_actuation(
7549 None,
7550 MvActuationKind::Relay,
7551 55.0,
7552 now + Duration::from_secs(1),
7553 now + Duration::from_secs(2),
7554 None,
7555 ));
7556 assert_eq!(
7557 verify_pending_mv_actuation_with(
7558 &pool,
7559 &args,
7560 "Unit1.LIC101.OP",
7561 &driver,
7562 &mut CtrlC::never(),
7563 false,
7564 &mut tracker,
7565 MvVerificationTrigger::Scheduled,
7566 MvVerificationCallLimit::None,
7567 ActuationAuditPolicy::Required,
7568 )
7569 .await
7570 .unwrap(),
7571 None
7572 );
7573 assert!(driver.read_batches().is_empty());
7574 }
7575
7576 #[tokio::test]
7577 async fn explicit_deadline_trigger_rejects_a_mismatch_before_the_clock_deadline() {
7578 let pool = seeded_pool().await;
7579 let (run_id, _config, _template, tags) =
7580 start_opc_test_run(&pool, "actuation-explicit-deadline").await;
7581 let driver = honeywell_driver_auto();
7582 let mut args = fast_simulator_args();
7583 args.driver = DriverKindArg::Opcda;
7584 let now = Instant::now();
7585 let mut tracker = MvActuationTracker::for_run(&args, &sample_initial_state()).unwrap();
7586 tracker
7587 .record_accepted(
7588 &pool,
7589 run_id,
7590 MvActuationKind::Relay,
7591 55.0,
7592 now,
7593 Utc::now(),
7594 now,
7595 0.1,
7596 )
7597 .await
7598 .unwrap();
7599
7600 let outcome = verify_pending_mv_actuation_with(
7601 &pool,
7602 &args,
7603 &tags.manipulated_variable,
7604 &driver,
7605 &mut CtrlC::never(),
7606 false,
7607 &mut tracker,
7608 MvVerificationTrigger::Deadline,
7609 MvVerificationCallLimit::None,
7610 ActuationAuditPolicy::Required,
7611 )
7612 .await
7613 .unwrap();
7614
7615 assert!(matches!(
7616 outcome,
7617 Some(AbortReason::MvActuationUnconfirmed {
7618 readback: Some(45.0),
7619 ..
7620 })
7621 ));
7622 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7623 .await
7624 .unwrap();
7625 assert_eq!(rows[0].status, MvActuationStatus::Failed);
7626 }
7627
7628 #[tokio::test]
7629 async fn first_verification_uses_the_switch_tick_even_when_write_acceptance_is_late() {
7630 let pool = seeded_pool().await;
7631 let (run_id, _config, _template, _tags) =
7632 start_opc_test_run(&pool, "actuation-switch-causality").await;
7633 let mut args = fast_simulator_args();
7634 args.driver = DriverKindArg::Opcda;
7635 let initial = sample_initial_state();
7636 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7637 let switch_instant = Instant::now();
7638 let switch_tick = DateTime::UNIX_EPOCH + chrono::Duration::seconds(12);
7639 let accepted_instant = switch_instant + Duration::from_secs(3);
7640 let accepted_at = switch_tick + chrono::Duration::seconds(3);
7641 let first_check_at = switch_instant + Duration::from_secs(2);
7642
7643 tracker
7644 .record_accepted_at_switch(
7645 &pool,
7646 run_id,
7647 MvActuationKind::Relay,
7648 55.0,
7649 switch_tick,
7650 switch_instant,
7651 first_check_at,
7652 accepted_at,
7653 accepted_instant,
7654 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7655 )
7656 .await
7657 .unwrap();
7658
7659 let pending = tracker.pending.as_ref().unwrap();
7660 assert_eq!(pending.switch_tick, switch_tick);
7661 assert_eq!(pending.switch_instant, switch_instant);
7662 assert_eq!(pending.accepted_instant, accepted_instant);
7663 assert_eq!(pending.first_check_at, first_check_at);
7664 assert_eq!(
7665 pending.deadline,
7666 accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS)
7667 );
7668 }
7669
7670 #[tokio::test]
7671 async fn accepted_mv_command_is_confirmed_without_waiting_when_readback_matches() {
7672 let pool = seeded_pool().await;
7673 let (run_id, _config, _template, tags) =
7674 start_opc_test_run(&pool, "actuation-confirmed").await;
7675 let driver = honeywell_driver_auto();
7676 let mut args = fast_simulator_args();
7677 args.driver = DriverKindArg::Opcda;
7678 let initial = sample_initial_state();
7679 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7680 let target = 55.0;
7681 write_value(&driver, &tags.manipulated_variable, target)
7682 .await
7683 .unwrap();
7684 let commanded_instant = Instant::now();
7685 let commanded_at = Utc::now();
7686 let tolerance =
7687 mv_actuation_tolerance(MvActuationKind::Relay, target, initial.mv_ini, 100.0).unwrap();
7688 tracker
7689 .record_accepted(
7690 &pool,
7691 run_id,
7692 MvActuationKind::Relay,
7693 target,
7694 commanded_instant,
7695 commanded_at,
7696 commanded_instant,
7697 tolerance,
7698 )
7699 .await
7700 .unwrap();
7701
7702 let outcome = verify_pending_mv_actuation(
7703 &pool,
7704 &args,
7705 &tags.manipulated_variable,
7706 &driver,
7707 &mut CtrlC::never(),
7708 false,
7709 &mut tracker,
7710 None,
7711 )
7712 .await
7713 .unwrap();
7714
7715 assert_eq!(outcome, None);
7716 assert!(tracker.pending.is_none());
7717 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7718 .await
7719 .unwrap();
7720 assert_eq!(rows.len(), 1);
7721 assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
7722 assert_eq!(rows[0].attempt_count, 1);
7723 assert_eq!(rows[0].readback_mv, Some(target));
7724 }
7725
7726 #[tokio::test]
7727 async fn later_retry_can_confirm_after_an_earlier_mismatch() {
7728 let pool = seeded_pool().await;
7729 let (run_id, _config, _template, tags) =
7730 start_opc_test_run(&pool, "actuation-late-confirmation").await;
7731 let driver =
7732 honeywell_driver_auto().with_read_sequence(&tags.manipulated_variable, &["50", "55"]);
7733 let mut args = fast_simulator_args();
7734 args.driver = DriverKindArg::Opcda;
7735 let initial = sample_initial_state();
7736 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7737 let commanded_instant = Instant::now();
7738 let commanded_at = Utc::now();
7739 tracker
7740 .record_accepted(
7741 &pool,
7742 run_id,
7743 MvActuationKind::Relay,
7744 55.0,
7745 commanded_instant,
7746 commanded_at,
7747 commanded_instant,
7748 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7749 )
7750 .await
7751 .unwrap();
7752
7753 for _ in 0..2 {
7754 assert_eq!(
7755 verify_pending_mv_actuation(
7756 &pool,
7757 &args,
7758 &tags.manipulated_variable,
7759 &driver,
7760 &mut CtrlC::never(),
7761 false,
7762 &mut tracker,
7763 None,
7764 )
7765 .await
7766 .unwrap(),
7767 None
7768 );
7769 }
7770
7771 assert!(tracker.pending.is_none());
7772 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7773 .await
7774 .unwrap();
7775 assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
7776 assert_eq!(rows[0].attempt_count, 2);
7777 assert_eq!(rows[0].readback_mv, Some(55.0));
7778 }
7779
7780 #[tokio::test]
7781 async fn early_mismatch_stays_pending_but_blocks_a_replacement_relay() {
7782 let pool = seeded_pool().await;
7783 let (run_id, _config, _template, tags) =
7784 start_opc_test_run(&pool, "actuation-mismatch").await;
7785 let driver = honeywell_driver_auto().distorting_write(&tags.manipulated_variable, -5.0);
7786 let mut args = fast_simulator_args();
7787 args.driver = DriverKindArg::Opcda;
7788 let initial = sample_initial_state();
7789 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7790 let target = 55.0;
7791 write_value(&driver, &tags.manipulated_variable, target)
7792 .await
7793 .unwrap();
7794 let commanded_instant = Instant::now();
7795 let commanded_at = Utc::now();
7796 let tolerance =
7797 mv_actuation_tolerance(MvActuationKind::Relay, target, initial.mv_ini, 100.0).unwrap();
7798 tracker
7799 .record_accepted(
7800 &pool,
7801 run_id,
7802 MvActuationKind::Relay,
7803 target,
7804 commanded_instant,
7805 commanded_at,
7806 commanded_instant,
7807 tolerance,
7808 )
7809 .await
7810 .unwrap();
7811
7812 let first = verify_pending_mv_actuation(
7813 &pool,
7814 &args,
7815 &tags.manipulated_variable,
7816 &driver,
7817 &mut CtrlC::never(),
7818 false,
7819 &mut tracker,
7820 None,
7821 )
7822 .await
7823 .unwrap();
7824 assert_eq!(first, None);
7825 assert!(tracker.pending.is_some());
7826
7827 let forced = reject_replacement_for_pending_actuation(
7828 &pool,
7829 &tags.manipulated_variable,
7830 &mut tracker,
7831 )
7832 .await
7833 .unwrap();
7834 assert!(matches!(
7835 forced,
7836 AbortReason::MvActuationUnconfirmed {
7837 target: 55.0,
7838 readback: Some(50.0),
7839 ..
7840 }
7841 ));
7842 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7843 .await
7844 .unwrap();
7845 assert_eq!(rows[0].status, MvActuationStatus::Failed);
7846 assert_eq!(rows[0].attempt_count, 1);
7847 }
7848
7849 #[tokio::test]
7850 async fn later_check_reads_fresh_instead_of_failing_from_an_earlier_mismatch() {
7851 let pool = seeded_pool().await;
7852 let (run_id, _config, _template, tags) =
7853 start_opc_test_run(&pool, "actuation-deadline").await;
7854 let driver =
7855 honeywell_driver_auto().with_read_sequence(&tags.manipulated_variable, &["50", "55"]);
7856 let mut args = fast_simulator_args();
7857 args.driver = DriverKindArg::Opcda;
7858 let initial = sample_initial_state();
7859 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7860 write_value(&driver, &tags.manipulated_variable, 55.0)
7861 .await
7862 .unwrap();
7863 let commanded_instant = Instant::now();
7864 let commanded_at = Utc::now();
7865 tracker
7866 .record_accepted(
7867 &pool,
7868 run_id,
7869 MvActuationKind::Relay,
7870 55.0,
7871 commanded_instant,
7872 commanded_at,
7873 commanded_instant,
7874 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7875 )
7876 .await
7877 .unwrap();
7878 assert_eq!(
7879 verify_pending_mv_actuation(
7880 &pool,
7881 &args,
7882 &tags.manipulated_variable,
7883 &driver,
7884 &mut CtrlC::never(),
7885 false,
7886 &mut tracker,
7887 None,
7888 )
7889 .await
7890 .unwrap(),
7891 None
7892 );
7893 tracker.pending.as_mut().unwrap().deadline = Instant::now() + Duration::from_secs(1);
7896
7897 let outcome = verify_pending_mv_actuation(
7898 &pool,
7899 &args,
7900 &tags.manipulated_variable,
7901 &driver,
7902 &mut CtrlC::never(),
7903 false,
7904 &mut tracker,
7905 None,
7906 )
7907 .await
7908 .unwrap();
7909
7910 assert_eq!(outcome, None);
7911 assert!(tracker.pending.is_none());
7912 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7913 .await
7914 .unwrap();
7915 assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
7916 assert_eq!(rows[0].attempt_count, 2);
7917 assert_eq!(rows[0].readback_mv, Some(55.0));
7918 }
7919
7920 #[tokio::test]
7921 async fn predeadline_read_is_dropped_and_late_matching_readback_still_fails() {
7922 let pool = seeded_pool().await;
7923 let (run_id, _config, _template, tags) =
7924 start_opc_test_run(&pool, "actuation-predeadline-bound").await;
7925 let driver = honeywell_driver_auto()
7926 .with_value(&tags.manipulated_variable, "55")
7927 .delaying_read(&tags.manipulated_variable, Duration::from_millis(50));
7928 let mut args = fast_simulator_args();
7929 args.driver = DriverKindArg::Opcda;
7930 args.op_timeout_secs = 30;
7931 let initial = sample_initial_state();
7932 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7933 let accepted_instant = Instant::now();
7934 tracker
7935 .record_accepted(
7936 &pool,
7937 run_id,
7938 MvActuationKind::Relay,
7939 55.0,
7940 accepted_instant,
7941 Utc::now(),
7942 accepted_instant,
7943 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7944 )
7945 .await
7946 .unwrap();
7947 tracker.pending.as_mut().unwrap().deadline = Instant::now() + Duration::from_millis(25);
7948
7949 let outcome = verify_pending_mv_actuation(
7950 &pool,
7951 &args,
7952 &tags.manipulated_variable,
7953 &driver,
7954 &mut CtrlC::never(),
7955 false,
7956 &mut tracker,
7957 None,
7958 )
7959 .await
7960 .unwrap();
7961
7962 assert!(matches!(
7963 outcome,
7964 Some(AbortReason::MvActuationUnconfirmed {
7965 readback: Some(55.0),
7966 ..
7967 })
7968 ));
7969 assert!(tracker.pending.is_none());
7970 assert_eq!(
7971 driver.read_batches(),
7972 vec![
7973 vec![tags.manipulated_variable.clone()],
7974 vec![tags.manipulated_variable.clone()]
7975 ],
7976 "the read cancelled at the deadline must not be reused as deadline evidence"
7977 );
7978 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7979 .await
7980 .unwrap();
7981 assert_eq!(rows[0].status, MvActuationStatus::Failed);
7982 assert_eq!(rows[0].attempt_count, 1);
7983 }
7984
7985 #[tokio::test]
7986 async fn fresh_deadline_read_is_tightly_bounded_below_the_operation_timeout() {
7987 let pool = seeded_pool().await;
7988 let (run_id, _config, _template, tags) =
7989 start_opc_test_run(&pool, "actuation-deadline-read-bound").await;
7990 let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
7991 let mut args = fast_simulator_args();
7992 args.driver = DriverKindArg::Opcda;
7993 args.op_timeout_secs = 30;
7994 let initial = sample_initial_state();
7995 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7996 let accepted_instant = Instant::now();
7997 tracker
7998 .record_accepted(
7999 &pool,
8000 run_id,
8001 MvActuationKind::Relay,
8002 55.0,
8003 accepted_instant,
8004 Utc::now(),
8005 accepted_instant,
8006 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8007 )
8008 .await
8009 .unwrap();
8010 tracker.pending.as_mut().unwrap().deadline = Instant::now();
8011
8012 let started = Instant::now();
8013 let outcome = verify_pending_mv_actuation(
8014 &pool,
8015 &args,
8016 &tags.manipulated_variable,
8017 &driver,
8018 &mut CtrlC::never(),
8019 false,
8020 &mut tracker,
8021 None,
8022 )
8023 .await
8024 .unwrap();
8025
8026 assert!(started.elapsed() < Duration::from_secs(2));
8027 assert!(matches!(
8028 outcome,
8029 Some(AbortReason::MvActuationUnconfirmed { readback: None, .. })
8030 ));
8031 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8032 .await
8033 .unwrap();
8034 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8035 assert_eq!(rows[0].attempt_count, 1);
8036 }
8037
8038 #[tokio::test]
8039 async fn stalled_shared_pv_mv_poll_is_cancelled_without_recording_a_sample() {
8040 let pool = seeded_pool().await;
8041 let (run_id, config, _template, tags) =
8042 start_opc_test_run(&pool, "actuation-shared-poll-cancel").await;
8043 let driver = honeywell_driver_auto()
8044 .delaying_read(&tags.manipulated_variable, Duration::from_secs(2))
8045 .degrade_quality_after(&tags.process_variable, 1, bhtune_driver::Quality::Bad);
8046 let mut args = fast_simulator_args();
8047 args.driver = DriverKindArg::Opcda;
8048 args.mrft_delay = 10;
8049 args.timeout_secs = 3;
8050 let initial = sample_initial_state();
8051 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8052 let commanded_instant = Instant::now();
8053 let commanded_at = Utc::now();
8054 tracker
8055 .record_accepted(
8056 &pool,
8057 run_id,
8058 MvActuationKind::Relay,
8059 55.0,
8060 commanded_instant + Duration::from_secs(1),
8061 commanded_at,
8062 commanded_instant,
8063 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8064 )
8065 .await
8066 .unwrap();
8067 let mut tracker = Some(tracker);
8068 let mut engine = MrftEngine::new(
8069 config,
8070 initial.direction,
8071 lookup(
8072 config.process_type,
8073 config.controller_type,
8074 ResponseLevel::Aggressive,
8075 )
8076 .beta,
8077 InitialReadings {
8078 pv_ini: initial.pv_ini,
8079 mv_ini: initial.mv_ini,
8080 mv_range_low: initial.mv_range_low,
8081 mv_range_high: initial.mv_range_high,
8082 },
8083 Utc::now(),
8084 MrftCompat::default(),
8085 );
8086 let (mut ctrl_c, tx) = CtrlC::test_pair();
8087 tokio::spawn(async move {
8088 tokio::time::sleep(Duration::from_millis(50)).await;
8089 let _ = tx.send(1);
8090 });
8091 let mut timing = timing_for_args(&args);
8092
8093 let outcome = run_polling_loop(
8094 &pool,
8095 run_id,
8096 &args,
8097 &tags,
8098 &driver,
8099 &mut engine,
8100 RunTimeAnchor::now(),
8101 &mut ctrl_c,
8102 &mut MutationGuard::default(),
8103 false,
8104 &mut timing,
8105 &mut tracker,
8106 config,
8107 )
8108 .await
8109 .unwrap();
8110
8111 assert!(matches!(
8112 outcome,
8113 PollOutcome::Aborted(AbortReason::UserInterrupt)
8114 ));
8115 assert!(
8116 driver.delayed_read_was_cancelled(&tags.manipulated_variable),
8117 "the shared PV/MV read must be dropped when Ctrl+C cancels the operation"
8118 );
8119 assert_eq!(
8120 driver.read_batches(),
8121 vec![vec![
8122 tags.process_variable.clone(),
8123 tags.manipulated_variable.clone()
8124 ]]
8125 );
8126 assert!(
8127 TuneSampleRow::list_for_run(&pool, run_id)
8128 .await
8129 .unwrap()
8130 .is_empty(),
8131 "a cancelled shared read has no valid PV sample to persist"
8132 );
8133 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8134 .await
8135 .unwrap();
8136 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8137 assert_eq!(rows[0].attempt_count, 0);
8138 }
8139
8140 #[tokio::test]
8141 async fn stalled_shared_pv_mv_poll_times_out_without_recording_a_sample() {
8142 let pool = seeded_pool().await;
8143 let (run_id, config, _template, tags) =
8144 start_opc_test_run(&pool, "actuation-shared-poll-timeout").await;
8145 let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
8146 let mut args = fast_simulator_args();
8147 args.driver = DriverKindArg::Opcda;
8148 args.mrft_delay = 10;
8149 args.op_timeout_secs = 0;
8150 let initial = sample_initial_state();
8151 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8152 let commanded_instant = Instant::now();
8153 let commanded_at = Utc::now();
8154 tracker
8155 .record_accepted(
8156 &pool,
8157 run_id,
8158 MvActuationKind::Relay,
8159 55.0,
8160 commanded_instant,
8161 commanded_at,
8162 commanded_instant,
8163 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8164 )
8165 .await
8166 .unwrap();
8167 let mut tracker = Some(tracker);
8168 let started_at = Utc::now();
8169 let mut engine = MrftEngine::new(
8170 config,
8171 initial.direction,
8172 lookup(
8173 config.process_type,
8174 config.controller_type,
8175 ResponseLevel::Aggressive,
8176 )
8177 .beta,
8178 InitialReadings {
8179 pv_ini: initial.pv_ini,
8180 mv_ini: initial.mv_ini,
8181 mv_range_low: initial.mv_range_low,
8182 mv_range_high: initial.mv_range_high,
8183 },
8184 started_at,
8185 MrftCompat::default(),
8186 );
8187 let mut timing = timing_for_args(&args);
8188
8189 let outcome = run_polling_loop(
8190 &pool,
8191 run_id,
8192 &args,
8193 &tags,
8194 &driver,
8195 &mut engine,
8196 time_anchor_at(started_at),
8197 &mut CtrlC::never(),
8198 &mut MutationGuard::default(),
8199 false,
8200 &mut timing,
8201 &mut tracker,
8202 config,
8203 )
8204 .await
8205 .unwrap();
8206
8207 assert!(matches!(
8208 outcome,
8209 PollOutcome::Aborted(AbortReason::OperationTimedOut {
8210 ref tag,
8211 op_timeout_secs: 0,
8212 }) if tag == &tags.manipulated_variable
8213 ));
8214 assert_eq!(
8215 driver.read_batches(),
8216 vec![vec![
8217 tags.process_variable.clone(),
8218 tags.manipulated_variable.clone()
8219 ]]
8220 );
8221 assert!(
8222 TuneSampleRow::list_for_run(&pool, run_id)
8223 .await
8224 .unwrap()
8225 .is_empty(),
8226 "a timed-out shared read has no valid PV sample to persist"
8227 );
8228 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8229 .await
8230 .unwrap();
8231 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8232 assert_eq!(rows[0].attempt_count, 1);
8233 assert!(tracker.is_none() || tracker.as_ref().unwrap().pending.is_none());
8234 }
8235
8236 #[tokio::test]
8237 async fn scheduled_mv_verification_keeps_an_early_mismatch_pending() {
8238 let pool = seeded_pool().await;
8239 let (run_id, config, _template, tags) =
8240 start_opc_test_run(&pool, "actuation-early-mismatch").await;
8241 let driver = honeywell_driver_auto();
8242 let mut args = fast_simulator_args();
8243 args.driver = DriverKindArg::Opcda;
8244 args.poll_interval_ms = 10_000;
8245 args.mrft_delay = 10;
8246 args.timeout_secs = 1;
8247 let initial = sample_initial_state();
8248 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8249 let accepted_instant = Instant::now();
8250 tracker
8251 .record_accepted(
8252 &pool,
8253 run_id,
8254 MvActuationKind::Relay,
8255 55.0,
8256 accepted_instant,
8257 Utc::now(),
8258 accepted_instant,
8259 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8260 )
8261 .await
8262 .unwrap();
8263 let pending = tracker.pending.as_mut().unwrap();
8264 pending.first_check_at = accepted_instant;
8265 pending.deadline = accepted_instant + Duration::from_millis(200);
8266 let mut tracker = Some(tracker);
8267 let started_at = Utc::now();
8268 let mut engine = MrftEngine::new(
8269 config,
8270 initial.direction,
8271 lookup(
8272 config.process_type,
8273 config.controller_type,
8274 ResponseLevel::Aggressive,
8275 )
8276 .beta,
8277 InitialReadings {
8278 pv_ini: initial.pv_ini,
8279 mv_ini: initial.mv_ini,
8280 mv_range_low: initial.mv_range_low,
8281 mv_range_high: initial.mv_range_high,
8282 },
8283 started_at,
8284 MrftCompat::default(),
8285 );
8286 let mut timing = timing_for_args(&args);
8287
8288 let outcome = run_polling_loop(
8289 &pool,
8290 run_id,
8291 &args,
8292 &tags,
8293 &driver,
8294 &mut engine,
8295 time_anchor_at(started_at),
8296 &mut CtrlC::never(),
8297 &mut MutationGuard::default(),
8298 false,
8299 &mut timing,
8300 &mut tracker,
8301 config,
8302 )
8303 .await
8304 .unwrap();
8305
8306 assert!(matches!(
8307 outcome,
8308 PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
8309 ));
8310 assert_eq!(
8311 driver.read_batches(),
8312 vec![
8313 vec![tags.manipulated_variable.clone()],
8314 vec![
8315 tags.process_variable.clone(),
8316 tags.manipulated_variable.clone()
8317 ],
8318 vec![tags.manipulated_variable.clone()]
8319 ]
8320 );
8321 assert_eq!(
8322 TuneSampleRow::list_for_run(&pool, run_id)
8323 .await
8324 .unwrap()
8325 .len(),
8326 1
8327 );
8328 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8329 .await
8330 .unwrap();
8331 assert_eq!(rows[0].status, MvActuationStatus::Failed);
8332 assert_eq!(rows[0].attempt_count, 3);
8333 }
8334
8335 #[tokio::test]
8336 async fn verification_deadline_wakes_without_waiting_for_a_long_poll_interval() {
8337 let pool = seeded_pool().await;
8338 let (run_id, config, _template, tags) =
8339 start_opc_test_run(&pool, "actuation-deadline-wakeup").await;
8340 let driver = honeywell_driver_auto();
8341 let mut args = fast_simulator_args();
8342 args.driver = DriverKindArg::Opcda;
8343 args.poll_interval_ms = 10_000;
8344 args.mrft_delay = 10;
8345 args.timeout_secs = 2;
8346 let initial = sample_initial_state();
8347 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8348 let accepted_instant = Instant::now();
8349 tracker
8350 .record_accepted(
8351 &pool,
8352 run_id,
8353 MvActuationKind::Relay,
8354 55.0,
8355 accepted_instant,
8356 Utc::now(),
8357 accepted_instant,
8358 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8359 )
8360 .await
8361 .unwrap();
8362 let deadline = Instant::now() + Duration::from_millis(25);
8363 let pending = tracker.pending.as_mut().unwrap();
8364 pending.first_check_at = deadline;
8365 pending.deadline = deadline;
8366 let mut tracker = Some(tracker);
8367 let started_at = Utc::now();
8368 let mut engine = MrftEngine::new(
8369 config,
8370 initial.direction,
8371 lookup(
8372 config.process_type,
8373 config.controller_type,
8374 ResponseLevel::Aggressive,
8375 )
8376 .beta,
8377 InitialReadings {
8378 pv_ini: initial.pv_ini,
8379 mv_ini: initial.mv_ini,
8380 mv_range_low: initial.mv_range_low,
8381 mv_range_high: initial.mv_range_high,
8382 },
8383 started_at,
8384 MrftCompat::default(),
8385 );
8386 let mut timing = timing_for_args(&args);
8387
8388 let started = Instant::now();
8389 let outcome = run_polling_loop(
8390 &pool,
8391 run_id,
8392 &args,
8393 &tags,
8394 &driver,
8395 &mut engine,
8396 time_anchor_at(started_at),
8397 &mut CtrlC::never(),
8398 &mut MutationGuard::default(),
8399 false,
8400 &mut timing,
8401 &mut tracker,
8402 config,
8403 )
8404 .await
8405 .unwrap();
8406
8407 assert!(started.elapsed() < Duration::from_secs(1));
8408 assert!(matches!(
8409 outcome,
8410 PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
8411 ));
8412 let reads = driver.read_batches();
8413 assert_eq!(
8414 reads[0],
8415 vec![
8416 tags.process_variable.clone(),
8417 tags.manipulated_variable.clone()
8418 ]
8419 );
8420 assert_eq!(reads[1], vec![tags.manipulated_variable.clone()]);
8421 assert_eq!(
8422 TuneSampleRow::list_for_run(&pool, run_id)
8423 .await
8424 .unwrap()
8425 .len(),
8426 1
8427 );
8428 }
8429
8430 #[tokio::test]
8431 async fn due_mv_verification_precedes_a_due_pv_poll() {
8432 let pool = seeded_pool().await;
8433 let (run_id, config, _template, tags) =
8434 start_opc_test_run(&pool, "actuation-before-due-poll").await;
8435 let driver = honeywell_driver_auto()
8436 .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
8437 let mut args = fast_simulator_args();
8438 args.driver = DriverKindArg::Opcda;
8439 args.poll_interval_ms = 10_000;
8440 args.mrft_delay = 10;
8441 args.timeout_secs = 2;
8442 let initial = sample_initial_state();
8443 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8444 let accepted_instant = Instant::now();
8445 tracker
8446 .record_accepted(
8447 &pool,
8448 run_id,
8449 MvActuationKind::Relay,
8450 55.0,
8451 accepted_instant,
8452 Utc::now(),
8453 accepted_instant,
8454 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8455 )
8456 .await
8457 .unwrap();
8458 tracker.pending.as_mut().unwrap().deadline = Instant::now();
8459 let mut tracker = Some(tracker);
8460 let started_at = Utc::now();
8461 let mut engine = MrftEngine::new(
8462 config,
8463 initial.direction,
8464 lookup(
8465 config.process_type,
8466 config.controller_type,
8467 ResponseLevel::Aggressive,
8468 )
8469 .beta,
8470 InitialReadings {
8471 pv_ini: initial.pv_ini,
8472 mv_ini: initial.mv_ini,
8473 mv_range_low: initial.mv_range_low,
8474 mv_range_high: initial.mv_range_high,
8475 },
8476 started_at,
8477 MrftCompat::default(),
8478 );
8479 let mut timing = timing_for_args(&args);
8480
8481 let outcome = run_polling_loop(
8482 &pool,
8483 run_id,
8484 &args,
8485 &tags,
8486 &driver,
8487 &mut engine,
8488 time_anchor_at(started_at),
8489 &mut CtrlC::never(),
8490 &mut MutationGuard::default(),
8491 false,
8492 &mut timing,
8493 &mut tracker,
8494 config,
8495 )
8496 .await
8497 .unwrap();
8498
8499 assert!(matches!(
8500 outcome,
8501 PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
8502 ));
8503 assert_eq!(
8504 driver.read_batches(),
8505 vec![vec![tags.manipulated_variable.clone()]],
8506 "a due verification deadline must be handled before the due PV poll"
8507 );
8508 assert!(
8509 TuneSampleRow::list_for_run(&pool, run_id)
8510 .await
8511 .unwrap()
8512 .is_empty()
8513 );
8514 }
8515
8516 #[tokio::test]
8517 async fn replacement_preview_uses_deadline_verification_and_records_the_abort_sample() {
8518 let pool = seeded_pool().await;
8519 let (run_id, config, _template, tags) =
8520 start_opc_test_run(&pool, "deadline-preview-verification").await;
8521 let driver = honeywell_driver_auto();
8522 let mut args = fast_simulator_args();
8523 args.driver = DriverKindArg::Opcda;
8524 args.timeout_secs = 1;
8525 let initial = sample_initial_state();
8526 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8527 let now = Instant::now();
8528 tracker.pending = Some(pending_actuation(
8529 None,
8530 MvActuationKind::Relay,
8531 35.0,
8532 now + Duration::from_secs(1),
8533 now,
8534 None,
8535 ));
8536 let mut tracker = Some(tracker);
8537 let started_at = Utc::now();
8538 let mut engine = MrftEngine::new(
8539 config,
8540 initial.direction,
8541 lookup(
8542 config.process_type,
8543 config.controller_type,
8544 ResponseLevel::Aggressive,
8545 )
8546 .beta,
8547 InitialReadings {
8548 pv_ini: initial.pv_ini,
8549 mv_ini: initial.mv_ini,
8550 mv_range_low: initial.mv_range_low,
8551 mv_range_high: initial.mv_range_high,
8552 },
8553 started_at,
8554 MrftCompat::default(),
8555 );
8556 let state_before = engine.state();
8557 let mut timing = timing_for_args(&args);
8558
8559 let outcome = run_polling_loop(
8560 &pool,
8561 run_id,
8562 &args,
8563 &tags,
8564 &driver,
8565 &mut engine,
8566 time_anchor_at(started_at),
8567 &mut CtrlC::never(),
8568 &mut MutationGuard::default(),
8569 false,
8570 &mut timing,
8571 &mut tracker,
8572 config,
8573 )
8574 .await
8575 .unwrap();
8576
8577 assert!(matches!(
8578 outcome,
8579 PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
8580 readback: Some(45.0),
8581 ..
8582 })
8583 ));
8584 assert_eq!(engine.state(), state_before);
8585 assert_eq!(
8586 TuneSampleRow::list_for_run(&pool, run_id)
8587 .await
8588 .unwrap()
8589 .len(),
8590 1
8591 );
8592 }
8593
8594 #[tokio::test]
8595 async fn replacement_preview_commits_after_confirming_the_prior_command() {
8596 let pool = seeded_pool().await;
8597 let (run_id, config, _template, tags) =
8598 start_opc_test_run(&pool, "confirmed-preview-replacement").await;
8599 let driver = honeywell_driver_auto().degrade_quality_after(
8600 &tags.process_variable,
8601 1,
8602 bhtune_driver::Quality::Bad,
8603 );
8604 let mut args = fast_simulator_args();
8605 args.driver = DriverKindArg::Opcda;
8606 args.timeout_secs = 1;
8607 let initial = sample_initial_state();
8608 let now = Instant::now();
8609 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8610 tracker.pending = Some(pending_actuation(
8611 None,
8612 MvActuationKind::Relay,
8613 initial.mv_ini,
8614 now + Duration::from_secs(1),
8615 now + Duration::from_secs(2),
8616 None,
8617 ));
8618 let mut tracker = Some(tracker);
8619 let started_at = Utc::now();
8620 let mut engine = MrftEngine::new(
8621 config,
8622 initial.direction,
8623 lookup(
8624 config.process_type,
8625 config.controller_type,
8626 ResponseLevel::Aggressive,
8627 )
8628 .beta,
8629 InitialReadings {
8630 pv_ini: initial.pv_ini,
8631 mv_ini: initial.mv_ini,
8632 mv_range_low: initial.mv_range_low,
8633 mv_range_high: initial.mv_range_high,
8634 },
8635 started_at,
8636 MrftCompat::default(),
8637 );
8638 let state_before = engine.state();
8639 let mut timing = timing_for_args(&args);
8640
8641 let outcome = run_polling_loop(
8642 &pool,
8643 run_id,
8644 &args,
8645 &tags,
8646 &driver,
8647 &mut engine,
8648 time_anchor_at(started_at),
8649 &mut CtrlC::never(),
8650 &mut MutationGuard::default(),
8651 false,
8652 &mut timing,
8653 &mut tracker,
8654 config,
8655 )
8656 .await
8657 .unwrap();
8658
8659 assert!(matches!(
8660 outcome,
8661 PollOutcome::Aborted(AbortReason::PoorQuality { .. })
8662 ));
8663 assert_ne!(engine.state(), state_before);
8664 assert_eq!(driver.write_log().len(), 1);
8665 }
8666
8667 #[tokio::test]
8668 async fn pending_actuation_preview_does_not_commit_or_write_a_replacement_relay() {
8669 let pool = seeded_pool().await;
8670 let (run_id, config, _template, tags) =
8671 start_opc_test_run(&pool, "actuation-preview").await;
8672 let driver = honeywell_driver_auto();
8673 let mut args = fast_simulator_args();
8674 args.driver = DriverKindArg::Opcda;
8675 args.timeout_secs = 1;
8676 let initial = sample_initial_state();
8677 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8678 let accepted_instant = Instant::now();
8679 tracker
8680 .record_accepted(
8681 &pool,
8682 run_id,
8683 MvActuationKind::Relay,
8684 35.0,
8685 accepted_instant,
8686 Utc::now(),
8687 accepted_instant,
8688 mv_actuation_tolerance(MvActuationKind::Relay, 35.0, 45.0, 100.0).unwrap(),
8689 )
8690 .await
8691 .unwrap();
8692 assert_eq!(
8693 verify_pending_mv_actuation(
8694 &pool,
8695 &args,
8696 &tags.manipulated_variable,
8697 &driver,
8698 &mut CtrlC::never(),
8699 false,
8700 &mut tracker,
8701 None,
8702 )
8703 .await
8704 .unwrap(),
8705 None
8706 );
8707 assert_eq!(
8708 tracker.pending.as_ref().unwrap().last_readback,
8709 Some(initial.mv_ini)
8710 );
8711 let mut tracker = Some(tracker);
8712 let started_at = Utc::now();
8713 let mut engine = MrftEngine::new(
8714 config,
8715 initial.direction,
8716 lookup(
8717 config.process_type,
8718 config.controller_type,
8719 ResponseLevel::Aggressive,
8720 )
8721 .beta,
8722 InitialReadings {
8723 pv_ini: initial.pv_ini,
8724 mv_ini: initial.mv_ini,
8725 mv_range_low: initial.mv_range_low,
8726 mv_range_high: initial.mv_range_high,
8727 },
8728 started_at,
8729 MrftCompat::default(),
8730 );
8731 let state_before = engine.state();
8732 let mut timing = timing_for_args(&args);
8733
8734 let outcome = run_polling_loop(
8735 &pool,
8736 run_id,
8737 &args,
8738 &tags,
8739 &driver,
8740 &mut engine,
8741 time_anchor_at(started_at),
8742 &mut CtrlC::never(),
8743 &mut MutationGuard::default(),
8744 false,
8745 &mut timing,
8746 &mut tracker,
8747 config,
8748 )
8749 .await
8750 .unwrap();
8751
8752 assert!(matches!(
8753 outcome,
8754 PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
8755 readback: Some(45.0),
8756 ..
8757 })
8758 ));
8759 assert_eq!(engine.state(), state_before);
8760 assert!(driver.write_log().is_empty());
8761 assert_eq!(
8762 driver.read_batches(),
8763 vec![
8764 vec![tags.manipulated_variable.clone()],
8765 vec![
8766 tags.process_variable.clone(),
8767 tags.manipulated_variable.clone()
8768 ],
8769 ],
8770 "the replacement preview must use the fresh batched PV/MV read from the preview tick"
8771 );
8772 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8773 .await
8774 .unwrap();
8775 assert_eq!(rows[0].status, MvActuationStatus::Failed);
8776 }
8777
8778 #[tokio::test]
8779 async fn verification_operation_timeout_preserves_the_timed_out_outcome() {
8780 let pool = seeded_pool().await;
8781 let (run_id, _config, _template, tags) = start_opc_test_run(&pool, "actuation-hang").await;
8782 let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
8783 let mut args = fast_simulator_args();
8784 args.driver = DriverKindArg::Opcda;
8785 args.op_timeout_secs = 1;
8786 let initial = sample_initial_state();
8787 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8788 let commanded_instant = Instant::now();
8789 let commanded_at = Utc::now();
8790 tracker
8791 .record_accepted(
8792 &pool,
8793 run_id,
8794 MvActuationKind::Relay,
8795 55.0,
8796 commanded_instant,
8797 commanded_at,
8798 commanded_instant,
8799 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8800 )
8801 .await
8802 .unwrap();
8803 let started = Instant::now();
8804 let outcome = verify_pending_mv_actuation(
8805 &pool,
8806 &args,
8807 &tags.manipulated_variable,
8808 &driver,
8809 &mut CtrlC::never(),
8810 false,
8811 &mut tracker,
8812 None,
8813 )
8814 .await
8815 .unwrap();
8816
8817 assert!(started.elapsed() < Duration::from_secs(2));
8818 assert!(matches!(
8819 outcome,
8820 Some(AbortReason::OperationTimedOut {
8821 op_timeout_secs: 1,
8822 ..
8823 })
8824 ));
8825 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8826 .await
8827 .unwrap();
8828 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8829 assert_eq!(rows[0].attempt_count, 1);
8830 }
8831
8832 #[tokio::test]
8833 async fn ctrl_c_during_verification_preserves_pending_row_for_restore_handoff() {
8834 let pool = seeded_pool().await;
8835 let (run_id, _config, _template, tags) =
8836 start_opc_test_run(&pool, "actuation-ctrl-c").await;
8837 let hanging_driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
8838 let healthy_driver = honeywell_driver_auto();
8839 let mut args = fast_simulator_args();
8840 args.driver = DriverKindArg::Opcda;
8841 let initial = sample_initial_state();
8842 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8843 let commanded_instant = Instant::now();
8844 let commanded_at = Utc::now();
8845 tracker
8846 .record_accepted(
8847 &pool,
8848 run_id,
8849 MvActuationKind::Relay,
8850 55.0,
8851 commanded_instant,
8852 commanded_at,
8853 commanded_instant,
8854 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8855 )
8856 .await
8857 .unwrap();
8858 let (mut ctrl_c, tx) = CtrlC::test_pair();
8859 tx.send(1).unwrap();
8860
8861 let outcome = verify_pending_mv_actuation(
8862 &pool,
8863 &args,
8864 &tags.manipulated_variable,
8865 &hanging_driver,
8866 &mut ctrl_c,
8867 false,
8868 &mut tracker,
8869 None,
8870 )
8871 .await
8872 .unwrap();
8873 assert_eq!(outcome, Some(AbortReason::UserInterrupt));
8874 assert!(tracker.pending.is_none());
8875
8876 let mut tracker = Some(tracker);
8877 let restored = restore_mv_with_verification(
8878 &pool,
8879 run_id,
8880 &args,
8881 &healthy_driver,
8882 &tags.manipulated_variable,
8883 initial.mv_ini,
8884 false,
8885 &mut CtrlC::never(),
8886 &mut tracker,
8887 Instant::now() + Duration::from_secs(args.restore_timeout_secs),
8888 )
8889 .await
8890 .unwrap();
8891 assert!(matches!(
8892 restored,
8893 RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
8894 ));
8895 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8896 .await
8897 .unwrap();
8898 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8899 assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
8900 }
8901
8902 #[tokio::test]
8903 async fn poor_quality_verification_preserves_the_poor_quality_outcome() {
8904 let pool = seeded_pool().await;
8905 let (run_id, _config, _template, tags) =
8906 start_opc_test_run(&pool, "actuation-quality-retry").await;
8907 let driver = honeywell_driver_auto()
8908 .with_quality(&tags.manipulated_variable, bhtune_driver::Quality::Bad);
8909 let mut args = fast_simulator_args();
8910 args.driver = DriverKindArg::Opcda;
8911 let initial = sample_initial_state();
8912 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8913 let commanded_instant = Instant::now();
8914 let commanded_at = Utc::now();
8915 tracker
8916 .record_accepted(
8917 &pool,
8918 run_id,
8919 MvActuationKind::Relay,
8920 55.0,
8921 commanded_instant,
8922 commanded_at,
8923 commanded_instant,
8924 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8925 )
8926 .await
8927 .unwrap();
8928
8929 let outcome = verify_pending_mv_actuation(
8930 &pool,
8931 &args,
8932 &tags.manipulated_variable,
8933 &driver,
8934 &mut CtrlC::never(),
8935 false,
8936 &mut tracker,
8937 None,
8938 )
8939 .await
8940 .unwrap();
8941 assert!(matches!(
8942 outcome,
8943 Some(AbortReason::PoorQuality {
8944 quality: bhtune_driver::Quality::Bad,
8945 ..
8946 })
8947 ));
8948 assert!(tracker.pending.is_none());
8949 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8950 .await
8951 .unwrap();
8952 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8953 assert_eq!(rows[0].readback_quality, Some(SampleQuality::Bad));
8954 assert_eq!(rows[0].attempt_count, 1);
8955 }
8956
8957 #[tokio::test]
8958 async fn verification_transport_error_is_an_ordinary_failed_run_error() {
8959 let pool = seeded_pool().await;
8960 let (run_id, _config, _template, tags) =
8961 start_opc_test_run(&pool, "actuation-timeout-retry").await;
8962 let driver = honeywell_driver_auto().erroring_read(&tags.manipulated_variable);
8963 let mut args = fast_simulator_args();
8964 args.driver = DriverKindArg::Opcda;
8965 let initial = sample_initial_state();
8966 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8967 let commanded_instant = Instant::now();
8968 let commanded_at = Utc::now();
8969 tracker
8970 .record_accepted(
8971 &pool,
8972 run_id,
8973 MvActuationKind::Relay,
8974 55.0,
8975 commanded_instant,
8976 commanded_at,
8977 commanded_instant,
8978 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8979 )
8980 .await
8981 .unwrap();
8982
8983 let error = verify_pending_mv_actuation(
8984 &pool,
8985 &args,
8986 &tags.manipulated_variable,
8987 &driver,
8988 &mut CtrlC::never(),
8989 false,
8990 &mut tracker,
8991 None,
8992 )
8993 .await
8994 .unwrap_err();
8995 assert!(error.to_string().contains("driver operation failed"));
8996 assert!(tracker.pending.is_none());
8997 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8998 .await
8999 .unwrap();
9000 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
9001 assert_eq!(rows[0].attempt_count, 1);
9002 assert_eq!(rows[0].readback_mv, None);
9003 }
9004
9005 #[tokio::test]
9006 async fn restore_verification_timeout_finalizes_the_pending_row_as_unverified() {
9007 let pool = seeded_pool().await;
9008 let (run_id, _config, _template, tags) =
9009 start_opc_test_run(&pool, "actuation-restore-verification-timeout").await;
9010 let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
9011 let mut args = fast_simulator_args();
9012 args.driver = DriverKindArg::Opcda;
9013 let initial = sample_initial_state();
9014 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9015 let accepted_instant = Instant::now();
9016 tracker
9017 .record_accepted(
9018 &pool,
9019 run_id,
9020 MvActuationKind::Restore,
9021 initial.mv_ini,
9022 accepted_instant,
9023 Utc::now(),
9024 accepted_instant,
9025 mv_actuation_tolerance(MvActuationKind::Restore, initial.mv_ini, 55.0, 100.0)
9026 .unwrap(),
9027 )
9028 .await
9029 .unwrap();
9030
9031 let outcome = verify_pending_mv_actuation_with(
9032 &pool,
9033 &args,
9034 &tags.manipulated_variable,
9035 &driver,
9036 &mut CtrlC::never(),
9037 false,
9038 &mut tracker,
9039 MvVerificationTrigger::Deadline,
9040 MvVerificationCallLimit::Restore(Instant::now()),
9041 ActuationAuditPolicy::BestEffort,
9042 )
9043 .await
9044 .unwrap();
9045
9046 assert!(matches!(
9047 outcome,
9048 Some(AbortReason::MvActuationUnconfirmed { readback: None, .. })
9049 ));
9050 assert!(tracker.pending.is_none());
9051 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9052 .await
9053 .unwrap();
9054 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
9055 assert_eq!(rows[0].attempt_count, 1);
9056 }
9057
9058 #[tokio::test]
9059 async fn required_confirmation_audit_failure_keeps_the_pending_state_for_retry() {
9060 let pool = seeded_pool().await;
9061 let (run_id, _config, _template, tags) =
9062 start_opc_test_run(&pool, "actuation-required-audit-failure").await;
9063 let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "55");
9064 let mut args = fast_simulator_args();
9065 args.driver = DriverKindArg::Opcda;
9066 let initial = sample_initial_state();
9067 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9068 let accepted_instant = Instant::now();
9069 tracker
9070 .record_accepted(
9071 &pool,
9072 run_id,
9073 MvActuationKind::Relay,
9074 55.0,
9075 accepted_instant,
9076 Utc::now(),
9077 accepted_instant,
9078 mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
9079 )
9080 .await
9081 .unwrap();
9082 pool.close().await;
9083
9084 let error = verify_pending_mv_actuation_with(
9085 &pool,
9086 &args,
9087 &tags.manipulated_variable,
9088 &driver,
9089 &mut CtrlC::never(),
9090 false,
9091 &mut tracker,
9092 MvVerificationTrigger::Scheduled,
9093 MvVerificationCallLimit::None,
9094 ActuationAuditPolicy::Required,
9095 )
9096 .await
9097 .unwrap_err();
9098
9099 assert!(error.to_string().contains("pool"));
9100 assert!(tracker.pending.is_some());
9101 assert_eq!(tracker.confirmed_mv, None);
9102 }
9103
9104 #[tokio::test]
9105 async fn best_effort_confirmation_audit_failure_does_not_block_restore_state() {
9106 let pool = seeded_pool().await;
9107 let (run_id, _config, _template, tags) =
9108 start_opc_test_run(&pool, "actuation-best-effort-audit-failure").await;
9109 let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "55");
9110 let mut args = fast_simulator_args();
9111 args.driver = DriverKindArg::Opcda;
9112 let initial = sample_initial_state();
9113 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9114 let accepted_instant = Instant::now();
9115 tracker
9116 .record_accepted(
9117 &pool,
9118 run_id,
9119 MvActuationKind::Restore,
9120 55.0,
9121 accepted_instant,
9122 Utc::now(),
9123 accepted_instant,
9124 mv_actuation_tolerance(MvActuationKind::Restore, 55.0, 45.0, 100.0).unwrap(),
9125 )
9126 .await
9127 .unwrap();
9128 pool.close().await;
9129
9130 let outcome = verify_pending_mv_actuation_with(
9131 &pool,
9132 &args,
9133 &tags.manipulated_variable,
9134 &driver,
9135 &mut CtrlC::never(),
9136 false,
9137 &mut tracker,
9138 MvVerificationTrigger::Scheduled,
9139 MvVerificationCallLimit::None,
9140 ActuationAuditPolicy::BestEffort,
9141 )
9142 .await
9143 .unwrap();
9144
9145 assert_eq!(outcome, None);
9146 assert!(tracker.pending.is_none());
9147 assert_eq!(tracker.confirmed_mv, Some(55.0));
9148 }
9149
9150 #[tokio::test]
9151 async fn restore_supersedes_final_snapback_still_pending_during_padding() {
9152 let pool = seeded_pool().await;
9153 let (run_id, _config, _template, tags) =
9154 start_opc_test_run(&pool, "actuation-restore").await;
9155 let driver = honeywell_driver_auto();
9156 let mut args = fast_simulator_args();
9157 args.driver = DriverKindArg::Opcda;
9158 let initial = sample_initial_state();
9159 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9160 tracker.previous_commanded_mv = 55.0;
9161 write_value(&driver, &tags.manipulated_variable, initial.mv_ini)
9162 .await
9163 .unwrap();
9164 let commanded_instant = Instant::now();
9165 let commanded_at = Utc::now();
9166 tracker
9167 .record_accepted(
9168 &pool,
9169 run_id,
9170 MvActuationKind::Relay,
9171 initial.mv_ini,
9172 commanded_instant + Duration::from_secs(10),
9173 commanded_at,
9174 commanded_instant,
9175 mv_actuation_tolerance(MvActuationKind::Relay, initial.mv_ini, 55.0, 100.0)
9176 .unwrap(),
9177 )
9178 .await
9179 .unwrap();
9180 let mut tracker = Some(tracker);
9181
9182 let outcome = restore_mv_with_verification(
9183 &pool,
9184 run_id,
9185 &args,
9186 &driver,
9187 &tags.manipulated_variable,
9188 initial.mv_ini,
9189 false,
9190 &mut CtrlC::never(),
9191 &mut tracker,
9192 Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9193 )
9194 .await
9195 .unwrap();
9196
9197 assert!(matches!(
9198 outcome,
9199 RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9200 ));
9201 assert_eq!(
9202 driver.write_log().len(),
9203 1,
9204 "restore must adopt a confirmed final snapback instead of writing MV again"
9205 );
9206 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9207 .await
9208 .unwrap();
9209 assert_eq!(rows.len(), 1);
9210 assert_eq!(rows[0].kind, MvActuationKind::Relay);
9211 assert_eq!(rows[0].status, MvActuationStatus::Superseded);
9212 assert_eq!(rows[0].attempt_count, 1);
9213 assert!(
9214 rows[0]
9215 .detail
9216 .as_deref()
9217 .is_some_and(|detail| detail.contains("no duplicate MV write"))
9218 );
9219 }
9220
9221 #[tokio::test]
9222 async fn restore_rewrites_an_unconfirmed_final_snapback_without_waiting_twice() {
9223 let pool = seeded_pool().await;
9224 let (run_id, _config, _template, tags) =
9225 start_opc_test_run(&pool, "actuation-restore-rewrite").await;
9226 let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "50");
9227 let mut args = fast_simulator_args();
9228 args.driver = DriverKindArg::Opcda;
9229 let initial = sample_initial_state();
9230 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9231 tracker.previous_commanded_mv = 55.0;
9232 let accepted_instant = Instant::now();
9233 tracker
9234 .record_accepted(
9235 &pool,
9236 run_id,
9237 MvActuationKind::Relay,
9238 initial.mv_ini,
9239 accepted_instant + Duration::from_secs(4),
9240 Utc::now(),
9241 accepted_instant,
9242 mv_actuation_tolerance(MvActuationKind::Relay, initial.mv_ini, 55.0, 100.0)
9243 .unwrap(),
9244 )
9245 .await
9246 .unwrap();
9247 let mut tracker = Some(tracker);
9248
9249 let started = Instant::now();
9250 let outcome = restore_mv_with_verification(
9251 &pool,
9252 run_id,
9253 &args,
9254 &driver,
9255 &tags.manipulated_variable,
9256 initial.mv_ini,
9257 false,
9258 &mut CtrlC::never(),
9259 &mut tracker,
9260 Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9261 )
9262 .await
9263 .unwrap();
9264
9265 assert!(started.elapsed() < Duration::from_secs(1));
9266 assert!(matches!(
9267 outcome,
9268 RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9269 ));
9270 assert_eq!(
9271 driver.write_log(),
9272 vec![(
9273 tags.manipulated_variable.clone(),
9274 initial.mv_ini.to_string()
9275 )]
9276 );
9277 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9278 .await
9279 .unwrap();
9280 assert_eq!(rows.len(), 2);
9281 assert_eq!(rows[0].status, MvActuationStatus::Superseded);
9282 assert_eq!(rows[0].readback_mv, Some(50.0));
9283 assert_eq!(rows[1].kind, MvActuationKind::Restore);
9284 assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
9285 }
9286
9287 #[tokio::test]
9288 async fn slow_snapback_handoff_reserves_time_for_authoritative_restore() {
9289 let pool = seeded_pool().await;
9290 let (run_id, _config, _template, tags) =
9291 start_opc_test_run(&pool, "actuation-restore-budget").await;
9292 let driver = honeywell_driver_auto()
9293 .delaying_read(&tags.manipulated_variable, Duration::from_millis(1_100));
9294 let mut args = fast_simulator_args();
9295 args.driver = DriverKindArg::Opcda;
9296 args.restore_timeout_secs = MV_ACTUATION_CONFIRMATION_SECS + 1;
9297 let initial = sample_initial_state();
9298 let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9299 tracker.previous_commanded_mv = 55.0;
9300 let accepted_instant = Instant::now();
9301 tracker
9302 .record_accepted(
9303 &pool,
9304 run_id,
9305 MvActuationKind::Relay,
9306 initial.mv_ini,
9307 accepted_instant + Duration::from_secs(4),
9308 Utc::now(),
9309 accepted_instant,
9310 mv_actuation_tolerance(MvActuationKind::Relay, initial.mv_ini, 55.0, 100.0)
9311 .unwrap(),
9312 )
9313 .await
9314 .unwrap();
9315 let mut tracker = Some(tracker);
9316
9317 let started = Instant::now();
9318 let outcome = restore_mv_with_verification(
9319 &pool,
9320 run_id,
9321 &args,
9322 &driver,
9323 &tags.manipulated_variable,
9324 initial.mv_ini,
9325 false,
9326 &mut CtrlC::never(),
9327 &mut tracker,
9328 Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9329 )
9330 .await
9331 .unwrap();
9332
9333 assert!(started.elapsed() < Duration::from_secs(4));
9334 assert!(matches!(
9335 outcome,
9336 RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9337 ));
9338 assert_eq!(
9339 driver.write_log(),
9340 vec![(
9341 tags.manipulated_variable.clone(),
9342 initial.mv_ini.to_string()
9343 )],
9344 "the handoff read must fall back before consuming the restore write budget"
9345 );
9346 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9347 .await
9348 .unwrap();
9349 assert_eq!(rows.len(), 2);
9350 assert_eq!(rows[0].status, MvActuationStatus::Superseded);
9351 assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
9352 }
9353
9354 #[tokio::test]
9355 async fn snapback_handoff_skips_reads_that_would_consume_the_restore_budget() {
9356 let pool = seeded_pool().await;
9357 let driver = honeywell_driver_auto();
9358 let mut args = fast_simulator_args();
9359 args.driver = DriverKindArg::Opcda;
9360 let now = Instant::now();
9361
9362 for restore_deadline in [
9363 now + Duration::from_secs(1),
9364 Instant::now() + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS),
9365 ] {
9366 let pending = pending_actuation(
9367 None,
9368 MvActuationKind::Relay,
9369 45.0,
9370 now,
9371 now + Duration::from_secs(10),
9372 None,
9373 );
9374 let mut tracker = tracker_with_pending(pending);
9375 let outcome = try_confirm_final_snapback_handoff(
9376 &pool,
9377 &args,
9378 &driver,
9379 "Unit1.LIC101.OP",
9380 45.0,
9381 false,
9382 &mut CtrlC::never(),
9383 &mut tracker,
9384 restore_deadline,
9385 )
9386 .await
9387 .unwrap();
9388
9389 assert!(matches!(outcome, Some(RestoreHandoffOutcome::Rewrite)));
9390 assert!(tracker.pending.is_none());
9391 }
9392 assert!(driver.read_batches().is_empty());
9393 }
9394
9395 #[tokio::test]
9396 async fn snapback_handoff_failures_fall_back_to_an_authoritative_rewrite() {
9397 let pool = seeded_pool().await;
9398 let mut args = fast_simulator_args();
9399 args.driver = DriverKindArg::Opcda;
9400 let restore_deadline = Instant::now() + Duration::from_secs(10);
9401
9402 let cases = [
9403 honeywell_driver_auto().erroring_read("Unit1.LIC101.OP"),
9404 honeywell_driver_auto()
9405 .hanging_read("Unit1.LIC101.OP")
9406 .with_quality("Unit1.LIC101.OP", bhtune_driver::Quality::Good),
9407 honeywell_driver_auto().with_quality("Unit1.LIC101.OP", bhtune_driver::Quality::Bad),
9408 ];
9409 for (index, driver) in cases.into_iter().enumerate() {
9410 let now = Instant::now();
9411 let pending = pending_actuation(
9412 None,
9413 MvActuationKind::Relay,
9414 45.0,
9415 now,
9416 now + Duration::from_secs(10),
9417 None,
9418 );
9419 let mut tracker = tracker_with_pending(pending);
9420 if index == 1 {
9421 args.op_timeout_secs = 0;
9422 } else {
9423 args.op_timeout_secs = 30;
9424 }
9425 let outcome = try_confirm_final_snapback_handoff(
9426 &pool,
9427 &args,
9428 &driver,
9429 "Unit1.LIC101.OP",
9430 45.0,
9431 false,
9432 &mut CtrlC::never(),
9433 &mut tracker,
9434 restore_deadline,
9435 )
9436 .await
9437 .unwrap();
9438
9439 assert!(matches!(outcome, Some(RestoreHandoffOutcome::Rewrite)));
9440 assert!(tracker.pending.is_none());
9441 }
9442 }
9443
9444 #[tokio::test]
9445 async fn snapback_handoff_preserves_pending_state_when_ctrl_c_interrupts_the_read() {
9446 let pool = seeded_pool().await;
9447 let driver = honeywell_driver_auto().hanging_read("Unit1.LIC101.OP");
9448 let mut args = fast_simulator_args();
9449 args.driver = DriverKindArg::Opcda;
9450 let now = Instant::now();
9451 let pending = pending_actuation(
9452 None,
9453 MvActuationKind::Relay,
9454 45.0,
9455 now,
9456 now + Duration::from_secs(10),
9457 None,
9458 );
9459 let mut tracker = tracker_with_pending(pending);
9460 let (mut ctrl_c, tx) = CtrlC::test_pair();
9461 tx.send(1).unwrap();
9462
9463 let outcome = try_confirm_final_snapback_handoff(
9464 &pool,
9465 &args,
9466 &driver,
9467 "Unit1.LIC101.OP",
9468 45.0,
9469 false,
9470 &mut ctrl_c,
9471 &mut tracker,
9472 Instant::now() + Duration::from_secs(10),
9473 )
9474 .await
9475 .unwrap();
9476
9477 assert!(matches!(
9478 outcome,
9479 Some(RestoreHandoffOutcome::Interrupted(_))
9480 ));
9481 assert!(tracker.pending.is_some());
9482 }
9483
9484 #[tokio::test]
9485 async fn restore_mv_propagates_an_interrupted_final_snapback_handoff() {
9486 let pool = seeded_pool().await;
9487 let driver = honeywell_driver_auto().hanging_read("Unit1.LIC101.OP");
9488 let mut args = fast_simulator_args();
9489 args.driver = DriverKindArg::Opcda;
9490 let now = Instant::now();
9491 let mut tracker = Some(tracker_with_pending(pending_actuation(
9492 None,
9493 MvActuationKind::Relay,
9494 45.0,
9495 now,
9496 now + Duration::from_secs(10),
9497 None,
9498 )));
9499 let (mut ctrl_c, tx) = CtrlC::test_pair();
9500 tx.send(1).unwrap();
9501
9502 let outcome = restore_mv_with_verification(
9503 &pool,
9504 0,
9505 &args,
9506 &driver,
9507 "Unit1.LIC101.OP",
9508 45.0,
9509 false,
9510 &mut ctrl_c,
9511 &mut tracker,
9512 Instant::now() + Duration::from_secs(10),
9513 )
9514 .await
9515 .unwrap();
9516
9517 assert!(matches!(
9518 outcome,
9519 RestoreMvOutcome::Interrupted(ref detail)
9520 if detail.contains("final MRFT snapback")
9521 ));
9522 assert!(tracker.as_ref().unwrap().pending.is_some());
9523 }
9524
9525 #[tokio::test]
9526 async fn restore_mv_without_a_tracker_reports_an_operation_timeout() {
9527 let pool = seeded_pool().await;
9528 let driver = honeywell_driver_auto().hanging_write("Unit1.LIC101.OP");
9529 let mut args = fast_simulator_args();
9530 args.op_timeout_secs = 0;
9531 let mut tracker = None;
9532
9533 let outcome = restore_mv_with_verification(
9534 &pool,
9535 0,
9536 &args,
9537 &driver,
9538 "Unit1.LIC101.OP",
9539 45.0,
9540 false,
9541 &mut CtrlC::never(),
9542 &mut tracker,
9543 Instant::now() + Duration::from_secs(1),
9544 )
9545 .await
9546 .unwrap();
9547
9548 assert!(matches!(
9549 outcome,
9550 RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(ref detail))
9551 if detail.contains("restore write did not complete")
9552 ));
9553 }
9554
9555 #[tokio::test]
9556 async fn tracked_restore_write_handles_deadline_cancel_and_operation_timeout() {
9557 let pool = seeded_pool().await;
9558 let driver = honeywell_driver_auto().hanging_write("Unit1.LIC101.OP");
9559 let initial = sample_initial_state();
9560
9561 let now = Instant::now();
9562 let mut deadline_tracker = Some(tracker_with_pending(pending_actuation(
9563 None,
9564 MvActuationKind::Relay,
9565 55.0,
9566 now,
9567 now + Duration::from_secs(1),
9568 None,
9569 )));
9570 let deadline_outcome = restore_mv_with_verification(
9571 &pool,
9572 0,
9573 &fast_simulator_args(),
9574 &driver,
9575 "Unit1.LIC101.OP",
9576 initial.mv_ini,
9577 false,
9578 &mut CtrlC::never(),
9579 &mut deadline_tracker,
9580 Instant::now(),
9581 )
9582 .await
9583 .unwrap();
9584 assert!(matches!(
9585 deadline_outcome,
9586 RestoreMvOutcome::Interrupted(ref detail)
9587 if detail.contains("[tuning].restore_timeout_secs")
9588 ));
9589
9590 let now = Instant::now();
9591 let mut cancel_tracker = Some(tracker_with_pending(pending_actuation(
9592 None,
9593 MvActuationKind::Relay,
9594 55.0,
9595 now,
9596 now + Duration::from_secs(1),
9597 None,
9598 )));
9599 let (mut ctrl_c, tx) = CtrlC::test_pair();
9600 tx.send(1).unwrap();
9601 let cancel_outcome = restore_mv_with_verification(
9602 &pool,
9603 0,
9604 &fast_simulator_args(),
9605 &driver,
9606 "Unit1.LIC101.OP",
9607 initial.mv_ini,
9608 false,
9609 &mut ctrl_c,
9610 &mut cancel_tracker,
9611 Instant::now() + Duration::from_secs(1),
9612 )
9613 .await
9614 .unwrap();
9615 assert!(matches!(
9616 cancel_outcome,
9617 RestoreMvOutcome::Interrupted(ref detail) if detail.contains("second Ctrl+C")
9618 ));
9619
9620 let now = Instant::now();
9621 let mut timeout_tracker = Some(tracker_with_pending(pending_actuation(
9622 None,
9623 MvActuationKind::Relay,
9624 55.0,
9625 now,
9626 now + Duration::from_secs(1),
9627 None,
9628 )));
9629 let mut timeout_args = fast_simulator_args();
9630 timeout_args.op_timeout_secs = 0;
9631 let timeout_outcome = restore_mv_with_verification(
9632 &pool,
9633 0,
9634 &timeout_args,
9635 &driver,
9636 "Unit1.LIC101.OP",
9637 initial.mv_ini,
9638 false,
9639 &mut CtrlC::never(),
9640 &mut timeout_tracker,
9641 Instant::now() + Duration::from_secs(1),
9642 )
9643 .await
9644 .unwrap();
9645 assert!(matches!(
9646 timeout_outcome,
9647 RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(ref detail))
9648 if detail.contains("restore write did not complete")
9649 ));
9650 }
9651
9652 #[tokio::test]
9653 async fn restore_verification_retries_a_mismatch_then_confirms() {
9654 let pool = seeded_pool().await;
9655 let (run_id, _config, _template, tags) =
9656 start_opc_test_run(&pool, "restore-retry-confirm").await;
9657 let driver =
9658 honeywell_driver_auto().with_read_sequence(&tags.manipulated_variable, &["50", "45"]);
9659 let mut args = fast_simulator_args();
9660 args.driver = DriverKindArg::Opcda;
9661 let initial = sample_initial_state();
9662 let mut tracker = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
9663
9664 let outcome = restore_mv_with_verification(
9665 &pool,
9666 run_id,
9667 &args,
9668 &driver,
9669 &tags.manipulated_variable,
9670 initial.mv_ini,
9671 false,
9672 &mut CtrlC::never(),
9673 &mut tracker,
9674 Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9675 )
9676 .await
9677 .unwrap();
9678
9679 assert!(matches!(
9680 outcome,
9681 RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9682 ));
9683 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9684 .await
9685 .unwrap();
9686 assert_eq!(rows[0].attempt_count, 2);
9687 assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
9688 }
9689
9690 #[tokio::test]
9691 async fn ctrl_c_during_restore_verification_interrupts_after_the_write() {
9692 let pool = seeded_pool().await;
9693 let (run_id, _config, _template, tags) =
9694 start_opc_test_run(&pool, "restore-verification-cancel").await;
9695 let driver = honeywell_driver_auto()
9696 .delaying_read(&tags.manipulated_variable, Duration::from_millis(500));
9697 let mut args = fast_simulator_args();
9698 args.driver = DriverKindArg::Opcda;
9699 let initial = sample_initial_state();
9700 let mut tracker = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
9701 let (mut ctrl_c, tx) = CtrlC::test_pair();
9702 tokio::spawn(async move {
9703 tokio::time::sleep(Duration::from_millis(30)).await;
9704 let _ = tx.send(1);
9705 });
9706
9707 let outcome = restore_mv_with_verification(
9708 &pool,
9709 run_id,
9710 &args,
9711 &driver,
9712 &tags.manipulated_variable,
9713 initial.mv_ini,
9714 false,
9715 &mut ctrl_c,
9716 &mut tracker,
9717 Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9718 )
9719 .await
9720 .unwrap();
9721
9722 assert!(matches!(
9723 outcome,
9724 RestoreMvOutcome::Interrupted(ref detail)
9725 if detail.contains("confirming the restored MV")
9726 ));
9727 }
9728
9729 #[tokio::test]
9730 async fn restore_verification_reports_the_expired_restore_deadline() {
9731 let pool = seeded_pool().await;
9732 let (run_id, _config, _template, tags) =
9733 start_opc_test_run(&pool, "restore-verification-deadline").await;
9734 let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
9735 let mut args = fast_simulator_args();
9736 args.driver = DriverKindArg::Opcda;
9737 args.op_timeout_secs = 30;
9738 let initial = sample_initial_state();
9739 let mut tracker = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
9740
9741 let outcome = restore_mv_with_verification(
9742 &pool,
9743 run_id,
9744 &args,
9745 &driver,
9746 &tags.manipulated_variable,
9747 initial.mv_ini,
9748 false,
9749 &mut CtrlC::never(),
9750 &mut tracker,
9751 Instant::now() + Duration::from_millis(30),
9752 )
9753 .await
9754 .unwrap();
9755
9756 assert!(matches!(
9757 outcome,
9758 RestoreMvOutcome::Interrupted(ref detail)
9759 if detail.contains("[tuning].restore_timeout_secs")
9760 ));
9761 }
9762
9763 #[tokio::test]
9764 async fn restore_attempts_mode_setpoint_and_attribute_after_mv_quality_failure() {
9765 let pool = seeded_pool().await;
9766 let (run_id, _config, template, tags) =
9767 start_opc_test_run(&pool, "actuation-restore-quality").await;
9768 let driver = honeywell_driver_auto()
9769 .with_quality(&tags.manipulated_variable, bhtune_driver::Quality::Bad);
9770 let mut args = fast_simulator_args();
9771 args.driver = DriverKindArg::Opcda;
9772 let initial = sample_initial_state();
9773 let mut tracker = MvActuationTracker::for_run(&args, &initial);
9774 let guard = MutationGuard {
9775 mode_attribute_written: true,
9776 mode_written: true,
9777 mv_written: true,
9778 };
9779
9780 let outcome = attempt_restore_with_actuation(
9781 &pool,
9782 run_id,
9783 &args,
9784 &driver,
9785 &tags,
9786 &template,
9787 &initial,
9788 &guard,
9789 false,
9790 &mut CtrlC::never(),
9791 &mut tracker,
9792 )
9793 .await;
9794
9795 assert!(matches!(outcome, RestoreAttempt::Incomplete { .. }));
9796 let writes = driver.write_log();
9797 let mv_index = writes
9798 .iter()
9799 .position(|(tag, _)| tag == &tags.manipulated_variable)
9800 .unwrap();
9801 let mode_index = writes
9802 .iter()
9803 .position(|(tag, _)| Some(tag) == tags.controller_mode.as_ref())
9804 .unwrap();
9805 assert!(
9806 mv_index < mode_index,
9807 "the MV must be restored while the loop is still in Manual"
9808 );
9809 assert!(
9810 writes
9811 .iter()
9812 .any(|(tag, _)| Some(tag) == tags.setpoint_variable.as_ref())
9813 );
9814 assert!(
9815 writes
9816 .iter()
9817 .any(|(tag, _)| Some(tag) == tags.mode_attribute.as_ref())
9818 );
9819 let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9820 .await
9821 .unwrap();
9822 assert_eq!(rows.len(), 1);
9823 assert_eq!(rows[0].kind, MvActuationKind::Restore);
9824 assert_eq!(rows[0].status, MvActuationStatus::Unverified);
9825 assert_eq!(rows[0].readback_quality, Some(SampleQuality::Bad));
9826 }
9827
9828 #[tokio::test]
9829 async fn restore_audit_failure_does_not_prevent_any_physical_restore_step() {
9830 let pool = seeded_pool().await;
9831 let (run_id, _config, template, tags) =
9832 start_opc_test_run(&pool, "actuation-restore-audit-failure").await;
9833 pool.close().await;
9834 let driver = honeywell_driver_auto();
9835 let mut args = fast_simulator_args();
9836 args.driver = DriverKindArg::Opcda;
9837 let initial = sample_initial_state();
9838 let mut tracker = MvActuationTracker::for_run(&args, &initial);
9839 let guard = MutationGuard {
9840 mode_attribute_written: true,
9841 mode_written: true,
9842 mv_written: true,
9843 };
9844
9845 let outcome = attempt_restore_with_actuation(
9846 &pool,
9847 run_id,
9848 &args,
9849 &driver,
9850 &tags,
9851 &template,
9852 &initial,
9853 &guard,
9854 false,
9855 &mut CtrlC::never(),
9856 &mut tracker,
9857 )
9858 .await;
9859
9860 assert!(matches!(outcome, RestoreAttempt::Confirmed));
9861 let writes = driver.write_log();
9862 assert!(
9863 writes
9864 .iter()
9865 .any(|(tag, _)| tag == &tags.manipulated_variable)
9866 );
9867 assert!(
9868 writes
9869 .iter()
9870 .any(|(tag, _)| Some(tag) == tags.controller_mode.as_ref())
9871 );
9872 assert!(
9873 writes
9874 .iter()
9875 .any(|(tag, _)| Some(tag) == tags.setpoint_variable.as_ref())
9876 );
9877 assert!(
9878 writes
9879 .iter()
9880 .any(|(tag, _)| Some(tag) == tags.mode_attribute.as_ref())
9881 );
9882 }
9883
9884 #[tokio::test]
9885 async fn completed_opc_run_audits_final_snapback_through_post_test_padding() {
9886 let pool = seeded_pool().await;
9887 let mut args = fast_simulator_args();
9888 args.driver = DriverKindArg::Opcda;
9889 args.cycles_skip = Some(0);
9890 args.cycles_count = Some(1);
9891 args.mrft_delay = 1;
9892 args.poll_interval_ms = 20;
9893 args.timeout_secs = 5;
9894 let config = build_loop_config(&args).unwrap();
9895 let template = honeywell_template();
9896 let tags = honeywell_tags();
9897 let run = TuneRunRow::start(
9898 &pool,
9899 None,
9900 "actuation-padding-snapback",
9901 TuneDriver::Opcda,
9902 config,
9903 TemplateOrigin::Builtin,
9904 &template,
9905 &tags,
9906 Utc::now(),
9907 )
9908 .await
9909 .unwrap();
9910 let driver = honeywell_driver_auto()
9911 .with_read_sequence(&tags.process_variable, &["50", "55", "45", "55"]);
9912
9913 let outcome = execute(
9914 &pool,
9915 run.id,
9916 &args,
9917 &template,
9918 &tags,
9919 &driver,
9920 config,
9921 RunTimeAnchor::now(),
9922 None,
9923 false,
9924 &mut CtrlC::never(),
9925 &mut std::io::empty(),
9926 )
9927 .await
9928 .unwrap();
9929
9930 assert!(matches!(outcome, RunOutcome::Completed { .. }));
9931 let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
9932 .await
9933 .unwrap();
9934 assert_eq!(rows.len(), 3);
9935 assert_eq!(rows[2].kind, MvActuationKind::Relay);
9936 assert_eq!(rows[2].target_mv, sample_initial_state().mv_ini);
9937 assert_eq!(rows[2].status, MvActuationStatus::Confirmed);
9938 assert!(
9939 TuneSampleRow::list_for_run(&pool, run.id)
9940 .await
9941 .unwrap()
9942 .len()
9943 > 3,
9944 "post-test padding must continue recording PV samples"
9945 );
9946 }
9947
9948 #[tokio::test]
9949 async fn execute_rejects_a_zero_effective_relay_step_before_any_mutation() {
9950 let pool = seeded_pool().await;
9951 let (run_id, config, template, tags) =
9952 start_opc_test_run(&pool, "actuation-zero-step").await;
9953 let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "100");
9954 let mut args = fast_simulator_args();
9955 args.driver = DriverKindArg::Opcda;
9956
9957 let error = execute(
9958 &pool,
9959 run_id,
9960 &args,
9961 &template,
9962 &tags,
9963 &driver,
9964 config,
9965 RunTimeAnchor::now(),
9966 None,
9967 false,
9968 &mut CtrlC::never(),
9969 &mut std::io::empty(),
9970 )
9971 .await
9972 .unwrap_err();
9973
9974 assert!(error.to_string().contains("too small to verify safely"));
9975 assert!(driver.write_log().is_empty());
9976 }
9977
9978 #[test]
9979 fn shared_restore_timeout_policy_keeps_simulator_positive_only() {
9980 assert!(validate_restore_timeout_secs(DriverKindArg::Simulator, 0).is_err());
9981 assert!(validate_restore_timeout_secs(DriverKindArg::Simulator, 1).is_ok());
9982 assert!(
9983 validate_restore_timeout_secs(DriverKindArg::Opcda, MV_ACTUATION_CONFIRMATION_SECS - 1)
9984 .is_err()
9985 );
9986 assert!(
9987 validate_restore_timeout_secs(DriverKindArg::Opcda, MV_ACTUATION_CONFIRMATION_SECS)
9988 .is_ok()
9989 );
9990 }
9991
9992 #[tokio::test]
9993 async fn prepare_rejects_short_opcda_restore_timeout_before_driver_or_database_mutation() {
9994 let pool = seeded_pool().await;
9995 let mut args = fast_simulator_args();
9996 args.driver = DriverKindArg::Opcda;
9997 args.server = Some("Mock.Server".to_string());
9998 args.bridge_host = Some("127.0.0.1:1".to_string());
9999 let mut config = test_config();
10000 config.tuning.restore_timeout_secs = Some(MV_ACTUATION_CONFIRMATION_SECS - 1);
10001
10002 let error = prepare(&pool, args, &config)
10003 .await
10004 .err()
10005 .expect("short OPC DA restore timeout must be rejected");
10006
10007 assert!(error.to_string().contains("tuning.restore_timeout_secs"));
10008 assert!(
10009 TuneRunRow::list(
10010 &pool,
10011 &bhtune_db::models::TuneRunFilter::default(),
10012 bhtune_db::models::Pagination::first(10),
10013 )
10014 .await
10015 .unwrap()
10016 .is_empty(),
10017 "validation must run before the tune_runs insert"
10018 );
10019 }
10020
10021 async fn assert_prepare_metadata_failure_is_terminal(column: &str) {
10022 let pool = seeded_pool().await;
10023 let trigger_name = format!("fail_{column}");
10024 let trigger = format!(
10025 "CREATE TRIGGER {trigger_name} \
10026 BEFORE UPDATE OF {column} ON tune_runs
10027 BEGIN SELECT RAISE(ABORT, 'injected metadata failure'); END"
10028 );
10029 sqlx::query(sqlx::AssertSqlSafe(trigger.as_str()))
10030 .execute(&pool)
10031 .await
10032 .unwrap();
10033
10034 let error = prepare(&pool, fast_simulator_args(), &test_config())
10035 .await
10036 .err()
10037 .expect("the injected metadata failure should abort preparation");
10038 assert!(error.to_string().contains("injected metadata failure"));
10039
10040 let runs = TuneRunRow::list(
10041 &pool,
10042 &bhtune_db::models::TuneRunFilter::default(),
10043 bhtune_db::models::Pagination::first(10),
10044 )
10045 .await
10046 .unwrap();
10047 assert_eq!(runs.len(), 1);
10048 assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Failed);
10049 assert!(
10050 runs[0]
10051 .failure_reason
10052 .as_deref()
10053 .is_some_and(|reason| reason.contains("injected metadata failure"))
10054 );
10055 }
10056
10057 #[tokio::test]
10058 async fn every_prepare_metadata_failure_marks_the_run_terminal() {
10059 for column in [
10060 "effective_tuning_json",
10061 "allow_uncertain_quality",
10062 "request_json",
10063 "notes",
10064 ] {
10065 assert_prepare_metadata_failure_is_terminal(column).await;
10066 }
10067 }
10068
10069 #[tokio::test]
10070 async fn prepare_deletes_the_run_when_terminalization_also_fails() {
10071 let pool = seeded_pool().await;
10072 sqlx::query(
10073 "CREATE TRIGGER fail_effective_tuning_update \
10074 BEFORE UPDATE OF effective_tuning_json ON tune_runs
10075 BEGIN SELECT RAISE(ABORT, 'injected metadata failure'); END",
10076 )
10077 .execute(&pool)
10078 .await
10079 .unwrap();
10080 sqlx::query(
10081 "CREATE TRIGGER fail_terminal_update \
10082 BEFORE UPDATE OF outcome ON tune_runs
10083 BEGIN SELECT RAISE(ABORT, 'injected terminalization failure'); END",
10084 )
10085 .execute(&pool)
10086 .await
10087 .unwrap();
10088
10089 let error = prepare(&pool, fast_simulator_args(), &test_config())
10090 .await
10091 .err()
10092 .expect("the injected metadata failure should abort preparation");
10093 assert!(error.to_string().contains("injected metadata failure"));
10094
10095 let runs = TuneRunRow::list(
10096 &pool,
10097 &bhtune_db::models::TuneRunFilter::default(),
10098 bhtune_db::models::Pagination::first(10),
10099 )
10100 .await
10101 .unwrap();
10102 assert!(
10103 runs.is_empty(),
10104 "the row must be removed when its failed outcome cannot be persisted"
10105 );
10106 }
10107
10108 #[tokio::test]
10109 async fn finalize_preparation_failure_reports_a_failed_cleanup() {
10110 let pool = seeded_pool().await;
10111 pool.close().await;
10112
10113 finalize_preparation_failure(&pool, 1, "injected preparation failure").await;
10114 }
10115
10116 #[tokio::test]
10117 async fn execute_aborts_and_records_reason_when_a_relay_command_is_unconfirmed() {
10118 let pool = seeded_pool().await;
10119 let mut args = fast_simulator_args();
10120 args.driver = DriverKindArg::Opcda;
10121 args.noise_protection_secs = Some(0);
10122 let config = build_loop_config(&args).unwrap();
10123 let template = honeywell_template();
10124 let tags = honeywell_tags();
10125 let run = TuneRunRow::start(
10126 &pool,
10127 None,
10128 "actuation-abort",
10129 TuneDriver::Opcda,
10130 config,
10131 TemplateOrigin::Builtin,
10132 &template,
10133 &tags,
10134 Utc::now(),
10135 )
10136 .await
10137 .unwrap();
10138 let driver =
10139 honeywell_driver_auto().distorting_first_writes(&tags.manipulated_variable, 1, -5.0);
10140
10141 let outcome = execute(
10142 &pool,
10143 run.id,
10144 &args,
10145 &template,
10146 &tags,
10147 &driver,
10148 config,
10149 RunTimeAnchor::now(),
10150 None,
10151 false,
10152 &mut CtrlC::never(),
10153 &mut std::io::empty(),
10154 )
10155 .await
10156 .unwrap();
10157
10158 assert!(matches!(
10159 outcome,
10160 RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
10161 ));
10162 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10163 assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
10164 assert!(
10165 stored
10166 .failure_reason
10167 .as_deref()
10168 .is_some_and(|reason| reason.contains("MV actuation unconfirmed"))
10169 );
10170 let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
10171 .await
10172 .unwrap();
10173 assert_eq!(rows.len(), 2);
10174 assert_eq!(rows[0].kind, MvActuationKind::Relay);
10175 assert_eq!(rows[0].status, MvActuationStatus::Failed);
10176 assert_eq!(rows[1].kind, MvActuationKind::Restore);
10177 assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
10178 }
10179
10180 #[tokio::test]
10181 async fn execute_finalizes_pending_actuation_when_sample_persistence_fails() {
10182 let pool = seeded_pool().await;
10183 let mut args = fast_simulator_args();
10184 args.driver = DriverKindArg::Opcda;
10185 args.noise_protection_secs = Some(10);
10186 let config = build_loop_config(&args).unwrap();
10187 let template = honeywell_template();
10188 let tags = honeywell_tags();
10189 let time_anchor = RunTimeAnchor::now();
10190 let run = TuneRunRow::start(
10191 &pool,
10192 None,
10193 "actuation-db-failure",
10194 TuneDriver::Opcda,
10195 config,
10196 TemplateOrigin::Builtin,
10197 &template,
10198 &tags,
10199 time_anchor.utc(),
10200 )
10201 .await
10202 .unwrap();
10203 let initial = sample_initial_state();
10204 let engine = MrftEngine::new(
10205 config,
10206 initial.direction,
10207 lookup(
10208 config.process_type,
10209 config.controller_type,
10210 ResponseLevel::Aggressive,
10211 )
10212 .beta,
10213 InitialReadings {
10214 pv_ini: initial.pv_ini,
10215 mv_ini: initial.mv_ini,
10216 mv_range_low: initial.mv_range_low,
10217 mv_range_high: initial.mv_range_high,
10218 },
10219 time_anchor.utc(),
10220 MrftCompat::default(),
10221 );
10222 TuneSampleRow::insert(
10223 &pool,
10224 run.id,
10225 0,
10226 Tick {
10227 time: time_anchor.utc(),
10228 pv: initial.pv_ini,
10229 },
10230 engine.state(),
10231 SampleQuality::Good,
10232 )
10233 .await
10234 .unwrap();
10235
10236 let error = execute(
10237 &pool,
10238 run.id,
10239 &args,
10240 &template,
10241 &tags,
10242 &honeywell_driver_auto(),
10243 config,
10244 time_anchor,
10245 None,
10246 false,
10247 &mut CtrlC::never(),
10248 &mut std::io::empty(),
10249 )
10250 .await
10251 .unwrap_err();
10252
10253 assert!(error.to_string().contains("UNIQUE constraint failed"));
10254 let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
10255 .await
10256 .unwrap();
10257 assert!(!rows.is_empty());
10258 assert!(
10259 rows.iter()
10260 .all(|row| row.status != MvActuationStatus::Pending)
10261 );
10262 }
10263
10264 #[tokio::test]
10265 async fn restore_incomplete_takes_precedence_over_actuation_failure() {
10266 let pool = seeded_pool().await;
10267 let mut args = fast_simulator_args();
10268 args.driver = DriverKindArg::Opcda;
10269 args.noise_protection_secs = Some(0);
10270 let config = build_loop_config(&args).unwrap();
10271 let template = honeywell_template();
10272 let tags = honeywell_tags();
10273 let run = TuneRunRow::start(
10274 &pool,
10275 None,
10276 "actuation-and-restore-fail",
10277 TuneDriver::Opcda,
10278 config,
10279 TemplateOrigin::Builtin,
10280 &template,
10281 &tags,
10282 Utc::now(),
10283 )
10284 .await
10285 .unwrap();
10286 let driver = honeywell_driver_auto()
10287 .distorting_first_writes(&tags.manipulated_variable, 1, -5.0)
10288 .rejecting_write_after(&tags.manipulated_variable, 1);
10289
10290 let outcome = execute(
10291 &pool,
10292 run.id,
10293 &args,
10294 &template,
10295 &tags,
10296 &driver,
10297 config,
10298 RunTimeAnchor::now(),
10299 None,
10300 false,
10301 &mut CtrlC::never(),
10302 &mut std::io::empty(),
10303 )
10304 .await
10305 .unwrap();
10306
10307 assert!(matches!(outcome, RunOutcome::RestoreIncomplete { .. }));
10308 assert_eq!(
10309 tune_outcome_for_run(&outcome),
10310 TuneOutcome::RestoreIncomplete
10311 );
10312 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10313 assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
10314 assert!(
10315 stored
10316 .failure_reason
10317 .as_deref()
10318 .is_some_and(|reason| reason.contains("MV actuation unconfirmed"))
10319 );
10320 let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
10321 .await
10322 .unwrap();
10323 assert_eq!(rows.len(), 1);
10324 assert_eq!(rows[0].status, MvActuationStatus::Failed);
10325 }
10326
10327 #[tokio::test]
10328 async fn audit_cleanup_failure_does_not_override_restore_incomplete() {
10329 let pool = seeded_pool().await;
10330 let mut args = fast_simulator_args();
10331 args.driver = DriverKindArg::Opcda;
10332 let config = build_loop_config(&args).unwrap();
10333 let template = honeywell_template();
10334 let tags = honeywell_tags();
10335 let started_at = Utc::now();
10336 let run = TuneRunRow::start(
10337 &pool,
10338 None,
10339 "actuation-cleanup-failure",
10340 TuneDriver::Opcda,
10341 config,
10342 TemplateOrigin::Builtin,
10343 &template,
10344 &tags,
10345 started_at,
10346 )
10347 .await
10348 .unwrap();
10349 TuneMvActuationRow::insert_pending(
10350 &pool,
10351 run.id,
10352 NewTuneMvActuation {
10353 sequence: 0,
10354 kind: MvActuationKind::Relay,
10355 commanded_at: started_at,
10356 target_mv: 55.0,
10357 previous_commanded_mv: Some(45.0),
10358 tolerance: 0.1,
10359 confirmation_due_at: started_at + chrono::Duration::seconds(4),
10360 },
10361 )
10362 .await
10363 .unwrap();
10364 sqlx::query(
10365 "CREATE TRIGGER reject_actuation_cleanup \
10366 BEFORE UPDATE OF status ON tune_mv_actuations \
10367 WHEN OLD.status = 'pending' AND NEW.status = 'unverified' \
10368 BEGIN SELECT RAISE(FAIL, 'forced actuation cleanup failure'); END",
10369 )
10370 .execute(&pool)
10371 .await
10372 .unwrap();
10373 let driver = honeywell_driver_auto()
10374 .degrade_quality_after(&tags.process_variable, 1, bhtune_driver::Quality::Bad)
10375 .erroring_write(&tags.manipulated_variable);
10376
10377 let outcome = execute(
10378 &pool,
10379 run.id,
10380 &args,
10381 &template,
10382 &tags,
10383 &driver,
10384 config,
10385 RunTimeAnchor::now(),
10386 None,
10387 false,
10388 &mut CtrlC::never(),
10389 &mut std::io::empty(),
10390 )
10391 .await
10392 .unwrap();
10393
10394 assert!(matches!(outcome, RunOutcome::RestoreIncomplete { .. }));
10395 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10396 assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
10397 assert_eq!(
10398 stored.restore_status,
10399 Some(bhtune_db::models::RestoreStatus::Incomplete)
10400 );
10401 }
10402
10403 #[test]
10406 fn check_quality_accepts_good_regardless_of_the_quality_policy() {
10407 assert!(check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Good, false).is_ok());
10408 assert!(check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Good, true).is_ok());
10409 }
10410
10411 #[test]
10412 fn check_quality_rejects_uncertain_unless_the_policy_allows_it() {
10413 let err =
10414 check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Uncertain, false).unwrap_err();
10415 assert!(err.to_string().contains("Uncertain"));
10416 assert!(err.to_string().contains("Unit1.LIC101.PV"));
10417 assert!(check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Uncertain, true).is_ok());
10418 }
10419
10420 #[test]
10421 fn check_quality_never_accepts_bad_regardless_of_the_policy() {
10422 let err_without_flag =
10423 check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Bad, false).unwrap_err();
10424 assert!(err_without_flag.to_string().contains("Bad"));
10425 let err_with_flag =
10426 check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Bad, true).unwrap_err();
10427 assert!(err_with_flag.to_string().contains("Bad"));
10428 }
10429
10430 #[test]
10433 fn pid_value_within_tolerance_accepts_an_exact_match() {
10434 assert!(pid_value_within_tolerance(10.0, 10.0));
10435 assert!(pid_value_within_tolerance(0.0, 0.0));
10436 }
10437
10438 #[test]
10439 fn pid_value_within_tolerance_accepts_within_the_one_percent_relative_band() {
10440 assert!(pid_value_within_tolerance(10.0, 10.09));
10442 assert!(pid_value_within_tolerance(10.0, 9.91));
10443 assert!(!pid_value_within_tolerance(10.0, 10.2));
10444 assert!(!pid_value_within_tolerance(10.0, 9.8));
10445 }
10446
10447 #[test]
10448 fn pid_value_within_tolerance_uses_the_absolute_floor_near_zero() {
10449 assert!(pid_value_within_tolerance(0.0, 0.0009));
10453 assert!(!pid_value_within_tolerance(0.0, 0.002));
10454 }
10455
10456 #[test]
10457 fn pid_value_within_tolerance_handles_negative_requested_values() {
10458 assert!(pid_value_within_tolerance(-10.0, -10.09));
10462 assert!(!pid_value_within_tolerance(-10.0, -10.2));
10463 }
10464
10465 fn honeywell_template() -> DcsTemplate {
10469 bhtune_core::built_in_templates()
10470 .into_iter()
10471 .find(|t| t.name == "Honeywell Experion")
10472 .expect("Honeywell Experion is a built-in template")
10473 }
10474
10475 fn honeywell_tags() -> LoopTags {
10476 LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &honeywell_template())
10477 }
10478
10479 fn yokogawa_template() -> DcsTemplate {
10480 bhtune_core::built_in_templates()
10481 .into_iter()
10482 .find(|t| t.name == "Yokogawa CentumVP")
10483 .expect("Yokogawa CentumVP is a built-in template")
10484 }
10485
10486 fn yokogawa_tags() -> LoopTags {
10487 LoopTags::derive_from_pv_tag("Unit1.FIC101.PV", &yokogawa_template())
10488 }
10489
10490 fn honeywell_driver_auto() -> MockDriver {
10495 MockDriver::new(&[
10496 ("Unit1.LIC101.PV", "50.0"),
10497 ("Unit1.LIC101.OP", "45.0"),
10498 ("Unit1.LIC101.MODE", "1"),
10499 ("Unit1.LIC101.MODEATTR", "1"),
10500 ("Unit1.LIC101.CTLACTN", "0"),
10501 ("Unit1.LIC101.PVEUHI", "100.0"),
10502 ("Unit1.LIC101.PVEULO", "0.0"),
10503 ("Unit1.LIC101.CVEUHI", "100.0"),
10504 ("Unit1.LIC101.CVEULO", "0.0"),
10505 ("Unit1.LIC101.SP", "55.0"),
10506 ("Unit1.LIC101.K", "10.0"),
10507 ("Unit1.LIC101.T1", "2.0"),
10508 ("Unit1.LIC101.T2", "0.5"),
10509 ])
10510 }
10511
10512 #[tokio::test]
10513 async fn read_initial_values_batches_the_opcda_tag_set_before_auto_setpoint() {
10514 let template = honeywell_template();
10515 let tags = honeywell_tags();
10516 let driver = honeywell_driver_auto();
10517
10518 let initial = read_initial_values(&driver, &tags, &template, false)
10519 .await
10520 .unwrap();
10521 assert_eq!(initial.pv_ini, 50.0);
10522 assert_eq!(initial.mv_ini, 45.0);
10523 assert_eq!(initial.pv_range_high, 100.0);
10524 assert_eq!(initial.pv_range_low, 0.0);
10525 assert_eq!(initial.mv_range_high, 100.0);
10526 assert_eq!(initial.mv_range_low, 0.0);
10527 assert_eq!(initial.direction, ControllerDirection::Direct);
10528 assert_eq!(initial.mode_raw.as_deref(), Some("1"));
10529 assert_eq!(initial.mode_attribute_raw.as_deref(), Some("1"));
10530 assert_eq!(
10531 driver.read_batches(),
10532 vec![
10533 vec![
10534 "Unit1.LIC101.PV".to_string(),
10535 "Unit1.LIC101.OP".to_string(),
10536 "Unit1.LIC101.MODE".to_string(),
10537 "Unit1.LIC101.MODEATTR".to_string(),
10538 "Unit1.LIC101.CTLACTN".to_string(),
10539 "Unit1.LIC101.PVEUHI".to_string(),
10540 "Unit1.LIC101.PVEULO".to_string(),
10541 "Unit1.LIC101.CVEUHI".to_string(),
10542 "Unit1.LIC101.CVEULO".to_string(),
10543 ],
10544 vec!["Unit1.LIC101.SP".to_string()],
10545 ]
10546 );
10547 }
10548
10549 #[tokio::test]
10550 async fn read_initial_values_batches_manual_tags_without_reading_setpoint() {
10551 let template = yokogawa_template();
10552 let tags = yokogawa_tags();
10553 let driver = MockDriver::new(&[
10554 ("Unit1.FIC101.PV", "50.0"),
10555 ("Unit1.FIC101.MV", "45.0"),
10556 ("Unit1.FIC101.MODE", "MAN"),
10557 ("Unit1.FIC101.DR", "0"),
10558 ("Unit1.FIC101.SH", "100.0"),
10559 ("Unit1.FIC101.SL", "0.0"),
10560 ("Unit1.FIC101.MSH", "100.0"),
10561 ("Unit1.FIC101.MSL", "0.0"),
10562 ]);
10563
10564 let initial = read_initial_values(&driver, &tags, &template, false)
10565 .await
10566 .unwrap();
10567
10568 assert_eq!(initial.mode_raw.as_deref(), Some("MAN"));
10569 assert_eq!(initial.setpoint_ini, None);
10570 assert_eq!(
10571 driver.read_batches(),
10572 vec![vec![
10573 "Unit1.FIC101.PV".to_string(),
10574 "Unit1.FIC101.MV".to_string(),
10575 "Unit1.FIC101.MODE".to_string(),
10576 "Unit1.FIC101.DR".to_string(),
10577 "Unit1.FIC101.SH".to_string(),
10578 "Unit1.FIC101.SL".to_string(),
10579 "Unit1.FIC101.MSH".to_string(),
10580 "Unit1.FIC101.MSL".to_string(),
10581 ]]
10582 );
10583 }
10584
10585 #[tokio::test]
10586 async fn read_initial_values_deduplicates_tags_and_skips_fixed_overrides() {
10587 let template = honeywell_template();
10588 let mut tags = honeywell_tags();
10589 tags.manipulated_variable = tags.process_variable.clone();
10590 tags.controller_mode = None;
10591 tags.mode_attribute = None;
10592 tags.controller_direction = TagOrValue::Value(ControllerDirection::Reverse);
10593 tags.upper_pv_range = TagOrValue::Value(100.0);
10594 tags.lower_pv_range = TagOrValue::Value(0.0);
10595 tags.upper_mv_range = TagOrValue::Value(100.0);
10596 tags.lower_mv_range = TagOrValue::Value(0.0);
10597 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "50.0")]);
10598
10599 let initial = read_initial_values(&driver, &tags, &template, false)
10600 .await
10601 .unwrap();
10602
10603 assert_eq!(initial.pv_ini, 50.0);
10604 assert_eq!(initial.mv_ini, 50.0);
10605 assert_eq!(initial.direction, ControllerDirection::Reverse);
10606 assert_eq!(
10607 driver.read_batches(),
10608 vec![vec!["Unit1.LIC101.PV".to_string()]]
10609 );
10610 }
10611
10612 #[tokio::test]
10613 async fn read_initial_values_errors_when_a_tag_returns_no_value() {
10614 let template = honeywell_template();
10615 let tags = honeywell_tags();
10616 let driver = honeywell_driver_auto().empty_read("Unit1.LIC101.PV");
10617
10618 let err = read_initial_values(&driver, &tags, &template, false)
10619 .await
10620 .unwrap_err();
10621 assert!(err.to_string().contains("no value"));
10622 }
10623
10624 fn sample_initial_state() -> InitialState {
10630 InitialState {
10631 pv_ini: 50.0,
10632 mv_ini: 45.0,
10633 pv_range_high: 100.0,
10634 pv_range_low: 0.0,
10635 mv_range_high: 100.0,
10636 mv_range_low: 0.0,
10637 direction: ControllerDirection::Direct,
10638 mode_raw: Some("1".to_string()),
10639 mode_attribute_raw: Some("1".to_string()),
10640 setpoint_ini: Some(55.0),
10641 }
10642 }
10643
10644 #[test]
10645 fn validate_initial_state_accepts_a_typical_reading() {
10646 assert!(validate_initial_state(&sample_initial_state()).is_ok());
10647 }
10648
10649 #[test]
10650 fn validate_initial_state_rejects_a_zero_span_pv_range() {
10651 let mut initial = sample_initial_state();
10652 initial.pv_range_high = 50.0;
10653 initial.pv_range_low = 50.0;
10654 let err = validate_initial_state(&initial).unwrap_err();
10655 assert!(err.to_string().contains("PV range"));
10656 }
10657
10658 #[test]
10659 fn validate_initial_state_rejects_an_mv_range_with_low_not_below_high() {
10660 let mut initial = sample_initial_state();
10661 initial.mv_range_high = 0.0;
10662 initial.mv_range_low = 100.0;
10663 let err = validate_initial_state(&initial).unwrap_err();
10664 assert!(err.to_string().contains("MV range"));
10665 }
10666
10667 #[test]
10668 fn validate_initial_state_rejects_equal_mv_range_bounds() {
10669 let mut initial = sample_initial_state();
10670 initial.mv_range_high = 50.0;
10671 initial.mv_range_low = 50.0;
10672 assert!(validate_initial_state(&initial).is_err());
10673 }
10674
10675 #[test]
10676 fn validate_initial_state_rejects_an_initial_mv_outside_the_mv_range() {
10677 let mut initial = sample_initial_state();
10678 initial.mv_ini = 150.0;
10679 let err = validate_initial_state(&initial).unwrap_err();
10680 assert!(err.to_string().contains("outside the MV range"));
10681 }
10682
10683 #[test]
10684 fn validate_initial_state_accepts_the_initial_mv_on_the_range_boundary() {
10685 let mut initial = sample_initial_state();
10686 initial.mv_ini = initial.mv_range_high;
10687 assert!(validate_initial_state(&initial).is_ok());
10688 }
10689
10690 #[tokio::test]
10694 async fn execute_hard_fails_when_the_pv_tag_reports_bad_quality() {
10695 let pool = seeded_pool().await;
10696 let template = honeywell_template();
10697 let tags = honeywell_tags();
10698 let driver = honeywell_driver_auto()
10699 .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
10700 let config = build_loop_config(&fast_simulator_args()).unwrap();
10701 let time_anchor = RunTimeAnchor::now();
10702 let run = TuneRunRow::start(
10703 &pool,
10704 None,
10705 "bad-quality-initial",
10706 TuneDriver::Opcda,
10707 config,
10708 TemplateOrigin::Builtin,
10709 &template,
10710 &tags,
10711 time_anchor.utc(),
10712 )
10713 .await
10714 .unwrap();
10715
10716 let err = execute(
10717 &pool,
10718 run.id,
10719 &fast_simulator_args(),
10720 &template,
10721 &tags,
10722 &driver,
10723 config,
10724 time_anchor,
10725 None,
10726 true,
10727 &mut CtrlC::never(),
10728 &mut std::io::empty(),
10729 )
10730 .await
10731 .unwrap_err();
10732
10733 assert!(err.to_string().contains(&tags.process_variable));
10734 assert!(err.to_string().contains("Bad"));
10735 assert!(driver.write_log().is_empty());
10738 }
10739
10740 #[tokio::test]
10741 async fn execute_hard_fails_when_the_pv_tag_reports_uncertain_quality_policy_rejects_it() {
10742 let pool = seeded_pool().await;
10743 let template = honeywell_template();
10744 let tags = honeywell_tags();
10745 let driver = honeywell_driver_auto()
10746 .with_quality(&tags.process_variable, bhtune_driver::Quality::Uncertain);
10747 let config = build_loop_config(&fast_simulator_args()).unwrap();
10748 let time_anchor = RunTimeAnchor::now();
10749 let run = TuneRunRow::start(
10750 &pool,
10751 None,
10752 "uncertain-quality-initial",
10753 TuneDriver::Opcda,
10754 config,
10755 TemplateOrigin::Builtin,
10756 &template,
10757 &tags,
10758 time_anchor.utc(),
10759 )
10760 .await
10761 .unwrap();
10762
10763 let err = execute(
10764 &pool,
10765 run.id,
10766 &fast_simulator_args(),
10767 &template,
10768 &tags,
10769 &driver,
10770 config,
10771 time_anchor,
10772 None,
10773 false,
10774 &mut CtrlC::never(),
10775 &mut std::io::empty(),
10776 )
10777 .await
10778 .unwrap_err();
10779
10780 assert!(err.to_string().contains(&tags.process_variable));
10781 assert!(err.to_string().contains("Uncertain"));
10782 assert!(driver.write_log().is_empty());
10783 }
10784
10785 #[tokio::test]
10786 async fn read_initial_values_and_transition_accept_uncertain_pv_quality_when_policy_allows_it()
10787 {
10788 let template = honeywell_template();
10789 let tags = honeywell_tags();
10790 let driver = honeywell_driver_auto()
10791 .with_quality(&tags.process_variable, bhtune_driver::Quality::Uncertain);
10792
10793 let initial = read_initial_values(&driver, &tags, &template, true)
10794 .await
10795 .unwrap();
10796 assert_eq!(initial.pv_ini, 50.0);
10797
10798 let mut guard = MutationGuard::default();
10799 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
10800 .await
10801 .unwrap();
10802 assert!(!driver.write_log().is_empty());
10807 }
10808
10809 #[tokio::test]
10810 async fn read_initial_values_hard_fails_when_the_setpoint_tag_reports_bad_quality() {
10811 let template = honeywell_template();
10818 let tags = honeywell_tags();
10819 let sp_tag = tags.setpoint_variable.clone().unwrap();
10820 let driver = honeywell_driver_auto().with_quality(&sp_tag, bhtune_driver::Quality::Bad);
10821
10822 let err = read_initial_values(&driver, &tags, &template, false)
10823 .await
10824 .unwrap_err();
10825 assert!(err.to_string().contains(&sp_tag));
10826 assert!(err.to_string().contains("Bad"));
10827 }
10828
10829 #[tokio::test]
10834 async fn execute_rejects_an_invalid_mv_range_before_any_mutation_of_the_loop() {
10835 let pool = seeded_pool().await;
10836 let template = honeywell_template();
10837 let tags = honeywell_tags();
10838 let driver = honeywell_driver_auto()
10841 .with_value("Unit1.LIC101.CVEUHI", "0.0")
10842 .with_value("Unit1.LIC101.CVEULO", "100.0");
10843 let config = build_loop_config(&fast_simulator_args()).unwrap();
10844 let time_anchor = RunTimeAnchor::now();
10845 let run = TuneRunRow::start(
10846 &pool,
10847 None,
10848 "invalid-mv-range",
10849 TuneDriver::Opcda,
10850 config,
10851 TemplateOrigin::Builtin,
10852 &template,
10853 &tags,
10854 time_anchor.utc(),
10855 )
10856 .await
10857 .unwrap();
10858
10859 let err = execute(
10860 &pool,
10861 run.id,
10862 &fast_simulator_args(),
10863 &template,
10864 &tags,
10865 &driver,
10866 config,
10867 time_anchor,
10868 None,
10869 true,
10870 &mut CtrlC::never(),
10871 &mut std::io::empty(),
10872 )
10873 .await
10874 .unwrap_err();
10875
10876 assert!(err.to_string().contains("MV range"));
10877 assert!(driver.write_log().is_empty());
10880 }
10881
10882 #[tokio::test]
10889 async fn execute_attempts_restore_when_transition_to_manual_fails_partway() {
10890 let pool = seeded_pool().await;
10891 let template = honeywell_template();
10892 let tags = honeywell_tags();
10893 let driver = honeywell_driver_auto().erroring_write("Unit1.LIC101.MODEATTR");
10894 let config = build_loop_config(&fast_simulator_args()).unwrap();
10895 let time_anchor = RunTimeAnchor::now();
10896 let run = TuneRunRow::start(
10897 &pool,
10898 None,
10899 "transition-to-manual-fails",
10900 TuneDriver::Opcda,
10901 config,
10902 TemplateOrigin::Builtin,
10903 &template,
10904 &tags,
10905 time_anchor.utc(),
10906 )
10907 .await
10908 .unwrap();
10909
10910 let err = execute(
10911 &pool,
10912 run.id,
10913 &fast_simulator_args(),
10914 &template,
10915 &tags,
10916 &driver,
10917 config,
10918 time_anchor,
10919 None,
10920 true,
10921 &mut CtrlC::never(),
10922 &mut std::io::empty(),
10923 )
10924 .await
10925 .unwrap_err();
10926
10927 assert!(err.to_string().contains("driver operation failed"));
10931
10932 assert!(
10936 driver
10937 .write_log()
10938 .iter()
10939 .any(|(tag, _)| tag == "Unit1.LIC101.OP")
10940 );
10941 assert!(
10945 driver
10946 .write_log()
10947 .iter()
10948 .all(|(tag, _)| tag != "Unit1.LIC101.MODE")
10949 );
10950
10951 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10955 assert_eq!(
10956 stored.restore_status,
10957 Some(bhtune_db::models::RestoreStatus::Incomplete)
10958 );
10959 assert!(
10960 stored
10961 .restore_detail
10962 .as_deref()
10963 .unwrap_or_default()
10964 .contains("mode attribute")
10965 );
10966 }
10967
10968 #[tokio::test]
10977 async fn execute_attempts_restore_when_persist_completed_results_fails() {
10978 let pool = seeded_pool().await;
10979 let template = bhtune_core::built_in_templates().remove(0);
10980 let args = fast_simulator_args();
10981 let config = build_loop_config(&args).unwrap();
10982 let tags = build_loop_tags(&args, &template).unwrap();
10983 let driver = crate::driver::build(&args).await.unwrap();
10984 let time_anchor = RunTimeAnchor::now();
10985 let run = TuneRunRow::start(
10986 &pool,
10987 None,
10988 "finish-completed-run-fails",
10989 TuneDriver::Simulator,
10990 config,
10991 TemplateOrigin::Builtin,
10992 &template,
10993 &tags,
10994 time_anchor.utc(),
10995 )
10996 .await
10997 .unwrap();
10998
10999 TuneResultRow::insert(
11005 &pool,
11006 &TuneResultRow {
11007 id: 0,
11008 run_id: run.id,
11009 response_level: ResponseLevel::Aggressive,
11010 kp: Some(1.0),
11011 ti_minutes: Some(1.0),
11012 td_minutes: Some(1.0),
11013 proportional: Some(1.0),
11014 integral: Some(1.0),
11015 derivative: Some(1.0),
11016 status: TuningResultStatus::Valid,
11017 invalid_reason: None,
11018 },
11019 )
11020 .await
11021 .unwrap();
11022
11023 let result = execute(
11024 &pool,
11025 run.id,
11026 &args,
11027 &template,
11028 &tags,
11029 driver.as_ref(),
11030 config,
11031 time_anchor,
11032 None,
11033 true,
11034 &mut CtrlC::never(),
11035 &mut std::io::empty(),
11036 )
11037 .await;
11038
11039 assert!(result.is_err());
11044
11045 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
11049 assert_eq!(
11050 stored.restore_status,
11051 Some(bhtune_db::models::RestoreStatus::Confirmed)
11052 );
11053 let timing = stored
11054 .timing_metrics
11055 .expect("cadence metrics should survive a post-poll persistence failure");
11056 assert!(timing.sample_gap_count > 0);
11057 assert_eq!(timing.measured_oscillation_period_ms, None);
11058 assert_eq!(timing.approximate_samples_per_period, None);
11059 }
11060
11061 #[derive(Debug)]
11062 struct RestoreFailingSimulator {
11063 inner: bhtune_driver::SimulatorDriver,
11064 writes: std::sync::Mutex<u32>,
11065 successful_writes: u32,
11066 }
11067
11068 #[async_trait::async_trait]
11069 impl Driver for RestoreFailingSimulator {
11070 async fn read(&self, tags: &[String]) -> bhtune_driver::DriverResult<Vec<TagValue>> {
11071 self.inner.read(tags).await
11072 }
11073
11074 async fn write(
11075 &self,
11076 tag: &String,
11077 value: TagWrite,
11078 ) -> bhtune_driver::DriverResult<bhtune_driver::WriteOutcome> {
11079 let reject = {
11080 let mut writes = self.writes.lock().unwrap();
11081 *writes += 1;
11082 *writes > self.successful_writes
11083 };
11084 if reject {
11085 Ok(bhtune_driver::WriteOutcome::failure(
11086 "restore intentionally rejected",
11087 ))
11088 } else {
11089 self.inner.write(tag, value).await
11090 }
11091 }
11092
11093 async fn browse(
11094 &self,
11095 _request: bhtune_driver::BrowsePageRequest,
11096 ) -> bhtune_driver::DriverResult<bhtune_driver::BrowsePage> {
11097 Err(bhtune_driver::DriverError::Unsupported {
11098 operation: "browse",
11099 })
11100 }
11101 }
11102
11103 #[tokio::test]
11104 async fn execute_reports_restore_incomplete_after_a_completed_run_cannot_restore_mv() {
11105 let pool = seeded_pool().await;
11106 let args = fast_simulator_args();
11107 let template = bhtune_core::built_in_templates().remove(0);
11108 let tags = build_loop_tags(&args, &template).unwrap();
11109 let config = build_loop_config(&args).unwrap();
11110 let time_anchor = RunTimeAnchor::now();
11111 let run = TuneRunRow::start(
11112 &pool,
11113 None,
11114 "completed-restore-incomplete",
11115 TuneDriver::Simulator,
11116 config,
11117 TemplateOrigin::Builtin,
11118 &template,
11119 &tags,
11120 time_anchor.utc(),
11121 )
11122 .await
11123 .unwrap();
11124 let simulator = bhtune_driver::SimulatorDriver::new(
11125 SIMULATOR_PV_TAG,
11126 SIMULATOR_MV_TAG,
11127 bhtune_driver::FopdtConfig::new(
11128 args.sim_gain,
11129 args.sim_tau,
11130 args.sim_dead_time,
11131 args.poll_interval_ms as f32 / 1000.0,
11132 ),
11133 args.sim_initial_pv,
11134 args.sim_initial_mv,
11135 args.sim_seed,
11136 );
11137 let driver = RestoreFailingSimulator {
11138 inner: simulator,
11139 writes: std::sync::Mutex::new(0),
11140 successful_writes: 7,
11141 };
11142
11143 let outcome = execute(
11144 &pool,
11145 run.id,
11146 &args,
11147 &template,
11148 &tags,
11149 &driver,
11150 config,
11151 time_anchor,
11152 None,
11153 false,
11154 &mut CtrlC::never(),
11155 &mut std::io::empty(),
11156 )
11157 .await
11158 .unwrap();
11159
11160 assert!(matches!(outcome, RunOutcome::RestoreIncomplete { .. }));
11161 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
11162 assert_eq!(
11163 stored.restore_status,
11164 Some(bhtune_db::models::RestoreStatus::Incomplete)
11165 );
11166 assert!(matches!(
11167 driver
11168 .browse(bhtune_driver::BrowsePageRequest::root(20))
11169 .await,
11170 Err(bhtune_driver::DriverError::Unsupported {
11171 operation: "browse"
11172 })
11173 ));
11174 }
11175
11176 #[tokio::test]
11189 async fn execute_reports_restore_incomplete_after_a_poor_quality_abort() {
11190 let pool = seeded_pool().await;
11191 let template = honeywell_template();
11192 let tags = honeywell_tags();
11193 let driver = honeywell_driver_auto()
11194 .erroring_write("Unit1.LIC101.OP")
11195 .degrade_quality_after(&tags.process_variable, 1, bhtune_driver::Quality::Bad);
11196 let config = build_loop_config(&fast_simulator_args()).unwrap();
11197 let time_anchor = RunTimeAnchor::now();
11198 let run = TuneRunRow::start(
11199 &pool,
11200 None,
11201 "poor-quality-abort-restore-incomplete",
11202 TuneDriver::Opcda,
11203 config,
11204 TemplateOrigin::Builtin,
11205 &template,
11206 &tags,
11207 time_anchor.utc(),
11208 )
11209 .await
11210 .unwrap();
11211
11212 let outcome = execute(
11213 &pool,
11214 run.id,
11215 &fast_simulator_args(),
11216 &template,
11217 &tags,
11218 &driver,
11219 config,
11220 time_anchor,
11221 None,
11222 true,
11223 &mut CtrlC::never(),
11224 &mut std::io::empty(),
11225 )
11226 .await
11227 .unwrap();
11228
11229 assert!(matches!(&outcome, RunOutcome::RestoreIncomplete { .. }));
11230 let outcome_text = format!("{outcome:?}");
11231 assert!(outcome_text.contains("run aborted"));
11232 assert!(outcome_text.contains("PoorQuality"));
11233 assert!(outcome_text.contains("MV"));
11234
11235 let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
11236 assert_eq!(
11237 stored.restore_status,
11238 Some(bhtune_db::models::RestoreStatus::Incomplete)
11239 );
11240 }
11241
11242 #[tokio::test]
11243 async fn read_f32_errors_on_a_non_numeric_value() {
11244 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "not-a-number")]);
11245 let err = read_f32(&driver, "Unit1.LIC101.PV", false)
11246 .await
11247 .unwrap_err();
11248 assert!(err.to_string().contains("not a number"));
11249 }
11250
11251 #[test]
11252 fn parse_f32_value_accepts_trimmed_finite_numbers() {
11253 assert_eq!(parse_f32_value("Unit1.LIC101.PV", " 42.5 ").unwrap(), 42.5);
11254 }
11255
11256 #[tokio::test]
11261 async fn read_f32_rejects_nan() {
11262 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "nan")]);
11263 let err = read_f32(&driver, "Unit1.LIC101.PV", false)
11264 .await
11265 .unwrap_err();
11266 assert!(err.to_string().contains("finite"));
11267 }
11268
11269 #[tokio::test]
11270 async fn read_f32_rejects_infinity() {
11271 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "inf")]);
11272 let err = read_f32(&driver, "Unit1.LIC101.PV", false)
11273 .await
11274 .unwrap_err();
11275 assert!(err.to_string().contains("finite"));
11276 }
11277
11278 #[tokio::test]
11279 async fn resolve_f32_accepts_a_finite_tag_or_value() {
11280 let driver = MockDriver::new(&[]);
11281 let value = resolve_f32(&driver, &TagOrValue::Value(42.0), false)
11282 .await
11283 .unwrap();
11284 assert_eq!(value, 42.0);
11285 }
11286
11287 #[tokio::test]
11288 async fn resolve_f32_reads_a_tag_backed_value() {
11289 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "42.5")]);
11290 let value = resolve_f32(
11291 &driver,
11292 &TagOrValue::Tag("Unit1.LIC101.PV".to_string()),
11293 false,
11294 )
11295 .await
11296 .unwrap();
11297 assert_eq!(value, 42.5);
11298 }
11299
11300 #[tokio::test]
11301 async fn resolve_f32_from_batch_reads_a_tag_from_the_batch() {
11302 let values = HashMap::from([(
11303 "Unit1.LIC101.PV".to_string(),
11304 TagValue {
11305 tag: "Unit1.LIC101.PV".to_string(),
11306 value: "42.5".to_string(),
11307 quality: bhtune_driver::Quality::Good,
11308 timestamp: None,
11309 },
11310 )]);
11311 let value = resolve_f32_from_batch(
11312 &MockDriver::default(),
11313 &values,
11314 &TagOrValue::Tag("Unit1.LIC101.PV".to_string()),
11315 false,
11316 )
11317 .await
11318 .unwrap();
11319 assert_eq!(value, 42.5);
11320 }
11321
11322 #[tokio::test]
11323 async fn resolve_direction_from_batch_reads_and_maps_a_tag() {
11324 let template = honeywell_template();
11325 let tags = TagOrValue::Tag("Unit1.LIC101.CTLACTN".to_string());
11326 let direction_tag = "Unit1.LIC101.CTLACTN".to_string();
11327 let values = HashMap::from([(
11328 direction_tag.clone(),
11329 TagValue {
11330 tag: direction_tag,
11331 value: "0".to_string(),
11332 quality: bhtune_driver::Quality::Good,
11333 timestamp: None,
11334 },
11335 )]);
11336 let direction =
11337 resolve_direction_from_batch(&MockDriver::default(), &values, &tags, &template, false)
11338 .await
11339 .unwrap();
11340 assert_eq!(direction, ControllerDirection::Direct);
11341 }
11342
11343 #[tokio::test]
11344 async fn resolve_direction_reads_and_maps_a_tag_directly() {
11345 let template = honeywell_template();
11346 let driver = MockDriver::new(&[("Unit1.LIC101.CTLACTN", "0")]);
11347 let direction = resolve_direction(
11348 &driver,
11349 &TagOrValue::Tag("Unit1.LIC101.CTLACTN".to_string()),
11350 &template,
11351 false,
11352 )
11353 .await
11354 .unwrap();
11355 assert_eq!(direction, ControllerDirection::Direct);
11356 }
11357
11358 #[tokio::test]
11362 async fn resolve_f32_rejects_a_non_finite_direct_value() {
11363 let driver = MockDriver::new(&[]);
11364 let err = resolve_f32(&driver, &TagOrValue::Value(f32::NAN), false)
11365 .await
11366 .unwrap_err();
11367 assert!(err.to_string().contains("finite"));
11368 }
11369
11370 #[tokio::test]
11371 async fn read_pv_sample_rejects_non_finite_values() {
11372 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "nan")]);
11373 let err = read_pv_sample(&driver, "Unit1.LIC101.PV")
11374 .await
11375 .unwrap_err();
11376 assert!(err.to_string().contains("finite"));
11377 }
11378
11379 #[test]
11380 fn read_batch_f32_returns_a_good_numeric_value() {
11381 let values = HashMap::from([(
11382 "Unit1.LIC101.PV".to_string(),
11383 TagValue {
11384 tag: "Unit1.LIC101.PV".to_string(),
11385 value: "42.5".to_string(),
11386 quality: bhtune_driver::Quality::Good,
11387 timestamp: None,
11388 },
11389 )]);
11390
11391 assert_eq!(
11392 read_batch_f32(&values, "Unit1.LIC101.PV", false).unwrap(),
11393 42.5
11394 );
11395 }
11396
11397 #[tokio::test]
11398 async fn read_poll_batch_maps_reordered_responses_by_tag() {
11399 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "42.5"), ("Unit1.LIC101.OP", "60.0")])
11400 .reversing_read_results();
11401
11402 let values = read_poll_batch(&driver, "Unit1.LIC101.PV", Some("Unit1.LIC101.OP"))
11403 .await
11404 .unwrap();
11405
11406 assert_eq!(
11407 read_numeric_from_batch(&values, "Unit1.LIC101.PV")
11408 .unwrap()
11409 .0,
11410 42.5
11411 );
11412 assert_eq!(
11413 read_numeric_from_batch(&values, "Unit1.LIC101.OP")
11414 .unwrap()
11415 .0,
11416 60.0
11417 );
11418 }
11419
11420 #[test]
11421 fn sample_quality_mapping_covers_all_driver_qualities() {
11422 assert_eq!(
11423 sample_quality_from_driver(bhtune_driver::Quality::Good),
11424 SampleQuality::Good
11425 );
11426 assert_eq!(
11427 sample_quality_from_driver(bhtune_driver::Quality::Uncertain),
11428 SampleQuality::Uncertain
11429 );
11430 assert_eq!(
11431 sample_quality_from_driver(bhtune_driver::Quality::Bad),
11432 SampleQuality::Bad
11433 );
11434 }
11435
11436 #[test]
11437 fn completed_oscillation_period_is_reported_for_a_successful_poll_result() {
11438 let completion = PollOutcome::Completed(Action::Complete {
11439 peaks: vec![52.0, 48.0, 52.0],
11440 troughs: vec![46.0, 50.0],
11441 switch_times: vec![
11442 Utc::now(),
11443 Utc::now() + chrono::Duration::seconds(30),
11444 Utc::now() + chrono::Duration::seconds(60),
11445 Utc::now() + chrono::Duration::seconds(90),
11446 Utc::now() + chrono::Duration::seconds(120),
11447 ],
11448 mv_sign_init: 1,
11449 });
11450 let result = completed_oscillation_period_ms(
11451 &Ok(completion),
11452 ControllerDirection::Reverse,
11453 build_loop_config(&fast_simulator_args()).unwrap(),
11454 PvRange {
11455 high: 100.0,
11456 low: 0.0,
11457 },
11458 );
11459 assert!(result.is_some());
11460 }
11461
11462 #[tokio::test]
11463 async fn read_raw_and_write_raw_propagate_a_hard_driver_error() {
11464 let driver = MockDriver::new(&[("Unit1.LIC101.PV", "50.0")])
11468 .erroring_read("Unit1.LIC101.PV")
11469 .erroring_write("Unit1.LIC101.OP");
11470
11471 let read_err = read_raw(&driver, "Unit1.LIC101.PV", false)
11472 .await
11473 .unwrap_err();
11474 assert!(read_err.to_string().contains("driver operation failed"));
11475
11476 let write_err = write_value(&driver, "Unit1.LIC101.OP", 45.0)
11477 .await
11478 .unwrap_err();
11479 assert!(write_err.to_string().contains("driver operation failed"));
11480 }
11481
11482 #[tokio::test(start_paused = true)]
11483 async fn transition_to_manual_writes_program_value_and_mode_when_starting_in_auto() {
11484 let template = honeywell_template();
11485 let tags = honeywell_tags();
11486 let driver = honeywell_driver_auto();
11487 let initial = read_initial_values(&driver, &tags, &template, false)
11488 .await
11489 .unwrap();
11490 assert_eq!(initial.setpoint_ini, Some(55.0));
11494
11495 let mut guard = MutationGuard::default();
11496 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11497 .await
11498 .unwrap();
11499
11500 assert_eq!(
11501 driver.value_of("Unit1.LIC101.MODEATTR").as_deref(),
11502 Some("2")
11503 );
11504 assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("0"));
11505 assert!(guard.mode_attribute_written);
11506 assert!(guard.mode_written);
11507 let log = driver.write_log();
11510 let attr_index = log
11511 .iter()
11512 .position(|(t, _)| t == "Unit1.LIC101.MODEATTR")
11513 .unwrap();
11514 let mode_index = log
11515 .iter()
11516 .position(|(t, _)| t == "Unit1.LIC101.MODE")
11517 .unwrap();
11518 assert!(attr_index < mode_index);
11519 }
11520
11521 #[tokio::test(start_paused = true)]
11522 async fn read_initial_values_skips_setpoint_capture_when_original_mode_is_not_auto() {
11523 let template = honeywell_template();
11524 let tags = honeywell_tags();
11525 let driver = MockDriver::new(&[
11527 ("Unit1.LIC101.PV", "50.0"),
11528 ("Unit1.LIC101.OP", "45.0"),
11529 ("Unit1.LIC101.MODE", "2"),
11530 ("Unit1.LIC101.MODEATTR", "2"),
11531 ("Unit1.LIC101.CTLACTN", "0"),
11532 ("Unit1.LIC101.PVEUHI", "100.0"),
11533 ("Unit1.LIC101.PVEULO", "0.0"),
11534 ("Unit1.LIC101.CVEUHI", "100.0"),
11535 ("Unit1.LIC101.CVEULO", "0.0"),
11536 ("Unit1.LIC101.SP", "55.0"),
11537 ]);
11538 let initial = read_initial_values(&driver, &tags, &template, false)
11539 .await
11540 .unwrap();
11541
11542 assert_eq!(initial.setpoint_ini, None);
11543
11544 let mut guard = MutationGuard::default();
11545 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11546 .await
11547 .unwrap();
11548
11549 assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("0"));
11550 }
11551
11552 #[tokio::test(start_paused = true)]
11553 async fn transition_to_manual_does_not_rewrite_mode_when_already_manual() {
11554 let template = honeywell_template();
11555 let tags = honeywell_tags();
11556 let driver = MockDriver::new(&[
11557 ("Unit1.LIC101.PV", "50.0"),
11558 ("Unit1.LIC101.OP", "45.0"),
11559 ("Unit1.LIC101.MODE", "0"),
11560 ("Unit1.LIC101.MODEATTR", "2"),
11561 ("Unit1.LIC101.CTLACTN", "0"),
11562 ("Unit1.LIC101.PVEUHI", "100.0"),
11563 ("Unit1.LIC101.PVEULO", "0.0"),
11564 ("Unit1.LIC101.CVEUHI", "100.0"),
11565 ("Unit1.LIC101.CVEULO", "0.0"),
11566 ("Unit1.LIC101.SP", "55.0"),
11567 ]);
11568 let initial = read_initial_values(&driver, &tags, &template, false)
11569 .await
11570 .unwrap();
11571 assert_eq!(initial.setpoint_ini, None);
11572
11573 let mut guard = MutationGuard::default();
11574 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11575 .await
11576 .unwrap();
11577
11578 let log = driver.write_log();
11582 assert_eq!(
11583 log,
11584 vec![("Unit1.LIC101.MODEATTR".to_string(), "2".to_string())]
11585 );
11586 assert!(guard.mode_attribute_written);
11587 assert!(!guard.mode_written);
11588 }
11589
11590 #[tokio::test(start_paused = true)]
11591 async fn restore_reverts_mode_setpoint_and_mode_attribute() {
11592 let template = honeywell_template();
11593 let tags = honeywell_tags();
11594 let driver = honeywell_driver_auto();
11595 let initial = read_initial_values(&driver, &tags, &template, false)
11596 .await
11597 .unwrap();
11598 let mut guard = MutationGuard::default();
11599 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11600 .await
11601 .unwrap();
11602
11603 let report = restore(&driver, &tags, &template, &initial, &guard).await;
11604 assert!(report.all_succeeded());
11605
11606 assert_eq!(driver.value_of("Unit1.LIC101.OP").as_deref(), Some("45")); assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("1")); assert_eq!(driver.value_of("Unit1.LIC101.SP").as_deref(), Some("55")); assert_eq!(
11610 driver.value_of("Unit1.LIC101.MODEATTR").as_deref(),
11611 Some("1")
11612 ); }
11614
11615 #[tokio::test(start_paused = true)]
11616 async fn restore_skips_mode_revert_when_template_disables_it() {
11617 let mut template = honeywell_template();
11618 template.revert_mode = false;
11619 let tags = honeywell_tags();
11620 let driver = honeywell_driver_auto();
11621 let initial = read_initial_values(&driver, &tags, &template, false)
11622 .await
11623 .unwrap();
11624 let mut guard = MutationGuard::default();
11625 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11626 .await
11627 .unwrap();
11628 let writes_before_restore = driver.write_log().len();
11629
11630 let report = restore(&driver, &tags, &template, &initial, &guard).await;
11631 assert!(report.all_succeeded());
11632
11633 assert_eq!(driver.value_of("Unit1.LIC101.OP").as_deref(), Some("45"));
11635 assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("0")); let new_writes = &driver.write_log()[writes_before_restore..];
11637 assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.MODE"));
11638 assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.SP"));
11639 }
11640
11641 #[tokio::test(start_paused = true)]
11642 async fn restore_skips_setpoint_revert_when_original_mode_was_not_auto() {
11643 let template = honeywell_template();
11644 let tags = honeywell_tags();
11645 let driver = MockDriver::new(&[
11646 ("Unit1.LIC101.PV", "50.0"),
11647 ("Unit1.LIC101.OP", "45.0"),
11648 ("Unit1.LIC101.MODE", "2"),
11649 ("Unit1.LIC101.MODEATTR", "2"),
11650 ("Unit1.LIC101.CTLACTN", "0"),
11651 ("Unit1.LIC101.PVEUHI", "100.0"),
11652 ("Unit1.LIC101.PVEULO", "0.0"),
11653 ("Unit1.LIC101.CVEUHI", "100.0"),
11654 ("Unit1.LIC101.CVEULO", "0.0"),
11655 ("Unit1.LIC101.SP", "55.0"),
11656 ]);
11657 let initial = read_initial_values(&driver, &tags, &template, false)
11658 .await
11659 .unwrap();
11660 let mut guard = MutationGuard::default();
11661 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11662 .await
11663 .unwrap();
11664 let writes_before_restore = driver.write_log().len();
11665
11666 let report = restore(&driver, &tags, &template, &initial, &guard).await;
11667 assert!(report.all_succeeded());
11668
11669 assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("2")); let new_writes = &driver.write_log()[writes_before_restore..];
11671 assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.SP"));
11672 }
11673
11674 #[tokio::test(start_paused = true)]
11675 async fn restore_skips_mode_attribute_revert_when_already_at_program_value() {
11676 let template = honeywell_template();
11677 let tags = honeywell_tags();
11678 let driver = MockDriver::new(&[
11679 ("Unit1.LIC101.PV", "50.0"),
11680 ("Unit1.LIC101.OP", "45.0"),
11681 ("Unit1.LIC101.MODE", "1"),
11682 ("Unit1.LIC101.MODEATTR", "2"), ("Unit1.LIC101.CTLACTN", "0"),
11684 ("Unit1.LIC101.PVEUHI", "100.0"),
11685 ("Unit1.LIC101.PVEULO", "0.0"),
11686 ("Unit1.LIC101.CVEUHI", "100.0"),
11687 ("Unit1.LIC101.CVEULO", "0.0"),
11688 ("Unit1.LIC101.SP", "55.0"),
11689 ]);
11690 let initial = read_initial_values(&driver, &tags, &template, false)
11691 .await
11692 .unwrap();
11693 let mut guard = MutationGuard::default();
11694 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11695 .await
11696 .unwrap();
11697 let writes_before_restore = driver.write_log().len();
11698
11699 let report = restore(&driver, &tags, &template, &initial, &guard).await;
11700 assert!(report.all_succeeded());
11701
11702 let new_writes = &driver.write_log()[writes_before_restore..];
11703 assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.MODEATTR"));
11704 }
11705
11706 #[tokio::test(start_paused = true)]
11714 async fn restore_reports_each_step_failed_independently_without_short_circuiting() {
11715 let template = honeywell_template();
11716 let tags = honeywell_tags();
11717 let driver = honeywell_driver_auto()
11718 .erroring_write("Unit1.LIC101.OP")
11719 .erroring_write("Unit1.LIC101.MODE")
11720 .erroring_write("Unit1.LIC101.SP")
11721 .erroring_write("Unit1.LIC101.MODEATTR");
11722 let initial = read_initial_values(&driver, &tags, &template, false)
11723 .await
11724 .unwrap();
11725 let guard = MutationGuard {
11726 mode_attribute_written: true,
11727 mode_written: true,
11728 mv_written: true,
11729 };
11730
11731 let report = restore(&driver, &tags, &template, &initial, &guard).await;
11732
11733 assert!(!report.all_succeeded());
11734 assert!(matches!(report.mv, RestoreStepOutcome::Failed(_)));
11735 assert!(matches!(report.mode, RestoreStepOutcome::Failed(_)));
11736 assert!(matches!(report.setpoint, RestoreStepOutcome::Failed(_)));
11737 assert!(matches!(
11738 report.mode_attribute,
11739 RestoreStepOutcome::Failed(_)
11740 ));
11741
11742 let summary = report.failure_summary().unwrap();
11746 assert!(summary.contains("MV:"));
11747 assert!(summary.contains("mode:"));
11748 assert!(summary.contains("setpoint:"));
11749 assert!(summary.contains("mode attribute:"));
11750 }
11751
11752 #[test]
11753 fn restore_report_failure_summary_is_none_when_nothing_failed() {
11754 let report = RestoreReport::default();
11757 assert!(report.all_succeeded());
11758 assert!(report.failure_summary().is_none());
11759 }
11760
11761 #[tokio::test(start_paused = true)]
11762 async fn write_raw_and_write_value_error_when_the_driver_rejects_the_write() {
11763 let driver = MockDriver::new(&[("Unit1.LIC101.MODE", "1")])
11764 .rejecting_write("Unit1.LIC101.MODE")
11765 .rejecting_write("Unit1.LIC101.OP");
11766
11767 let raw_err = write_raw(&driver, "Unit1.LIC101.MODE", "0".to_string())
11768 .await
11769 .unwrap_err();
11770 assert!(raw_err.to_string().contains("rejected"));
11771
11772 let value_err = write_value(&driver, "Unit1.LIC101.OP", 45.0)
11773 .await
11774 .unwrap_err();
11775 assert!(value_err.to_string().contains("rejected"));
11776 }
11777
11778 #[tokio::test]
11779 async fn persist_results_bails_on_a_non_complete_action() {
11780 let pool = seeded_pool().await;
11781 let template = honeywell_template();
11782 let err = persist_results(
11783 &pool,
11784 1,
11785 Action::WriteMv(0.0),
11786 ControllerDirection::Direct,
11787 build_loop_config(&fast_simulator_args()).unwrap(),
11788 PvRange {
11789 high: 100.0,
11790 low: 0.0,
11791 },
11792 &template,
11793 )
11794 .await
11795 .unwrap_err();
11796 assert!(err.to_string().contains("internal error"));
11797 }
11798
11799 async fn run_with_recorded_results() -> (SqlitePool, i64) {
11803 let pool = seeded_pool().await;
11804 let config = build_loop_config(&fast_simulator_args()).unwrap();
11805 let run = TuneRunRow::start(
11806 &pool,
11807 None,
11808 "write-back-test",
11809 TuneDriver::Opcda,
11810 config,
11811 TemplateOrigin::Builtin,
11812 &honeywell_template(),
11813 &honeywell_tags(),
11814 Utc::now(),
11815 )
11816 .await
11817 .unwrap();
11818 for (level, kp, ti, td, p, i, d) in [
11819 (ResponseLevel::Aggressive, 1.0, 0.5, 0.1, 10.0, 2.0, 0.5),
11820 (ResponseLevel::Moderate, 1.5, 0.7, 0.15, 12.0, 2.5, 0.6),
11821 (ResponseLevel::Sluggish, 2.0, 0.9, 0.2, 14.0, 3.0, 0.7),
11822 ] {
11823 TuneResultRow::insert(
11824 &pool,
11825 &TuneResultRow {
11826 id: 0,
11827 run_id: run.id,
11828 response_level: level,
11829 kp: Some(kp),
11830 ti_minutes: Some(ti),
11831 td_minutes: Some(td),
11832 proportional: Some(p),
11833 integral: Some(i),
11834 derivative: Some(d),
11835 status: TuningResultStatus::Valid,
11836 invalid_reason: None,
11837 },
11838 )
11839 .await
11840 .unwrap();
11841 }
11842 (pool, run.id)
11843 }
11844
11845 #[tokio::test]
11846 async fn maybe_write_back_skips_when_no_pid_constant_tags_are_configured() {
11847 let (pool, run_id) = run_with_recorded_results().await;
11848 let template = honeywell_template();
11849 let mut tags = honeywell_tags();
11850 tags.proportional_constant = None;
11851 let driver = honeywell_driver_auto();
11852
11853 let (outcome, write_back_detail) = maybe_write_back(
11854 &pool,
11855 run_id,
11856 &tags,
11857 &template,
11858 &driver,
11859 build_loop_config(&fast_simulator_args()).unwrap(),
11860 None,
11861 OutputFormat::Table,
11862 false,
11863 &mut std::io::Cursor::new(b"1\n".as_slice()),
11864 )
11865 .await
11866 .unwrap();
11867
11868 assert_eq!(outcome, WriteBackOutcome::Skipped);
11869 assert_eq!(
11870 write_back_detail.as_deref(),
11871 Some("no PID constant tags configured for this run's driver/template")
11872 );
11873 assert!(
11874 TuneWriteRow::list_for_run(&pool, run_id)
11875 .await
11876 .unwrap()
11877 .is_empty()
11878 );
11879 }
11880
11881 #[tokio::test]
11882 async fn maybe_write_back_skips_when_no_results_were_recorded() {
11883 let pool = seeded_pool().await;
11884 let config = build_loop_config(&fast_simulator_args()).unwrap();
11885 let template = honeywell_template();
11886 let tags = honeywell_tags();
11887 let run = TuneRunRow::start(
11888 &pool,
11889 None,
11890 "no-results",
11891 TuneDriver::Opcda,
11892 config,
11893 TemplateOrigin::Builtin,
11894 &template,
11895 &tags,
11896 Utc::now(),
11897 )
11898 .await
11899 .unwrap();
11900 let driver = honeywell_driver_auto();
11901
11902 let (outcome, write_back_detail) = maybe_write_back(
11903 &pool,
11904 run.id,
11905 &tags,
11906 &template,
11907 &driver,
11908 config,
11909 None,
11910 OutputFormat::Table,
11911 false,
11912 &mut std::io::Cursor::new(b"1\n".as_slice()),
11913 )
11914 .await
11915 .unwrap();
11916
11917 assert_eq!(outcome, WriteBackOutcome::Skipped);
11918 assert_eq!(
11919 write_back_detail.as_deref(),
11920 Some("no calculated results were recorded for this run")
11921 );
11922 assert!(
11923 TuneWriteRow::list_for_run(&pool, run.id)
11924 .await
11925 .unwrap()
11926 .is_empty()
11927 );
11928 }
11929
11930 async fn write_back_with_input(
11934 input: &[u8],
11935 ) -> (WriteBackOutcome, Vec<bhtune_db::models::TuneWriteRow>) {
11936 let (pool, run_id) = run_with_recorded_results().await;
11937 let template = honeywell_template();
11938 let tags = honeywell_tags();
11939 let driver = honeywell_driver_auto();
11940
11941 let (outcome, _write_back_detail) = maybe_write_back(
11942 &pool,
11943 run_id,
11944 &tags,
11945 &template,
11946 &driver,
11947 build_loop_config(&fast_simulator_args()).unwrap(),
11948 None,
11949 OutputFormat::Table,
11950 false,
11951 &mut std::io::Cursor::new(input),
11952 )
11953 .await
11954 .unwrap();
11955
11956 (
11957 outcome,
11958 TuneWriteRow::list_for_run(&pool, run_id).await.unwrap(),
11959 )
11960 }
11961
11962 #[tokio::test]
11963 async fn maybe_write_back_skips_on_eof() {
11964 let (outcome, writes) = write_back_with_input(b"").await;
11965 assert_eq!(outcome, WriteBackOutcome::Skipped);
11966 assert!(writes.is_empty());
11967 }
11968
11969 #[tokio::test]
11970 async fn maybe_write_back_skips_on_blank_input() {
11971 let (outcome, writes) = write_back_with_input(b"\n").await;
11972 assert_eq!(outcome, WriteBackOutcome::Skipped);
11973 assert!(writes.is_empty());
11974 }
11975
11976 #[tokio::test]
11977 async fn maybe_write_back_skips_on_n() {
11978 let (outcome, writes) = write_back_with_input(b"N\n").await;
11979 assert_eq!(outcome, WriteBackOutcome::Skipped);
11980 assert!(writes.is_empty());
11981 }
11982
11983 #[tokio::test]
11984 async fn maybe_write_back_skips_on_out_of_range_selection() {
11985 let (outcome, writes) = write_back_with_input(b"99\n").await;
11986 assert_eq!(outcome, WriteBackOutcome::Skipped);
11987 assert!(writes.is_empty());
11988 }
11989
11990 #[tokio::test]
11991 async fn maybe_write_back_skips_on_non_numeric_selection() {
11992 let (outcome, writes) = write_back_with_input(b"banana\n").await;
11993 assert_eq!(outcome, WriteBackOutcome::Skipped);
11994 assert!(writes.is_empty());
11995 }
11996
11997 #[tokio::test]
11998 async fn maybe_write_back_reports_blank_and_n_as_no_selection() {
11999 for input in [b"\n".as_slice(), b"N\n".as_slice()] {
12000 let (pool, run_id) = run_with_recorded_results().await;
12001 let template = honeywell_template();
12002 let tags = honeywell_tags();
12003 let driver = honeywell_driver_auto();
12004
12005 let (outcome, detail) = maybe_write_back(
12006 &pool,
12007 run_id,
12008 &tags,
12009 &template,
12010 &driver,
12011 build_loop_config(&fast_simulator_args()).unwrap(),
12012 None,
12013 OutputFormat::Table,
12014 false,
12015 &mut std::io::Cursor::new(input),
12016 )
12017 .await
12018 .unwrap();
12019
12020 assert_eq!(outcome, WriteBackOutcome::Skipped);
12021 assert_eq!(
12022 detail.as_deref(),
12023 Some("skipped interactively (no selection made)")
12024 );
12025 assert!(
12026 TuneWriteRow::list_for_run(&pool, run_id)
12027 .await
12028 .unwrap()
12029 .is_empty()
12030 );
12031 }
12032 }
12033
12034 #[tokio::test]
12035 async fn maybe_write_back_writes_and_confirms_a_valid_selection() {
12036 let (outcome, writes) = write_back_with_input(b"2\n").await; assert_eq!(
12038 outcome,
12039 WriteBackOutcome::Written {
12040 response_level: ResponseLevel::Moderate
12041 }
12042 );
12043 assert_eq!(writes.len(), 1);
12044 let write = &writes[0];
12045 assert!(write.success);
12046 assert_eq!(write.response_level, ResponseLevel::Moderate);
12047 assert!(write.error_message.is_none());
12048 assert!(write.previous.is_some());
12051 assert!(write.proportional_written.is_some());
12052 assert!(write.integral_written.is_some());
12053 assert!(write.derivative_written.is_some());
12054 assert!(write.proportional_readback.is_some());
12055 assert!(write.integral_readback.is_some());
12056 assert!(write.derivative_readback.is_some());
12057 assert_eq!(write.rollback_state, None);
12058 assert!(write.rollback_error.is_none());
12059 }
12060
12061 #[tokio::test]
12062 async fn maybe_write_back_records_failure_when_the_pre_read_fails() {
12063 let (pool, run_id) = run_with_recorded_results().await;
12064 let template = honeywell_template();
12065 let tags = honeywell_tags();
12066 let driver = honeywell_driver_auto().erroring_read("Unit1.LIC101.K");
12069
12070 let (outcome, write_back_detail) = maybe_write_back(
12071 &pool,
12072 run_id,
12073 &tags,
12074 &template,
12075 &driver,
12076 build_loop_config(&fast_simulator_args()).unwrap(),
12077 None,
12078 OutputFormat::Table,
12079 false,
12080 &mut std::io::Cursor::new(b"1\n".as_slice()),
12081 )
12082 .await
12083 .unwrap();
12084
12085 assert_eq!(outcome, WriteBackOutcome::Failed);
12086 assert!(
12087 write_back_detail
12088 .as_deref()
12089 .unwrap_or_default()
12090 .starts_with("pre-read failed:")
12091 );
12092 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12093 assert_eq!(writes.len(), 1);
12094 let write = &writes[0];
12095 assert!(!write.success);
12096 assert!(write.previous.is_none());
12099 assert!(write.proportional_written.is_none());
12100 assert!(write.integral_written.is_none());
12101 assert!(write.derivative_written.is_none());
12102 assert!(
12103 write
12104 .error_message
12105 .as_deref()
12106 .unwrap_or_default()
12107 .starts_with("pre-read of Proportional")
12108 );
12109 assert_eq!(write.rollback_state, None);
12110 assert!(driver.write_log().is_empty());
12113 }
12114
12115 #[tokio::test]
12116 async fn maybe_write_back_rolls_back_a_confirmed_write_when_a_later_constant_fails() {
12117 let (pool, run_id) = run_with_recorded_results().await;
12118 let template = honeywell_template();
12119 let tags = honeywell_tags();
12120 let driver = honeywell_driver_auto().rejecting_write("Unit1.LIC101.T1");
12123
12124 let (outcome, write_back_detail) = maybe_write_back(
12125 &pool,
12126 run_id,
12127 &tags,
12128 &template,
12129 &driver,
12130 build_loop_config(&fast_simulator_args()).unwrap(),
12131 None,
12132 OutputFormat::Table,
12133 false,
12134 &mut std::io::Cursor::new(b"1\n".as_slice()),
12135 )
12136 .await
12137 .unwrap();
12138
12139 assert_eq!(outcome, WriteBackOutcome::Failed);
12140 assert!(
12141 write_back_detail
12142 .as_deref()
12143 .unwrap_or_default()
12144 .ends_with("(rolled back)")
12145 );
12146 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12147 assert_eq!(writes.len(), 1);
12148 let write = &writes[0];
12149 assert!(!write.success);
12150 assert!(write.previous.is_some());
12151 assert!(write.proportional_written.is_some());
12154 assert!(write.proportional_readback.is_some());
12155 assert!(write.integral_written.is_some());
12156 assert!(write.integral_readback.is_none());
12157 assert!(write.derivative_written.is_none());
12158 assert!(write.derivative_readback.is_none());
12159 assert_eq!(write.rollback_state, Some(RollbackState::Succeeded));
12160 assert!(write.rollback_error.is_none());
12161 let p_previous = write.previous.as_ref().unwrap().proportional;
12164 assert_eq!(
12165 driver
12166 .value_of("Unit1.LIC101.K")
12167 .and_then(|v| v.parse::<f32>().ok()),
12168 Some(p_previous)
12169 );
12170 }
12171
12172 #[tokio::test]
12173 async fn maybe_write_back_records_a_failed_rollback_when_the_rollback_write_is_also_rejected() {
12174 let (pool, run_id) = run_with_recorded_results().await;
12175 let template = honeywell_template();
12176 let tags = honeywell_tags();
12177 let driver = honeywell_driver_auto()
12181 .rejecting_write("Unit1.LIC101.T1")
12182 .rejecting_write_after("Unit1.LIC101.K", 1);
12183
12184 let (outcome, write_back_detail) = maybe_write_back(
12185 &pool,
12186 run_id,
12187 &tags,
12188 &template,
12189 &driver,
12190 build_loop_config(&fast_simulator_args()).unwrap(),
12191 None,
12192 OutputFormat::Table,
12193 false,
12194 &mut std::io::Cursor::new(b"1\n".as_slice()),
12195 )
12196 .await
12197 .unwrap();
12198
12199 assert_eq!(outcome, WriteBackOutcome::Failed);
12200 let detail = write_back_detail.unwrap_or_default();
12201 assert!(detail.contains("rollback also failed"));
12202 assert!(detail.contains("history revert"));
12203 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12204 assert_eq!(writes.len(), 1);
12205 let write = &writes[0];
12206 assert!(!write.success);
12207 assert_eq!(write.rollback_state, Some(RollbackState::Failed));
12208 let rollback_error = write.rollback_error.as_deref().unwrap_or_default();
12209 assert!(rollback_error.contains("Proportional"));
12210 assert!(rollback_error.contains("rollback"));
12211 }
12212
12213 #[tokio::test]
12214 async fn maybe_write_back_records_failure_when_the_readback_is_outside_tolerance() {
12215 let (pool, run_id) = run_with_recorded_results().await;
12216 let template = honeywell_template();
12217 let tags = honeywell_tags();
12218 let driver = honeywell_driver_auto().distorting_write("Unit1.LIC101.K", 5.0);
12222
12223 let (outcome, _write_back_detail) = maybe_write_back(
12224 &pool,
12225 run_id,
12226 &tags,
12227 &template,
12228 &driver,
12229 build_loop_config(&fast_simulator_args()).unwrap(),
12230 None,
12231 OutputFormat::Table,
12232 false,
12233 &mut std::io::Cursor::new(b"1\n".as_slice()),
12234 )
12235 .await
12236 .unwrap();
12237
12238 assert_eq!(outcome, WriteBackOutcome::Failed);
12239 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12240 assert_eq!(writes.len(), 1);
12241 let write = &writes[0];
12242 assert!(!write.success);
12243 let message = write.error_message.as_deref().unwrap_or_default();
12244 assert!(message.contains("outside tolerance"));
12245 assert!(message.contains("Proportional"));
12246 assert_eq!(write.rollback_state, None);
12249 }
12250
12251 #[tokio::test]
12252 async fn maybe_write_back_records_failure_when_a_write_is_rejected() {
12253 let (pool, run_id) = run_with_recorded_results().await;
12254 let template = honeywell_template();
12255 let tags = honeywell_tags();
12256 let driver = honeywell_driver_auto().rejecting_write("Unit1.LIC101.K");
12257
12258 let (outcome, _write_back_detail) = maybe_write_back(
12259 &pool,
12260 run_id,
12261 &tags,
12262 &template,
12263 &driver,
12264 build_loop_config(&fast_simulator_args()).unwrap(),
12265 None,
12266 OutputFormat::Table,
12267 false,
12268 &mut std::io::Cursor::new(b"1\n".as_slice()),
12269 )
12270 .await
12271 .unwrap();
12272
12273 assert_eq!(outcome, WriteBackOutcome::Failed);
12274 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12275 assert_eq!(writes.len(), 1);
12276 assert!(!writes[0].success);
12277 assert!(writes[0].error_message.is_some());
12278 }
12279
12280 #[tokio::test]
12281 async fn maybe_write_back_records_failure_when_the_readback_fails() {
12282 let (pool, run_id) = run_with_recorded_results().await;
12283 let template = honeywell_template();
12284 let tags = honeywell_tags();
12285 let driver = honeywell_driver_auto().erroring_read_after("Unit1.LIC101.K", 1);
12288
12289 let (outcome, _write_back_detail) = maybe_write_back(
12290 &pool,
12291 run_id,
12292 &tags,
12293 &template,
12294 &driver,
12295 build_loop_config(&fast_simulator_args()).unwrap(),
12296 None,
12297 OutputFormat::Table,
12298 false,
12299 &mut std::io::Cursor::new(b"1\n".as_slice()),
12300 )
12301 .await
12302 .unwrap();
12303 assert_eq!(outcome, WriteBackOutcome::Failed);
12304 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12305
12306 assert_eq!(writes.len(), 1);
12307 assert!(!writes[0].success);
12308 assert!(writes[0].previous.is_some());
12311 let message = writes[0].error_message.as_deref().unwrap_or_default();
12312 assert!(message.starts_with("Proportional readback from"));
12313 assert!(!message.starts_with("pre-read of"));
12314 assert_eq!(writes[0].rollback_state, None);
12316 }
12317
12318 #[tokio::test]
12319 async fn maybe_write_back_records_failure_when_the_readback_reports_poor_quality() {
12320 let (pool, run_id) = run_with_recorded_results().await;
12321 let template = honeywell_template();
12322 let tags = honeywell_tags();
12323 let driver = honeywell_driver_auto().degrade_quality_after(
12329 "Unit1.LIC101.K",
12330 1,
12331 bhtune_driver::Quality::Bad,
12332 );
12333
12334 let (outcome, _write_back_detail) = maybe_write_back(
12335 &pool,
12336 run_id,
12337 &tags,
12338 &template,
12339 &driver,
12340 build_loop_config(&fast_simulator_args()).unwrap(),
12341 None,
12342 OutputFormat::Table,
12343 false,
12344 &mut std::io::Cursor::new(b"1\n".as_slice()),
12345 )
12346 .await
12347 .unwrap();
12348 assert_eq!(outcome, WriteBackOutcome::Failed);
12349
12350 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12351 assert_eq!(writes.len(), 1);
12352 assert!(!writes[0].success);
12353 assert!(writes[0].previous.is_some());
12354 let message = writes[0].error_message.as_deref().unwrap_or_default();
12355 assert!(message.contains("quality"));
12356 assert!(message.contains("Unit1.LIC101.K"));
12357 assert!(message.starts_with("Proportional readback from"));
12362 assert!(!message.starts_with("pre-read of"));
12363 assert_eq!(writes[0].rollback_state, None);
12364 }
12365
12366 #[tokio::test]
12367 async fn maybe_write_back_accepts_an_uncertain_readback_when_the_flag_is_set() {
12368 let (pool, run_id) = run_with_recorded_results().await;
12369 let template = honeywell_template();
12370 let tags = honeywell_tags();
12371 let driver = honeywell_driver_auto()
12372 .with_quality("Unit1.LIC101.K", bhtune_driver::Quality::Uncertain);
12373
12374 let (outcome, _write_back_detail) = maybe_write_back(
12375 &pool,
12376 run_id,
12377 &tags,
12378 &template,
12379 &driver,
12380 build_loop_config(&fast_simulator_args()).unwrap(),
12381 None,
12382 OutputFormat::Table,
12383 true, &mut std::io::Cursor::new(b"1\n".as_slice()),
12385 )
12386 .await
12387 .unwrap();
12388
12389 assert!(matches!(outcome, WriteBackOutcome::Written { .. }));
12390 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12391 assert_eq!(writes.len(), 1);
12392 assert!(writes[0].success);
12393 }
12394
12395 #[tokio::test]
12396 async fn maybe_write_back_writes_non_interactively_via_write_pid_without_touching_stdin() {
12397 let (pool, run_id) = run_with_recorded_results().await;
12398 let template = honeywell_template();
12399 let tags = honeywell_tags();
12400 let driver = honeywell_driver_auto();
12401
12402 let (outcome, _write_back_detail) = maybe_write_back(
12405 &pool,
12406 run_id,
12407 &tags,
12408 &template,
12409 &driver,
12410 build_loop_config(&fast_simulator_args()).unwrap(),
12411 Some(ResponseLevel::Aggressive),
12412 OutputFormat::Table,
12413 false,
12414 &mut std::io::Cursor::new(b"".as_slice()),
12415 )
12416 .await
12417 .unwrap();
12418
12419 assert_eq!(
12420 outcome,
12421 WriteBackOutcome::Written {
12422 response_level: ResponseLevel::Aggressive
12423 }
12424 );
12425 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12426 assert_eq!(writes.len(), 1);
12427 assert!(writes[0].success);
12428 assert_eq!(writes[0].response_level, ResponseLevel::Aggressive);
12429 }
12430
12431 #[tokio::test]
12432 async fn maybe_write_back_fails_when_write_pid_names_a_level_with_no_recorded_result() {
12433 let pool = seeded_pool().await;
12434 let config = build_loop_config(&fast_simulator_args()).unwrap();
12435 let template = honeywell_template();
12436 let tags = honeywell_tags();
12437 let run = TuneRunRow::start(
12438 &pool,
12439 None,
12440 "partial-results",
12441 TuneDriver::Opcda,
12442 config,
12443 TemplateOrigin::Builtin,
12444 &template,
12445 &tags,
12446 Utc::now(),
12447 )
12448 .await
12449 .unwrap();
12450 for (level, kp, ti, td, p, i, d) in [
12455 (ResponseLevel::Aggressive, 1.0, 0.5, 0.1, 10.0, 2.0, 0.5),
12456 (ResponseLevel::Moderate, 1.5, 0.7, 0.15, 12.0, 2.5, 0.6),
12457 ] {
12458 TuneResultRow::insert(
12459 &pool,
12460 &TuneResultRow {
12461 id: 0,
12462 run_id: run.id,
12463 response_level: level,
12464 kp: Some(kp),
12465 ti_minutes: Some(ti),
12466 td_minutes: Some(td),
12467 proportional: Some(p),
12468 integral: Some(i),
12469 derivative: Some(d),
12470 status: TuningResultStatus::Valid,
12471 invalid_reason: None,
12472 },
12473 )
12474 .await
12475 .unwrap();
12476 }
12477 let driver = honeywell_driver_auto();
12478
12479 let (outcome, write_back_detail) = maybe_write_back(
12480 &pool,
12481 run.id,
12482 &tags,
12483 &template,
12484 &driver,
12485 config,
12486 Some(ResponseLevel::Sluggish),
12487 OutputFormat::Table,
12488 false,
12489 &mut std::io::Cursor::new(b"".as_slice()),
12490 )
12491 .await
12492 .unwrap();
12493
12494 assert_eq!(outcome, WriteBackOutcome::Failed);
12495 assert_eq!(
12496 write_back_detail.as_deref(),
12497 Some("no calculated result recorded for response level Sluggish")
12498 );
12499 assert!(
12501 TuneWriteRow::list_for_run(&pool, run.id)
12502 .await
12503 .unwrap()
12504 .is_empty()
12505 );
12506 }
12507
12508 #[tokio::test]
12509 async fn maybe_write_back_skips_the_interactive_prompt_without_touching_stdin_when_json_output_is_set_without_write_pid()
12510 {
12511 let (pool, run_id) = run_with_recorded_results().await;
12521 let template = honeywell_template();
12522 let tags = honeywell_tags();
12523 let driver = honeywell_driver_auto();
12524 let mut reader = std::io::Cursor::new(b"1\n".as_slice());
12525
12526 let (outcome, write_back_detail) = maybe_write_back(
12527 &pool,
12528 run_id,
12529 &tags,
12530 &template,
12531 &driver,
12532 build_loop_config(&fast_simulator_args()).unwrap(),
12533 None,
12534 OutputFormat::Json,
12535 false,
12536 &mut reader,
12537 )
12538 .await
12539 .unwrap();
12540
12541 assert_eq!(outcome, WriteBackOutcome::Skipped);
12542 let detail = write_back_detail.unwrap_or_default();
12543 assert!(detail.contains("--output json"));
12544 assert!(detail.contains("--write-pid"));
12545 assert_eq!(reader.position(), 0, "reader must not be read from at all");
12546 assert!(
12547 TuneWriteRow::list_for_run(&pool, run_id)
12548 .await
12549 .unwrap()
12550 .is_empty()
12551 );
12552 }
12553
12554 #[test]
12555 fn output_predicates_match_table_and_json_write_back_modes() {
12556 assert!(prints_table_output(OutputFormat::Table));
12557 assert!(!prints_table_output(OutputFormat::Json));
12558 assert!(skips_interactive_prompt(None, OutputFormat::Json));
12559 assert!(!skips_interactive_prompt(
12560 Some(ResponseLevel::Aggressive),
12561 OutputFormat::Json
12562 ));
12563 assert!(!skips_interactive_prompt(None, OutputFormat::Table));
12564 }
12565
12566 #[test]
12567 fn delayed_live_timing_emits_the_observational_warning() {
12568 warn_on_missed_poll_opportunities(42, &delayed_live_timing_metrics());
12569 }
12570
12571 #[tokio::test]
12572 async fn timing_persistence_failure_does_not_replace_the_run_outcome() {
12573 let pool = seeded_pool().await;
12574 pool.close().await;
12575
12576 record_timing_metrics_best_effort(&pool, 42, delayed_live_timing_metrics()).await;
12577 }
12578
12579 #[tokio::test]
12580 async fn present_timing_metrics_are_persisted_by_the_optional_wrapper() {
12581 let (pool, run_id) = run_with_recorded_results().await;
12582 let metrics = delayed_live_timing_metrics();
12583
12584 record_timing_metrics_if_present(&pool, run_id, Some(metrics)).await;
12585
12586 let stored = TuneRunRow::get(&pool, run_id).await.unwrap().unwrap();
12587 assert_eq!(stored.timing_metrics, Some(metrics));
12588 }
12589
12590 #[test]
12591 fn restore_incomplete_warning_message_names_the_reason_and_mv_restore_target() {
12592 let message = restore_incomplete_warning_message(
12593 &honeywell_tags(),
12594 &sample_initial_state(),
12595 "restore timed out",
12596 );
12597
12598 assert!(message.contains("restore timed out"));
12599 assert!(message.contains("Unit1.LIC101.OP"));
12600 assert!(message.contains("45"));
12601 assert!(message.contains("loop's mode"));
12602 }
12603
12604 #[test]
12605 fn warn_restore_incomplete_returns_the_message_it_emits() {
12606 let tags = honeywell_tags();
12607 let initial = sample_initial_state();
12608 let message = warn_restore_incomplete(&tags, &initial, "restore timed out");
12609
12610 assert_eq!(
12611 message,
12612 restore_incomplete_warning_message(&tags, &initial, "restore timed out")
12613 );
12614 }
12615
12616 #[test]
12617 fn tune_outcome_for_run_maps_every_run_outcome_variant() {
12618 assert_eq!(
12619 tune_outcome_for_run(&RunOutcome::Completed {
12620 write_back: WriteBackOutcome::Skipped,
12621 write_back_detail: None,
12622 }),
12623 TuneOutcome::Completed
12624 );
12625 assert_eq!(
12626 tune_outcome_for_run(&RunOutcome::Completed {
12627 write_back: WriteBackOutcome::Written {
12628 response_level: ResponseLevel::Moderate
12629 },
12630 write_back_detail: None,
12631 }),
12632 TuneOutcome::Completed
12633 );
12634 assert_eq!(
12635 tune_outcome_for_run(&RunOutcome::Completed {
12636 write_back: WriteBackOutcome::Failed,
12637 write_back_detail: None,
12638 }),
12639 TuneOutcome::WriteBackFailed
12640 );
12641 assert_eq!(
12642 tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::UserInterrupt)),
12643 TuneOutcome::Aborted
12644 );
12645 assert_eq!(
12646 tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::Timeout {
12647 timeout_secs: 3600
12648 })),
12649 TuneOutcome::TimedOut
12650 );
12651 assert_eq!(
12652 tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::PoorQuality {
12653 tag: "Unit1.LIC101.PV".to_string(),
12654 quality: bhtune_driver::Quality::Bad,
12655 })),
12656 TuneOutcome::PoorQuality
12657 );
12658 assert_eq!(
12659 tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
12660 tag: "Unit1.LIC101.OP".to_string(),
12661 target: 55.0,
12662 readback: Some(50.0),
12663 tolerance: 0.1,
12664 elapsed_ms: 4_000,
12665 deadline_secs: 4,
12666 })),
12667 TuneOutcome::ActuationFailed
12668 );
12669 }
12670
12671 #[test]
12672 fn print_summary_returns_the_tune_outcome_matching_the_run_outcome_in_every_output_format() {
12673 assert_eq!(
12679 print_summary(
12680 1,
12681 &RunOutcome::Completed {
12682 write_back: WriteBackOutcome::Skipped,
12683 write_back_detail: None,
12684 },
12685 OutputFormat::Table
12686 ),
12687 TuneOutcome::Completed
12688 );
12689 assert_eq!(
12690 print_summary(
12691 1,
12692 &RunOutcome::Completed {
12693 write_back: WriteBackOutcome::Skipped,
12694 write_back_detail: None,
12695 },
12696 OutputFormat::Json
12697 ),
12698 TuneOutcome::Completed
12699 );
12700 assert_eq!(
12701 print_summary(
12702 1,
12703 &RunOutcome::Completed {
12704 write_back: WriteBackOutcome::Written {
12705 response_level: ResponseLevel::Aggressive
12706 },
12707 write_back_detail: None,
12708 },
12709 OutputFormat::Table
12710 ),
12711 TuneOutcome::Completed
12712 );
12713 assert_eq!(
12714 print_summary(
12715 1,
12716 &RunOutcome::Completed {
12717 write_back: WriteBackOutcome::Written {
12718 response_level: ResponseLevel::Aggressive
12719 },
12720 write_back_detail: None,
12721 },
12722 OutputFormat::Json
12723 ),
12724 TuneOutcome::Completed
12725 );
12726 assert_eq!(
12727 print_summary(
12728 1,
12729 &RunOutcome::Completed {
12730 write_back: WriteBackOutcome::Failed,
12731 write_back_detail: None,
12732 },
12733 OutputFormat::Table
12734 ),
12735 TuneOutcome::WriteBackFailed
12736 );
12737 assert_eq!(
12738 print_summary(
12739 1,
12740 &RunOutcome::Completed {
12741 write_back: WriteBackOutcome::Failed,
12742 write_back_detail: None,
12743 },
12744 OutputFormat::Json
12745 ),
12746 TuneOutcome::WriteBackFailed
12747 );
12748 assert_eq!(
12749 print_summary(
12750 1,
12751 &RunOutcome::Aborted(AbortReason::UserInterrupt),
12752 OutputFormat::Table
12753 ),
12754 TuneOutcome::Aborted
12755 );
12756 assert_eq!(
12757 print_summary(
12758 1,
12759 &RunOutcome::Aborted(AbortReason::UserInterrupt),
12760 OutputFormat::Json
12761 ),
12762 TuneOutcome::Aborted
12763 );
12764 assert_eq!(
12765 print_summary(
12766 1,
12767 &RunOutcome::Aborted(AbortReason::Timeout { timeout_secs: 3600 }),
12768 OutputFormat::Table
12769 ),
12770 TuneOutcome::TimedOut
12771 );
12772 assert_eq!(
12773 print_summary(
12774 1,
12775 &RunOutcome::Aborted(AbortReason::Timeout { timeout_secs: 3600 }),
12776 OutputFormat::Json
12777 ),
12778 TuneOutcome::TimedOut
12779 );
12780 assert_eq!(
12781 print_summary(
12782 1,
12783 &RunOutcome::Aborted(AbortReason::OperationTimedOut {
12784 tag: "Unit1.LIC101.PV".to_string(),
12785 op_timeout_secs: 30,
12786 }),
12787 OutputFormat::Table
12788 ),
12789 TuneOutcome::TimedOut
12790 );
12791 assert_eq!(
12792 print_summary(
12793 1,
12794 &RunOutcome::Aborted(AbortReason::OperationTimedOut {
12795 tag: "Unit1.LIC101.PV".to_string(),
12796 op_timeout_secs: 30,
12797 }),
12798 OutputFormat::Json
12799 ),
12800 TuneOutcome::TimedOut
12801 );
12802 assert_eq!(
12803 print_summary(
12804 1,
12805 &RunOutcome::Aborted(AbortReason::PoorQuality {
12806 tag: "Unit1.LIC101.PV".to_string(),
12807 quality: bhtune_driver::Quality::Uncertain,
12808 }),
12809 OutputFormat::Table
12810 ),
12811 TuneOutcome::PoorQuality
12812 );
12813 for output in [OutputFormat::Table, OutputFormat::Json] {
12814 assert_eq!(
12815 print_summary(
12816 1,
12817 &RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
12818 tag: "Unit1.LIC101.OP".to_string(),
12819 target: 55.0,
12820 readback: Some(50.0),
12821 tolerance: 0.1,
12822 elapsed_ms: 4_000,
12823 deadline_secs: 4,
12824 }),
12825 output,
12826 ),
12827 TuneOutcome::ActuationFailed
12828 );
12829 }
12830 assert_eq!(
12831 print_summary(
12832 1,
12833 &RunOutcome::Aborted(AbortReason::PoorQuality {
12834 tag: "Unit1.LIC101.PV".to_string(),
12835 quality: bhtune_driver::Quality::Uncertain,
12836 }),
12837 OutputFormat::Json
12838 ),
12839 TuneOutcome::PoorQuality
12840 );
12841 assert_eq!(
12842 print_summary(
12843 1,
12844 &RunOutcome::RestoreIncomplete {
12845 reason: "run aborted (UserInterrupt); a second Ctrl+C was received while \
12846 restoring the loop"
12847 .to_string(),
12848 },
12849 OutputFormat::Table
12850 ),
12851 TuneOutcome::RestoreIncomplete
12852 );
12853 assert_eq!(
12854 print_summary(
12855 1,
12856 &RunOutcome::RestoreIncomplete {
12857 reason: "run aborted (UserInterrupt); a second Ctrl+C was received while \
12858 restoring the loop"
12859 .to_string(),
12860 },
12861 OutputFormat::Json
12862 ),
12863 TuneOutcome::RestoreIncomplete
12864 );
12865 }
12866
12867 #[tokio::test]
12868 async fn run_rejects_write_pid_without_yes_before_starting_the_tune() {
12869 let pool = seeded_pool().await;
12870 let mut args = fast_simulator_args();
12871 args.write_pid = Some(crate::args::ResponseLevelArg::Aggressive);
12872 args.yes = false;
12873
12874 let err = run(&pool, args, &test_config()).await.unwrap_err();
12875 assert!(err.to_string().contains("--write-pid requires --yes"));
12876
12877 let runs = TuneRunRow::list(
12879 &pool,
12880 &bhtune_db::models::TuneRunFilter::default(),
12881 bhtune_db::models::Pagination::first(10),
12882 )
12883 .await
12884 .unwrap();
12885 assert!(runs.is_empty());
12886 }
12887
12888 #[tokio::test]
12889 async fn a_full_simulator_tune_with_write_pid_and_yes_still_skips_write_back() {
12890 let pool = seeded_pool().await;
12894 let mut args = fast_simulator_args();
12895 args.write_pid = Some(crate::args::ResponseLevelArg::Aggressive);
12896 args.yes = true;
12897
12898 let outcome = run(&pool, args, &test_config()).await.unwrap();
12899 assert_eq!(outcome, TuneOutcome::Completed);
12900 }
12901
12902 #[tokio::test]
12903 async fn run_times_out_and_aborts_when_timeout_secs_elapses_before_completion() {
12904 let pool = seeded_pool().await;
12917 let mut args = fast_simulator_args();
12918 args.poll_interval_ms = 3;
12919 args.timeout_secs = 1;
12920 args.cycles_count = Some(100_000);
12921
12922 let outcome = run(&pool, args, &test_config()).await.unwrap();
12923 assert_eq!(outcome, TuneOutcome::TimedOut);
12924
12925 let runs = TuneRunRow::list(
12926 &pool,
12927 &bhtune_db::models::TuneRunFilter::default(),
12928 bhtune_db::models::Pagination::first(10),
12929 )
12930 .await
12931 .unwrap();
12932 assert_eq!(runs.len(), 1);
12933 assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Aborted);
12936
12937 let samples = TuneSampleRow::list_for_run(&pool, runs[0].id)
12940 .await
12941 .unwrap();
12942 assert!(!samples.is_empty());
12943 }
12944
12945 #[tokio::test]
12949 async fn bounded_driver_call_returns_completed_when_the_call_finishes_first() {
12950 let mut ctrl_c = CtrlC::never();
12951 let result = bounded_driver_call(30, &mut ctrl_c, async { Ok::<_, anyhow::Error>(42) })
12952 .await
12953 .unwrap();
12954 assert!(matches!(result, TickOperation::Completed(42)));
12955 }
12956
12957 #[tokio::test]
12958 async fn bounded_driver_call_propagates_a_genuine_error_from_the_call() {
12959 let mut ctrl_c = CtrlC::never();
12963 let err = bounded_driver_call(30, &mut ctrl_c, async {
12964 Err::<(), _>(anyhow::anyhow!("boom"))
12965 })
12966 .await
12967 .unwrap_err();
12968 assert!(err.to_string().contains("boom"));
12969 }
12970
12971 #[tokio::test]
12972 async fn bounded_driver_call_returns_cancelled_when_ctrl_c_fires_first() {
12973 let (mut ctrl_c, tx) = CtrlC::test_pair();
12974 tx.send(1).unwrap();
12975 let result: TickOperation<()> = bounded_driver_call(
12976 30,
12977 &mut ctrl_c,
12978 std::future::pending::<anyhow::Result<()>>(),
12979 )
12980 .await
12981 .unwrap();
12982 assert!(matches!(result, TickOperation::Cancelled));
12983 }
12984
12985 #[tokio::test(start_paused = true)]
12986 async fn bounded_driver_call_returns_timed_out_when_the_driver_call_stalls() {
12987 let mut ctrl_c = CtrlC::never();
12990 let result: TickOperation<()> =
12991 bounded_driver_call(1, &mut ctrl_c, std::future::pending::<anyhow::Result<()>>())
12992 .await
12993 .unwrap();
12994 assert!(matches!(result, TickOperation::TimedOut));
12995 }
12996
12997 #[tokio::test]
12998 async fn a_stalled_pv_read_during_a_tick_is_cancelled_without_recording_a_sample() {
12999 let pool = seeded_pool().await;
13000 let template = honeywell_template();
13001 let tags = honeywell_tags();
13002 let driver = honeywell_driver_auto().hanging_read(&tags.process_variable);
13003 let mut args = fast_simulator_args();
13004 args.op_timeout_secs = 30;
13005 let config = build_loop_config(&args).unwrap();
13006 let started_at = Utc::now();
13007 let run = TuneRunRow::start(
13008 &pool,
13009 None,
13010 "stalled-pv-read",
13011 TuneDriver::Opcda,
13012 config,
13013 TemplateOrigin::Builtin,
13014 &template,
13015 &tags,
13016 started_at,
13017 )
13018 .await
13019 .unwrap();
13020 let initial = sample_initial_state();
13021 let beta = lookup(
13022 config.process_type,
13023 config.controller_type,
13024 ResponseLevel::Aggressive,
13025 )
13026 .beta;
13027 let mut engine = MrftEngine::new(
13028 config,
13029 initial.direction,
13030 beta,
13031 InitialReadings {
13032 pv_ini: initial.pv_ini,
13033 mv_ini: initial.mv_ini,
13034 mv_range_low: initial.mv_range_low,
13035 mv_range_high: initial.mv_range_high,
13036 },
13037 started_at,
13038 MrftCompat::default(),
13039 );
13040 let (mut ctrl_c, tx) = CtrlC::test_pair();
13041 tokio::spawn(async move {
13042 tokio::time::sleep(Duration::from_millis(50)).await;
13043 let _ = tx.send(1);
13044 });
13045
13046 let mut timing = timing_for_args(&args);
13047 let outcome = run_polling_loop(
13048 &pool,
13049 run.id,
13050 &args,
13051 &tags,
13052 &driver,
13053 &mut engine,
13054 time_anchor_at(started_at),
13055 &mut ctrl_c,
13056 &mut MutationGuard::default(),
13057 false,
13058 &mut timing,
13059 &mut None,
13060 config,
13061 )
13062 .await
13063 .unwrap();
13064
13065 assert!(matches!(
13066 outcome,
13067 PollOutcome::Aborted(AbortReason::UserInterrupt)
13068 ));
13069 assert!(
13070 TuneSampleRow::list_for_run(&pool, run.id)
13071 .await
13072 .unwrap()
13073 .is_empty()
13074 );
13075 }
13076
13077 #[tokio::test]
13078 async fn a_stalled_mv_write_during_a_tick_times_out_after_recording_the_sample() {
13079 let pool = seeded_pool().await;
13080 let template = honeywell_template();
13081 let tags = honeywell_tags();
13082 let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
13083 let mut args = fast_simulator_args();
13084 args.op_timeout_secs = 1;
13085 let config = build_loop_config(&args).unwrap();
13086 let started_at = Utc::now();
13087 let run = TuneRunRow::start(
13088 &pool,
13089 None,
13090 "stalled-mv-write-timeout",
13091 TuneDriver::Opcda,
13092 config,
13093 TemplateOrigin::Builtin,
13094 &template,
13095 &tags,
13096 started_at,
13097 )
13098 .await
13099 .unwrap();
13100 let initial = sample_initial_state();
13101 let mut engine = MrftEngine::new(
13102 config,
13103 initial.direction,
13104 lookup(
13105 config.process_type,
13106 config.controller_type,
13107 ResponseLevel::Aggressive,
13108 )
13109 .beta,
13110 InitialReadings {
13111 pv_ini: initial.pv_ini,
13112 mv_ini: initial.mv_ini,
13113 mv_range_low: initial.mv_range_low,
13114 mv_range_high: initial.mv_range_high,
13115 },
13116 started_at,
13117 MrftCompat::default(),
13118 );
13119
13120 let mut timing = timing_for_args(&args);
13121 let outcome = run_polling_loop(
13122 &pool,
13123 run.id,
13124 &args,
13125 &tags,
13126 &driver,
13127 &mut engine,
13128 time_anchor_at(started_at),
13129 &mut CtrlC::never(),
13130 &mut MutationGuard::default(),
13131 false,
13132 &mut timing,
13133 &mut None,
13134 config,
13135 )
13136 .await
13137 .unwrap();
13138
13139 assert!(matches!(
13140 outcome,
13141 PollOutcome::Aborted(AbortReason::OperationTimedOut { ref tag, op_timeout_secs })
13142 if tag == &tags.manipulated_variable && op_timeout_secs == 1
13143 ));
13144 assert_eq!(
13145 TuneSampleRow::list_for_run(&pool, run.id)
13146 .await
13147 .unwrap()
13148 .len(),
13149 1
13150 );
13151 }
13152
13153 #[tokio::test(start_paused = true)]
13157 async fn attempt_restore_confirms_a_normal_restore() {
13158 let template = honeywell_template();
13159 let tags = honeywell_tags();
13160 let driver = honeywell_driver_auto();
13161 let initial = read_initial_values(&driver, &tags, &template, false)
13162 .await
13163 .unwrap();
13164 let mut guard = MutationGuard::default();
13165 transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
13166 .await
13167 .unwrap();
13168 let pool = SqlitePool::connect_lazy("sqlite::memory:").unwrap();
13169 let args = fast_simulator_args();
13170
13171 let outcome = attempt_restore_with_actuation(
13172 &pool,
13173 0,
13174 &args,
13175 &driver,
13176 &tags,
13177 &template,
13178 &initial,
13179 &guard,
13180 false,
13181 &mut CtrlC::never(),
13182 &mut None,
13183 )
13184 .await;
13185
13186 assert!(matches!(outcome, RestoreAttempt::Confirmed));
13187 assert_eq!(
13188 driver.value_of(&tags.manipulated_variable).as_deref(),
13189 Some("45")
13190 );
13191 }
13192
13193 #[tokio::test]
13194 async fn record_restore_status_best_effort_swallows_database_errors() {
13195 let pool = seeded_pool().await;
13196 record_restore_status_best_effort(&pool, i64::MAX, &RestoreAttempt::Confirmed).await;
13197 }
13198
13199 #[tokio::test(start_paused = true)]
13200 async fn attempt_restore_reports_incomplete_when_restore_timeout_secs_elapses() {
13201 let template = honeywell_template();
13202 let tags = honeywell_tags();
13203 let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
13206 let initial = sample_initial_state();
13207 let guard = MutationGuard::default();
13208 let pool = SqlitePool::connect_lazy("sqlite::memory:").unwrap();
13209 let mut args = fast_simulator_args();
13210 args.restore_timeout_secs = MV_ACTUATION_CONFIRMATION_SECS;
13211 args.op_timeout_secs = 30;
13212
13213 let outcome = attempt_restore_with_actuation(
13214 &pool,
13215 0,
13216 &args,
13217 &driver,
13218 &tags,
13219 &template,
13220 &initial,
13221 &guard,
13222 false,
13223 &mut CtrlC::never(),
13224 &mut None,
13225 )
13226 .await;
13227
13228 match outcome {
13229 RestoreAttempt::Incomplete { reason } => {
13230 assert!(reason.contains("[tuning].restore_timeout_secs"));
13231 }
13232 RestoreAttempt::Confirmed => panic!("expected RestoreAttempt::Incomplete"),
13233 }
13234 }
13235
13236 #[tokio::test]
13237 async fn attempt_restore_reports_incomplete_on_a_second_ctrl_c() {
13238 let template = honeywell_template();
13239 let tags = honeywell_tags();
13240 let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
13241 let initial = sample_initial_state();
13242 let guard = MutationGuard::default();
13243 let (mut ctrl_c, tx) = CtrlC::test_pair();
13244 tx.send(1).unwrap();
13245 let pool = seeded_pool().await;
13246 let args = fast_simulator_args();
13247
13248 let outcome = attempt_restore_with_actuation(
13249 &pool,
13250 0,
13251 &args,
13252 &driver,
13253 &tags,
13254 &template,
13255 &initial,
13256 &guard,
13257 false,
13258 &mut ctrl_c,
13259 &mut None,
13260 )
13261 .await;
13262
13263 match outcome {
13264 RestoreAttempt::Incomplete { reason } => {
13265 assert!(reason.contains("second Ctrl+C"));
13266 }
13267 RestoreAttempt::Confirmed => panic!("expected RestoreAttempt::Incomplete"),
13268 }
13269 }
13270
13271 #[tokio::test]
13272 async fn restore_wrapper_handles_ctrl_c_after_mv_restore_completes() {
13273 let pool = seeded_pool().await;
13274 let template = honeywell_template();
13275 let tags = honeywell_tags();
13276 let driver = honeywell_driver_auto().hanging_write(tags.controller_mode.as_ref().unwrap());
13277 let initial = sample_initial_state();
13278 let guard = MutationGuard {
13279 mode_written: true,
13280 ..MutationGuard::default()
13281 };
13282 let (mut ctrl_c, tx) = CtrlC::test_pair();
13283 tokio::spawn(async move {
13284 tokio::time::sleep(Duration::from_millis(30)).await;
13285 let _ = tx.send(1);
13286 });
13287
13288 let outcome = attempt_restore_with_actuation(
13289 &pool,
13290 0,
13291 &fast_simulator_args(),
13292 &driver,
13293 &tags,
13294 &template,
13295 &initial,
13296 &guard,
13297 false,
13298 &mut ctrl_c,
13299 &mut None,
13300 )
13301 .await;
13302
13303 assert!(matches!(
13304 outcome,
13305 RestoreAttempt::Incomplete { ref reason } if reason.contains("second Ctrl+C")
13306 ));
13307 assert_eq!(
13308 driver.value_of(&tags.manipulated_variable).as_deref(),
13309 Some("45")
13310 );
13311 }
13312
13313 #[tokio::test(start_paused = true)]
13314 async fn restore_wrapper_handles_deadline_after_mv_restore_completes() {
13315 let pool = SqlitePool::connect_lazy("sqlite::memory:").unwrap();
13316 let template = honeywell_template();
13317 let tags = honeywell_tags();
13318 let driver = honeywell_driver_auto().hanging_write(tags.controller_mode.as_ref().unwrap());
13319 let initial = sample_initial_state();
13320 let guard = MutationGuard {
13321 mode_written: true,
13322 ..MutationGuard::default()
13323 };
13324 let mut args = fast_simulator_args();
13325 args.restore_timeout_secs = 1;
13326 args.op_timeout_secs = 30;
13327
13328 let outcome = attempt_restore_with_actuation(
13329 &pool,
13330 0,
13331 &args,
13332 &driver,
13333 &tags,
13334 &template,
13335 &initial,
13336 &guard,
13337 false,
13338 &mut CtrlC::never(),
13339 &mut None,
13340 )
13341 .await;
13342
13343 assert!(matches!(
13344 outcome,
13345 RestoreAttempt::Incomplete { ref reason }
13346 if reason.contains("[tuning].restore_timeout_secs")
13347 ));
13348 assert_eq!(
13349 driver.value_of(&tags.manipulated_variable).as_deref(),
13350 Some("45")
13351 );
13352 }
13353
13354 #[tokio::test]
13355 async fn accepted_mv_restore_gets_a_full_confirmation_window_before_remaining_restore_steps() {
13356 let pool = seeded_pool().await;
13357 let (run_id, _config, template, tags) =
13358 start_opc_test_run(&pool, "actuation-restore-deadline-extension").await;
13359 let driver = honeywell_driver_auto()
13360 .delaying_write(&tags.manipulated_variable, Duration::from_millis(2_300))
13361 .delaying_read(&tags.manipulated_variable, Duration::from_millis(900))
13362 .delaying_write(
13363 tags.controller_mode.as_ref().unwrap(),
13364 Duration::from_millis(500),
13365 );
13366 let initial = sample_initial_state();
13367 let guard = MutationGuard {
13368 mode_written: true,
13369 ..MutationGuard::default()
13370 };
13371 let mut args = fast_simulator_args();
13372 args.driver = DriverKindArg::Opcda;
13373 args.restore_timeout_secs = MV_ACTUATION_CONFIRMATION_SECS;
13374 args.op_timeout_secs = 30;
13375 let mut mv_actuations = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
13376
13377 let outcome = attempt_restore_with_actuation(
13378 &pool,
13379 run_id,
13380 &args,
13381 &driver,
13382 &tags,
13383 &template,
13384 &initial,
13385 &guard,
13386 false,
13387 &mut CtrlC::never(),
13388 &mut mv_actuations,
13389 )
13390 .await;
13391
13392 assert!(matches!(outcome, RestoreAttempt::Confirmed));
13393 assert_eq!(
13394 driver.value_of(&tags.manipulated_variable).as_deref(),
13395 Some("45")
13396 );
13397 assert_eq!(
13398 driver
13399 .value_of(tags.controller_mode.as_ref().unwrap())
13400 .as_deref(),
13401 Some("1")
13402 );
13403 assert!(!driver.delayed_write_was_cancelled(&tags.manipulated_variable));
13404 assert!(!driver.delayed_read_was_cancelled(&tags.manipulated_variable));
13405 }
13406
13407 #[tokio::test]
13408 async fn restore_failure_wrapper_preserves_error_when_status_and_cleanup_writes_fail() {
13409 let pool = seeded_pool().await;
13410 pool.close().await;
13411 let original = anyhow::anyhow!("original polling failure");
13412 let error = restore_best_effort_then_propagate(
13413 &pool,
13414 42,
13415 &honeywell_driver_auto(),
13416 &honeywell_tags(),
13417 &honeywell_template(),
13418 &sample_initial_state(),
13419 &MutationGuard::default(),
13420 &fast_simulator_args(),
13421 false,
13422 &mut CtrlC::never(),
13423 &mut None,
13424 original,
13425 )
13426 .await;
13427
13428 assert_eq!(error.to_string(), "original polling failure");
13429 }
13430
13431 #[tokio::test]
13440 async fn run_with_ctrl_c_aborts_the_run_when_signalled_during_the_poll() {
13441 let pool = seeded_pool().await;
13442 let mut args = fast_simulator_args();
13443 args.cycles_count = Some(100_000);
13446 let (mut ctrl_c, tx) = CtrlC::test_pair();
13447 tokio::spawn(async move {
13448 tokio::time::sleep(Duration::from_millis(50)).await;
13449 let _ = tx.send(1);
13450 });
13451
13452 let outcome = run_with_ctrl_c(&pool, args, &test_config(), &mut ctrl_c)
13453 .await
13454 .unwrap();
13455 assert_eq!(outcome, TuneOutcome::Aborted);
13456
13457 let runs = TuneRunRow::list(
13458 &pool,
13459 &bhtune_db::models::TuneRunFilter::default(),
13460 bhtune_db::models::Pagination::first(10),
13461 )
13462 .await
13463 .unwrap();
13464 assert_eq!(runs.len(), 1);
13465 assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Aborted);
13466 }
13467}