1#![allow(rustdoc::broken_intra_doc_links)]
2
3use std::collections::{HashMap, HashSet};
4use std::time::Duration;
5
6use bhtune_core::{
7 Action, ControllerDirection, ControllerType, DcsTemplate, InitialReadings, LoopConfig,
8 LoopTags, MrftEngine, MvRange, ProcessType, PvRange, ResponseLevel, TagOrValue, TagOverrides,
9 lookup,
10};
11use bhtune_db::SqlitePool;
12use bhtune_db::models::{
13 DcsTemplateRow, LiveOperationKind, TimingBasis, TimingMetrics, TuneDriver,
14 TuneRunInitialReadings, TuneRunRow,
15};
16use bhtune_driver::{Driver, TagValue};
17use chrono::Utc;
18
19use crate::cancel::CtrlC;
20use crate::live_ownership::{AuditedDriver, LiveOperationGuard};
21use crate::timing::{PollTimingAccumulator, RunTimeAnchor};
22
23use super::actuation::{
24 MvActuationTracker, finalize_pending_for_run_best_effort, validate_relay_actuation_step,
25};
26#[cfg(test)]
27use super::config::test_effective_timing;
28use super::config::{EffectiveTiming, build_loop_config_with_timing, build_loop_tags};
29use super::outcome::{
30 AbortReason, RunOutcome, TuneOutcome, TuneRunReport, format_mv_actuation_abort_reason,
31 tune_outcome_for_run,
32};
33use super::poll::{CompletedPoll, PollOutcome, persist_results, run_polling_loop_with_timing};
34use super::quality::{
35 read_batch_f32, read_batch_raw, read_f32, resolve_direction_from_batch, resolve_f32_from_batch,
36 write_raw,
37};
38use super::request::{DriverKind, TuneRequest, ValidatedTuneRequest};
39use super::restore::{
40 RestoreAttempt, attempt_restore_with_actuation_with_timing, record_restore_status_best_effort,
41 restore_best_effort_then_propagate_with_timing,
42};
43use super::timing::{
44 completed_oscillation_period_ms, record_timing_metrics_if_present,
45 warn_on_missed_poll_opportunities,
46};
47use super::writeback::{PidWritePreview, WriteBackHandler, maybe_write_back_with_owner};
48
49pub struct PreparedTune {
62 pub(super) run_id: i64,
63 pub(super) args: TuneRequest,
64 pub(super) template: DcsTemplate,
65 pub(super) tags: LoopTags,
66 pub(super) driver: Box<dyn Driver>,
67 pub(super) config: LoopConfig,
68 pub(super) timing: EffectiveTiming,
69 pub(super) time_anchor: RunTimeAnchor,
70 pub(super) write_pid: Option<ResponseLevel>,
71 pub(super) allow_uncertain_quality: bool,
72 pub(super) ownership: Option<LiveOperationGuard>,
73}
74pub async fn prepare_owned<R>(
77 pool: &SqlitePool,
78 args: R,
79 app_config: &crate::config::BhtuneConfig,
80 demo_session_id: i64,
81) -> anyhow::Result<PreparedTune>
82where
83 R: TryInto<ValidatedTuneRequest>,
84 R::Error: std::error::Error + Send + Sync + 'static,
85{
86 let args = args.try_into()?;
87 if args.as_request().driver != DriverKind::Simulator {
88 anyhow::bail!("demo sessions may only start simulator runs");
89 }
90 prepare_internal(pool, args, app_config, Some(demo_session_id)).await
91}
92impl PreparedTune {
93 pub fn run_id(&self) -> i64 {
97 self.run_id
98 }
99}
100#[derive(serde::Serialize)]
111pub(super) struct RequestSnapshot<'a> {
112 pub(super) tagname: &'a str,
113 pub(super) template: &'a str,
114 pub(super) process_type: ProcessType,
115 pub(super) controller_type: ControllerType,
116 pub(super) relay_amp: f32,
117 pub(super) cycles_skip: Option<u32>,
118 pub(super) cycles_count: Option<u32>,
119 pub(super) noise_protection_secs: Option<u32>,
120 pub(super) driver: TuneDriver,
121 pub(super) bridge_host: Option<&'a str>,
122 pub(super) server: Option<&'a str>,
123 pub(super) sim_gain: f32,
124 pub(super) sim_tau: f32,
125 pub(super) sim_dead_time: f32,
126 pub(super) sim_noise: f32,
127 pub(super) sim_seed: u64,
128 pub(super) sim_initial_pv: f32,
129 pub(super) sim_initial_mv: f32,
130 pub(super) pv_range_high: Option<f32>,
131 pub(super) pv_range_low: Option<f32>,
132 pub(super) mv_range_high: Option<f32>,
133 pub(super) mv_range_low: Option<f32>,
134 pub(super) direction: Option<ControllerDirection>,
135 pub(super) tag_overrides: Option<&'a TagOverrides>,
136 pub(super) notes: Option<&'a str>,
137 pub(super) yes: bool,
138 pub(super) write_pid: Option<ResponseLevel>,
139}
140pub async fn prepare<R>(
146 pool: &SqlitePool,
147 args: R,
148 app_config: &crate::config::BhtuneConfig,
149) -> anyhow::Result<PreparedTune>
150where
151 R: TryInto<ValidatedTuneRequest>,
152 R::Error: std::error::Error + Send + Sync + 'static,
153{
154 let args = args.try_into()?;
155 prepare_internal(pool, args, app_config, None).await
156}
157pub(super) async fn prepare_internal(
158 pool: &SqlitePool,
159 args: ValidatedTuneRequest,
160 app_config: &crate::config::BhtuneConfig,
161 demo_session_id: Option<i64>,
162) -> anyhow::Result<PreparedTune> {
163 let mut args = args.into_request();
164 if args.write_pid.is_some() && !args.yes {
168 anyhow::bail!(
169 "--write-pid requires --yes: writing PID constants back to the DCS with no \
170 human present to confirm must be an explicit, deliberate choice"
171 );
172 }
173 let timing: EffectiveTiming = crate::config::resolve_and_validate_tuning_config(
174 &app_config.tuning,
175 args.driver == DriverKind::Opcda,
176 )?
177 .into();
178 let allow_uncertain_quality = app_config.allow_uncertain_quality;
179
180 let db_driver = args.driver.into();
181
182 #[allow(
186 clippy::expect_used,
187 reason = "RequestSnapshot is plain enums and finite scalars, serialized before any driver I/O"
188 )]
189 let request_json = serde_json::to_string(&RequestSnapshot {
190 tagname: &args.tagname,
191 template: &args.template,
192 process_type: args.process_type,
193 controller_type: args.controller_type,
194 relay_amp: args.relay_amp,
195 cycles_skip: args.cycles_skip,
196 cycles_count: args.cycles_count,
197 noise_protection_secs: args.noise_protection_secs,
198 driver: db_driver,
199 bridge_host: args.bridge_host.as_deref(),
200 server: args.server.as_deref(),
201 sim_gain: args.sim_gain,
202 sim_tau: args.sim_tau,
203 sim_dead_time: args.sim_dead_time,
204 sim_noise: args.sim_noise,
205 sim_seed: args.sim_seed,
206 sim_initial_pv: args.sim_initial_pv,
207 sim_initial_mv: args.sim_initial_mv,
208 pv_range_high: args.pv_range_high,
209 pv_range_low: args.pv_range_low,
210 mv_range_high: args.mv_range_high,
211 mv_range_low: args.mv_range_low,
212 direction: args.direction,
213 tag_overrides: args.tag_overrides.as_ref(),
214 notes: args.notes.as_deref(),
215 yes: args.yes,
216 write_pid: args.write_pid,
217 })
218 .expect(
219 "RequestSnapshot serialization is infallible: plain enum/scalar fields, no maps and \
220 no floats that JSON can't represent (every f32 here is validated finite before \
221 reaching this call, per safety-validation)",
222 );
223
224 args.bridge_host = Some(crate::config::resolve_bridge_host(
225 args.bridge_host.take(),
226 app_config,
227 ));
228 if matches!(args.driver, DriverKind::Opcda) {
229 args.server = Some(crate::config::resolve_server(
230 args.server.take(),
231 app_config,
232 )?);
233 }
234
235 let template_row = DcsTemplateRow::get_by_name(pool, &args.template)
236 .await?
237 .ok_or_else(|| anyhow::anyhow!("no template named '{}'", args.template))?;
238 let template_origin = template_row.origin;
239 let template = template_row.template;
240
241 let config = build_loop_config_with_timing(&args, timing)?;
242 let tags = build_loop_tags(&args, &template)?;
243 let gateway_compatibility = if let (DriverKind::Opcda, Some(bridge_host), Some(server)) = (
246 args.driver,
247 args.bridge_host.as_deref(),
248 args.server.as_deref(),
249 ) {
250 Some(crate::gateway::require_live_gateway_compatible(bridge_host, Some(server)).await?)
251 } else {
252 None
253 };
254 let time_anchor = RunTimeAnchor::now();
255 let started_at = time_anchor.utc();
256 let run = TuneRunRow::start_with_demo_session(
257 pool,
258 demo_session_id,
259 None,
260 &args.tagname,
261 db_driver,
262 config,
263 template_origin,
264 &template,
265 &tags,
266 started_at,
267 )
268 .await?;
269 let metadata_result = async {
270 TuneRunRow::record_effective_tuning(pool, run.id, timing.into()).await?;
271 TuneRunRow::record_allow_uncertain_quality(pool, run.id, allow_uncertain_quality).await?;
272
273 let (opc_server, bridge_host) = if db_driver == TuneDriver::Opcda {
280 (args.server.as_deref(), args.bridge_host.as_deref())
281 } else {
282 (None, None)
283 };
284 TuneRunRow::record_connection(pool, run.id, opc_server, bridge_host, &request_json).await?;
285 if let Some(report) = gateway_compatibility.as_ref() {
286 TuneRunRow::record_gateway_compatibility(
287 pool,
288 run.id,
289 &crate::gateway::compatibility_json(report),
290 )
291 .await?;
292 }
293 let notes = args
294 .notes
295 .as_deref()
296 .map(str::trim)
297 .filter(|notes| !notes.is_empty());
298 TuneRunRow::update_notes(pool, run.id, notes).await?;
299 Ok::<(), anyhow::Error>(())
300 }
301 .await;
302 if let Err(error) = metadata_result {
303 finalize_preparation_failure(pool, run.id, &error.to_string()).await;
304 return Err(error);
305 }
306
307 let ownership = if args.driver == DriverKind::Opcda {
308 let ownership_result = async {
309 let bridge_host = args
310 .bridge_host
311 .as_deref()
312 .ok_or_else(|| anyhow::anyhow!("resolved OPC bridge host is missing"))?;
313 let server = args
314 .server
315 .as_deref()
316 .ok_or_else(|| anyhow::anyhow!("resolved OPC server is missing"))?;
317 let intent = serde_json::to_string(&serde_json::json!({
318 "version": 1,
319 "kind": "tune_restore",
320 "run_id": run.id,
321 "state": "awaiting_initial_readings",
322 "mv_tag": tags.manipulated_variable,
323 }))?;
324 LiveOperationGuard::acquire(
325 pool,
326 Some(run.id),
327 LiveOperationKind::Tune,
328 bridge_host,
329 server,
330 &tags.manipulated_variable,
331 Some(intent),
332 )
333 .await
334 .map_err(anyhow::Error::from)
335 }
336 .await;
337 match ownership_result {
338 Ok(ownership) => Some(ownership),
339 Err(error) => {
340 finalize_preparation_failure(pool, run.id, &error.to_string()).await;
341 return Err(error);
342 }
343 }
344 } else {
345 None
346 };
347
348 let setup_result =
349 crate::driver::build_with_poll_interval(&args, timing.poll_interval_ms).await;
350 let (driver, ownership) = complete_driver_setup(pool, run.id, setup_result, ownership).await?;
351
352 tracing::info!(
353 run_id = run.id,
354 template = %args.template,
355 process_type = ?config.process_type,
356 controller_type = ?config.controller_type,
357 driver = ?db_driver,
358 allow_uncertain_quality,
359 "starting tune run"
360 );
361
362 let write_pid = args.write_pid;
363
364 Ok(PreparedTune {
365 run_id: run.id,
366 args,
367 template,
368 tags,
369 driver,
370 config,
371 timing,
372 time_anchor,
373 write_pid,
374 allow_uncertain_quality,
375 ownership,
376 })
377}
378pub(super) async fn finalize_preparation_failure(pool: &SqlitePool, run_id: i64, reason: &str) {
379 if let Err(error) = TuneRunRow::fail(pool, run_id, Utc::now(), reason).await {
380 tracing::error!(
381 run_id,
382 error = %error,
383 "could not mark a failed preparation run terminal; attempting to delete it"
384 );
385 if let Err(delete_error) = TuneRunRow::delete(pool, run_id).await {
386 tracing::error!(
387 run_id,
388 error = %delete_error,
389 "could not remove a failed preparation run"
390 );
391 }
392 }
393}
394pub(super) async fn complete_driver_setup(
395 pool: &SqlitePool,
396 run_id: i64,
397 setup_result: anyhow::Result<Box<dyn Driver>>,
398 ownership: Option<LiveOperationGuard>,
399) -> anyhow::Result<(Box<dyn Driver>, Option<LiveOperationGuard>)> {
400 match setup_result {
401 Ok(driver) => Ok((driver, ownership)),
402 Err(error) => {
403 let failure_persisted =
404 match TuneRunRow::fail(pool, run_id, Utc::now(), &error.to_string()).await {
405 Ok(_) => true,
406 Err(persist_error) => {
407 tracing::error!(
408 run_id,
409 error = %persist_error,
410 "failed to persist live tune preparation failure"
411 );
412 false
413 }
414 };
415 if failure_persisted && let Some(ownership) = ownership {
416 ownership.release().await?;
417 }
418 if !failure_persisted {
419 tracing::error!(
420 run_id,
421 "retaining live ownership so startup can export this orphaned running row"
422 );
423 }
424 Err(error)
425 }
426 }
427}
428pub async fn drive(
431 pool: &SqlitePool,
432 prepared: PreparedTune,
433 ctrl_c: &mut CtrlC,
434) -> anyhow::Result<TuneOutcome> {
435 let report = drive_report(pool, prepared, ctrl_c, None).await?;
436 Ok(tune_outcome_for_run(&report.outcome))
437}
438
439pub async fn drive_report(
443 pool: &SqlitePool,
444 prepared: PreparedTune,
445 ctrl_c: &mut CtrlC,
446 handler: Option<&mut dyn WriteBackHandler>,
447) -> anyhow::Result<TuneRunReport> {
448 let PreparedTune {
449 run_id,
450 args,
451 template,
452 tags,
453 driver,
454 config,
455 timing,
456 time_anchor,
457 write_pid,
458 allow_uncertain_quality,
459 ownership,
460 } = prepared;
461 let mut ownership = ownership;
462 let audited_driver = ownership
463 .as_ref()
464 .map(|owner| AuditedDriver::new(driver.as_ref(), pool, owner, Some(run_id)));
465 let execution_driver: &dyn Driver = audited_driver
466 .as_ref()
467 .map_or(driver.as_ref(), |audited| audited);
468
469 let outcome = execute_with_timing(
470 pool,
471 run_id,
472 &args,
473 &template,
474 &tags,
475 execution_driver,
476 config,
477 timing,
478 time_anchor,
479 write_pid,
480 allow_uncertain_quality,
481 ctrl_c,
482 handler,
483 ownership.as_ref(),
484 )
485 .await;
486
487 match outcome {
488 Ok(run_outcome) => {
489 let tune_outcome = tune_outcome_for_run(&run_outcome);
490 tracing::info!(run_id, outcome = tune_outcome.label(), "tune run finished");
491 if let Some(ownership) = ownership.take() {
492 ownership.release().await?;
493 }
494 Ok(TuneRunReport {
495 run_id,
496 outcome: run_outcome,
497 })
498 }
499 Err(e) => {
500 tracing::error!(run_id, error = %e, "tune run failed");
501 finalize_pending_for_run_best_effort(
502 pool,
503 run_id,
504 "the run failed before MV confirmation completed",
505 )
506 .await;
507 let failure_persistence =
508 TuneRunRow::fail(pool, run_id, Utc::now(), &e.to_string()).await;
509 if let Err(persist_error) = failure_persistence {
510 return Err(anyhow::anyhow!(
511 "{e}; additionally failed to persist the terminal run failure: \
512 {persist_error}"
513 ));
514 }
515 let ownership_release = match ownership.take() {
516 Some(ownership) => ownership.release().await,
517 None => Ok(()),
518 };
519 if let Err(release_error) = ownership_release {
520 return Err(anyhow::anyhow!(
521 "{e}; additionally failed to persist live ownership release: \
522 {release_error}"
523 ));
524 }
525 Err(e)
526 }
527 }
528}
529#[derive(Debug)]
532pub(super) struct InitialState {
533 pub(super) pv_ini: f32,
534 pub(super) mv_ini: f32,
535 pub(super) pv_range_high: f32,
536 pub(super) pv_range_low: f32,
537 pub(super) mv_range_high: f32,
538 pub(super) mv_range_low: f32,
539 pub(super) direction: ControllerDirection,
540 pub(super) mode_raw: Option<String>,
541 pub(super) mode_attribute_raw: Option<String>,
542 pub(super) setpoint_ini: Option<f32>,
552}
553#[derive(Debug, Default)]
559pub(super) struct MutationGuard {
560 pub(super) mode_attribute_written: bool,
562 pub(super) mode_written: bool,
564 pub(super) mv_written: bool,
571}
572pub(super) async fn persist_completed_results(
577 pool: &SqlitePool,
578 run_id: i64,
579 completion: Action,
580 direction: ControllerDirection,
581 config: LoopConfig,
582 pv_range: PvRange,
583 template: &DcsTemplate,
584) -> anyhow::Result<Vec<PidWritePreview>> {
585 persist_results(
586 pool, run_id, completion, direction, config, pv_range, template,
587 )
588 .await
589}
590#[allow(clippy::too_many_arguments)]
594pub(super) async fn execute_with_timing(
595 pool: &SqlitePool,
596 run_id: i64,
597 args: &TuneRequest,
598 template: &DcsTemplate,
599 tags: &LoopTags,
600 driver: &dyn Driver,
601 config: LoopConfig,
602 effective_timing: EffectiveTiming,
603 time_anchor: RunTimeAnchor,
604 write_pid: Option<ResponseLevel>,
605 allow_uncertain_quality: bool,
606 ctrl_c: &mut CtrlC,
607 handler: Option<&mut dyn WriteBackHandler>,
608 ownership: Option<&LiveOperationGuard>,
609) -> anyhow::Result<RunOutcome> {
610 let started_at = time_anchor.utc();
611 let initial = read_initial_values(driver, tags, template, allow_uncertain_quality).await?;
612 validate_initial_state(&initial)?;
613 validate_relay_actuation_step(args, config, &initial)?;
614
615 TuneRunRow::record_initial_readings(
620 pool,
621 run_id,
622 TuneRunInitialReadings {
623 pv_ini: initial.pv_ini,
624 mv_ini: initial.mv_ini,
625 mv_range_low: initial.mv_range_low,
626 mv_range_high: initial.mv_range_high,
627 pv_range_high: initial.pv_range_high,
628 pv_range_low: initial.pv_range_low,
629 controller_direction: initial.direction,
630 mode_raw: initial.mode_raw.clone(),
631 mode_attribute_raw: initial.mode_attribute_raw.clone(),
632 setpoint_ini: initial.setpoint_ini,
633 },
634 )
635 .await?;
636 if let Some(ownership) = ownership {
637 let restore_intent = serde_json::to_string(&serde_json::json!({
638 "version": 1,
639 "kind": "tune_restore",
640 "run_id": run_id,
641 "state": "ready_to_restore",
642 "mv_tag": tags.manipulated_variable,
643 "initial_readings": {
644 "pv_ini": initial.pv_ini,
645 "mv_ini": initial.mv_ini,
646 "mv_range_low": initial.mv_range_low,
647 "mv_range_high": initial.mv_range_high,
648 "pv_range_low": initial.pv_range_low,
649 "pv_range_high": initial.pv_range_high,
650 "controller_direction": initial.direction,
651 "mode_raw": initial.mode_raw,
652 "mode_attribute_raw": initial.mode_attribute_raw,
653 "setpoint_ini": initial.setpoint_ini,
654 },
655 "template_policy": {
656 "revert_mode": template.revert_mode,
657 "mode_auto_value": template.mode_auto_value,
658 "mode_manual_value": template.mode_manual_value,
659 "mode_attribute_program_value": template.mode_attribute_program_value,
660 },
661 }))?;
662 ownership.persist_restore_intent(&restore_intent).await?;
663 }
664
665 let mut guard = MutationGuard::default();
666 let mut mv_actuations = MvActuationTracker::for_run(args, &initial);
667 if let Err(e) = transition_to_manual(driver, tags, template, &initial, &mut guard).await {
668 return Err(restore_best_effort_then_propagate_with_timing(
669 pool,
670 run_id,
671 driver,
672 tags,
673 template,
674 &initial,
675 &guard,
676 args,
677 effective_timing,
678 allow_uncertain_quality,
679 ctrl_c,
680 &mut mv_actuations,
681 e,
682 )
683 .await);
684 }
685
686 let beta = lookup(
687 config.process_type,
688 config.controller_type,
689 ResponseLevel::Aggressive,
690 )
691 .beta;
692
693 let mut engine = MrftEngine::new(
694 config,
695 initial.direction,
696 beta,
697 InitialReadings {
698 pv_ini: initial.pv_ini,
699 mv_ini: initial.mv_ini,
700 mv_range_low: initial.mv_range_low,
701 mv_range_high: initial.mv_range_high,
702 },
703 started_at,
704 );
705 let timing_basis = match args.driver {
706 DriverKind::Opcda => TimingBasis::LiveMonotonic,
707 DriverKind::Simulator => TimingBasis::SimulatedFixedStep,
708 };
709 let mut timing = PollTimingAccumulator::new(timing_basis, effective_timing.poll_interval_ms);
710
711 let poll_result = run_polling_loop_with_timing(
712 pool,
713 run_id,
714 args,
715 effective_timing,
716 tags,
717 driver,
718 &mut engine,
719 time_anchor,
720 ctrl_c,
721 &mut guard,
722 allow_uncertain_quality,
723 &mut timing,
724 &mut mv_actuations,
725 config,
726 )
727 .await;
728 let measured_oscillation_period_ms = completed_oscillation_period_ms(
729 &poll_result,
730 initial.direction,
731 config,
732 PvRange {
733 high: initial.pv_range_high,
734 low: initial.pv_range_low,
735 },
736 );
737 let timing_metrics_without_period = timing.finish(None);
738 if let Some(timing_metrics) = timing_metrics_without_period.as_ref() {
739 warn_on_missed_poll_opportunities(run_id, timing_metrics);
740 }
741
742 match poll_result {
743 Ok(PollOutcome::Completed(completion)) => {
744 finish_completed_run(
745 pool,
746 run_id,
747 args,
748 effective_timing,
749 template,
750 tags,
751 driver,
752 config,
753 &initial,
754 &guard,
755 write_pid,
756 allow_uncertain_quality,
757 ctrl_c,
758 handler,
759 ownership,
760 &mut mv_actuations,
761 completion,
762 &mut timing,
763 measured_oscillation_period_ms,
764 )
765 .await
766 }
767 Ok(PollOutcome::Aborted(reason)) => {
768 finish_aborted_run(
769 pool,
770 run_id,
771 args,
772 effective_timing,
773 template,
774 tags,
775 driver,
776 &initial,
777 &guard,
778 allow_uncertain_quality,
779 ctrl_c,
780 &mut mv_actuations,
781 reason,
782 timing_metrics_without_period,
783 )
784 .await
785 }
786 Err(error) => {
787 finish_failed_run(
788 pool,
789 run_id,
790 template,
791 tags,
792 driver,
793 &initial,
794 &guard,
795 args,
796 effective_timing,
797 allow_uncertain_quality,
798 ctrl_c,
799 &mut mv_actuations,
800 error,
801 timing_metrics_without_period,
802 )
803 .await
804 }
805 }
806}
807#[cfg(test)]
808#[allow(clippy::too_many_arguments)]
809pub(super) async fn execute<R: std::io::BufRead>(
810 pool: &SqlitePool,
811 run_id: i64,
812 args: &TuneRequest,
813 template: &DcsTemplate,
814 tags: &LoopTags,
815 driver: &dyn Driver,
816 config: LoopConfig,
817 time_anchor: RunTimeAnchor,
818 write_pid: Option<ResponseLevel>,
819 allow_uncertain_quality: bool,
820 ctrl_c: &mut CtrlC,
821 _reader: &mut R,
822) -> anyhow::Result<RunOutcome> {
823 execute_with_timing(
824 pool,
825 run_id,
826 args,
827 template,
828 tags,
829 driver,
830 config,
831 test_effective_timing(args),
832 time_anchor,
833 write_pid,
834 allow_uncertain_quality,
835 ctrl_c,
836 None,
837 None,
838 )
839 .await
840}
841#[allow(clippy::too_many_arguments)]
842pub(super) async fn attempt_and_record_restore(
843 pool: &SqlitePool,
844 run_id: i64,
845 args: &TuneRequest,
846 effective_timing: EffectiveTiming,
847 driver: &dyn Driver,
848 tags: &LoopTags,
849 template: &DcsTemplate,
850 initial: &InitialState,
851 guard: &MutationGuard,
852 allow_uncertain_quality: bool,
853 ctrl_c: &mut CtrlC,
854 mv_actuations: &mut Option<MvActuationTracker>,
855) -> RestoreAttempt {
856 attempt_and_record_restore_with_settling(
857 pool,
858 run_id,
859 args,
860 effective_timing,
861 driver,
862 tags,
863 template,
864 initial,
865 guard,
866 allow_uncertain_quality,
867 ctrl_c,
868 mv_actuations,
869 None,
870 None,
871 None,
872 )
873 .await
874}
875#[allow(clippy::too_many_arguments)]
876pub(super) async fn attempt_and_record_restore_with_settling(
877 pool: &SqlitePool,
878 run_id: i64,
879 args: &TuneRequest,
880 effective_timing: EffectiveTiming,
881 driver: &dyn Driver,
882 tags: &LoopTags,
883 template: &DcsTemplate,
884 initial: &InitialState,
885 guard: &MutationGuard,
886 allow_uncertain_quality: bool,
887 ctrl_c: &mut CtrlC,
888 mv_actuations: &mut Option<MvActuationTracker>,
889 completion: Option<&mut CompletedPoll>,
890 measured_oscillation_period_ms: Option<f64>,
891 timing: Option<&mut PollTimingAccumulator>,
892) -> RestoreAttempt {
893 let restore_attempt = attempt_restore_with_actuation_with_timing(
894 pool,
895 run_id,
896 args,
897 effective_timing,
898 driver,
899 tags,
900 template,
901 initial,
902 guard,
903 allow_uncertain_quality,
904 ctrl_c,
905 mv_actuations,
906 completion,
907 measured_oscillation_period_ms,
908 timing,
909 )
910 .await;
911 record_restore_status_best_effort(pool, run_id, &restore_attempt).await;
912 finalize_pending_for_run_best_effort(
913 pool,
914 run_id,
915 "the run ended before MV confirmation completed",
916 )
917 .await;
918 restore_attempt
919}
920#[allow(clippy::too_many_arguments)]
921pub(super) async fn finish_completed_run(
922 pool: &SqlitePool,
923 run_id: i64,
924 args: &TuneRequest,
925 effective_timing: EffectiveTiming,
926 template: &DcsTemplate,
927 tags: &LoopTags,
928 driver: &dyn Driver,
929 config: LoopConfig,
930 initial: &InitialState,
931 guard: &MutationGuard,
932 write_pid: Option<ResponseLevel>,
933 allow_uncertain_quality: bool,
934 ctrl_c: &mut CtrlC,
935 mut handler: Option<&mut dyn WriteBackHandler>,
936 ownership: Option<&LiveOperationGuard>,
937 mv_actuations: &mut Option<MvActuationTracker>,
938 mut completion: CompletedPoll,
939 timing: &mut PollTimingAccumulator,
940 measured_oscillation_period_ms: Option<f64>,
941) -> anyhow::Result<RunOutcome> {
942 let pv_range = PvRange {
943 high: initial.pv_range_high,
944 low: initial.pv_range_low,
945 };
946 let previews = match persist_completed_results(
947 pool,
948 run_id,
949 completion.action.clone(),
950 initial.direction,
951 config,
952 pv_range,
953 template,
954 )
955 .await
956 {
957 Ok(previews) => previews,
958 Err(error) => {
959 let restore_attempt = attempt_and_record_restore_with_settling(
960 pool,
961 run_id,
962 args,
963 effective_timing,
964 driver,
965 tags,
966 template,
967 initial,
968 guard,
969 allow_uncertain_quality,
970 ctrl_c,
971 mv_actuations,
972 Some(&mut completion),
973 measured_oscillation_period_ms,
974 Some(timing),
975 )
976 .await;
977 let error = match restore_attempt {
978 RestoreAttempt::Confirmed => error,
979 RestoreAttempt::Incomplete { reason } => {
980 tracing::warn!(
981 run_id,
982 reason = %reason,
983 "completed MRFT result persistence failed and restore was incomplete"
984 );
985 error
986 }
987 };
988 record_timing_metrics_if_present(pool, run_id, timing.finish(None)).await;
989 return Err(error);
990 }
991 };
992 let restore_attempt = attempt_and_record_restore_with_settling(
993 pool,
994 run_id,
995 args,
996 effective_timing,
997 driver,
998 tags,
999 template,
1000 initial,
1001 guard,
1002 allow_uncertain_quality,
1003 ctrl_c,
1004 mv_actuations,
1005 Some(&mut completion),
1006 measured_oscillation_period_ms,
1007 Some(timing),
1008 )
1009 .await;
1010 TuneRunRow::complete_with_timing_metrics(
1011 pool,
1012 run_id,
1013 Utc::now(),
1014 timing.finish(measured_oscillation_period_ms),
1015 )
1016 .await?;
1017 if let Some(handler) = handler.as_deref_mut() {
1018 handler.pid_results_ready(&previews);
1019 }
1020 match restore_attempt {
1021 RestoreAttempt::Confirmed => {
1022 let (write_back, write_back_detail) = maybe_write_back_with_owner(
1023 pool,
1024 run_id,
1025 tags,
1026 template,
1027 driver,
1028 config,
1029 write_pid,
1030 allow_uncertain_quality,
1031 handler,
1032 ownership,
1033 )
1034 .await?;
1035 Ok(RunOutcome::Completed {
1036 write_back,
1037 write_back_detail,
1038 })
1039 }
1040 RestoreAttempt::Incomplete { reason } => Ok(RunOutcome::RestoreIncomplete { reason }),
1041 }
1042}
1043#[allow(clippy::too_many_arguments)]
1044pub(super) async fn finish_aborted_run(
1045 pool: &SqlitePool,
1046 run_id: i64,
1047 args: &TuneRequest,
1048 effective_timing: EffectiveTiming,
1049 template: &DcsTemplate,
1050 tags: &LoopTags,
1051 driver: &dyn Driver,
1052 initial: &InitialState,
1053 guard: &MutationGuard,
1054 allow_uncertain_quality: bool,
1055 ctrl_c: &mut CtrlC,
1056 mv_actuations: &mut Option<MvActuationTracker>,
1057 reason: AbortReason,
1058 timing_metrics_without_period: Option<TimingMetrics>,
1059) -> anyhow::Result<RunOutcome> {
1060 let restore_attempt = attempt_and_record_restore(
1061 pool,
1062 run_id,
1063 args,
1064 effective_timing,
1065 driver,
1066 tags,
1067 template,
1068 initial,
1069 guard,
1070 allow_uncertain_quality,
1071 ctrl_c,
1072 mv_actuations,
1073 )
1074 .await;
1075 if matches!(reason, AbortReason::MvActuationUnconfirmed { .. }) {
1076 TuneRunRow::abort_with_timing_metrics_and_reason(
1077 pool,
1078 run_id,
1079 Utc::now(),
1080 timing_metrics_without_period,
1081 &format_mv_actuation_abort_reason(&reason),
1082 )
1083 .await?;
1084 } else {
1085 TuneRunRow::abort_with_timing_metrics(
1086 pool,
1087 run_id,
1088 Utc::now(),
1089 timing_metrics_without_period,
1090 )
1091 .await?;
1092 }
1093 match restore_attempt {
1094 RestoreAttempt::Confirmed => Ok(RunOutcome::Aborted(reason)),
1095 RestoreAttempt::Incomplete {
1096 reason: restore_reason,
1097 } => Ok(RunOutcome::RestoreIncomplete {
1098 reason: format!("run aborted ({reason:?}); {restore_reason}"),
1099 }),
1100 }
1101}
1102#[allow(clippy::too_many_arguments)]
1103pub(super) async fn finish_failed_run(
1104 pool: &SqlitePool,
1105 run_id: i64,
1106 template: &DcsTemplate,
1107 tags: &LoopTags,
1108 driver: &dyn Driver,
1109 initial: &InitialState,
1110 guard: &MutationGuard,
1111 args: &TuneRequest,
1112 effective_timing: EffectiveTiming,
1113 allow_uncertain_quality: bool,
1114 ctrl_c: &mut CtrlC,
1115 mv_actuations: &mut Option<MvActuationTracker>,
1116 error: anyhow::Error,
1117 timing_metrics_without_period: Option<TimingMetrics>,
1118) -> anyhow::Result<RunOutcome> {
1119 let error = restore_best_effort_then_propagate_with_timing(
1124 pool,
1125 run_id,
1126 driver,
1127 tags,
1128 template,
1129 initial,
1130 guard,
1131 args,
1132 effective_timing,
1133 allow_uncertain_quality,
1134 ctrl_c,
1135 mv_actuations,
1136 error,
1137 )
1138 .await;
1139 record_timing_metrics_if_present(pool, run_id, timing_metrics_without_period).await;
1140 Err(error)
1141}
1142pub(super) async fn read_initial_values(
1144 driver: &dyn Driver,
1145 tags: &LoopTags,
1146 template: &DcsTemplate,
1147 allow_uncertain: bool,
1148) -> anyhow::Result<InitialState> {
1149 let mut requested_tags = Vec::new();
1150 let mut seen_tags = HashSet::new();
1151 let mut request_tag = |tag: &str| {
1152 if seen_tags.insert(tag.to_string()) {
1153 requested_tags.push(tag.to_string());
1154 }
1155 };
1156
1157 request_tag(&tags.process_variable);
1158 request_tag(&tags.manipulated_variable);
1159 if let Some(tag) = &tags.controller_mode {
1160 request_tag(tag);
1161 }
1162 if let Some(tag) = &tags.mode_attribute {
1163 request_tag(tag);
1164 }
1165 if let TagOrValue::Tag(tag) = &tags.controller_direction {
1166 request_tag(tag.as_str());
1167 }
1168 for tag_or_value in [
1169 &tags.upper_pv_range,
1170 &tags.lower_pv_range,
1171 &tags.upper_mv_range,
1172 &tags.lower_mv_range,
1173 ] {
1174 if let TagOrValue::Tag(tag) = tag_or_value {
1175 request_tag(tag.as_str());
1176 }
1177 }
1178
1179 let values_by_tag: HashMap<String, TagValue> = driver
1180 .read(&requested_tags)
1181 .await?
1182 .into_iter()
1183 .map(|value| (value.tag.clone(), value))
1184 .collect();
1185
1186 let pv_ini = read_batch_f32(&values_by_tag, &tags.process_variable, allow_uncertain)?;
1187 let mv_ini = read_batch_f32(&values_by_tag, &tags.manipulated_variable, allow_uncertain)?;
1188
1189 let mode_raw = match &tags.controller_mode {
1190 Some(tag) => Some(read_batch_raw(&values_by_tag, tag, allow_uncertain)?),
1191 None => None,
1192 };
1193 let mode_attribute_raw = match &tags.mode_attribute {
1194 Some(tag) => Some(read_batch_raw(&values_by_tag, tag, allow_uncertain)?),
1195 None => None,
1196 };
1197
1198 let setpoint_ini = match (&tags.setpoint_variable, &mode_raw) {
1202 (Some(sv_tag), Some(mode_raw)) if mode_raw == &template.mode_auto_value => {
1203 Some(read_f32(driver, sv_tag, allow_uncertain).await?)
1204 }
1205 _ => None,
1206 };
1207
1208 let direction = resolve_direction_from_batch(
1209 driver,
1210 &values_by_tag,
1211 &tags.controller_direction,
1212 template,
1213 allow_uncertain,
1214 )
1215 .await?;
1216 let pv_range_high = resolve_f32_from_batch(
1217 driver,
1218 &values_by_tag,
1219 &tags.upper_pv_range,
1220 allow_uncertain,
1221 )
1222 .await?;
1223 let pv_range_low = resolve_f32_from_batch(
1224 driver,
1225 &values_by_tag,
1226 &tags.lower_pv_range,
1227 allow_uncertain,
1228 )
1229 .await?;
1230 let mv_range_high = resolve_f32_from_batch(
1231 driver,
1232 &values_by_tag,
1233 &tags.upper_mv_range,
1234 allow_uncertain,
1235 )
1236 .await?;
1237 let mv_range_low = resolve_f32_from_batch(
1238 driver,
1239 &values_by_tag,
1240 &tags.lower_mv_range,
1241 allow_uncertain,
1242 )
1243 .await?;
1244
1245 Ok(InitialState {
1246 pv_ini,
1247 mv_ini,
1248 pv_range_high,
1249 pv_range_low,
1250 mv_range_high,
1251 mv_range_low,
1252 direction,
1253 mode_raw,
1254 mode_attribute_raw,
1255 setpoint_ini,
1256 })
1257}
1258pub(super) fn validate_initial_state(initial: &InitialState) -> anyhow::Result<()> {
1265 PvRange::new(initial.pv_range_high, initial.pv_range_low)
1266 .map_err(|e| anyhow::anyhow!("invalid PV range: {e}"))?;
1267 let mv_range = MvRange::new(initial.mv_range_high, initial.mv_range_low)
1268 .map_err(|e| anyhow::anyhow!("invalid MV range: {e}"))?;
1269 if !mv_range.contains(initial.mv_ini) {
1270 anyhow::bail!(
1271 "initial MV {} is outside the MV range [{}, {}]",
1272 initial.mv_ini,
1273 initial.mv_range_low,
1274 initial.mv_range_high
1275 );
1276 }
1277 Ok(())
1278}
1279pub(super) async fn transition_to_manual(
1285 driver: &dyn Driver,
1286 tags: &LoopTags,
1287 template: &DcsTemplate,
1288 initial: &InitialState,
1289 guard: &mut MutationGuard,
1290) -> anyhow::Result<()> {
1291 if let (Some(attr_tag), Some(program_value)) =
1292 (&tags.mode_attribute, &template.mode_attribute_program_value)
1293 {
1294 guard.mode_attribute_written = true;
1295 write_raw(driver, attr_tag, program_value.clone()).await?;
1296 tokio::time::sleep(Duration::from_millis(1000)).await;
1297 }
1298
1299 if let Some(mode_tag) = &tags.controller_mode {
1300 let mode_raw = initial.mode_raw.as_deref().unwrap_or_default();
1301 if mode_raw != template.mode_manual_value {
1302 guard.mode_written = true;
1303 write_raw(driver, mode_tag, template.mode_manual_value.clone()).await?;
1304 }
1305 }
1306
1307 Ok(())
1308}