1use bhtune_db::SqlitePool;
4use bhtune_db::models::{
5 MvActuationKind, MvActuationStatus, Pagination, SampleQuality, SamplingAdequacy, TimingSummary,
6 TuneDriver, TuneMvActuationRow, TuneResultRow, TuneRunFilter, TuneRunRow, TuneSampleRow,
7 TuneWriteRow, WriteKind,
8};
9use bhtune_driver::OpcDaDriver;
10
11use crate::args::HistoryCommand;
12use crate::output::OutputFormat;
13
14pub async fn run(
15 pool: &SqlitePool,
16 command: HistoryCommand,
17 config: &crate::config::BhtuneConfig,
18) -> anyhow::Result<()> {
19 match command {
20 HistoryCommand::List {
21 outcome,
22 limit,
23 offset,
24 output,
25 } => list(pool, outcome.map(Into::into), limit, offset, output).await,
26 HistoryCommand::Show { run_id, output } => show(pool, run_id, output).await,
27 HistoryCommand::Revert {
28 run_id,
29 bridge_host,
30 server,
31 yes,
32 output,
33 } => revert(pool, run_id, bridge_host, server, yes, output, config).await,
34 HistoryCommand::Prune {
35 older_than_days,
36 dry_run,
37 output,
38 } => prune(pool, config, older_than_days, dry_run, output).await,
39 }
40}
41
42#[derive(serde::Serialize)]
46struct RunSummaryJson {
47 id: i64,
48 tag_name: String,
49 notes: Option<String>,
50 driver: bhtune_db::models::TuneDriver,
51 outcome: bhtune_db::models::TuneOutcome,
52 process_type: bhtune_core::ProcessType,
53 started_at: chrono::DateTime<chrono::Utc>,
54}
55
56#[derive(serde::Serialize)]
57struct RunListJson {
58 runs: Vec<RunSummaryJson>,
59 returned: usize,
62 total: i64,
63}
64
65#[derive(serde::Serialize)]
69struct InitialReadingsJson {
70 pv_ini: f32,
71 mv_ini: f32,
72 mv_range_low: f32,
73 mv_range_high: f32,
74 pv_range_high: f32,
75 pv_range_low: f32,
76 controller_direction: bhtune_core::ControllerDirection,
77 mode_raw: Option<String>,
78 mode_attribute_raw: Option<String>,
79 setpoint_ini: Option<f32>,
80}
81
82impl From<bhtune_db::models::TuneRunInitialReadings> for InitialReadingsJson {
83 fn from(r: bhtune_db::models::TuneRunInitialReadings) -> Self {
84 Self {
85 pv_ini: r.pv_ini,
86 mv_ini: r.mv_ini,
87 mv_range_low: r.mv_range_low,
88 mv_range_high: r.mv_range_high,
89 pv_range_high: r.pv_range_high,
90 pv_range_low: r.pv_range_low,
91 controller_direction: r.controller_direction,
92 mode_raw: r.mode_raw,
93 mode_attribute_raw: r.mode_attribute_raw,
94 setpoint_ini: r.setpoint_ini,
95 }
96 }
97}
98
99#[derive(serde::Serialize)]
103struct ResultJson {
104 response_level: bhtune_core::ResponseLevel,
105 kp: Option<f32>,
106 ti_minutes: Option<f32>,
107 td_minutes: Option<f32>,
108 proportional: Option<f32>,
109 integral: Option<f32>,
110 derivative: Option<f32>,
111 status: bhtune_core::TuningResultStatus,
112 invalid_reason: Option<bhtune_core::TuningResultInvalidReason>,
113}
114
115impl From<&TuneResultRow> for ResultJson {
116 fn from(r: &TuneResultRow) -> Self {
117 Self {
118 response_level: r.response_level,
119 kp: r.kp,
120 ti_minutes: r.ti_minutes,
121 td_minutes: r.td_minutes,
122 proportional: r.proportional,
123 integral: r.integral,
124 derivative: r.derivative,
125 status: r.status,
126 invalid_reason: r.invalid_reason,
127 }
128 }
129}
130
131#[derive(serde::Serialize)]
135struct WriteJson {
136 kind: WriteKind,
139 response_level: bhtune_core::ResponseLevel,
140 written_at: chrono::DateTime<chrono::Utc>,
141 proportional_previous: Option<f32>,
144 integral_previous: Option<f32>,
145 derivative_previous: Option<f32>,
146 proportional_written: Option<f32>,
149 integral_written: Option<f32>,
150 derivative_written: Option<f32>,
151 proportional_readback: Option<f32>,
152 integral_readback: Option<f32>,
153 derivative_readback: Option<f32>,
154 success: bool,
155 error_message: Option<String>,
156 rollback_state: Option<bhtune_db::models::RollbackState>,
161 rollback_error: Option<String>,
162}
163
164impl From<&TuneWriteRow> for WriteJson {
165 fn from(w: &TuneWriteRow) -> Self {
166 Self {
167 kind: w.kind,
168 response_level: w.response_level,
169 written_at: w.written_at,
170 proportional_previous: w.previous.map(|p| p.proportional),
171 integral_previous: w.previous.map(|p| p.integral),
172 derivative_previous: w.previous.map(|p| p.derivative),
173 proportional_written: w.proportional_written,
174 integral_written: w.integral_written,
175 derivative_written: w.derivative_written,
176 proportional_readback: w.proportional_readback,
177 integral_readback: w.integral_readback,
178 derivative_readback: w.derivative_readback,
179 success: w.success,
180 error_message: w.error_message.clone(),
181 rollback_state: w.rollback_state,
182 rollback_error: w.rollback_error.clone(),
183 }
184 }
185}
186
187#[derive(serde::Serialize)]
188struct MvActuationJson {
189 id: i64,
190 sequence: i64,
191 kind: MvActuationKind,
192 commanded_at: chrono::DateTime<chrono::Utc>,
193 target_mv: f32,
194 previous_commanded_mv: Option<f32>,
195 tolerance: f32,
196 confirmation_due_at: chrono::DateTime<chrono::Utc>,
197 last_checked_at: Option<chrono::DateTime<chrono::Utc>>,
198 readback_mv: Option<f32>,
199 readback_quality: Option<SampleQuality>,
200 attempt_count: i64,
201 status: MvActuationStatus,
202 detail: Option<String>,
203}
204
205impl From<&TuneMvActuationRow> for MvActuationJson {
206 fn from(row: &TuneMvActuationRow) -> Self {
207 Self {
208 id: row.id,
209 sequence: row.sequence,
210 kind: row.kind,
211 commanded_at: row.commanded_at,
212 target_mv: row.target_mv,
213 previous_commanded_mv: row.previous_commanded_mv,
214 tolerance: row.tolerance,
215 confirmation_due_at: row.confirmation_due_at,
216 last_checked_at: row.last_checked_at,
217 readback_mv: row.readback_mv,
218 readback_quality: row.readback_quality,
219 attempt_count: row.attempt_count,
220 status: row.status,
221 detail: row.detail.clone(),
222 }
223 }
224}
225
226#[derive(serde::Serialize)]
227struct RunDetailJson {
228 id: i64,
229 tag_name: String,
230 notes: Option<String>,
231 driver: bhtune_db::models::TuneDriver,
232 outcome: bhtune_db::models::TuneOutcome,
233 failure_reason: Option<String>,
234 started_at: chrono::DateTime<chrono::Utc>,
235 completed_at: Option<chrono::DateTime<chrono::Utc>>,
236 template_name: String,
240 template_origin: bhtune_db::models::TemplateOrigin,
241 config: bhtune_core::LoopConfig,
242 opc_server: Option<String>,
246 bridge_host: Option<String>,
248 initial_readings: Option<InitialReadingsJson>,
249 timing_metrics: Option<bhtune_db::models::TimingMetrics>,
250 samples_recorded: usize,
251 results: Vec<ResultJson>,
252 writes: Vec<WriteJson>,
253 mv_actuations: Vec<MvActuationJson>,
254 restore_status: Option<bhtune_db::models::RestoreStatus>,
257 restore_detail: Option<String>,
258}
259
260async fn list(
261 pool: &SqlitePool,
262 outcome: Option<bhtune_db::models::TuneOutcome>,
263 limit: i64,
264 offset: i64,
265 output: OutputFormat,
266) -> anyhow::Result<()> {
267 let mut filter = TuneRunFilter::default();
268 if let Some(outcome) = outcome {
269 filter = filter.with_outcome(outcome);
270 }
271 let pagination = Pagination::new(limit, offset);
272 let runs = TuneRunRow::list(pool, &filter, pagination).await?;
273 let total = TuneRunRow::count(pool, &filter).await?;
274
275 match output {
276 OutputFormat::Table => {
277 if runs.is_empty() {
278 println!("No runs found.");
279 return Ok(());
280 }
281
282 println!(
283 "{:<5} {:<30} {:<10} {:<10} {:<10} {:<25}",
284 "ID", "TAG NAME", "DRIVER", "OUTCOME", "PROCESS", "STARTED"
285 );
286 for run in &runs {
287 println!(
288 "{:<5} {:<30} {:<10} {:<10} {:<10} {:<25}",
289 run.id,
290 run.loop_name,
291 format!("{:?}", run.driver),
292 format!("{:?}", run.outcome),
293 format!("{:?}", run.config.process_type),
294 run.started_at.to_rfc3339(),
295 );
296 }
297 println!("Showing {} of {total} total run(s).", runs.len());
298 }
299 OutputFormat::Json => {
300 let json = RunListJson {
301 returned: runs.len(),
302 total,
303 runs: runs
304 .iter()
305 .map(|run| RunSummaryJson {
306 id: run.id,
307 tag_name: run.loop_name.clone(),
308 notes: run.notes.clone(),
309 driver: run.driver,
310 outcome: run.outcome,
311 process_type: run.config.process_type,
312 started_at: run.started_at,
313 })
314 .collect(),
315 };
316 println!("{}", serde_json::to_string_pretty(&json)?);
317 }
318 }
319 Ok(())
320}
321
322fn has_rows<T>(rows: &[T]) -> bool {
323 !rows.is_empty()
324}
325
326fn has_recorded_connection(opc_server: Option<&str>, bridge_host: Option<&str>) -> bool {
327 opc_server.is_some() || bridge_host.is_some()
328}
329
330fn is_table_output(output: OutputFormat) -> bool {
331 output == OutputFormat::Table
332}
333
334async fn prune(
351 pool: &SqlitePool,
352 config: &crate::config::BhtuneConfig,
353 older_than_days: Option<u32>,
354 dry_run: bool,
355 output: OutputFormat,
356) -> anyhow::Result<()> {
357 let days = older_than_days.or(config.retention_days).ok_or_else(|| {
358 anyhow::anyhow!(
359 "no retention policy configured; pass --older-than-days, or set --retention-days \
360 / BHTUNE_RETENTION_DAYS / the config file's retention_days key"
361 )
362 })?;
363 let now = chrono::Utc::now();
364 let cutoff = crate::retention::cutoff_for(days, now);
365
366 if dry_run {
367 let count =
368 TuneRunRow::count(pool, &TuneRunFilter::default().with_started_before(cutoff)).await?;
369 match output {
370 OutputFormat::Table => {
371 println!(
372 "Would delete {count} run(s) started at or before {} ({days} day(s)).",
373 cutoff.to_rfc3339()
374 );
375 }
376 OutputFormat::Json => {
377 let json = PruneJson {
378 retention_days: days,
379 cutoff,
380 dry_run: true,
381 deleted: count as u64,
382 };
383 println!("{}", serde_json::to_string_pretty(&json)?);
384 }
385 }
386 return Ok(());
387 }
388
389 let deleted = crate::retention::sweep_retention(pool, days, now).await?;
390 match output {
391 OutputFormat::Table => {
392 println!(
393 "Deleted {deleted} run(s) started at or before {} ({days} day(s)).",
394 cutoff.to_rfc3339()
395 );
396 }
397 OutputFormat::Json => {
398 let json = PruneJson {
399 retention_days: days,
400 cutoff,
401 dry_run: false,
402 deleted,
403 };
404 println!("{}", serde_json::to_string_pretty(&json)?);
405 }
406 }
407 Ok(())
408}
409
410#[derive(serde::Serialize)]
411struct PruneJson {
412 retention_days: u32,
413 cutoff: chrono::DateTime<chrono::Utc>,
414 dry_run: bool,
415 deleted: u64,
416}
417
418fn fmt_opt_f32(value: Option<f32>) -> String {
421 match value {
422 Some(v) => format!("{v:.4}"),
423 None => "-".to_string(),
424 }
425}
426
427fn fmt_opt_f64(value: Option<f64>) -> String {
428 match value {
429 Some(v) => format!("{v:.3}"),
430 None => "-".to_string(),
431 }
432}
433
434fn sampling_adequacy_label(adequacy: SamplingAdequacy) -> &'static str {
435 match adequacy {
436 SamplingAdequacy::Adequate => "adequate",
437 SamplingAdequacy::Marginal => "marginal",
438 SamplingAdequacy::NotAssessed => "not assessed",
439 }
440}
441
442fn print_latency_summary(label: &str, summary: TimingSummary) {
443 if summary.count == 0 {
444 println!(" {label:<26} none");
445 return;
446 }
447
448 println!(
449 " {label:<26} count={} mean={} ms max={} ms",
450 summary.count,
451 fmt_opt_f64(summary.mean_ms),
452 fmt_opt_f64(summary.max_ms),
453 );
454}
455
456fn print_show_table(
457 run: &TuneRunRow,
458 samples: &[TuneSampleRow],
459 results: &[TuneResultRow],
460 writes: &[TuneWriteRow],
461 mv_actuations: &[TuneMvActuationRow],
462) {
463 println!("Run #{} — Tag name: {}", run.id, run.loop_name);
464 println!(" Notes: {}", run.notes.as_deref().unwrap_or("—"));
465 println!(" Driver: {:?}", run.driver);
466 println!(" Outcome: {:?}", run.outcome);
467 if let Some(reason) = &run.failure_reason {
468 println!(" Failure reason: {reason}");
469 }
470 println!(" Started at: {}", run.started_at.to_rfc3339());
471 if let Some(completed_at) = run.completed_at {
472 println!(" Completed at: {}", completed_at.to_rfc3339());
473 }
474 println!(
475 " Template: {} ({:?})",
476 run.template.name, run.template_origin
477 );
478 if has_recorded_connection(run.opc_server.as_deref(), run.bridge_host.as_deref()) {
479 println!(
480 " Connection: server={} bridge_host={}",
481 run.opc_server.as_deref().unwrap_or("-"),
482 run.bridge_host.as_deref().unwrap_or("-"),
483 );
484 }
485 println!(
486 " Process/controller: {:?} / {:?}",
487 run.config.process_type, run.config.controller_type
488 );
489 println!(" Relay amplitude: {}%", run.config.relay_amp_percent);
490 println!(
491 " Cycles skip/count: {} / {}",
492 run.config.num_cycles_skip, run.config.num_cycles_count
493 );
494
495 if let Some(readings) = &run.initial_readings {
496 println!(
497 " Initial PV / MV: {} / {}",
498 readings.pv_ini, readings.mv_ini
499 );
500 println!(
501 " MV range: {} - {}",
502 readings.mv_range_low, readings.mv_range_high
503 );
504 println!(
505 " PV range: {} - {}",
506 readings.pv_range_low, readings.pv_range_high
507 );
508 println!(" Direction: {:?}", readings.controller_direction);
509 }
510
511 println!(" Samples recorded: {}", samples.len());
512
513 if let Some(timing) = run.timing_metrics {
514 println!(" Timing:");
515 println!(" Basis: {:?}", timing.basis);
516 println!(
517 " Requested interval: {} ms",
518 timing.requested_interval_ms
519 );
520 println!(" Observed sample gaps: {}", timing.sample_gap_count);
521 println!(
522 " Mean / max sample gap: {} / {} ms",
523 fmt_opt_f64(timing.mean_sample_gap_ms),
524 fmt_opt_f64(timing.max_sample_gap_ms)
525 );
526 println!(
527 " Missed poll opportunities: {}",
528 timing.missed_poll_opportunity_count
529 );
530 println!(
531 " Oscillation period: {} ms",
532 fmt_opt_f64(timing.measured_oscillation_period_ms)
533 );
534 println!(
535 " Approx. samples / period: {}",
536 fmt_opt_f64(timing.approximate_samples_per_period)
537 );
538 println!(
539 " Sampling adequacy: {}",
540 sampling_adequacy_label(timing.sampling_adequacy)
541 );
542 if let Some(poll_latency) = timing.poll_latency {
543 println!(" Poll latency:");
544 print_latency_summary("PV reads:", poll_latency.pv_read);
545 print_latency_summary("MV writes:", poll_latency.mv_write);
546 print_latency_summary("MV verification:", poll_latency.mv_verification);
547 print_latency_summary("Sample persistence:", poll_latency.sample_persist);
548 print_latency_summary("Total tick work:", poll_latency.tick_work);
549 }
550 }
551
552 match (run.restore_status, &run.restore_detail) {
553 (Some(bhtune_db::models::RestoreStatus::Confirmed), _) => {
554 println!(" Restore: confirmed");
555 }
556 (Some(bhtune_db::models::RestoreStatus::Incomplete), detail) => {
557 println!(
558 " Restore: INCOMPLETE -- {}",
559 detail.as_deref().unwrap_or("no detail recorded")
560 );
561 }
562 (None, _) => {}
563 }
564
565 print_show_results(results);
566 print_show_writes(writes);
567 print_show_mv_actuations(mv_actuations);
568}
569
570fn print_show_results(results: &[TuneResultRow]) {
571 if !has_rows(results) {
572 return;
573 }
574 println!(" Calculated results:");
575 println!(
576 " {:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<10} {:<10} REASON",
577 "LEVEL", "STATUS", "KP", "TI(min)", "TD(min)", "PROP", "INTEGRAL", "DERIV"
578 );
579 for result in results {
580 println!(
581 " {:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<10} {:<10} {}",
582 format!("{:?}", result.response_level),
583 format!("{:?}", result.status),
584 fmt_opt_f32(result.kp),
585 fmt_opt_f32(result.ti_minutes),
586 fmt_opt_f32(result.td_minutes),
587 fmt_opt_f32(result.proportional),
588 fmt_opt_f32(result.integral),
589 fmt_opt_f32(result.derivative),
590 result
591 .invalid_reason
592 .map(|reason| reason.to_string())
593 .unwrap_or_else(|| "-".to_string())
594 );
595 }
596}
597
598fn print_show_writes(writes: &[TuneWriteRow]) {
599 if !has_rows(writes) {
600 return;
601 }
602 println!(" PID write-back audit:");
603 for write in writes {
604 println!(
605 " [{}] {:?} ({:?} level): success={} previous(P={} I={} D={}) \
606 written(P={} I={} D={}) readback(P={} I={} D={}){}",
607 write.written_at.to_rfc3339(),
608 write.kind,
609 write.response_level,
610 write.success,
611 fmt_opt_f32(write.previous.map(|p| p.proportional)),
612 fmt_opt_f32(write.previous.map(|p| p.integral)),
613 fmt_opt_f32(write.previous.map(|p| p.derivative)),
614 fmt_opt_f32(write.proportional_written),
615 fmt_opt_f32(write.integral_written),
616 fmt_opt_f32(write.derivative_written),
617 fmt_opt_f32(write.proportional_readback),
618 fmt_opt_f32(write.integral_readback),
619 fmt_opt_f32(write.derivative_readback),
620 write
621 .error_message
622 .as_ref()
623 .map(|message| format!(" error={message}"))
624 .unwrap_or_default(),
625 );
626 print_show_rollback(write);
627 }
628}
629
630fn print_show_rollback(write: &TuneWriteRow) {
631 let Some(rollback_state) = write.rollback_state else {
632 return;
633 };
634 println!(
635 " rollback: {rollback_state:?}{}",
636 write
637 .rollback_error
638 .as_ref()
639 .map(|message| format!(" error={message}"))
640 .unwrap_or_default(),
641 );
642}
643
644fn print_show_mv_actuations(actuations: &[TuneMvActuationRow]) {
645 if !has_rows(actuations) {
646 return;
647 }
648 println!(" MV actuation verification:");
649 println!(
650 " {:<5} {:<8} {:<25} {:<10} {:<10} {:<10} {:<10} {:<12} DETAIL",
651 "SEQ", "KIND", "COMMANDED", "TARGET", "READBACK", "TOLERANCE", "ATTEMPTS", "STATUS"
652 );
653 for actuation in actuations {
654 println!(
655 " {:<5} {:<8} {:<25} {:<10} {:<10} {:<10} {:<10} {:<12} {}",
656 actuation.sequence,
657 format!("{:?}", actuation.kind),
658 actuation.commanded_at.to_rfc3339(),
659 fmt_opt_f32(Some(actuation.target_mv)),
660 fmt_opt_f32(actuation.readback_mv),
661 fmt_opt_f32(Some(actuation.tolerance)),
662 actuation.attempt_count,
663 format!("{:?}", actuation.status),
664 actuation.detail.as_deref().unwrap_or("-"),
665 );
666 println!(
667 " confirmation due: {} last checked: {} quality: {}",
668 actuation.confirmation_due_at.to_rfc3339(),
669 actuation
670 .last_checked_at
671 .map(|value| value.to_rfc3339())
672 .unwrap_or_else(|| "-".to_string()),
673 actuation
674 .readback_quality
675 .map(|quality| format!("{quality:?}"))
676 .unwrap_or_else(|| "-".to_string()),
677 );
678 }
679}
680
681fn print_show_json(
682 run: &TuneRunRow,
683 samples: &[TuneSampleRow],
684 results: &[TuneResultRow],
685 writes: &[TuneWriteRow],
686 mv_actuations: &[TuneMvActuationRow],
687) -> anyhow::Result<()> {
688 let json = RunDetailJson {
689 id: run.id,
690 tag_name: run.loop_name.clone(),
691 notes: run.notes.clone(),
692 driver: run.driver,
693 outcome: run.outcome,
694 failure_reason: run.failure_reason.clone(),
695 started_at: run.started_at,
696 completed_at: run.completed_at,
697 template_name: run.template.name.clone(),
698 template_origin: run.template_origin,
699 config: run.config,
700 opc_server: run.opc_server.clone(),
701 bridge_host: run.bridge_host.clone(),
702 initial_readings: run
703 .initial_readings
704 .as_ref()
705 .map(|readings| InitialReadingsJson::from(readings.clone())),
706 timing_metrics: run.timing_metrics,
707 samples_recorded: samples.len(),
708 results: results.iter().map(ResultJson::from).collect(),
709 writes: writes.iter().map(WriteJson::from).collect(),
710 mv_actuations: mv_actuations.iter().map(MvActuationJson::from).collect(),
711 restore_status: run.restore_status,
712 restore_detail: run.restore_detail.clone(),
713 };
714 println!("{}", serde_json::to_string_pretty(&json)?);
715 Ok(())
716}
717
718async fn show(pool: &SqlitePool, run_id: i64, output: OutputFormat) -> anyhow::Result<()> {
719 let run = TuneRunRow::get(pool, run_id)
720 .await?
721 .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
722 let samples = TuneSampleRow::list_for_run(pool, run_id).await?;
723 let results = TuneResultRow::list_for_run(pool, run_id).await?;
724 let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
725 let mv_actuations = TuneMvActuationRow::list_for_run(pool, run_id).await?;
726
727 match output {
728 OutputFormat::Table => print_show_table(&run, &samples, &results, &writes, &mv_actuations),
729 OutputFormat::Json => print_show_json(&run, &samples, &results, &writes, &mv_actuations)?,
730 }
731
732 Ok(())
733}
734
735#[derive(serde::Serialize)]
739struct RevertJson {
740 run_id: i64,
741 response_level: bhtune_core::ResponseLevel,
744 reverted_to: RevertedTargetJson,
745 success: bool,
746 error_message: Option<String>,
747}
748
749#[derive(serde::Serialize)]
750struct RevertedTargetJson {
751 proportional: f32,
752 integral: f32,
753 derivative: f32,
754}
755
756fn resolve_revert_connection(
766 run: &TuneRunRow,
767 bridge_host_flag: Option<&str>,
768 server_flag: Option<&str>,
769) -> anyhow::Result<(String, String)> {
770 let stored_server = run.opc_server.as_deref().ok_or_else(|| {
771 anyhow::anyhow!(
772 "run {}'s recorded OPC server is missing; refusing to guess which server to \
773 revert against",
774 run.id
775 )
776 })?;
777 let stored_bridge_host = run.bridge_host.as_deref().ok_or_else(|| {
778 anyhow::anyhow!(
779 "run {}'s recorded bridge host is missing; refusing to guess which gateway to \
780 revert against",
781 run.id
782 )
783 })?;
784
785 if let Some(server_flag) = server_flag
786 && server_flag != stored_server
787 {
788 anyhow::bail!(
789 "--server {server_flag:?} contradicts run {}'s recorded OPC server \
790 {stored_server:?}; refusing to revert against a different server than the run \
791 actually used -- omit --server to use the recorded value",
792 run.id
793 );
794 }
795 if let Some(bridge_host_flag) = bridge_host_flag
796 && bridge_host_flag != stored_bridge_host
797 {
798 anyhow::bail!(
799 "--bridge-host {bridge_host_flag:?} contradicts run {}'s recorded bridge host \
800 {stored_bridge_host:?}; refusing to revert through a different gateway than the \
801 run actually used -- omit --bridge-host to use the recorded value",
802 run.id
803 );
804 }
805
806 Ok((stored_bridge_host.to_string(), stored_server.to_string()))
807}
808
809#[allow(clippy::too_many_arguments)]
833async fn revert(
834 pool: &SqlitePool,
835 run_id: i64,
836 bridge_host: Option<String>,
837 server: Option<String>,
838 yes: bool,
839 output: OutputFormat,
840 config: &crate::config::BhtuneConfig,
841) -> anyhow::Result<()> {
842 let allow_uncertain_quality = config.allow_uncertain_quality;
843 let run = TuneRunRow::get(pool, run_id)
844 .await?
845 .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
846
847 if run.driver != TuneDriver::Opcda {
848 anyhow::bail!(
849 "run {run_id} used the {:?} driver, which has no live loop to revert a write \
850 against",
851 run.driver
852 );
853 }
854
855 let writes = TuneWriteRow::list_for_run(pool, run_id).await?;
856 let last_write = writes
857 .iter()
858 .rev()
859 .find(|w| w.kind == WriteKind::Write)
860 .ok_or_else(|| anyhow::anyhow!("run {run_id} has no recorded PID write-back to revert"))?;
861 let response_level = last_write.response_level;
862 let target = last_write.previous.ok_or_else(|| {
863 anyhow::anyhow!(
864 "run {run_id}'s {response_level:?} PID write-back never recorded pre-write \
865 values (its pre-read failed at the time); nothing to revert to"
866 )
867 })?;
868
869 if !yes {
870 anyhow::bail!("reverting writes PID constants back to a live loop; pass --yes to confirm");
871 }
872
873 let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
874 &run.tags.proportional_constant,
875 &run.tags.integral_constant,
876 &run.tags.derivative_constant,
877 ) else {
878 anyhow::bail!("run {run_id}'s snapshotted tags have no PID constant tags configured");
879 };
880
881 let (bridge_host, server) =
882 resolve_revert_connection(&run, bridge_host.as_deref(), server.as_deref())?;
883 let driver = OpcDaDriver::connect(&bridge_host, &server).await?;
884
885 if is_table_output(output) {
886 println!(
887 "Reverting run {run_id}'s {response_level:?} PID write-back on tag '{}' to \
888 P={:.4} I={:.4} D={:.4}...",
889 run.loop_name, target.proportional, target.integral, target.derivative
890 );
891 }
892
893 let outcome = crate::commands::tune::write_pid_values(
894 pool,
895 run_id,
896 &driver,
897 p_tag,
898 i_tag,
899 d_tag,
900 response_level,
901 target,
902 WriteKind::Revert,
903 allow_uncertain_quality,
904 )
905 .await?;
906
907 match outcome {
908 crate::commands::tune::PidWriteOutcome::Written => {
909 tracing::info!(run_id, ?response_level, "PID revert succeeded");
910 match output {
911 OutputFormat::Table => {
912 println!(
913 "Reverted and confirmed run {run_id}'s {response_level:?} PID write-back."
914 );
915 }
916 OutputFormat::Json => {
917 let json = RevertJson {
918 run_id,
919 response_level,
920 reverted_to: RevertedTargetJson {
921 proportional: target.proportional,
922 integral: target.integral,
923 derivative: target.derivative,
924 },
925 success: true,
926 error_message: None,
927 };
928 println!("{}", serde_json::to_string_pretty(&json)?);
929 }
930 }
931 Ok(())
932 }
933 crate::commands::tune::PidWriteOutcome::Failed { detail } => {
934 tracing::error!(run_id, ?response_level, error_message = %detail, "PID revert failed partway through; the loop may hold a mismatched set of PID constants -- see `history show` for the recorded partial state");
935 anyhow::bail!(
936 "revert failed partway through: {detail} (the loop may now hold a mismatched \
937 set of PID constants -- see `history show {run_id}` for the recorded partial \
938 state)"
939 );
940 }
941 }
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947 use bhtune_core::{ControllerType, DcsTemplate, LoopConfig, LoopTags, ProcessType};
948 use bhtune_db::models::{
949 NewTuneMvActuation, TemplateOrigin, TuneDriver, TuneRunInitialReadings,
950 };
951
952 fn sample_config() -> LoopConfig {
953 LoopConfig {
954 process_type: ProcessType::Flow,
955 controller_type: ControllerType::Pi,
956 relay_amp_percent: 10.0,
957 num_cycles_skip: 1,
958 num_cycles_count: 2,
959 noise_protection_secs: 3,
960 mrft_delay_secs: 0,
961 }
962 }
963
964 fn sample_template() -> DcsTemplate {
965 bhtune_core::built_in_templates().remove(0)
966 }
967
968 fn sample_tags() -> LoopTags {
969 LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &sample_template())
970 }
971
972 #[test]
973 fn row_and_connection_display_decisions_match_their_data() {
974 assert!(!has_rows::<u8>(&[]));
975 assert!(has_rows(&[1_u8]));
976 assert!(!has_recorded_connection(None, None));
977 assert!(has_recorded_connection(Some("Sim.Server"), None));
978 assert!(has_recorded_connection(None, Some("127.0.0.1:7600")));
979 assert!(has_recorded_connection(
980 Some("Sim.Server"),
981 Some("127.0.0.1:7600")
982 ));
983 }
984
985 #[test]
986 fn table_output_decision_matches_the_requested_format() {
987 assert!(is_table_output(OutputFormat::Table));
988 assert!(!is_table_output(OutputFormat::Json));
989 }
990
991 #[test]
992 fn sampling_adequacy_labels_cover_all_states() {
993 assert_eq!(
994 sampling_adequacy_label(SamplingAdequacy::Adequate),
995 "adequate"
996 );
997 assert_eq!(
998 sampling_adequacy_label(SamplingAdequacy::Marginal),
999 "marginal"
1000 );
1001 assert_eq!(
1002 sampling_adequacy_label(SamplingAdequacy::NotAssessed),
1003 "not assessed"
1004 );
1005 }
1006
1007 #[test]
1008 fn fmt_opt_f32_formats_values_and_missing_fields() {
1009 assert_eq!(fmt_opt_f32(Some(1.23456)), "1.2346");
1010 assert_eq!(fmt_opt_f32(Some(-0.5)), "-0.5000");
1011 assert_eq!(fmt_opt_f32(None), "-");
1012 }
1013
1014 #[test]
1015 fn fmt_opt_f64_formats_values_and_missing_fields() {
1016 assert_eq!(fmt_opt_f64(Some(1.23456)), "1.235");
1017 assert_eq!(fmt_opt_f64(Some(-0.5)), "-0.500");
1018 assert_eq!(fmt_opt_f64(None), "-");
1019 }
1020
1021 #[tokio::test]
1022 async fn list_handles_an_empty_database() {
1023 let pool = bhtune_db::connect_in_memory().await.unwrap();
1024 list(&pool, None, 50, 0, OutputFormat::Table).await.unwrap();
1025 list(&pool, None, 50, 0, OutputFormat::Json).await.unwrap();
1026 }
1027
1028 #[tokio::test]
1029 async fn list_propagates_database_errors() {
1030 let pool = bhtune_db::connect_in_memory().await.unwrap();
1031 pool.close().await;
1032
1033 assert!(list(&pool, None, 50, 0, OutputFormat::Table).await.is_err());
1034 }
1035
1036 #[tokio::test]
1037 async fn list_and_show_reflect_a_real_run() {
1038 let pool = bhtune_db::connect_in_memory().await.unwrap();
1039 let now = chrono::Utc::now();
1040 let run = TuneRunRow::start(
1041 &pool,
1042 None,
1043 "Unit1.LIC101.PV",
1044 TuneDriver::Simulator,
1045 sample_config(),
1046 TemplateOrigin::Builtin,
1047 &sample_template(),
1048 &sample_tags(),
1049 now,
1050 )
1051 .await
1052 .unwrap();
1053
1054 TuneRunRow::record_initial_readings(
1055 &pool,
1056 run.id,
1057 TuneRunInitialReadings {
1058 pv_ini: 50.0,
1059 mv_ini: 50.0,
1060 mv_range_low: 0.0,
1061 mv_range_high: 100.0,
1062 pv_range_high: 100.0,
1063 pv_range_low: 0.0,
1064 controller_direction: bhtune_core::ControllerDirection::Reverse,
1065 mode_raw: Some("1".to_string()),
1066 mode_attribute_raw: None,
1067 setpoint_ini: Some(50.0),
1068 },
1069 )
1070 .await
1071 .unwrap();
1072
1073 TuneRunRow::record_timing_metrics(
1074 &pool,
1075 run.id,
1076 bhtune_db::models::TimingMetrics {
1077 basis: bhtune_db::models::TimingBasis::SimulatedFixedStep,
1078 requested_interval_ms: 5,
1079 sample_gap_count: 9,
1080 mean_sample_gap_ms: Some(5.0),
1081 max_sample_gap_ms: Some(5.0),
1082 missed_poll_opportunity_count: 0,
1083 measured_oscillation_period_ms: Some(50.0),
1084 approximate_samples_per_period: Some(10.0),
1085 sampling_adequacy: bhtune_db::models::SamplingAdequacy::Adequate,
1086 poll_latency: None,
1087 },
1088 )
1089 .await
1090 .unwrap();
1091
1092 TuneRunRow::complete(&pool, run.id, now).await.unwrap();
1093
1094 list(&pool, None, 50, 0, OutputFormat::Table).await.unwrap();
1095 list(
1096 &pool,
1097 Some(bhtune_db::models::TuneOutcome::Completed),
1098 50,
1099 0,
1100 OutputFormat::Table,
1101 )
1102 .await
1103 .unwrap();
1104 show(&pool, run.id, OutputFormat::Table).await.unwrap();
1105 show(&pool, run.id, OutputFormat::Json).await.unwrap();
1109 }
1110
1111 #[tokio::test]
1112 async fn show_errors_for_an_unknown_run() {
1113 let pool = bhtune_db::connect_in_memory().await.unwrap();
1114 let err = show(&pool, 999, OutputFormat::Table).await.unwrap_err();
1115 assert!(err.to_string().contains("999"));
1116 }
1117
1118 #[tokio::test]
1119 async fn show_handles_a_failed_run_with_no_initial_readings() {
1120 let pool = bhtune_db::connect_in_memory().await.unwrap();
1121 let now = chrono::Utc::now();
1122 let run = TuneRunRow::start(
1123 &pool,
1124 None,
1125 "Unit1.LIC101.PV",
1126 TuneDriver::Simulator,
1127 sample_config(),
1128 TemplateOrigin::Builtin,
1129 &sample_template(),
1130 &sample_tags(),
1131 now,
1132 )
1133 .await
1134 .unwrap();
1135 TuneRunRow::fail(&pool, run.id, now, "connection refused")
1136 .await
1137 .unwrap();
1138 show(&pool, run.id, OutputFormat::Table).await.unwrap();
1139 show(&pool, run.id, OutputFormat::Json).await.unwrap();
1140 }
1141
1142 #[tokio::test]
1143 async fn show_prints_incomplete_restore_status() {
1144 let pool = bhtune_db::connect_in_memory().await.unwrap();
1145 let now = chrono::Utc::now();
1146 let run = TuneRunRow::start(
1147 &pool,
1148 None,
1149 "Unit1.LIC101.PV",
1150 TuneDriver::Simulator,
1151 sample_config(),
1152 TemplateOrigin::Builtin,
1153 &sample_template(),
1154 &sample_tags(),
1155 now,
1156 )
1157 .await
1158 .unwrap();
1159 TuneRunRow::complete(&pool, run.id, now).await.unwrap();
1160 TuneRunRow::record_restore_status(
1161 &pool,
1162 run.id,
1163 bhtune_db::models::RestoreStatus::Incomplete,
1164 Some("MV: write failed"),
1165 )
1166 .await
1167 .unwrap();
1168
1169 show(&pool, run.id, OutputFormat::Table).await.unwrap();
1170 }
1171
1172 async fn run_with_results_and_writes() -> (SqlitePool, i64) {
1177 let pool = bhtune_db::connect_in_memory().await.unwrap();
1178 let now = chrono::Utc::now();
1179 let run = TuneRunRow::start(
1180 &pool,
1181 None,
1182 "Unit1.LIC101.PV",
1183 TuneDriver::Opcda,
1184 sample_config(),
1185 TemplateOrigin::Builtin,
1186 &sample_template(),
1187 &sample_tags(),
1188 now,
1189 )
1190 .await
1191 .unwrap();
1192 TuneRunRow::record_connection(
1196 &pool,
1197 run.id,
1198 Some("Kepware.KEPServerEX.V6"),
1199 Some("127.0.0.1:7600"),
1200 "{}",
1201 )
1202 .await
1203 .unwrap();
1204 TuneRunRow::complete(&pool, run.id, now).await.unwrap();
1205
1206 TuneResultRow::insert(
1207 &pool,
1208 &TuneResultRow {
1209 id: 0,
1210 run_id: run.id,
1211 response_level: bhtune_core::ResponseLevel::Moderate,
1212 kp: Some(1.5),
1213 ti_minutes: Some(0.7),
1214 td_minutes: Some(0.15),
1215 proportional: Some(12.0),
1216 integral: Some(2.5),
1217 derivative: Some(0.6),
1218 status: bhtune_core::TuningResultStatus::Valid,
1219 invalid_reason: None,
1220 },
1221 )
1222 .await
1223 .unwrap();
1224
1225 let mut successful =
1226 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1227 successful.previous = Some(bhtune_db::models::WriteReadback {
1228 proportional: 10.0,
1229 integral: 2.0,
1230 derivative: 0.5,
1231 });
1232 successful.proportional_written = Some(12.0);
1233 successful.integral_written = Some(2.5);
1234 successful.derivative_written = Some(0.6);
1235 successful.proportional_readback = Some(12.0);
1236 successful.integral_readback = Some(2.5);
1237 successful.derivative_readback = Some(0.6);
1238 successful.success = true;
1239 TuneWriteRow::insert(&pool, run.id, successful)
1240 .await
1241 .unwrap();
1242
1243 let mut failed =
1244 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1245 failed.previous = Some(bhtune_db::models::WriteReadback {
1246 proportional: 10.0,
1247 integral: 2.0,
1248 derivative: 0.5,
1249 });
1250 failed.proportional_written = Some(12.0);
1251 failed.error_message = Some("mock failure".to_string());
1252 failed.rollback_state = Some(bhtune_db::models::RollbackState::Failed);
1253 failed.rollback_error = Some("mock rollback failure".to_string());
1254 TuneWriteRow::insert(&pool, run.id, failed).await.unwrap();
1255
1256 let confirmed_actuation = TuneMvActuationRow::insert_pending(
1257 &pool,
1258 run.id,
1259 NewTuneMvActuation {
1260 sequence: 0,
1261 kind: MvActuationKind::Relay,
1262 commanded_at: now,
1263 target_mv: 55.0,
1264 previous_commanded_mv: Some(50.0),
1265 tolerance: 0.5,
1266 confirmation_due_at: now + chrono::Duration::seconds(4),
1267 },
1268 )
1269 .await
1270 .unwrap();
1271 TuneMvActuationRow::record_final_observation(
1272 &pool,
1273 confirmed_actuation.id,
1274 now + chrono::Duration::seconds(1),
1275 Some(55.0),
1276 Some(SampleQuality::Good),
1277 MvActuationStatus::Confirmed,
1278 None,
1279 )
1280 .await
1281 .unwrap();
1282
1283 let superseded_actuation = TuneMvActuationRow::insert_pending(
1284 &pool,
1285 run.id,
1286 NewTuneMvActuation {
1287 sequence: 1,
1288 kind: MvActuationKind::Restore,
1289 commanded_at: now + chrono::Duration::seconds(5),
1290 target_mv: 50.0,
1291 previous_commanded_mv: Some(55.0),
1292 tolerance: 0.5,
1293 confirmation_due_at: now + chrono::Duration::seconds(9),
1294 },
1295 )
1296 .await
1297 .unwrap();
1298 TuneMvActuationRow::finalize(
1299 &pool,
1300 superseded_actuation.id,
1301 MvActuationStatus::Superseded,
1302 Some("restore took over confirmation"),
1303 )
1304 .await
1305 .unwrap();
1306
1307 (pool, run.id)
1308 }
1309
1310 #[tokio::test]
1311 async fn show_prints_calculated_results_and_write_back_audit_rows() {
1312 let (pool, run_id) = run_with_results_and_writes().await;
1313 show(&pool, run_id, OutputFormat::Table).await.unwrap();
1314 }
1315
1316 #[tokio::test]
1317 async fn show_prints_timing_metrics_when_present() {
1318 let (pool, run_id) = run_with_results_and_writes().await;
1319 TuneRunRow::record_timing_metrics(
1320 &pool,
1321 run_id,
1322 bhtune_db::models::TimingMetrics {
1323 basis: bhtune_db::models::TimingBasis::LiveMonotonic,
1324 requested_interval_ms: 800,
1325 sample_gap_count: 2,
1326 mean_sample_gap_ms: Some(900.0),
1327 max_sample_gap_ms: Some(1_200.0),
1328 missed_poll_opportunity_count: 1,
1329 measured_oscillation_period_ms: Some(4_800.0),
1330 approximate_samples_per_period: Some(5.33),
1331 sampling_adequacy: bhtune_db::models::SamplingAdequacy::Marginal,
1332 poll_latency: Some(bhtune_db::models::PollLatencyMetrics {
1333 pv_read: bhtune_db::models::TimingSummary {
1334 count: 2,
1335 mean_ms: Some(11.0),
1336 max_ms: Some(15.0),
1337 },
1338 mv_write: bhtune_db::models::TimingSummary {
1339 count: 1,
1340 mean_ms: Some(20.0),
1341 max_ms: Some(20.0),
1342 },
1343 mv_verification: bhtune_db::models::TimingSummary {
1344 count: 1,
1345 mean_ms: Some(30.0),
1346 max_ms: Some(30.0),
1347 },
1348 sample_persist: bhtune_db::models::TimingSummary {
1349 count: 2,
1350 mean_ms: Some(4.0),
1351 max_ms: Some(5.0),
1352 },
1353 tick_work: bhtune_db::models::TimingSummary {
1354 count: 2,
1355 mean_ms: Some(70.0),
1356 max_ms: Some(75.0),
1357 },
1358 }),
1359 },
1360 )
1361 .await
1362 .unwrap();
1363 show(&pool, run_id, OutputFormat::Table).await.unwrap();
1364 }
1365
1366 #[tokio::test]
1367 async fn list_output_json_is_valid_json_with_the_expected_shape() {
1368 let (pool, _run_id) = run_with_results_and_writes().await;
1369 list(&pool, None, 50, 0, OutputFormat::Json).await.unwrap();
1373 list(
1374 &pool,
1375 Some(bhtune_db::models::TuneOutcome::Completed),
1376 50,
1377 0,
1378 OutputFormat::Json,
1379 )
1380 .await
1381 .unwrap();
1382 }
1383
1384 #[tokio::test]
1385 async fn show_output_json_covers_readings_results_and_writes() {
1386 let (pool, run_id) = run_with_results_and_writes().await;
1387 show(&pool, run_id, OutputFormat::Json).await.unwrap();
1388 }
1389
1390 #[tokio::test]
1391 async fn run_dispatches_list_and_show_in_both_output_formats() {
1392 let (pool, run_id) = run_with_results_and_writes().await;
1393 let config = crate::config::BhtuneConfig::default();
1394 run(
1395 &pool,
1396 HistoryCommand::List {
1397 outcome: None,
1398 limit: 50,
1399 offset: 0,
1400 output: OutputFormat::Table,
1401 },
1402 &config,
1403 )
1404 .await
1405 .unwrap();
1406 run(
1407 &pool,
1408 HistoryCommand::List {
1409 outcome: None,
1410 limit: 50,
1411 offset: 0,
1412 output: OutputFormat::Json,
1413 },
1414 &config,
1415 )
1416 .await
1417 .unwrap();
1418 run(
1419 &pool,
1420 HistoryCommand::Show {
1421 run_id,
1422 output: OutputFormat::Table,
1423 },
1424 &config,
1425 )
1426 .await
1427 .unwrap();
1428 run(
1429 &pool,
1430 HistoryCommand::Show {
1431 run_id,
1432 output: OutputFormat::Json,
1433 },
1434 &config,
1435 )
1436 .await
1437 .unwrap();
1438 run(
1439 &pool,
1440 HistoryCommand::Prune {
1441 older_than_days: Some(1),
1442 dry_run: true,
1443 output: OutputFormat::Table,
1444 },
1445 &crate::config::BhtuneConfig {
1446 retention_days: Some(1),
1447 ..crate::config::BhtuneConfig::default()
1448 },
1449 )
1450 .await
1451 .unwrap();
1452 }
1453
1454 #[tokio::test]
1455 async fn run_dispatches_revert_and_surfaces_its_error() {
1456 let (pool, run_id) = run_with_results_and_writes().await;
1464 let config = crate::config::BhtuneConfig::default();
1465 let err = run(
1466 &pool,
1467 HistoryCommand::Revert {
1468 run_id,
1469 bridge_host: None,
1470 server: None,
1471 yes: true,
1472 output: OutputFormat::Table,
1473 },
1474 &config,
1475 )
1476 .await
1477 .unwrap_err();
1478 assert!(!err.to_string().is_empty());
1479 }
1480
1481 async fn opcda_run_with_no_writes(bridge_host: &str, server: &str) -> (SqlitePool, i64) {
1489 let pool = bhtune_db::connect_in_memory().await.unwrap();
1490 let now = chrono::Utc::now();
1491 let run = TuneRunRow::start(
1492 &pool,
1493 None,
1494 "Unit1.LIC101.PV",
1495 TuneDriver::Opcda,
1496 sample_config(),
1497 TemplateOrigin::Builtin,
1498 &sample_template(),
1499 &sample_tags(),
1500 now,
1501 )
1502 .await
1503 .unwrap();
1504 TuneRunRow::record_connection(&pool, run.id, Some(server), Some(bridge_host), "{}")
1505 .await
1506 .unwrap();
1507 (pool, run.id)
1508 }
1509
1510 #[tokio::test]
1511 async fn revert_errors_when_no_such_run_exists() {
1512 let pool = bhtune_db::connect_in_memory().await.unwrap();
1513 let err = revert(
1514 &pool,
1515 999,
1516 None,
1517 None,
1518 true,
1519 OutputFormat::Table,
1520 &crate::config::BhtuneConfig::default(),
1521 )
1522 .await
1523 .unwrap_err();
1524 assert!(err.to_string().contains("no run with id 999"));
1525 }
1526
1527 #[tokio::test]
1528 async fn revert_errors_when_the_run_did_not_use_the_opcda_driver() {
1529 let pool = bhtune_db::connect_in_memory().await.unwrap();
1530 let now = chrono::Utc::now();
1531 let run = TuneRunRow::start(
1532 &pool,
1533 None,
1534 "Unit1.LIC101.PV",
1535 TuneDriver::Simulator,
1536 sample_config(),
1537 TemplateOrigin::Builtin,
1538 &sample_template(),
1539 &sample_tags(),
1540 now,
1541 )
1542 .await
1543 .unwrap();
1544 let err = revert(
1545 &pool,
1546 run.id,
1547 None,
1548 None,
1549 true,
1550 OutputFormat::Table,
1551 &crate::config::BhtuneConfig::default(),
1552 )
1553 .await
1554 .unwrap_err();
1555 assert!(err.to_string().contains("Simulator"));
1556 }
1557
1558 #[tokio::test]
1559 async fn revert_errors_when_no_write_back_is_recorded() {
1560 let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1561 let err = revert(
1562 &pool,
1563 run_id,
1564 None,
1565 None,
1566 true,
1567 OutputFormat::Table,
1568 &crate::config::BhtuneConfig::default(),
1569 )
1570 .await
1571 .unwrap_err();
1572 assert!(err.to_string().contains("no recorded PID write-back"));
1573 }
1574
1575 #[tokio::test]
1576 async fn revert_errors_when_the_original_writes_pre_read_failed() {
1577 let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1578 let now = chrono::Utc::now();
1579 let mut failed_pre_read =
1580 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1581 failed_pre_read.error_message = Some("pre-read of Proportional tag 'X' failed".to_string());
1582 TuneWriteRow::insert(&pool, run_id, failed_pre_read)
1583 .await
1584 .unwrap();
1585
1586 let err = revert(
1587 &pool,
1588 run_id,
1589 None,
1590 None,
1591 true,
1592 OutputFormat::Table,
1593 &crate::config::BhtuneConfig::default(),
1594 )
1595 .await
1596 .unwrap_err();
1597 assert!(err.to_string().contains("nothing to revert to"));
1598 }
1599
1600 #[tokio::test]
1601 async fn revert_errors_when_yes_is_not_set() {
1602 let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1603 let now = chrono::Utc::now();
1604 let mut successful =
1605 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1606 successful.previous = Some(bhtune_db::models::WriteReadback {
1607 proportional: 10.0,
1608 integral: 2.0,
1609 derivative: 0.5,
1610 });
1611 successful.success = true;
1612 TuneWriteRow::insert(&pool, run_id, successful)
1613 .await
1614 .unwrap();
1615
1616 let err = revert(
1617 &pool,
1618 run_id,
1619 None,
1620 None,
1621 false,
1622 OutputFormat::Table,
1623 &crate::config::BhtuneConfig::default(),
1624 )
1625 .await
1626 .unwrap_err();
1627 assert!(err.to_string().contains("--yes"));
1628 }
1629
1630 #[tokio::test]
1631 async fn revert_errors_when_the_snapshotted_tags_have_no_pid_constant_tags() {
1632 let pool = bhtune_db::connect_in_memory().await.unwrap();
1633 let now = chrono::Utc::now();
1634 let mut tags = sample_tags();
1635 tags.proportional_constant = None;
1636 let run = TuneRunRow::start(
1637 &pool,
1638 None,
1639 "Unit1.LIC101.PV",
1640 TuneDriver::Opcda,
1641 sample_config(),
1642 TemplateOrigin::Builtin,
1643 &sample_template(),
1644 &tags,
1645 now,
1646 )
1647 .await
1648 .unwrap();
1649 TuneRunRow::record_connection(&pool, run.id, Some("Sim.Server"), Some("bridge:1"), "{}")
1650 .await
1651 .unwrap();
1652 let mut successful =
1653 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1654 successful.previous = Some(bhtune_db::models::WriteReadback {
1655 proportional: 10.0,
1656 integral: 2.0,
1657 derivative: 0.5,
1658 });
1659 successful.success = true;
1660 TuneWriteRow::insert(&pool, run.id, successful)
1661 .await
1662 .unwrap();
1663
1664 let err = revert(
1665 &pool,
1666 run.id,
1667 None,
1668 None,
1669 true,
1670 OutputFormat::Table,
1671 &crate::config::BhtuneConfig::default(),
1672 )
1673 .await
1674 .unwrap_err();
1675 assert!(err.to_string().contains("no PID constant tags"));
1676 }
1677
1678 #[tokio::test]
1679 async fn revert_errors_when_the_driver_connection_fails() {
1680 let (pool, run_id) = opcda_run_with_no_writes("127.0.0.1:1", "Sim.Server").await;
1685 let now = chrono::Utc::now();
1686 let mut successful =
1687 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1688 successful.previous = Some(bhtune_db::models::WriteReadback {
1689 proportional: 10.0,
1690 integral: 2.0,
1691 derivative: 0.5,
1692 });
1693 successful.success = true;
1694 TuneWriteRow::insert(&pool, run_id, successful)
1695 .await
1696 .unwrap();
1697
1698 let err = revert(
1699 &pool,
1700 run_id,
1701 None,
1702 None,
1703 true,
1704 OutputFormat::Table,
1705 &crate::config::BhtuneConfig::default(),
1706 )
1707 .await
1708 .unwrap_err();
1709 assert!(!err.to_string().is_empty());
1710 }
1711
1712 #[tokio::test]
1713 async fn revert_errors_when_the_run_has_no_recorded_connection() {
1714 let pool = bhtune_db::connect_in_memory().await.unwrap();
1719 let now = chrono::Utc::now();
1720 let run = TuneRunRow::start(
1721 &pool,
1722 None,
1723 "Unit1.LIC101.PV",
1724 TuneDriver::Opcda,
1725 sample_config(),
1726 TemplateOrigin::Builtin,
1727 &sample_template(),
1728 &sample_tags(),
1729 now,
1730 )
1731 .await
1732 .unwrap();
1733 let mut successful =
1734 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1735 successful.previous = Some(bhtune_db::models::WriteReadback {
1736 proportional: 10.0,
1737 integral: 2.0,
1738 derivative: 0.5,
1739 });
1740 successful.success = true;
1741 TuneWriteRow::insert(&pool, run.id, successful)
1742 .await
1743 .unwrap();
1744
1745 let err = revert(
1746 &pool,
1747 run.id,
1748 None,
1749 None,
1750 true,
1751 OutputFormat::Table,
1752 &crate::config::BhtuneConfig::default(),
1753 )
1754 .await
1755 .unwrap_err();
1756 assert!(err.to_string().contains("recorded OPC server is missing"));
1757 }
1758
1759 #[tokio::test]
1760 async fn resolve_revert_connection_rejects_a_missing_recorded_bridge_host() {
1761 let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1762 let mut run = TuneRunRow::get(&pool, run_id).await.unwrap().unwrap();
1763 run.bridge_host = None;
1764
1765 let err = resolve_revert_connection(&run, None, None).unwrap_err();
1766 assert!(err.to_string().contains("recorded bridge host is missing"));
1767 }
1768
1769 #[tokio::test]
1770 async fn revert_errors_when_an_explicit_server_flag_contradicts_the_recorded_one() {
1771 let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1772 let now = chrono::Utc::now();
1773 let mut successful =
1774 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1775 successful.previous = Some(bhtune_db::models::WriteReadback {
1776 proportional: 10.0,
1777 integral: 2.0,
1778 derivative: 0.5,
1779 });
1780 successful.success = true;
1781 TuneWriteRow::insert(&pool, run_id, successful)
1782 .await
1783 .unwrap();
1784
1785 let err = revert(
1786 &pool,
1787 run_id,
1788 None,
1789 Some("Different.Server".to_string()),
1790 true,
1791 OutputFormat::Table,
1792 &crate::config::BhtuneConfig::default(),
1793 )
1794 .await
1795 .unwrap_err();
1796 let message = err.to_string();
1797 assert!(message.contains("contradicts"));
1798 assert!(message.contains("Sim.Server"));
1799 }
1800
1801 #[tokio::test]
1802 async fn revert_errors_when_an_explicit_bridge_host_flag_contradicts_the_recorded_one() {
1803 let (pool, run_id) = opcda_run_with_no_writes("bridge:1", "Sim.Server").await;
1804 let now = chrono::Utc::now();
1805 let mut successful =
1806 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1807 successful.previous = Some(bhtune_db::models::WriteReadback {
1808 proportional: 10.0,
1809 integral: 2.0,
1810 derivative: 0.5,
1811 });
1812 successful.success = true;
1813 TuneWriteRow::insert(&pool, run_id, successful)
1814 .await
1815 .unwrap();
1816
1817 let err = revert(
1818 &pool,
1819 run_id,
1820 Some("different-bridge:2".to_string()),
1821 None,
1822 true,
1823 OutputFormat::Table,
1824 &crate::config::BhtuneConfig::default(),
1825 )
1826 .await
1827 .unwrap_err();
1828 let message = err.to_string();
1829 assert!(message.contains("contradicts"));
1830 assert!(message.contains("bridge:1"));
1831 }
1832
1833 #[tokio::test]
1834 async fn revert_succeeds_and_records_a_revert_kind_write() {
1835 use crate::test_support::{MockBridgeService, start_mock_server};
1836 use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
1837
1838 let (host, server) = start_mock_server(MockBridgeService {
1844 read_response: ReadResponse {
1845 values: vec![ProtoTagValue {
1846 tag_id: "ignored".to_string(),
1847 value: "10.0".to_string(),
1848 quality: "Good".to_string(),
1849 timestamp: "2024-01-15 10:23:45".to_string(),
1850 }],
1851 },
1852 write_response: WriteResponse {
1853 tag_id: "ignored".to_string(),
1854 success: true,
1855 error: None,
1856 },
1857 ..Default::default()
1858 })
1859 .await;
1860
1861 let (pool, run_id) = opcda_run_with_no_writes(&host, "Sim.Server").await;
1862 let now = chrono::Utc::now();
1863 let mut successful =
1864 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1865 successful.previous = Some(bhtune_db::models::WriteReadback {
1866 proportional: 10.0,
1867 integral: 10.0,
1868 derivative: 10.0,
1869 });
1870 successful.success = true;
1871 TuneWriteRow::insert(&pool, run_id, successful)
1872 .await
1873 .unwrap();
1874
1875 revert(
1880 &pool,
1881 run_id,
1882 Some(host),
1883 Some("Sim.Server".to_string()),
1884 true,
1885 OutputFormat::Table,
1886 &crate::config::BhtuneConfig::default(),
1887 )
1888 .await
1889 .unwrap();
1890
1891 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
1892 let revert_row = writes.iter().find(|w| w.kind == WriteKind::Revert).unwrap();
1893 assert!(revert_row.success);
1894 assert_eq!(revert_row.proportional_written, Some(10.0));
1895 assert_eq!(revert_row.integral_written, Some(10.0));
1896 assert_eq!(revert_row.derivative_written, Some(10.0));
1897 assert_eq!(revert_row.proportional_readback, Some(10.0));
1898 assert_eq!(revert_row.integral_readback, Some(10.0));
1899 assert_eq!(revert_row.derivative_readback, Some(10.0));
1900 assert_eq!(revert_row.rollback_state, None);
1902 assert_eq!(
1905 revert_row.previous,
1906 Some(bhtune_db::models::WriteReadback {
1907 proportional: 10.0,
1908 integral: 10.0,
1909 derivative: 10.0,
1910 })
1911 );
1912
1913 server.shutdown().await;
1914 }
1915
1916 #[tokio::test]
1917 async fn revert_records_a_failed_audit_row_when_a_later_constant_fails_verification() {
1918 use crate::test_support::{MockBridgeService, start_mock_server};
1919 use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
1920
1921 let (host, server) = start_mock_server(
1926 MockBridgeService {
1927 read_response: ReadResponse {
1928 values: vec![ProtoTagValue {
1929 tag_id: "ignored".to_string(),
1930 value: "10.0".to_string(),
1931 quality: "Good".to_string(),
1932 timestamp: "2024-01-15 10:23:45".to_string(),
1933 }],
1934 },
1935 write_response: WriteResponse {
1936 tag_id: "ignored".to_string(),
1937 success: true,
1938 error: None,
1939 },
1940 ..Default::default()
1941 }
1942 .failing_read_from_call(5),
1943 )
1944 .await;
1945
1946 let (pool, run_id) = opcda_run_with_no_writes(&host, "Sim.Server").await;
1947 let now = chrono::Utc::now();
1948 let mut successful =
1949 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
1950 successful.previous = Some(bhtune_db::models::WriteReadback {
1951 proportional: 10.0,
1952 integral: 10.0,
1953 derivative: 10.0,
1954 });
1955 successful.success = true;
1956 TuneWriteRow::insert(&pool, run_id, successful)
1957 .await
1958 .unwrap();
1959
1960 let err = revert(
1961 &pool,
1962 run_id,
1963 None,
1964 None,
1965 true,
1966 OutputFormat::Table,
1967 &crate::config::BhtuneConfig::default(),
1968 )
1969 .await
1970 .unwrap_err();
1971 assert!(err.to_string().contains("revert failed partway through"));
1972
1973 let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
1974 let revert_row = writes.iter().find(|w| w.kind == WriteKind::Revert).unwrap();
1975 assert!(!revert_row.success);
1976 assert!(
1977 revert_row
1978 .error_message
1979 .as_ref()
1980 .unwrap()
1981 .contains("Integral")
1982 );
1983 assert_eq!(revert_row.proportional_written, Some(10.0));
1984 assert_eq!(revert_row.proportional_readback, Some(10.0));
1985 assert_eq!(revert_row.integral_written, Some(10.0));
1986 assert_eq!(revert_row.integral_readback, None);
1987 assert_eq!(revert_row.derivative_written, None);
1988 assert_eq!(revert_row.derivative_readback, None);
1989 assert_eq!(revert_row.rollback_state, None);
1990
1991 server.shutdown().await;
1992 }
1993
1994 #[tokio::test]
1995 async fn revert_succeeds_with_json_output_format() {
1996 use crate::test_support::{MockBridgeService, start_mock_server};
1997 use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
1998
1999 let (host, server) = start_mock_server(MockBridgeService {
2006 read_response: ReadResponse {
2007 values: vec![ProtoTagValue {
2008 tag_id: "ignored".to_string(),
2009 value: "10.0".to_string(),
2010 quality: "Good".to_string(),
2011 timestamp: "2024-01-15 10:23:45".to_string(),
2012 }],
2013 },
2014 write_response: WriteResponse {
2015 tag_id: "ignored".to_string(),
2016 success: true,
2017 error: None,
2018 },
2019 ..Default::default()
2020 })
2021 .await;
2022
2023 let (pool, run_id) = opcda_run_with_no_writes(&host, "Sim.Server").await;
2024 let now = chrono::Utc::now();
2025 let mut successful =
2026 bhtune_db::models::NewTuneWrite::new(bhtune_core::ResponseLevel::Moderate, now);
2027 successful.previous = Some(bhtune_db::models::WriteReadback {
2028 proportional: 10.0,
2029 integral: 10.0,
2030 derivative: 10.0,
2031 });
2032 successful.success = true;
2033 TuneWriteRow::insert(&pool, run_id, successful)
2034 .await
2035 .unwrap();
2036
2037 revert(
2038 &pool,
2039 run_id,
2040 None,
2041 None,
2042 true,
2043 OutputFormat::Json,
2044 &crate::config::BhtuneConfig::default(),
2045 )
2046 .await
2047 .unwrap();
2048
2049 server.shutdown().await;
2050 }
2051
2052 async fn start_run_at(pool: &SqlitePool, started_at: chrono::DateTime<chrono::Utc>) -> i64 {
2056 TuneRunRow::start(
2057 pool,
2058 None,
2059 "Unit1.LIC101.PV",
2060 TuneDriver::Simulator,
2061 sample_config(),
2062 TemplateOrigin::Builtin,
2063 &sample_template(),
2064 &sample_tags(),
2065 started_at,
2066 )
2067 .await
2068 .unwrap()
2069 .id
2070 }
2071
2072 #[tokio::test]
2073 async fn prune_errors_when_no_retention_policy_is_configured() {
2074 let pool = bhtune_db::connect_in_memory().await.unwrap();
2075 let config = crate::config::BhtuneConfig::default();
2076 let err = prune(&pool, &config, None, false, OutputFormat::Table)
2077 .await
2078 .unwrap_err();
2079 assert!(err.to_string().contains("no retention policy configured"));
2080 }
2081
2082 #[tokio::test]
2083 async fn prune_dry_run_reports_the_count_without_deleting_anything() {
2084 let pool = bhtune_db::connect_in_memory().await.unwrap();
2085 let now = chrono::Utc::now();
2086 let old_id = start_run_at(&pool, now - chrono::Duration::days(45)).await;
2087 let recent_id = start_run_at(&pool, now - chrono::Duration::days(1)).await;
2088 let config = crate::config::BhtuneConfig {
2089 retention_days: Some(30),
2090 ..Default::default()
2091 };
2092
2093 prune(&pool, &config, None, true, OutputFormat::Table)
2094 .await
2095 .unwrap();
2096 prune(&pool, &config, None, true, OutputFormat::Json)
2097 .await
2098 .unwrap();
2099
2100 assert!(TuneRunRow::get(&pool, old_id).await.unwrap().is_some());
2102 assert!(TuneRunRow::get(&pool, recent_id).await.unwrap().is_some());
2103 }
2104
2105 #[tokio::test]
2106 async fn prune_deletes_matching_runs_when_not_a_dry_run() {
2107 let pool = bhtune_db::connect_in_memory().await.unwrap();
2108 let now = chrono::Utc::now();
2109 let old_id = start_run_at(&pool, now - chrono::Duration::days(45)).await;
2110 let recent_id = start_run_at(&pool, now - chrono::Duration::days(1)).await;
2111 let config = crate::config::BhtuneConfig {
2112 retention_days: Some(30),
2113 ..Default::default()
2114 };
2115
2116 prune(&pool, &config, None, false, OutputFormat::Table)
2117 .await
2118 .unwrap();
2119
2120 assert!(TuneRunRow::get(&pool, old_id).await.unwrap().is_none());
2121 assert!(TuneRunRow::get(&pool, recent_id).await.unwrap().is_some());
2122 }
2123
2124 #[tokio::test]
2125 async fn prune_older_than_days_overrides_the_configured_policy() {
2126 let pool = bhtune_db::connect_in_memory().await.unwrap();
2127 let now = chrono::Utc::now();
2128 let run_id = start_run_at(&pool, now - chrono::Duration::days(45)).await;
2131 let config = crate::config::BhtuneConfig {
2132 retention_days: Some(90),
2133 ..Default::default()
2134 };
2135
2136 prune(&pool, &config, Some(30), false, OutputFormat::Table)
2137 .await
2138 .unwrap();
2139
2140 assert!(TuneRunRow::get(&pool, run_id).await.unwrap().is_none());
2141 }
2142
2143 #[tokio::test]
2144 async fn prune_json_output_is_a_single_parseable_object() {
2145 let pool = bhtune_db::connect_in_memory().await.unwrap();
2146 let config = crate::config::BhtuneConfig {
2147 retention_days: Some(30),
2148 ..Default::default()
2149 };
2150 prune(&pool, &config, None, true, OutputFormat::Json)
2155 .await
2156 .unwrap();
2157 prune(&pool, &config, None, false, OutputFormat::Json)
2158 .await
2159 .unwrap();
2160 }
2161}