Skip to main content

bhtune_runtime/tune/
prepare.rs

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
49/// Everything [`prepare`] resolves before a tune's long-running polling phase can start:
50/// the already-validated [`ValidatedTuneRequest`], the resolved template and derived tags, a
51/// connected driver, the built [`LoopConfig`], the run's start time, and the response level
52/// (if any) to write back at the end -- plus the `tune_runs` row's assigned id.
53///
54/// The first phase (template lookup, tag derivation, driver connect, and the `tune_runs`
55/// insert that assigns [`PreparedTune::run_id`]) is fast enough to await in an HTTP request
56/// handler, which can then spawn [`drive`] and return the assigned id before the tune ends.
57///
58/// Every field but `run_id` is private: a caller that isn't this module has no legitimate
59/// reason to inspect a template/tags/driver/config mid-flight, only to hand the whole
60/// prepared bundle to [`drive`] unchanged.
61pub 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}
74/// Prepare a simulator tune and bind its history to a demo session before the background
75/// execution is started. Live OPC DA preparation is intentionally rejected by this helper.
76pub 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    /// The `tune_runs` row id assigned to this run -- returned to an HTTP caller immediately
94    /// (before the run has necessarily finished, or even started polling) so it can be used
95    /// to poll `GET /api/runs/{id}` or issue `POST /api/runs/{id}/cancel`.
96    pub fn run_id(&self) -> i64 {
97        self.run_id
98    }
99}
100/// The shape persisted into `tune_runs.request_json`.
101/// Its stable fields correspond to the transport-neutral [`TuneRequest`] shared by the CLI
102/// and HTTP adapters, so either adapter persists the same snapshot shape.
103///
104/// Built from `args` *before* [`prepare`]'s own `bridge_host`/`server` resolution mutates
105/// them, so a field left unset by the caller stays absent here rather than silently baking
106/// in a resolved default -- this lets the last-run form prefill show blanks where the user
107/// relied on a default, instead of freezing resolved values into the next
108/// form. `output` is deliberately excluded: it's a CLI/HTTP-transport concern with no
109/// meaning as a "setting" to remember or duplicate.
110#[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}
140/// Validates a tune request and prepares the driver and persisted run record.
141///
142/// The unattended-write guard and timing/config validation run before driver I/O. The
143/// caller's request is snapshotted before effective connection defaults are resolved, and
144/// the resolved connection and tuning context are persisted before polling starts.
145pub 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    // Fails before any driver/database I/O at all: an unattended write-back must be an
165    // explicit, deliberate choice, not something a stray `--write-pid` without `--yes` can
166    // trigger by accident.
167    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    // Snapshotted before `bridge_host`/`server` are resolved to their effective values just
183    // below, so a field the caller left unset stays absent here instead of silently baking
184    // in a resolved default -- see `RequestSnapshot`'s doc comment.
185    #[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    // This gateway-only preflight is read-only and rejects unsupported peers before a run
244    // row exists. The live driver connection remains after ownership is acquired below.
245    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        // The *resolved, effective* connection this run actually used -- `None`/`None` for a
274        // non-opcda run even though `args.bridge_host` was just unconditionally resolved to a
275        // default above (a pre-existing, harmless quirk of that resolution call). Forcing both
276        // to `None` here is what makes a simulator/replay run correctly show "no connection"
277        // rather than an OPC DA gateway it never actually touched -- load-bearing for
278        // `history revert`'s connection safety fix.
279        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}
428/// Runs an already-prepared tune without an interactive selector and returns its coarse
429/// outcome. An unrequested write-back is skipped.
430pub 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
439/// Runs an already-prepared tune and returns the detailed runtime outcome for adapters that
440/// report it to a user. An optional handler may select an interactive PID response level and
441/// observe write-back progress; it never replaces runtime validation or safety checks.
442pub 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/// Everything read from the driver before any mode transition is attempted — mirrors
530/// `ReadInitialOPCvalues`.
531#[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    /// The setpoint, captured here -- before any mutation of the loop -- whenever the loop's
543    /// original mode is Auto and both a mode and a setpoint tag are configured. Persisting
544    /// this through [`TuneRunRow::record_initial_readings`] before
545    /// `transition_to_manual`'s first mutating write lets a crashed run recover its restore
546    /// intent. Note that this field being `Some(..)` only proves the loop *was* in Auto --
547    /// not that a mode transition was actually attempted, since `read_initial_values` runs
548    /// unconditionally, before any such decision is made -- so `restore`'s setpoint-revert
549    /// step is additionally gated on [`MutationGuard::mode_written`], not on this field
550    /// alone.
551    pub(super) setpoint_ini: Option<f32>,
552}
553/// Tracks which of `transition_to_manual`'s mutations were actually *attempted* -- armed
554/// immediately before each write is issued, not after it succeeds -- so `restore` can
555/// independently decide what's safe/necessary to revert even when `transition_to_manual`
556/// itself returns partway through with an error. The captured setpoint lives on
557/// [`InitialState`], where it can be persisted before any mutation.
558#[derive(Debug, Default)]
559pub(super) struct MutationGuard {
560    /// The mode-attribute tag's "put in program/computer mode" write was attempted.
561    pub(super) mode_attribute_written: bool,
562    /// The mode tag's "put in Manual" write was attempted.
563    pub(super) mode_written: bool,
564    /// A relay-test MV write was attempted at least once during `run_polling_loop`. Tracked
565    /// for completeness/audit parity with the other two flags -- `restore`'s MV-revert step
566    /// is unconditional and never gated by this flag, since a redundant write-back is always
567    /// harmless (idempotent if the pre-test value is already there, or safely rejected by
568    /// the DCS if the loop never actually left Auto), so there is no correctness reason to
569    /// skip attempting it even while this is still `false`.
570    pub(super) mv_written: bool,
571}
572/// Persists the three calculated response levels after a completed MRFT test. The run's
573/// terminal outcome is deliberately recorded later, after restoration, together with its
574/// timing snapshot; this keeps SSE-triggered readers from seeing a terminal row before those
575/// diagnostics are visible.
576pub(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/// The runtime owns tune execution and delegates any interactive write-back choice to an
591/// adapter-provided handler. A missing handler means no unrequested write-back; no terminal
592/// or transport I/O is performed here.
593#[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    // Persisted before any mutation is attempted: a crash between here and a confirmed
616    // restore still leaves a durable record of the mode/mode-attribute/
617    // setpoint as they were *before* anything was written, so `bhtune restore-loop` can
618    // reconstruct and restore the loop later even if the process never gets to do so itself.
619    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    // Best-effort: a failed test still stroked the valve, so try to put it back even
1120    // though the overall run is going to be reported as failed regardless. Still
1121    // bounded/interruptible (a second Ctrl+C or `[tuning].restore_timeout_secs` still cuts
1122    // it short) and still warns loudly on an incomplete restore.
1123    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}
1142/// Pure port of `ReadInitialOPCvalues`: everything read before any mode transition.
1143pub(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    // Captured here, before any mutation, whenever the loop's original mode is Auto -- see
1199    // `InitialState::setpoint_ini`'s doc comment for why this read is hoisted out of
1200    // `transition_to_manual`, where the legacy app captures the analogous `SvValueIni`.
1201    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}
1258/// The single choke point validating an `InitialState` -- from live driver tags and/or CLI
1259/// flag overrides alike -- before any mutation of the loop happens (i.e. called between
1260/// `read_initial_values` and `transition_to_manual` in `execute`, never after). `read_f32`/
1261/// `resolve_f32` already reject a non-finite individual value as it's read; this additionally
1262/// checks values *together*: range ordering, zero span, and that the initial MV actually
1263/// lies inside its own reported range.
1264pub(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}
1279/// Pure port of `ChangeControllerModeToMan`. No-ops entirely when `tags.controller_mode` and
1280/// `tags.mode_attribute` are both `None` (the simulator's case). `guard`'s flags are armed
1281/// immediately before each write is attempted, not after it succeeds -- see
1282/// [`MutationGuard`]'s doc comment for why that ordering matters -- so a caller still knows
1283/// exactly what was attempted even if this function returns partway through with an error.
1284pub(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}