Skip to main content

bhtune_cli/commands/
tune.rs

1//! `bhtune tune`/`bhtune simulate`: runs a full MRFT test end-to-end — resolving a template,
2//! deriving tags, transitioning the loop to Manual, polling the driver and driving a real
3//! [`bhtune_core::MrftEngine`], persisting every tick and the final calculated results, then
4//! restoring the loop and optionally writing back the chosen PID constants.
5//!
6//! Mirrors the legacy `MRFTstart`/`ReadInitialOPCvalues`/`ChangeControllerModeToMan`/
7//! `ResetOPC` sequence from `OPCClass.cs`. The mode-transition and write-back steps
8//! automatically no-op for the simulator driver, since its [`bhtune_core::LoopTags`] has no
9//! setpoint/mode/mode-attribute/PID-constant tags at all (see `build_loop_tags` below) — no
10//! separate "is this the simulator?" branching is needed in that logic.
11
12use std::collections::{HashMap, HashSet};
13use std::future::Future;
14use std::time::Duration;
15
16use bhtune_core::mrft::clamp_relay_amplitude;
17use bhtune_core::{
18    Action, ControllerDirection, ControllerType, DcsTemplate, InitialReadings, LoopConfig,
19    LoopTags, MrftCompat, MrftEngine, MvRange, PidParameters, ProcessType, PvRange, ResponseLevel,
20    TagOrValue, TagOverrides, Tick, TuningMathCompat, TuningResultStatus, calculate_all_checked,
21    lookup, measure_oscillation, opc_write_values,
22};
23use bhtune_db::SqlitePool;
24use bhtune_db::models::{
25    DcsTemplateRow, EffectiveTuning, MvActuationKind, MvActuationStatus, NewTuneMvActuation,
26    NewTuneWrite, RollbackState, SampleQuality, TimingBasis, TimingMetrics, TuneDriver,
27    TuneMvActuationRow, TuneResultRow, TuneRunInitialReadings, TuneRunRow, TuneSampleRow,
28    TuneWriteRow, WriteKind, WriteReadback,
29};
30use bhtune_driver::{Driver, TagValue, TagWrite};
31use chrono::{DateTime, Utc};
32use tokio::time::Instant;
33
34use crate::args::{DriverKindArg, TuneArgs};
35use crate::cancel::CtrlC;
36use crate::driver::{SIMULATOR_MV_TAG, SIMULATOR_PV_TAG};
37use crate::output::OutputFormat;
38use crate::timing::{PollTimingAccumulator, RunTimeAnchor, TickTimeSource};
39
40/// Maximum interval from an accepted OPC DA MV write to its mandatory confirmation check.
41///
42/// Public so HTTP and other non-clap adapters can expose the same validation policy without
43/// copying the safety-critical literal.
44pub const MV_ACTUATION_CONFIRMATION_SECS: u64 = 4;
45const MV_ACTUATION_RETRY_INTERVAL: Duration = Duration::from_millis(100);
46const MV_ACTUATION_DEADLINE_READ_MAX: Duration = Duration::from_secs(1);
47const MV_ACTUATION_FALLBACK_HEADROOM: Duration = Duration::from_secs(1);
48const MV_RESTORE_HANDOFF_READ_MAX: Duration = Duration::from_secs(1);
49const MV_SPAN_TOLERANCE_FRACTION: f32 = 0.001;
50const RELAY_STEP_TOLERANCE_FRACTION: f32 = 0.25;
51const MIN_RELAY_STEP: f32 = 0.01;
52
53/// Concrete timing policy frozen during [`prepare`] and carried unchanged through the
54/// complete tune lifecycle.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56struct EffectiveTiming {
57    mrft_delay_secs: u32,
58    poll_interval_ms: u64,
59    timeout_secs: u64,
60    op_timeout_secs: u64,
61    restore_timeout_secs: u64,
62}
63
64impl From<crate::config::EffectiveTuningConfig> for EffectiveTiming {
65    fn from(value: crate::config::EffectiveTuningConfig) -> Self {
66        Self {
67            mrft_delay_secs: value.mrft_delay_secs,
68            poll_interval_ms: value.poll_interval_ms,
69            timeout_secs: value.timeout_secs,
70            op_timeout_secs: value.op_timeout_secs,
71            restore_timeout_secs: value.restore_timeout_secs,
72        }
73    }
74}
75
76impl From<EffectiveTiming> for EffectiveTuning {
77    fn from(value: EffectiveTiming) -> Self {
78        Self {
79            mrft_delay_secs: value.mrft_delay_secs,
80            poll_interval_ms: value.poll_interval_ms,
81            timeout_secs: value.timeout_secs,
82            op_timeout_secs: value.op_timeout_secs,
83            restore_timeout_secs: value.restore_timeout_secs,
84        }
85    }
86}
87
88#[cfg(test)]
89fn test_effective_timing(args: &TuneArgs) -> EffectiveTiming {
90    EffectiveTiming {
91        mrft_delay_secs: args.mrft_delay,
92        poll_interval_ms: args.poll_interval_ms,
93        timeout_secs: args.timeout_secs,
94        op_timeout_secs: args.op_timeout_secs,
95        restore_timeout_secs: args.restore_timeout_secs,
96    }
97}
98
99/// Validates the restore budget shared by CLI and HTTP-started tunes.
100///
101/// Every driver requires a positive timeout. OPC DA additionally needs the complete fixed MV
102/// confirmation window so the authoritative restore can be read back before the operation is
103/// declared successful.
104pub fn validate_restore_timeout_secs(
105    driver: DriverKindArg,
106    restore_timeout_secs: u64,
107) -> anyhow::Result<()> {
108    if restore_timeout_secs == 0 {
109        anyhow::bail!("[tuning].restore_timeout_secs must be greater than zero");
110    }
111    if driver == DriverKindArg::Opcda
112        && restore_timeout_secs < crate::config::MIN_OPC_RESTORE_TIMEOUT_SECS
113    {
114        anyhow::bail!(
115            "[tuning].restore_timeout_secs must be at least {} seconds for OPC DA MV confirmation",
116            crate::config::MIN_OPC_RESTORE_TIMEOUT_SECS
117        );
118    }
119    Ok(())
120}
121
122/// The final disposition of a `tune`/`simulate` run -- drives the printed summary (see
123/// [`print_summary`]) and, via `crate::tune_outcome_exit_code` in `lib.rs`, the process's
124/// exit code.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub enum TuneOutcome {
127    /// The test completed. Either no write-back was requested/possible/attempted, or a
128    /// requested write-back succeeded.
129    Completed,
130    /// The user pressed Ctrl+C; the loop was restored to its original mode/setpoint before
131    /// returning.
132    Aborted,
133    /// `[tuning].timeout_secs` elapsed before the engine reported completion; the loop was
134    /// restored to its original mode/setpoint before returning, exactly like
135    /// [`TuneOutcome::Aborted`]
136    /// but distinguished so a scheduler's alerting can tell "this run had to be killed for
137    /// running too long" apart from "an operator stopped it on purpose".
138    TimedOut,
139    /// A driver reported a non-`Good` OPC quality for a tuning-critical reading -- an
140    /// initial reading (including the setpoint capture, when the loop starts in Auto) or an
141    /// in-flight PV poll sample when the global Config > OPC quality policy rejects
142    /// `Uncertain` (or with the policy enabled, but the quality was
143    /// `Bad` rather than merely `Uncertain`) -- and the run was aborted and the
144    /// loop restored before returning, exactly like
145    /// [`TuneOutcome::Aborted`]/[`TuneOutcome::TimedOut`] but distinguished so a scheduler's
146    /// alerting can tell "the plant data itself couldn't be trusted" apart from either of
147    /// those. See `safety-quality` in AGENTS.md.
148    PoorQuality,
149    /// An accepted OPC DA MV command could not be confirmed before its deadline or before
150    /// the engine requested a replacement relay command. The loop was restored before
151    /// returning unless [`TuneOutcome::RestoreIncomplete`] took precedence.
152    ActuationFailed,
153    /// The test itself completed, but writing the chosen PID parameters back to the DCS
154    /// failed (rejected write, failed confirmation readback, or -- defensively -- a
155    /// `--write-pid` level with no matching calculated result).
156    WriteBackFailed,
157    /// The run ended (via normal completion, Ctrl+C, or a timeout) without being able to
158    /// confirm the loop was fully restored to its pre-test mode/MV/setpoint -- a second
159    /// Ctrl+C arrived while the restore was in flight, or
160    /// `[tuning].restore_timeout_secs` elapsed first. The loop may still be sitting at a
161    /// relay-test MV/mode; an operator must check it by hand using the tag/value named in the
162    /// warning printed to stderr. See
163    /// `safety-cancellation` in AGENTS.md.
164    RestoreIncomplete,
165}
166
167impl TuneOutcome {
168    /// A short machine-readable label, used in the `--output json` summary.
169    pub fn label(self) -> &'static str {
170        match self {
171            TuneOutcome::Completed => "completed",
172            TuneOutcome::Aborted => "aborted",
173            TuneOutcome::TimedOut => "timed_out",
174            TuneOutcome::PoorQuality => "poor_quality",
175            TuneOutcome::ActuationFailed => "actuation_failed",
176            TuneOutcome::WriteBackFailed => "write_back_failed",
177            TuneOutcome::RestoreIncomplete => "restore_incomplete",
178        }
179    }
180}
181
182/// Runs one full tune. Never returns `Err` for a tune that simply didn't complete
183/// successfully (a failed/aborted run is recorded in the database and reported to stdout);
184/// `Err` is reserved for setup problems (unknown template, invalid flag combination,
185/// database errors) surfaced directly to the caller.
186///
187/// Test-facing entry point: delegates to [`run_with_ctrl_c`] with a [`CtrlC::never`] handle,
188/// so this crate's large existing test suite never installs a real process-wide signal
189/// handler -- see `cancel`'s module doc comment for why that matters. `#[cfg(test)]`-gated
190/// (rather than merely unused outside tests) because it depends on [`CtrlC::never`], itself
191/// only defined for test builds. Real dispatch (`lib.rs::run_with_cli_and_ctrl_c`) calls
192/// [`run_with_ctrl_c`] directly with a real, installed [`CtrlC`] instead of going through
193/// this wrapper.
194#[cfg(test)]
195pub async fn run(
196    pool: &SqlitePool,
197    args: TuneArgs,
198    app_config: &crate::config::BhtuneConfig,
199) -> anyhow::Result<TuneOutcome> {
200    run_with_ctrl_c(pool, args, app_config, &mut CtrlC::never()).await
201}
202
203/// Resolves `args.bridge_host` (always) and `args.server` (only for the `opcda` driver,
204/// since the simulator driver has no OPC server concept at all) through `app_config`'s
205/// `CLI > env > config file > default` precedence before anything else runs, so every
206/// downstream consumer (`crate::driver::build`, mainly) can keep reading the plain
207/// `TuneArgs` fields it always has.
208///
209/// `ctrl_c` is threaded through to [`execute`] (and, from there, to every driver await in
210/// [`run_polling_loop`] and the final restore), so a Ctrl+C delivered at any point during
211/// the run -- not just while idle between polls -- is observed. See `safety-cancellation`
212/// in AGENTS.md.
213pub(crate) async fn run_with_ctrl_c(
214    pool: &SqlitePool,
215    args: TuneArgs,
216    app_config: &crate::config::BhtuneConfig,
217    ctrl_c: &mut CtrlC,
218) -> anyhow::Result<TuneOutcome> {
219    let prepared = prepare(pool, args, app_config).await?;
220    let PreparedTune {
221        run_id,
222        args,
223        template,
224        tags,
225        driver,
226        config,
227        timing,
228        time_anchor,
229        write_pid,
230        allow_uncertain_quality,
231    } = prepared;
232
233    let outcome = execute_with_timing(
234        pool,
235        run_id,
236        &args,
237        &template,
238        &tags,
239        driver.as_ref(),
240        config,
241        timing,
242        time_anchor,
243        write_pid,
244        allow_uncertain_quality,
245        ctrl_c,
246        &mut std::io::stdin().lock(),
247    )
248    .await;
249
250    match outcome {
251        Ok(run_outcome) => {
252            let tune_outcome = print_summary(run_id, &run_outcome, args.output);
253            let outcome_label = tune_outcome.label();
254            tracing::info!(run_id, outcome = outcome_label, "tune run finished");
255            Ok(tune_outcome)
256        }
257        Err(e) => {
258            tracing::error!(run_id, error = %e, "tune run failed");
259            finalize_pending_for_run_best_effort(
260                pool,
261                run_id,
262                "the run failed before MV confirmation completed",
263            )
264            .await;
265            TuneRunRow::fail(pool, run_id, Utc::now(), &e.to_string())
266                .await
267                .ok();
268            Err(e)
269        }
270    }
271}
272
273/// Everything [`prepare`] resolves before a tune's long-running polling phase can start:
274/// the already-validated/defaulted [`TuneArgs`], the resolved template and derived tags, a
275/// connected driver, the built [`LoopConfig`], the run's start time, and the response level
276/// (if any) to write back at the end -- plus the `tune_runs` row's assigned id.
277///
278/// Exists to split [`run_with_ctrl_c`]'s single monolithic body into a fast, synchronous-ish
279/// setup phase (this struct's construction: template lookup, tag derivation, driver
280/// connect, and the `tune_runs` insert that assigns [`PreparedTune::run_id`]) and a
281/// long-running phase ([`drive`]/[`execute`]'s actual polling loop, potentially minutes
282/// long) -- so an HTTP caller (`bhtune-server`'s `POST /api/runs`) can run the first phase
283/// inline in its request handler (fast enough to await directly, and any failure here -- bad
284/// template name, unreachable driver -- is exactly the kind of problem an HTTP client
285/// expects a synchronous error response for) and `tokio::spawn` the second, returning the
286/// assigned `run_id` immediately rather than blocking the HTTP response for the whole test.
287///
288/// Every field but `run_id` is private: a caller that isn't this module has no legitimate
289/// reason to inspect a template/tags/driver/config mid-flight, only to hand the whole
290/// prepared bundle to [`drive`] (or, internally, [`run_with_ctrl_c`]) unchanged.
291pub struct PreparedTune {
292    run_id: i64,
293    args: TuneArgs,
294    template: DcsTemplate,
295    tags: LoopTags,
296    driver: Box<dyn Driver>,
297    config: LoopConfig,
298    timing: EffectiveTiming,
299    time_anchor: RunTimeAnchor,
300    write_pid: Option<ResponseLevel>,
301    allow_uncertain_quality: bool,
302}
303
304/// Prepare a simulator tune and bind its history to a demo session before the background
305/// execution is started. Live OPC DA preparation is intentionally rejected by this helper.
306pub async fn prepare_owned(
307    pool: &SqlitePool,
308    args: TuneArgs,
309    app_config: &crate::config::BhtuneConfig,
310    demo_session_id: i64,
311) -> anyhow::Result<PreparedTune> {
312    if args.driver != DriverKindArg::Simulator {
313        anyhow::bail!("demo sessions may only start simulator runs");
314    }
315    prepare_internal(pool, args, app_config, Some(demo_session_id)).await
316}
317
318impl PreparedTune {
319    /// The `tune_runs` row id assigned to this run -- returned to an HTTP caller immediately
320    /// (before the run has necessarily finished, or even started polling) so it can be used
321    /// to poll `GET /api/runs/{id}` or issue `POST /api/runs/{id}/cancel`.
322    pub fn run_id(&self) -> i64 {
323        self.run_id
324    }
325}
326
327/// The shape persisted into `tune_runs.request_json` (`db-run-request-snapshot`).
328/// Deliberately mirrors `bhtune-server`'s `StartRunRequest` DTO field-for-field -- core
329/// types, not this crate's clap-facing `*Arg` wrapper enums -- so a CLI-originated and an
330/// HTTP-originated run produce byte-identical JSON snapshots with no duplicated
331/// construction logic between the two crates (`bhtune-server` can't reuse this struct
332/// directly, since `bhtune-cli` doesn't depend on it, but the two shapes are kept in sync by
333/// convention the same way `StartRunRequest::into_tune_args` already keeps its own field
334/// list in sync with [`TuneArgs`]).
335///
336/// Built from `args` *before* [`prepare`]'s own `bridge_host`/`server` resolution mutates
337/// them, so a field left unset by the caller stays absent here rather than silently baking
338/// in a resolved default -- this is what lets `ui-prefill-last-run` show blanks where the
339/// user relied on a default, instead of freezing yesterday's resolved values into today's
340/// form. `output` is deliberately excluded: it's a CLI/HTTP-transport concern with no
341/// meaning as a "setting" to remember or duplicate.
342#[derive(serde::Serialize)]
343struct RequestSnapshot<'a> {
344    tagname: &'a str,
345    template: &'a str,
346    process_type: ProcessType,
347    controller_type: ControllerType,
348    relay_amp: f32,
349    cycles_skip: Option<u32>,
350    cycles_count: Option<u32>,
351    noise_protection_secs: Option<u32>,
352    driver: TuneDriver,
353    bridge_host: Option<&'a str>,
354    server: Option<&'a str>,
355    sim_gain: f32,
356    sim_tau: f32,
357    sim_dead_time: f32,
358    sim_noise: f32,
359    sim_seed: u64,
360    sim_initial_pv: f32,
361    sim_initial_mv: f32,
362    pv_range_high: Option<f32>,
363    pv_range_low: Option<f32>,
364    mv_range_high: Option<f32>,
365    mv_range_low: Option<f32>,
366    direction: Option<ControllerDirection>,
367    tag_overrides: Option<&'a TagOverrides>,
368    notes: Option<&'a str>,
369    yes: bool,
370    write_pid: Option<ResponseLevel>,
371}
372
373/// The fast setup phase shared by [`run_with_ctrl_c`] (the CLI's entry point) and [`drive`]
374/// (the entry point for a caller -- `bhtune-server` -- that needs to start a run and return
375/// control to its own caller before the run finishes). See [`PreparedTune`]'s doc comment
376/// for why this split exists.
377///
378/// Identical in behavior to what `run_with_ctrl_c` did inline before this split: the
379/// `--write-pid`-without-`--yes` guard, `bridge_host`/`server` resolution, template lookup,
380/// `LoopConfig`/`LoopTags` construction, driver connection, and the `tune_runs` insert all
381/// run in exactly the same order against exactly the same inputs. Extracting this into its
382/// own function changes nothing about what runs or when -- only who else can call it.
383pub async fn prepare(
384    pool: &SqlitePool,
385    args: TuneArgs,
386    app_config: &crate::config::BhtuneConfig,
387) -> anyhow::Result<PreparedTune> {
388    prepare_internal(pool, args, app_config, None).await
389}
390
391async fn prepare_internal(
392    pool: &SqlitePool,
393    mut args: TuneArgs,
394    app_config: &crate::config::BhtuneConfig,
395    demo_session_id: Option<i64>,
396) -> anyhow::Result<PreparedTune> {
397    // Fails before any driver/database I/O at all: an unattended write-back must be an
398    // explicit, deliberate choice, not something a stray `--write-pid` without `--yes` can
399    // trigger by accident.
400    if args.write_pid.is_some() && !args.yes {
401        anyhow::bail!(
402            "--write-pid requires --yes: writing PID constants back to the DCS with no \
403             human present to confirm must be an explicit, deliberate choice"
404        );
405    }
406    let timing: EffectiveTiming = crate::config::resolve_and_validate_tuning_config(
407        &app_config.tuning,
408        args.driver == DriverKindArg::Opcda,
409    )?
410    .into();
411    if let Some(tag_overrides) = &args.tag_overrides {
412        tag_overrides.validate()?;
413    }
414    let allow_uncertain_quality = app_config.allow_uncertain_quality;
415
416    let db_driver = match args.driver {
417        DriverKindArg::Opcda => TuneDriver::Opcda,
418        DriverKindArg::Simulator => TuneDriver::Simulator,
419    };
420
421    // Snapshotted before `bridge_host`/`server` are resolved to their effective values just
422    // below, so a field the caller left unset stays absent here instead of silently baking
423    // in a resolved default (`db-run-request-snapshot`) -- see `RequestSnapshot`'s doc
424    // comment.
425    let request_json = serde_json::to_string(&RequestSnapshot {
426        tagname: &args.tagname,
427        template: &args.template,
428        process_type: args.process_type.into(),
429        controller_type: args.controller_type.into(),
430        relay_amp: args.relay_amp,
431        cycles_skip: args.cycles_skip,
432        cycles_count: args.cycles_count,
433        noise_protection_secs: args.noise_protection_secs,
434        driver: db_driver,
435        bridge_host: args.bridge_host.as_deref(),
436        server: args.server.as_deref(),
437        sim_gain: args.sim_gain,
438        sim_tau: args.sim_tau,
439        sim_dead_time: args.sim_dead_time,
440        sim_noise: args.sim_noise,
441        sim_seed: args.sim_seed,
442        sim_initial_pv: args.sim_initial_pv,
443        sim_initial_mv: args.sim_initial_mv,
444        pv_range_high: args.pv_range_high,
445        pv_range_low: args.pv_range_low,
446        mv_range_high: args.mv_range_high,
447        mv_range_low: args.mv_range_low,
448        direction: args.direction.map(Into::into),
449        tag_overrides: args.tag_overrides.as_ref(),
450        notes: args.notes.as_deref(),
451        yes: args.yes,
452        write_pid: args.write_pid.map(Into::into),
453    })
454    .expect(
455        "RequestSnapshot serialization is infallible: plain enum/scalar fields, no maps and \
456         no floats that JSON can't represent (every f32 here is validated finite before \
457         reaching this call, per safety-validation)",
458    );
459
460    args.bridge_host = Some(crate::config::resolve_bridge_host(
461        args.bridge_host.take(),
462        app_config,
463    ));
464    if matches!(args.driver, DriverKindArg::Opcda) {
465        args.server = Some(crate::config::resolve_server(
466            args.server.take(),
467            app_config,
468        )?);
469    }
470
471    let template_row = DcsTemplateRow::get_by_name(pool, &args.template)
472        .await?
473        .ok_or_else(|| anyhow::anyhow!("no template named '{}'", args.template))?;
474    let template_origin = template_row.origin;
475    let template = template_row.template;
476
477    let config = build_loop_config_with_timing(&args, timing)?;
478    let tags = build_loop_tags(&args, &template)?;
479    let driver = crate::driver::build_with_poll_interval(&args, timing.poll_interval_ms).await?;
480
481    let time_anchor = RunTimeAnchor::now();
482    let started_at = time_anchor.utc();
483    let run = TuneRunRow::start_with_demo_session(
484        pool,
485        demo_session_id,
486        None,
487        &args.tagname,
488        db_driver,
489        config,
490        template_origin,
491        &template,
492        &tags,
493        started_at,
494    )
495    .await?;
496    let metadata_result = async {
497        TuneRunRow::record_effective_tuning(pool, run.id, timing.into()).await?;
498        TuneRunRow::record_allow_uncertain_quality(pool, run.id, allow_uncertain_quality).await?;
499
500        // The *resolved, effective* connection this run actually used -- `None`/`None` for a
501        // non-opcda run even though `args.bridge_host` was just unconditionally resolved to a
502        // default above (a pre-existing, harmless quirk of that resolution call). Forcing both
503        // to `None` here is what makes a simulator/replay run correctly show "no connection"
504        // rather than an OPC DA gateway it never actually touched -- load-bearing for
505        // `history revert`'s connection safety fix.
506        let (opc_server, bridge_host) = if db_driver == TuneDriver::Opcda {
507            (args.server.as_deref(), args.bridge_host.as_deref())
508        } else {
509            (None, None)
510        };
511        TuneRunRow::record_connection(pool, run.id, opc_server, bridge_host, &request_json).await?;
512        let notes = args
513            .notes
514            .as_deref()
515            .map(str::trim)
516            .filter(|notes| !notes.is_empty());
517        TuneRunRow::update_notes(pool, run.id, notes).await?;
518        Ok::<(), anyhow::Error>(())
519    }
520    .await;
521    if let Err(error) = metadata_result {
522        finalize_preparation_failure(pool, run.id, &error.to_string()).await;
523        return Err(error);
524    }
525
526    tracing::info!(
527        run_id = run.id,
528        template = %args.template,
529        process_type = ?config.process_type,
530        controller_type = ?config.controller_type,
531        driver = ?db_driver,
532        allow_uncertain_quality,
533        "starting tune run"
534    );
535
536    let write_pid: Option<ResponseLevel> = args.write_pid.map(Into::into);
537
538    Ok(PreparedTune {
539        run_id: run.id,
540        args,
541        template,
542        tags,
543        driver,
544        config,
545        timing,
546        time_anchor,
547        write_pid,
548        allow_uncertain_quality,
549    })
550}
551
552async fn finalize_preparation_failure(pool: &SqlitePool, run_id: i64, reason: &str) {
553    if let Err(error) = TuneRunRow::fail(pool, run_id, Utc::now(), reason).await {
554        tracing::error!(
555            run_id,
556            error = %error,
557            "could not mark a failed preparation run terminal; attempting to delete it"
558        );
559        if let Err(delete_error) = TuneRunRow::delete(pool, run_id).await {
560            tracing::error!(
561                run_id,
562                error = %delete_error,
563                "could not remove a failed preparation run"
564            );
565        }
566    }
567}
568
569/// Runs an already-[`prepare`]d tune to completion -- the print-free counterpart to
570/// [`run_with_ctrl_c`], for a caller with no terminal to print a summary to and no stdin to
571/// prompt on (`bhtune-server`'s background tune task, `tokio::spawn`ed after its
572/// `POST /api/runs` handler has already returned `prepared.run_id()` to the HTTP client).
573///
574/// Calls the exact same [`execute`] this module's CLI path calls, with the exact same
575/// arguments, so the actual tuning behavior -- quality checks, restore-on-abort, write-back
576/// rollback, all of it -- is identical between the CLI and an HTTP-started run; only the
577/// reporting differs. On success, returns the same coarse [`TuneOutcome`]
578/// `run_with_ctrl_c`'s printed summary would have shown, computed via the same
579/// `tune_outcome_for_run` mapping, and logs it exactly as `run_with_ctrl_c` does. On
580/// failure, records the same `tune_runs.fail` row `run_with_ctrl_c` would have.
581///
582/// A caller that wants the same rich per-response-level detail `print_summary` shows on the
583/// CLI should instead read the run back from the database once this resolves (over HTTP,
584/// `GET /api/runs/{id}`) -- `execute`'s own DB writes (`tune_results`/`tune_writes`) are the
585/// authoritative record of everything `print_summary` would have printed, so there is
586/// nothing this function needs to hand back beyond the coarse outcome.
587///
588/// `prepared.args.output` should be [`OutputFormat::Json`] for every caller of this
589/// function, even though nothing here actually prints: [`maybe_write_back`] (called from
590/// inside [`execute`]) skips its interactive stdin prompt only when `output ==
591/// OutputFormat::Json` (see that function's doc comment) -- a caller with no stdin to read
592/// from at all must never risk hitting that prompt. Accordingly, this function passes
593/// [`execute`] a [`std::io::empty()`] reader rather than real stdin -- besides there being
594/// no human to prompt, `std::io::Empty` is `Send` (unlike a real [`std::io::StdinLock`]),
595/// which is what allows the future returned by a call to this function to be
596/// `tokio::spawn`ed at all (see `execute`'s own doc comment).
597pub async fn drive(
598    pool: &SqlitePool,
599    prepared: PreparedTune,
600    ctrl_c: &mut CtrlC,
601) -> anyhow::Result<TuneOutcome> {
602    let PreparedTune {
603        run_id,
604        args,
605        template,
606        tags,
607        driver,
608        config,
609        timing,
610        time_anchor,
611        write_pid,
612        allow_uncertain_quality,
613    } = prepared;
614
615    let outcome = execute_with_timing(
616        pool,
617        run_id,
618        &args,
619        &template,
620        &tags,
621        driver.as_ref(),
622        config,
623        timing,
624        time_anchor,
625        write_pid,
626        allow_uncertain_quality,
627        ctrl_c,
628        &mut std::io::empty(),
629    )
630    .await;
631
632    match outcome {
633        Ok(run_outcome) => {
634            let tune_outcome = tune_outcome_for_run(&run_outcome);
635            tracing::info!(run_id, outcome = tune_outcome.label(), "tune run finished");
636            Ok(tune_outcome)
637        }
638        Err(e) => {
639            tracing::error!(run_id, error = %e, "tune run failed");
640            finalize_pending_for_run_best_effort(
641                pool,
642                run_id,
643                "the run failed before MV confirmation completed",
644            )
645            .await;
646            TuneRunRow::fail(pool, run_id, Utc::now(), &e.to_string())
647                .await
648                .ok();
649            Err(e)
650        }
651    }
652}
653
654#[derive(Debug)]
655enum RunOutcome {
656    Completed {
657        write_back: WriteBackOutcome,
658        /// Human-readable reason `write_back` was `Skipped`/`Failed`, or `None` when it
659        /// succeeded ([`WriteBackOutcome::Written`]) or the outcome is otherwise
660        /// self-explanatory. Exists so `--output json` can report the same explanation
661        /// `maybe_write_back`'s suppressed `println!`s would have shown in `Table` mode,
662        /// without printing anything ahead of the run's final JSON object
663        /// (`safety-json-contract`, finding 8).
664        write_back_detail: Option<String>,
665    },
666    Aborted(AbortReason),
667    /// The run ended (via normal completion or [`RunOutcome::Aborted`]) but the subsequent
668    /// restore attempt ([`attempt_restore_with_actuation`]) could not be confirmed -- a
669    /// second Ctrl+C arrived, or `[tuning].restore_timeout_secs` elapsed.
670    /// `reason` is a human-readable description of what happened, already including the
671    /// original abort trigger (if any) -- see `execute`'s composition of it. Write-back is
672    /// always skipped in this case, since writing new PID constants to a loop whose mode/MV
673    /// cannot be confirmed restored would compound the uncertainty.
674    RestoreIncomplete {
675        reason: String,
676    },
677}
678
679/// Why a run ended via [`RunOutcome::Aborted`] instead of a normal engine completion.
680#[derive(Debug, Clone, PartialEq)]
681enum AbortReason {
682    /// Ctrl+C.
683    UserInterrupt,
684    /// `[tuning].timeout_secs` elapsed before the engine reported completion. Carries the
685    /// configured limit that was hit, for the printed/JSON summary.
686    Timeout { timeout_secs: u64 },
687    /// A single driver read/write during a poll tick did not resolve within
688    /// `[tuning].op_timeout_secs` -- distinct from [`AbortReason::Timeout`], which bounds the whole
689    /// run rather than one operation. Carries the tag that stalled and the configured limit,
690    /// for the printed/JSON summary. Maps to the same [`TuneOutcome::TimedOut`] as
691    /// `Timeout`, since both mean "gave up waiting", differing only in what exactly timed
692    /// out.
693    OperationTimedOut { tag: String, op_timeout_secs: u64 },
694    /// An in-flight PV poll sample's quality was `Bad`, or `Uncertain` without
695    /// the global Config > OPC quality policy set (finding 5 of the live-plant safety
696    /// review). Unlike
697    /// the two variants above, this is checked and constructed from inside
698    /// [`run_polling_loop`] itself rather than from [`execute`]'s outer `tokio::select!`,
699    /// since it depends on the value just read, not an independent timer/signal. A poor
700    /// quality reading *before* the mode transition (any of `read_initial_values`'s
701    /// readings, including the setpoint capture) is instead a hard failure via a plain
702    /// `anyhow::Error` -- see `check_quality` -- since nothing has been mutated yet at that
703    /// point, so there's no loop state to restore and no reason to route it through this
704    /// enum. Carries `tag`/`quality` for the printed/JSON summary.
705    PoorQuality {
706        tag: String,
707        quality: bhtune_driver::Quality,
708    },
709    /// An accepted OPC DA MV write was not physically observed within tolerance before its
710    /// four-second deadline or before a later relay action needed to replace it.
711    MvActuationUnconfirmed {
712        tag: String,
713        target: f32,
714        readback: Option<f32>,
715        tolerance: f32,
716        elapsed_ms: u64,
717        deadline_secs: u64,
718    },
719}
720
721/// The result of `maybe_write_back`'s attempt (or non-attempt) to write calculated PID
722/// parameters back to the DCS. A real write (`Written`/`Failed`) is always fully recorded in
723/// `tune_writes`; `Skipped` never touches the driver at all, so it leaves no row there. This
724/// enum exists purely to drive the printed summary and the process exit code.
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726enum WriteBackOutcome {
727    /// No write was attempted: no PID constant tags configured, no results recorded, or
728    /// (interactive path only) the user chose to skip / gave invalid input.
729    Skipped,
730    /// The write succeeded and was confirmed by a readback.
731    Written { response_level: ResponseLevel },
732    /// A write was attempted (interactively selected, or requested via `--write-pid`) but
733    /// failed -- the driver rejected it, the confirmation readback failed, or (defensively)
734    /// `--write-pid` named a response level with no recorded calculated result.
735    Failed,
736}
737
738/// Maps one run's full outcome down to the coarser [`TuneOutcome`] that drives the process
739/// exit code -- a write-back failure demotes an otherwise-successful test completion to
740/// [`TuneOutcome::WriteBackFailed`], since an unattended caller needs to know the loop was
741/// left with its *old* PID constants, not the newly calculated ones.
742fn tune_outcome_for_run(outcome: &RunOutcome) -> TuneOutcome {
743    match outcome {
744        RunOutcome::Completed {
745            write_back: WriteBackOutcome::Failed,
746            ..
747        } => TuneOutcome::WriteBackFailed,
748        RunOutcome::Completed { .. } => TuneOutcome::Completed,
749        RunOutcome::Aborted(AbortReason::UserInterrupt) => TuneOutcome::Aborted,
750        RunOutcome::Aborted(AbortReason::Timeout { .. }) => TuneOutcome::TimedOut,
751        RunOutcome::Aborted(AbortReason::OperationTimedOut { .. }) => TuneOutcome::TimedOut,
752        RunOutcome::Aborted(AbortReason::PoorQuality { .. }) => TuneOutcome::PoorQuality,
753        RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. }) => {
754            TuneOutcome::ActuationFailed
755        }
756        RunOutcome::RestoreIncomplete { .. } => TuneOutcome::RestoreIncomplete,
757    }
758}
759
760fn format_mv_actuation_abort_reason(reason: &AbortReason) -> String {
761    let AbortReason::MvActuationUnconfirmed {
762        tag,
763        target,
764        readback,
765        tolerance,
766        elapsed_ms,
767        deadline_secs,
768    } = reason
769    else {
770        return format!("{reason:?}");
771    };
772    let readback = readback
773        .map(|value| value.to_string())
774        .unwrap_or_else(|| "unavailable".to_string());
775    format!(
776        "MV actuation unconfirmed: tag '{tag}', target {target}, readback {readback}, tolerance {tolerance}, elapsed {elapsed_ms} ms, deadline {deadline_secs} s"
777    )
778}
779
780/// Prints this run's final outcome line -- either the plain-text shape or a `--output json`
781/// object -- and returns the [`TuneOutcome`] the caller should propagate as the process's
782/// exit code.
783fn print_summary(run_id: i64, outcome: &RunOutcome, output: OutputFormat) -> TuneOutcome {
784    let tune_outcome = tune_outcome_for_run(outcome);
785    match output {
786        OutputFormat::Table => print_table_summary(run_id, outcome),
787        OutputFormat::Json => print_json_summary(run_id, outcome, tune_outcome),
788    }
789    tune_outcome
790}
791
792fn print_table_summary(run_id: i64, outcome: &RunOutcome) {
793    match outcome {
794        RunOutcome::Completed {
795            write_back: WriteBackOutcome::Written { response_level },
796            ..
797        } => {
798            println!(
799                "Tune completed successfully (run id {run_id}); wrote {response_level:?} PID parameters."
800            );
801        }
802        RunOutcome::Completed {
803            write_back: WriteBackOutcome::Skipped,
804            ..
805        } => {
806            println!("Tune completed successfully (run id {run_id}).");
807        }
808        RunOutcome::Completed {
809            write_back: WriteBackOutcome::Failed,
810            ..
811        } => {
812            println!(
813                "Tune completed successfully (run id {run_id}), but PID write-back failed; the loop was left with its previous PID constants."
814            );
815        }
816        RunOutcome::Aborted(AbortReason::UserInterrupt) => {
817            println!("Tune aborted (Ctrl+C received; loop restored).");
818        }
819        RunOutcome::Aborted(AbortReason::Timeout { timeout_secs }) => {
820            println!(
821                "Tune aborted: exceeded the {timeout_secs}s [tuning].timeout_secs limit before completing; loop restored."
822            );
823        }
824        RunOutcome::Aborted(AbortReason::OperationTimedOut {
825            tag,
826            op_timeout_secs,
827        }) => {
828            println!(
829                "Tune aborted: tag '{tag}' did not respond within the {op_timeout_secs}s [tuning].op_timeout_secs limit; loop restored."
830            );
831        }
832        RunOutcome::Aborted(AbortReason::PoorQuality { tag, quality }) => {
833            println!(
834                "Tune aborted: tag '{tag}' reported OPC quality {quality:?} during polling; loop restored."
835            );
836        }
837        RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
838            tag,
839            target,
840            readback,
841            tolerance,
842            elapsed_ms,
843            deadline_secs,
844        }) => {
845            let readback = readback
846                .map(|value| value.to_string())
847                .unwrap_or_else(|| "unavailable".to_string());
848            println!(
849                "Tune aborted: MV tag '{tag}' did not confirm target {target} (readback {readback}, tolerance {tolerance}) after {:.3}s; the confirmation deadline was {deadline_secs}s. Loop restored.",
850                *elapsed_ms as f64 / 1_000.0
851            );
852        }
853        RunOutcome::RestoreIncomplete { reason } => {
854            println!(
855                "Tune ended, but the loop's restore could not be confirmed ({reason}). Check the loop by hand -- see the warning above for the tag and value to check."
856            );
857        }
858    }
859}
860
861fn print_json_summary(run_id: i64, outcome: &RunOutcome, tune_outcome: TuneOutcome) {
862    let (write_back, response_level) = match outcome {
863        RunOutcome::Completed {
864            write_back: WriteBackOutcome::Written { response_level },
865            ..
866        } => ("written", Some(*response_level)),
867        RunOutcome::Completed {
868            write_back: WriteBackOutcome::Skipped,
869            ..
870        } => ("skipped", None),
871        RunOutcome::Completed {
872            write_back: WriteBackOutcome::Failed,
873            ..
874        } => ("failed", None),
875        RunOutcome::Aborted(_) => ("not_attempted", None),
876        RunOutcome::RestoreIncomplete { .. } => ("not_attempted", None),
877    };
878    let write_back_detail = match outcome {
879        RunOutcome::Completed {
880            write_back_detail, ..
881        } => write_back_detail.clone(),
882        _ => None,
883    };
884    let timeout_secs = match outcome {
885        RunOutcome::Aborted(AbortReason::Timeout { timeout_secs }) => Some(*timeout_secs),
886        _ => None,
887    };
888    let (poor_quality_tag, poor_quality) = match outcome {
889        RunOutcome::Aborted(AbortReason::PoorQuality { tag, quality }) => (
890            Some(tag.clone()),
891            Some(format!("{quality:?}").to_lowercase()),
892        ),
893        _ => (None, None),
894    };
895    let (op_timeout_tag, op_timeout_secs) = match outcome {
896        RunOutcome::Aborted(AbortReason::OperationTimedOut {
897            tag,
898            op_timeout_secs,
899        }) => (Some(tag.clone()), Some(*op_timeout_secs)),
900        _ => (None, None),
901    };
902    let restore_incomplete_reason = match outcome {
903        RunOutcome::RestoreIncomplete { reason } => Some(reason.clone()),
904        _ => None,
905    };
906    let actuation = match outcome {
907        RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
908            tag,
909            target,
910            readback,
911            tolerance,
912            elapsed_ms,
913            deadline_secs,
914        }) => Some(serde_json::json!({
915            "tag": tag,
916            "target": target,
917            "readback": readback,
918            "tolerance": tolerance,
919            "elapsed_ms": elapsed_ms,
920            "deadline_secs": deadline_secs,
921        })),
922        _ => None,
923    };
924    let json = serde_json::json!({
925        "run_id": run_id,
926        "outcome": tune_outcome.label(),
927        "write_back": write_back,
928        "write_back_response_level": response_level,
929        "write_back_detail": write_back_detail,
930        "timeout_secs": timeout_secs,
931        "poor_quality_tag": poor_quality_tag,
932        "poor_quality": poor_quality,
933        "op_timeout_tag": op_timeout_tag,
934        "op_timeout_secs": op_timeout_secs,
935        "mv_actuation": actuation,
936        "restore_incomplete_reason": restore_incomplete_reason,
937    });
938    println!(
939        "{}",
940        render_json_summary(&json, serde_json::to_string_pretty)
941    );
942}
943
944fn render_json_summary<E>(
945    json: &serde_json::Value,
946    serialize: impl FnOnce(&serde_json::Value) -> Result<String, E>,
947) -> String
948where
949    E: std::fmt::Display,
950{
951    serialize(json).unwrap_or_else(|error| format!("{{\"error\": \"{error}\"}}"))
952}
953
954fn build_loop_config_with_timing(
955    args: &TuneArgs,
956    timing: EffectiveTiming,
957) -> anyhow::Result<LoopConfig> {
958    let process_type: ProcessType = args.process_type.into();
959    let controller_type: ControllerType = args.controller_type.into();
960
961    if !controller_type.is_allowed_for(process_type) {
962        anyhow::bail!(
963            "{controller_type:?} controller is not valid for {process_type:?} (PID is only offered for the two Temperature process types)"
964        );
965    }
966
967    let config = LoopConfig {
968        process_type,
969        controller_type,
970        relay_amp_percent: args.relay_amp,
971        num_cycles_skip: args
972            .cycles_skip
973            .unwrap_or_else(|| process_type.default_cycles_skip()),
974        num_cycles_count: args
975            .cycles_count
976            .unwrap_or_else(|| process_type.default_cycles_test()),
977        noise_protection_secs: args
978            .noise_protection_secs
979            .unwrap_or_else(|| process_type.default_noise_protection_secs()),
980        mrft_delay_secs: timing.mrft_delay_secs,
981    };
982    // Real range validation at the model level (see `LoopConfig::validate`), not just this
983    // flag parse -- catches an out-of-range `--relay-amp` (including the legacy predecessor's
984    // "not blank" bug of a stray debug shortcut reaching this field) before any driver
985    // connection or database write.
986    config.validate()?;
987    Ok(config)
988}
989
990#[cfg(test)]
991fn build_loop_config(args: &TuneArgs) -> anyhow::Result<LoopConfig> {
992    build_loop_config_with_timing(args, test_effective_timing(args))
993}
994
995/// Builds the loop's full tag set. For `--driver opcda`, derives from `--tagname` and the
996/// template, then layers any explicit `--pv-range-*`/`--mv-range-*`/`--direction` overrides
997/// on top. For `--driver simulator`, `SimulatorDriver`'s fixed two-tag contract means the
998/// range/direction overrides are mandatory (normally supplied by
999/// `SimulateArgs::into_tune_args`); a direct `bhtune tune --driver simulator` invocation
1000/// missing any of them is a clear usage error rather than a confusing runtime failure.
1001fn build_loop_tags(args: &TuneArgs, template: &DcsTemplate) -> anyhow::Result<LoopTags> {
1002    match args.driver {
1003        DriverKindArg::Opcda => {
1004            let mut tags = LoopTags::derive_from_pv_tag(&args.tagname, template);
1005            if let Some(overrides) = &args.tag_overrides {
1006                overrides.apply_to(&mut tags);
1007            }
1008            // Fixed values are applied after custom read-tag overrides, so an explicit fixed
1009            // request remains authoritative when input contains both.
1010            if let Some(v) = args.pv_range_high {
1011                tags.upper_pv_range = TagOrValue::Value(v);
1012            }
1013            if let Some(v) = args.pv_range_low {
1014                tags.lower_pv_range = TagOrValue::Value(v);
1015            }
1016            if let Some(v) = args.mv_range_high {
1017                tags.upper_mv_range = TagOrValue::Value(v);
1018            }
1019            if let Some(v) = args.mv_range_low {
1020                tags.lower_mv_range = TagOrValue::Value(v);
1021            }
1022            if let Some(d) = args.direction {
1023                tags.controller_direction = TagOrValue::Value(d.into());
1024            }
1025            Ok(tags)
1026        }
1027        DriverKindArg::Simulator => {
1028            let pv_range_high = args.pv_range_high.ok_or_else(|| {
1029                anyhow::anyhow!(
1030                    "--pv-range-high is required with --driver simulator (or use `bhtune simulate`)"
1031                )
1032            })?;
1033            let pv_range_low = args.pv_range_low.ok_or_else(|| {
1034                anyhow::anyhow!(
1035                    "--pv-range-low is required with --driver simulator (or use `bhtune simulate`)"
1036                )
1037            })?;
1038            let mv_range_high = args.mv_range_high.ok_or_else(|| {
1039                anyhow::anyhow!(
1040                    "--mv-range-high is required with --driver simulator (or use `bhtune simulate`)"
1041                )
1042            })?;
1043            let mv_range_low = args.mv_range_low.ok_or_else(|| {
1044                anyhow::anyhow!(
1045                    "--mv-range-low is required with --driver simulator (or use `bhtune simulate`)"
1046                )
1047            })?;
1048            let direction = args.direction.ok_or_else(|| {
1049                anyhow::anyhow!(
1050                    "--direction is required with --driver simulator (or use `bhtune simulate`)"
1051                )
1052            })?;
1053
1054            Ok(LoopTags {
1055                process_variable: SIMULATOR_PV_TAG.to_string(),
1056                manipulated_variable: SIMULATOR_MV_TAG.to_string(),
1057                setpoint_variable: None,
1058                controller_mode: None,
1059                mode_attribute: None,
1060                upper_pv_range: TagOrValue::Value(pv_range_high),
1061                lower_pv_range: TagOrValue::Value(pv_range_low),
1062                upper_mv_range: TagOrValue::Value(mv_range_high),
1063                lower_mv_range: TagOrValue::Value(mv_range_low),
1064                controller_direction: TagOrValue::Value(direction.into()),
1065                proportional_constant: None,
1066                integral_constant: None,
1067                derivative_constant: None,
1068            })
1069        }
1070    }
1071}
1072
1073/// Everything read from the driver before any mode transition is attempted — mirrors
1074/// `ReadInitialOPCvalues`.
1075#[derive(Debug)]
1076struct InitialState {
1077    pv_ini: f32,
1078    mv_ini: f32,
1079    pv_range_high: f32,
1080    pv_range_low: f32,
1081    mv_range_high: f32,
1082    mv_range_low: f32,
1083    direction: ControllerDirection,
1084    mode_raw: Option<String>,
1085    mode_attribute_raw: Option<String>,
1086    /// The setpoint, captured here -- before any mutation of the loop -- whenever the loop's
1087    /// original mode is Auto and both a mode and a setpoint tag are configured (mirrors
1088    /// `SvValueIni` in the legacy app, which captured it later, at the moment of actually
1089    /// transitioning out of Auto). Hoisting the read this early means it's durably persisted
1090    /// via [`TuneRunRow::record_initial_readings`] before `transition_to_manual`'s first
1091    /// mutating write is even attempted, so a crashed run's restore intent survives the
1092    /// process dying outright (`safety-restore-guard`, finding 3 of the live-plant safety
1093    /// review). Note that this field being `Some(..)` only proves the loop *was* in Auto --
1094    /// not that a mode transition was actually attempted, since `read_initial_values` runs
1095    /// unconditionally, before any such decision is made -- so `restore`'s setpoint-revert
1096    /// step is additionally gated on [`MutationGuard::mode_written`], not on this field
1097    /// alone.
1098    setpoint_ini: Option<f32>,
1099}
1100
1101/// Tracks which of `transition_to_manual`'s mutations were actually *attempted* -- armed
1102/// immediately before each write is issued, not after it succeeds -- so `restore` can
1103/// independently decide what's safe/necessary to revert even when `transition_to_manual`
1104/// itself returns partway through with an error (`safety-restore-guard`, finding 3 of the
1105/// live-plant safety review). Renamed from the former `ModeRestoreState`, which held only
1106/// the captured setpoint; that value now lives on [`InitialState`] instead, read before any
1107/// mutation rather than during `transition_to_manual` -- see that field's doc comment for
1108/// why.
1109#[derive(Debug, Default)]
1110struct MutationGuard {
1111    /// The mode-attribute tag's "put in program/computer mode" write was attempted.
1112    mode_attribute_written: bool,
1113    /// The mode tag's "put in Manual" write was attempted.
1114    mode_written: bool,
1115    /// A relay-test MV write was attempted at least once during `run_polling_loop`. Tracked
1116    /// for completeness/audit parity with the other two flags -- `restore`'s MV-revert step
1117    /// is unconditional and never gated by this flag, since a redundant write-back is always
1118    /// harmless (idempotent if the pre-test value is already there, or safely rejected by
1119    /// the DCS if the loop never actually left Auto), so there is no correctness reason to
1120    /// skip attempting it even while this is still `false`.
1121    mv_written: bool,
1122}
1123
1124#[derive(Debug)]
1125struct PendingMvActuation {
1126    id: Option<i64>,
1127    kind: MvActuationKind,
1128    target: f32,
1129    tolerance: f32,
1130    switch_tick: DateTime<Utc>,
1131    switch_instant: Instant,
1132    accepted_instant: Instant,
1133    first_check_at: Instant,
1134    deadline: Instant,
1135    last_readback: Option<f32>,
1136}
1137
1138#[derive(Debug)]
1139struct MvActuationTracker {
1140    next_sequence: i64,
1141    previous_commanded_mv: f32,
1142    confirmed_mv: Option<f32>,
1143    pending: Option<PendingMvActuation>,
1144    mv_span: f32,
1145}
1146
1147impl MvActuationTracker {
1148    fn for_run(args: &TuneArgs, initial: &InitialState) -> Option<Self> {
1149        (args.driver == DriverKindArg::Opcda).then_some(Self {
1150            next_sequence: 0,
1151            previous_commanded_mv: initial.mv_ini,
1152            confirmed_mv: None,
1153            pending: None,
1154            mv_span: initial.mv_range_high - initial.mv_range_low,
1155        })
1156    }
1157
1158    #[cfg(test)]
1159    #[allow(clippy::too_many_arguments)]
1160    async fn record_accepted(
1161        &mut self,
1162        pool: &SqlitePool,
1163        run_id: i64,
1164        kind: MvActuationKind,
1165        target: f32,
1166        first_check_at: Instant,
1167        accepted_at: DateTime<Utc>,
1168        accepted_instant: Instant,
1169        tolerance: f32,
1170    ) -> anyhow::Result<()> {
1171        self.record_accepted_at_switch(
1172            pool,
1173            run_id,
1174            kind,
1175            target,
1176            accepted_at,
1177            accepted_instant,
1178            first_check_at,
1179            accepted_at,
1180            accepted_instant,
1181            tolerance,
1182        )
1183        .await
1184    }
1185
1186    #[allow(clippy::too_many_arguments)]
1187    async fn record_accepted_at_switch(
1188        &mut self,
1189        pool: &SqlitePool,
1190        run_id: i64,
1191        kind: MvActuationKind,
1192        target: f32,
1193        switch_tick: DateTime<Utc>,
1194        switch_instant: Instant,
1195        first_check_at: Instant,
1196        accepted_at: DateTime<Utc>,
1197        accepted_instant: Instant,
1198        tolerance: f32,
1199    ) -> anyhow::Result<()> {
1200        let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
1201        let confirmation_due_at =
1202            accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
1203        let previous_commanded_mv = Some(self.previous_commanded_mv);
1204        let row = TuneMvActuationRow::insert_pending(
1205            pool,
1206            run_id,
1207            NewTuneMvActuation {
1208                sequence: self.next_sequence,
1209                kind,
1210                commanded_at: accepted_at,
1211                target_mv: target,
1212                previous_commanded_mv,
1213                tolerance,
1214                confirmation_due_at,
1215            },
1216        )
1217        .await?;
1218        self.accept_pending(PendingMvActuation {
1219            id: Some(row.id),
1220            kind,
1221            target,
1222            tolerance,
1223            switch_tick,
1224            switch_instant,
1225            accepted_instant,
1226            first_check_at: first_check_at.min(deadline),
1227            deadline,
1228            last_readback: None,
1229        });
1230        Ok(())
1231    }
1232
1233    #[allow(clippy::too_many_arguments)]
1234    async fn record_restore_accepted_best_effort(
1235        &mut self,
1236        pool: &SqlitePool,
1237        run_id: i64,
1238        target: f32,
1239        accepted_at: DateTime<Utc>,
1240        accepted_instant: Instant,
1241        tolerance: f32,
1242    ) {
1243        let deadline = accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
1244        let confirmation_due_at =
1245            accepted_at + chrono::Duration::seconds(MV_ACTUATION_CONFIRMATION_SECS as i64);
1246        let pending = PendingMvActuation {
1247            id: None,
1248            kind: MvActuationKind::Restore,
1249            target,
1250            tolerance,
1251            switch_tick: accepted_at,
1252            switch_instant: accepted_instant,
1253            accepted_instant,
1254            first_check_at: accepted_instant,
1255            deadline,
1256            last_readback: None,
1257        };
1258        let row = TuneMvActuationRow::insert_pending(
1259            pool,
1260            run_id,
1261            NewTuneMvActuation {
1262                sequence: self.next_sequence,
1263                kind: MvActuationKind::Restore,
1264                commanded_at: accepted_at,
1265                target_mv: target,
1266                previous_commanded_mv: Some(self.previous_commanded_mv),
1267                tolerance,
1268                confirmation_due_at,
1269            },
1270        )
1271        .await;
1272        let mut pending = pending;
1273        match row {
1274            Ok(row) => pending.id = Some(row.id),
1275            Err(error) => {
1276                tracing::error!(
1277                    run_id,
1278                    error = %error,
1279                    "failed to record accepted restore MV command; continuing physical restore"
1280                );
1281            }
1282        }
1283        self.accept_pending(pending);
1284    }
1285
1286    fn accept_pending(&mut self, pending: PendingMvActuation) {
1287        self.next_sequence += 1;
1288        self.previous_commanded_mv = pending.target;
1289        self.confirmed_mv = None;
1290        self.pending = Some(pending);
1291    }
1292
1293    fn next_verification_wakeup(&self) -> Option<Instant> {
1294        self.pending.as_ref().map(|pending| {
1295            if pending.last_readback.is_some() {
1296                pending.deadline
1297            } else {
1298                pending
1299                    .first_check_at
1300                    .max(pending.deadline - MV_ACTUATION_FALLBACK_HEADROOM)
1301            }
1302        })
1303    }
1304}
1305
1306#[allow(clippy::too_many_arguments)]
1307async fn record_relay_actuation(
1308    tracker: &mut MvActuationTracker,
1309    pool: &SqlitePool,
1310    run_id: i64,
1311    target: f32,
1312    switch_tick: DateTime<Utc>,
1313    switch_instant: Instant,
1314    first_check_at: Instant,
1315    elapsed_since_observation: Duration,
1316    accepted_instant: Instant,
1317    tolerance: f32,
1318) -> anyhow::Result<()> {
1319    let accepted_at = utc_after_elapsed(switch_tick, elapsed_since_observation)?;
1320    tracker
1321        .record_accepted_at_switch(
1322            pool,
1323            run_id,
1324            MvActuationKind::Relay,
1325            target,
1326            switch_tick,
1327            switch_instant,
1328            first_check_at,
1329            accepted_at,
1330            accepted_instant,
1331            tolerance,
1332        )
1333        .await
1334}
1335
1336fn f32_precision_floor(target: f32, previous: f32) -> f32 {
1337    4.0 * f32::EPSILON * target.abs().max(previous.abs()).max(1.0)
1338}
1339
1340fn mv_actuation_tolerance(
1341    kind: MvActuationKind,
1342    target: f32,
1343    previous: f32,
1344    mv_span: f32,
1345) -> anyhow::Result<f32> {
1346    let uncapped = mv_actuation_uncapped_tolerance(target, previous, mv_span);
1347    if kind == MvActuationKind::Restore {
1348        return Ok(uncapped);
1349    }
1350
1351    let step = (target - previous).abs();
1352    let relay_cap = step * RELAY_STEP_TOLERANCE_FRACTION;
1353    if !step.is_finite()
1354        || step < MIN_RELAY_STEP
1355        || relay_cap <= f32_precision_floor(target, previous)
1356    {
1357        let minimum_step = MIN_RELAY_STEP
1358            .max(f32_precision_floor(target, previous) / RELAY_STEP_TOLERANCE_FRACTION);
1359        anyhow::bail!(
1360            "the effective relay step {step} is too small to verify safely (minimum {})",
1361            minimum_step
1362        );
1363    }
1364    Ok(uncapped.min(relay_cap))
1365}
1366
1367fn mv_actuation_uncapped_tolerance(target: f32, previous: f32, mv_span: f32) -> f32 {
1368    let precision_floor = f32_precision_floor(target, previous);
1369    let span_tolerance = mv_span.abs() * MV_SPAN_TOLERANCE_FRACTION;
1370    precision_floor + span_tolerance
1371}
1372
1373fn validate_relay_actuation_step(
1374    args: &TuneArgs,
1375    config: LoopConfig,
1376    initial: &InitialState,
1377) -> anyhow::Result<()> {
1378    if args.driver != DriverKindArg::Opcda {
1379        return Ok(());
1380    }
1381    let relay_step = clamp_relay_amplitude(
1382        config.relay_amp_percent,
1383        initial.mv_ini,
1384        initial.mv_range_low,
1385        initial.mv_range_high,
1386        MrftCompat::default(),
1387    );
1388    mv_actuation_tolerance(
1389        MvActuationKind::Relay,
1390        initial.mv_ini + relay_step,
1391        initial.mv_ini,
1392        initial.mv_range_high - initial.mv_range_low,
1393    )
1394    .map(|_| ())
1395}
1396
1397/// Persists the three calculated response levels after a completed MRFT test. The run's
1398/// terminal outcome is deliberately recorded later, after restoration, together with its
1399/// timing snapshot; this keeps SSE-triggered readers from seeing a terminal row before those
1400/// diagnostics are visible.
1401async fn persist_completed_results(
1402    pool: &SqlitePool,
1403    run_id: i64,
1404    completion: Action,
1405    direction: ControllerDirection,
1406    config: LoopConfig,
1407    pv_range: PvRange,
1408    template: &DcsTemplate,
1409) -> anyhow::Result<()> {
1410    persist_results(
1411        pool, run_id, completion, direction, config, pv_range, template,
1412    )
1413    .await
1414}
1415
1416/// Generic over `reader` (rather than hardcoding `std::io::stdin().lock()` internally) so
1417/// this function's generated future is monomorphized separately per call site: [`run_with_ctrl_c`]
1418/// (the CLI path) instantiates it with the real, process-wide [`std::io::StdinLock`], which
1419/// is `!Send` -- fine there, since that future is only ever `.await`ed directly, never
1420/// `tokio::spawn`ed. [`drive`] (the HTTP path) instantiates it with [`std::io::Empty`]
1421/// (`std::io::empty()`), which *is* `Send`, so `bhtune-server` can spawn the resulting
1422/// future onto its background tune task. Passing a live `StdinLock` through as a plain
1423/// parameter of a single non-generic `execute` would force both instantiations to share one
1424/// concrete (and therefore `!Send`) future type, which is exactly the compile error this
1425/// split avoids -- see `ActiveRun::start`'s `Send` bound in `bhtune-server`.
1426#[allow(clippy::too_many_arguments)]
1427async fn execute_with_timing<R: std::io::BufRead>(
1428    pool: &SqlitePool,
1429    run_id: i64,
1430    args: &TuneArgs,
1431    template: &DcsTemplate,
1432    tags: &LoopTags,
1433    driver: &dyn Driver,
1434    config: LoopConfig,
1435    effective_timing: EffectiveTiming,
1436    time_anchor: RunTimeAnchor,
1437    write_pid: Option<ResponseLevel>,
1438    allow_uncertain_quality: bool,
1439    ctrl_c: &mut CtrlC,
1440    reader: &mut R,
1441) -> anyhow::Result<RunOutcome> {
1442    let started_at = time_anchor.utc();
1443    let initial = read_initial_values(driver, tags, template, allow_uncertain_quality).await?;
1444    validate_initial_state(&initial)?;
1445    validate_relay_actuation_step(args, config, &initial)?;
1446
1447    // Persisted before any mutation is attempted (`safety-restore-guard`): a crash between
1448    // here and a confirmed restore still leaves a durable record of the mode/mode-attribute/
1449    // setpoint as they were *before* anything was written, so `bhtune restore-loop` can
1450    // reconstruct and restore the loop later even if the process never gets to do so itself.
1451    TuneRunRow::record_initial_readings(
1452        pool,
1453        run_id,
1454        TuneRunInitialReadings {
1455            pv_ini: initial.pv_ini,
1456            mv_ini: initial.mv_ini,
1457            mv_range_low: initial.mv_range_low,
1458            mv_range_high: initial.mv_range_high,
1459            pv_range_high: initial.pv_range_high,
1460            pv_range_low: initial.pv_range_low,
1461            controller_direction: initial.direction,
1462            mode_raw: initial.mode_raw.clone(),
1463            mode_attribute_raw: initial.mode_attribute_raw.clone(),
1464            setpoint_ini: initial.setpoint_ini,
1465        },
1466    )
1467    .await?;
1468
1469    let mut guard = MutationGuard::default();
1470    let mut mv_actuations = MvActuationTracker::for_run(args, &initial);
1471    if let Err(e) = transition_to_manual(driver, tags, template, &initial, &mut guard).await {
1472        return Err(restore_best_effort_then_propagate_with_timing(
1473            pool,
1474            run_id,
1475            driver,
1476            tags,
1477            template,
1478            &initial,
1479            &guard,
1480            args,
1481            effective_timing,
1482            allow_uncertain_quality,
1483            ctrl_c,
1484            &mut mv_actuations,
1485            e,
1486        )
1487        .await);
1488    }
1489
1490    let beta = lookup(
1491        config.process_type,
1492        config.controller_type,
1493        ResponseLevel::Aggressive,
1494    )
1495    .beta;
1496
1497    let mut engine = MrftEngine::new(
1498        config,
1499        initial.direction,
1500        beta,
1501        InitialReadings {
1502            pv_ini: initial.pv_ini,
1503            mv_ini: initial.mv_ini,
1504            mv_range_low: initial.mv_range_low,
1505            mv_range_high: initial.mv_range_high,
1506        },
1507        started_at,
1508        MrftCompat::default(),
1509    );
1510    let timing_basis = match args.driver {
1511        DriverKindArg::Opcda => TimingBasis::LiveMonotonic,
1512        DriverKindArg::Simulator => TimingBasis::SimulatedFixedStep,
1513    };
1514    let mut timing = PollTimingAccumulator::new(timing_basis, effective_timing.poll_interval_ms);
1515
1516    let poll_result = run_polling_loop_with_timing(
1517        pool,
1518        run_id,
1519        args,
1520        effective_timing,
1521        tags,
1522        driver,
1523        &mut engine,
1524        time_anchor,
1525        ctrl_c,
1526        &mut guard,
1527        allow_uncertain_quality,
1528        &mut timing,
1529        &mut mv_actuations,
1530        config,
1531    )
1532    .await;
1533    let measured_oscillation_period_ms = completed_oscillation_period_ms(
1534        &poll_result,
1535        initial.direction,
1536        config,
1537        PvRange {
1538            high: initial.pv_range_high,
1539            low: initial.pv_range_low,
1540        },
1541    );
1542    let timing_metrics_without_period = timing.finish(None);
1543    if let Some(timing_metrics) = timing_metrics_without_period.as_ref() {
1544        warn_on_missed_poll_opportunities(run_id, timing_metrics);
1545    }
1546
1547    match poll_result {
1548        Ok(PollOutcome::Completed(completion)) => {
1549            finish_completed_run(
1550                pool,
1551                run_id,
1552                args,
1553                effective_timing,
1554                template,
1555                tags,
1556                driver,
1557                config,
1558                &initial,
1559                &guard,
1560                write_pid,
1561                allow_uncertain_quality,
1562                ctrl_c,
1563                reader,
1564                &mut mv_actuations,
1565                completion,
1566                &mut timing,
1567                timing_metrics_without_period,
1568                measured_oscillation_period_ms,
1569            )
1570            .await
1571        }
1572        Ok(PollOutcome::Aborted(reason)) => {
1573            finish_aborted_run(
1574                pool,
1575                run_id,
1576                args,
1577                effective_timing,
1578                template,
1579                tags,
1580                driver,
1581                &initial,
1582                &guard,
1583                allow_uncertain_quality,
1584                ctrl_c,
1585                &mut mv_actuations,
1586                reason,
1587                timing_metrics_without_period,
1588            )
1589            .await
1590        }
1591        Err(error) => {
1592            finish_failed_run(
1593                pool,
1594                run_id,
1595                template,
1596                tags,
1597                driver,
1598                &initial,
1599                &guard,
1600                args,
1601                effective_timing,
1602                allow_uncertain_quality,
1603                ctrl_c,
1604                &mut mv_actuations,
1605                error,
1606                timing_metrics_without_period,
1607            )
1608            .await
1609        }
1610    }
1611}
1612
1613#[cfg(test)]
1614#[allow(clippy::too_many_arguments)]
1615async fn execute<R: std::io::BufRead>(
1616    pool: &SqlitePool,
1617    run_id: i64,
1618    args: &TuneArgs,
1619    template: &DcsTemplate,
1620    tags: &LoopTags,
1621    driver: &dyn Driver,
1622    config: LoopConfig,
1623    time_anchor: RunTimeAnchor,
1624    write_pid: Option<ResponseLevel>,
1625    allow_uncertain_quality: bool,
1626    ctrl_c: &mut CtrlC,
1627    reader: &mut R,
1628) -> anyhow::Result<RunOutcome> {
1629    execute_with_timing(
1630        pool,
1631        run_id,
1632        args,
1633        template,
1634        tags,
1635        driver,
1636        config,
1637        test_effective_timing(args),
1638        time_anchor,
1639        write_pid,
1640        allow_uncertain_quality,
1641        ctrl_c,
1642        reader,
1643    )
1644    .await
1645}
1646
1647#[allow(clippy::too_many_arguments)]
1648async fn attempt_and_record_restore(
1649    pool: &SqlitePool,
1650    run_id: i64,
1651    args: &TuneArgs,
1652    effective_timing: EffectiveTiming,
1653    driver: &dyn Driver,
1654    tags: &LoopTags,
1655    template: &DcsTemplate,
1656    initial: &InitialState,
1657    guard: &MutationGuard,
1658    allow_uncertain_quality: bool,
1659    ctrl_c: &mut CtrlC,
1660    mv_actuations: &mut Option<MvActuationTracker>,
1661) -> RestoreAttempt {
1662    let restore_attempt = attempt_restore_with_actuation_with_timing(
1663        pool,
1664        run_id,
1665        args,
1666        effective_timing,
1667        driver,
1668        tags,
1669        template,
1670        initial,
1671        guard,
1672        allow_uncertain_quality,
1673        ctrl_c,
1674        mv_actuations,
1675    )
1676    .await;
1677    record_restore_status_best_effort(pool, run_id, &restore_attempt).await;
1678    finalize_pending_for_run_best_effort(
1679        pool,
1680        run_id,
1681        "the run ended before MV confirmation completed",
1682    )
1683    .await;
1684    restore_attempt
1685}
1686
1687#[allow(clippy::too_many_arguments)]
1688async fn finish_completed_run<R: std::io::BufRead>(
1689    pool: &SqlitePool,
1690    run_id: i64,
1691    args: &TuneArgs,
1692    effective_timing: EffectiveTiming,
1693    template: &DcsTemplate,
1694    tags: &LoopTags,
1695    driver: &dyn Driver,
1696    config: LoopConfig,
1697    initial: &InitialState,
1698    guard: &MutationGuard,
1699    write_pid: Option<ResponseLevel>,
1700    allow_uncertain_quality: bool,
1701    ctrl_c: &mut CtrlC,
1702    reader: &mut R,
1703    mv_actuations: &mut Option<MvActuationTracker>,
1704    completion: Action,
1705    timing: &mut PollTimingAccumulator,
1706    timing_metrics_without_period: Option<TimingMetrics>,
1707    measured_oscillation_period_ms: Option<f64>,
1708) -> anyhow::Result<RunOutcome> {
1709    let pv_range = PvRange {
1710        high: initial.pv_range_high,
1711        low: initial.pv_range_low,
1712    };
1713    if let Err(error) = persist_completed_results(
1714        pool,
1715        run_id,
1716        completion,
1717        initial.direction,
1718        config,
1719        pv_range,
1720        template,
1721    )
1722    .await
1723    {
1724        let error = restore_best_effort_then_propagate_with_timing(
1725            pool,
1726            run_id,
1727            driver,
1728            tags,
1729            template,
1730            initial,
1731            guard,
1732            args,
1733            effective_timing,
1734            allow_uncertain_quality,
1735            ctrl_c,
1736            mv_actuations,
1737            error,
1738        )
1739        .await;
1740        record_timing_metrics_if_present(pool, run_id, timing_metrics_without_period).await;
1741        return Err(error);
1742    }
1743
1744    let restore_attempt = attempt_and_record_restore(
1745        pool,
1746        run_id,
1747        args,
1748        effective_timing,
1749        driver,
1750        tags,
1751        template,
1752        initial,
1753        guard,
1754        allow_uncertain_quality,
1755        ctrl_c,
1756        mv_actuations,
1757    )
1758    .await;
1759    TuneRunRow::complete_with_timing_metrics(
1760        pool,
1761        run_id,
1762        Utc::now(),
1763        timing.finish(measured_oscillation_period_ms),
1764    )
1765    .await?;
1766    match restore_attempt {
1767        RestoreAttempt::Confirmed => {
1768            let (write_back, write_back_detail) = maybe_write_back(
1769                pool,
1770                run_id,
1771                tags,
1772                template,
1773                driver,
1774                config,
1775                write_pid,
1776                args.output,
1777                allow_uncertain_quality,
1778                reader,
1779            )
1780            .await?;
1781            Ok(RunOutcome::Completed {
1782                write_back,
1783                write_back_detail,
1784            })
1785        }
1786        RestoreAttempt::Incomplete { reason } => Ok(RunOutcome::RestoreIncomplete { reason }),
1787    }
1788}
1789
1790#[allow(clippy::too_many_arguments)]
1791async fn finish_aborted_run(
1792    pool: &SqlitePool,
1793    run_id: i64,
1794    args: &TuneArgs,
1795    effective_timing: EffectiveTiming,
1796    template: &DcsTemplate,
1797    tags: &LoopTags,
1798    driver: &dyn Driver,
1799    initial: &InitialState,
1800    guard: &MutationGuard,
1801    allow_uncertain_quality: bool,
1802    ctrl_c: &mut CtrlC,
1803    mv_actuations: &mut Option<MvActuationTracker>,
1804    reason: AbortReason,
1805    timing_metrics_without_period: Option<TimingMetrics>,
1806) -> anyhow::Result<RunOutcome> {
1807    let restore_attempt = attempt_and_record_restore(
1808        pool,
1809        run_id,
1810        args,
1811        effective_timing,
1812        driver,
1813        tags,
1814        template,
1815        initial,
1816        guard,
1817        allow_uncertain_quality,
1818        ctrl_c,
1819        mv_actuations,
1820    )
1821    .await;
1822    if matches!(reason, AbortReason::MvActuationUnconfirmed { .. }) {
1823        TuneRunRow::abort_with_timing_metrics_and_reason(
1824            pool,
1825            run_id,
1826            Utc::now(),
1827            timing_metrics_without_period,
1828            &format_mv_actuation_abort_reason(&reason),
1829        )
1830        .await?;
1831    } else {
1832        TuneRunRow::abort_with_timing_metrics(
1833            pool,
1834            run_id,
1835            Utc::now(),
1836            timing_metrics_without_period,
1837        )
1838        .await?;
1839    }
1840    match restore_attempt {
1841        RestoreAttempt::Confirmed => Ok(RunOutcome::Aborted(reason)),
1842        RestoreAttempt::Incomplete {
1843            reason: restore_reason,
1844        } => Ok(RunOutcome::RestoreIncomplete {
1845            reason: format!("run aborted ({reason:?}); {restore_reason}"),
1846        }),
1847    }
1848}
1849
1850#[allow(clippy::too_many_arguments)]
1851async fn finish_failed_run(
1852    pool: &SqlitePool,
1853    run_id: i64,
1854    template: &DcsTemplate,
1855    tags: &LoopTags,
1856    driver: &dyn Driver,
1857    initial: &InitialState,
1858    guard: &MutationGuard,
1859    args: &TuneArgs,
1860    effective_timing: EffectiveTiming,
1861    allow_uncertain_quality: bool,
1862    ctrl_c: &mut CtrlC,
1863    mv_actuations: &mut Option<MvActuationTracker>,
1864    error: anyhow::Error,
1865    timing_metrics_without_period: Option<TimingMetrics>,
1866) -> anyhow::Result<RunOutcome> {
1867    // Best-effort: a failed test still stroked the valve, so try to put it back even
1868    // though the overall run is going to be reported as failed regardless. Still
1869    // bounded/interruptible (a second Ctrl+C or `[tuning].restore_timeout_secs` still cuts
1870    // it short) and still warns loudly on an incomplete restore.
1871    let error = restore_best_effort_then_propagate_with_timing(
1872        pool,
1873        run_id,
1874        driver,
1875        tags,
1876        template,
1877        initial,
1878        guard,
1879        args,
1880        effective_timing,
1881        allow_uncertain_quality,
1882        ctrl_c,
1883        mv_actuations,
1884        error,
1885    )
1886    .await;
1887    record_timing_metrics_if_present(pool, run_id, timing_metrics_without_period).await;
1888    Err(error)
1889}
1890
1891/// The single choke point enforcing finding 5 of the live-plant safety review
1892/// ("`Quality::is_trustworthy()` exists and is documented as the rule; nothing in the tune
1893/// path calls it"): `Quality::Bad` is never accepted; `Quality::Uncertain` is accepted only
1894/// when the global Config > OPC quality policy (`allow_uncertain_quality` in TOML) permits
1895/// it, and each use of it is logged loudly so a run executed under relaxed rules is never
1896/// silently indistinguishable from a normal one; `Quality::Good` always passes.
1897fn check_quality(
1898    tag: &str,
1899    quality: bhtune_driver::Quality,
1900    allow_uncertain: bool,
1901) -> anyhow::Result<()> {
1902    match quality {
1903        bhtune_driver::Quality::Good => Ok(()),
1904        bhtune_driver::Quality::Uncertain if allow_uncertain => {
1905            tracing::warn!(
1906                tag,
1907                "accepting Uncertain-quality reading because Config > OPC quality policy \
1908                 (allow_uncertain_quality) permits it"
1909            );
1910            Ok(())
1911        }
1912        bhtune_driver::Quality::Uncertain => {
1913            anyhow::bail!(
1914                "tag '{tag}' reported OPC quality Uncertain; refusing to trust it for a \
1915                 tuning-critical reading (set Config > OPC quality policy \
1916                 `allow_uncertain_quality = true` to accept Uncertain readings; Bad is never \
1917                 accepted)"
1918            )
1919        }
1920        bhtune_driver::Quality::Bad => {
1921            anyhow::bail!(
1922                "tag '{tag}' reported OPC quality Bad; refusing to trust it for a \
1923                 tuning-critical reading"
1924            )
1925        }
1926    }
1927}
1928
1929/// Maps the driver's live [`bhtune_driver::Quality`] to the database's persisted
1930/// [`SampleQuality`] -- two separate enums (rather than one shared type) because
1931/// `bhtune-driver` and `bhtune-db` are sibling crates that each depend only on
1932/// `bhtune-core`, not on each other; only a crate depending on both needs this mapping, so
1933/// it lives here rather than forcing a new cross-dependency onto either crate. `pub` (not
1934/// just used by [`run_polling_loop`] below) because `bhtune-server`'s `routes::opc` reuses
1935/// it verbatim for `GET /api/opc/read`'s quality field, rather than a second copy of the
1936/// same three-arm match.
1937pub fn sample_quality_from_driver(quality: bhtune_driver::Quality) -> SampleQuality {
1938    match quality {
1939        bhtune_driver::Quality::Good => SampleQuality::Good,
1940        bhtune_driver::Quality::Uncertain => SampleQuality::Uncertain,
1941        bhtune_driver::Quality::Bad => SampleQuality::Bad,
1942    }
1943}
1944
1945async fn read_raw(driver: &dyn Driver, tag: &str, allow_uncertain: bool) -> anyhow::Result<String> {
1946    let values = driver.read(&[tag.to_string()]).await?;
1947    let value = values
1948        .into_iter()
1949        .next()
1950        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
1951    check_quality(tag, value.quality, allow_uncertain)?;
1952    Ok(value.value)
1953}
1954
1955async fn read_f32(driver: &dyn Driver, tag: &str, allow_uncertain: bool) -> anyhow::Result<f32> {
1956    let raw = read_raw(driver, tag, allow_uncertain).await?;
1957    parse_f32_value(tag, &raw)
1958}
1959
1960async fn resolve_f32(
1961    driver: &dyn Driver,
1962    tag_or_value: &TagOrValue<f32>,
1963    allow_uncertain: bool,
1964) -> anyhow::Result<f32> {
1965    match tag_or_value {
1966        TagOrValue::Value(v) => {
1967            if !v.is_finite() {
1968                anyhow::bail!("value {v} is not a finite number");
1969            }
1970            Ok(*v)
1971        }
1972        TagOrValue::Tag(tag) => read_f32(driver, tag, allow_uncertain).await,
1973    }
1974}
1975
1976async fn resolve_direction(
1977    driver: &dyn Driver,
1978    tag_or_value: &TagOrValue<ControllerDirection>,
1979    template: &DcsTemplate,
1980    allow_uncertain: bool,
1981) -> anyhow::Result<ControllerDirection> {
1982    match tag_or_value {
1983        TagOrValue::Value(d) => Ok(*d),
1984        TagOrValue::Tag(tag) => {
1985            let raw = read_raw(driver, tag, allow_uncertain).await?;
1986            Ok(ControllerDirection::from_raw_tag_value(
1987                &raw,
1988                &template.controller_action_direct_value,
1989            ))
1990        }
1991    }
1992}
1993
1994fn parse_f32_value(tag: &str, raw: &str) -> anyhow::Result<f32> {
1995    let value: f32 = raw
1996        .trim()
1997        .parse::<f32>()
1998        .map_err(|_| anyhow::anyhow!("tag '{tag}' value '{raw}' is not a number"))?;
1999    if !value.is_finite() {
2000        anyhow::bail!("tag '{tag}' value '{raw}' is not a finite number");
2001    }
2002    Ok(value)
2003}
2004
2005fn read_batch_raw(
2006    values: &HashMap<String, TagValue>,
2007    tag: &str,
2008    allow_uncertain: bool,
2009) -> anyhow::Result<String> {
2010    let value = values
2011        .get(tag)
2012        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
2013    check_quality(tag, value.quality, allow_uncertain)?;
2014    Ok(value.value.clone())
2015}
2016
2017fn read_batch_f32(
2018    values: &HashMap<String, TagValue>,
2019    tag: &str,
2020    allow_uncertain: bool,
2021) -> anyhow::Result<f32> {
2022    let raw = read_batch_raw(values, tag, allow_uncertain)?;
2023    parse_f32_value(tag, &raw)
2024}
2025
2026async fn resolve_f32_from_batch(
2027    driver: &dyn Driver,
2028    values: &HashMap<String, TagValue>,
2029    tag_or_value: &TagOrValue<f32>,
2030    allow_uncertain: bool,
2031) -> anyhow::Result<f32> {
2032    match tag_or_value {
2033        TagOrValue::Value(_) => resolve_f32(driver, tag_or_value, allow_uncertain).await,
2034        TagOrValue::Tag(tag) => read_batch_f32(values, tag, allow_uncertain),
2035    }
2036}
2037
2038async fn resolve_direction_from_batch(
2039    driver: &dyn Driver,
2040    values: &HashMap<String, TagValue>,
2041    tag_or_value: &TagOrValue<ControllerDirection>,
2042    template: &DcsTemplate,
2043    allow_uncertain: bool,
2044) -> anyhow::Result<ControllerDirection> {
2045    match tag_or_value {
2046        TagOrValue::Value(_) => {
2047            resolve_direction(driver, tag_or_value, template, allow_uncertain).await
2048        }
2049        TagOrValue::Tag(tag) => {
2050            let raw = read_batch_raw(values, tag, allow_uncertain)?;
2051            Ok(ControllerDirection::from_raw_tag_value(
2052                &raw,
2053                &template.controller_action_direct_value,
2054            ))
2055        }
2056    }
2057}
2058
2059/// Test-only single-tag PV reader that preserves raw quality alongside the value. The production
2060/// polling path uses [`read_poll_batch`] so pending OPC relay checks can share one read with PV
2061/// sampling. This helper remains for focused tests of the single-tag parsing behavior and still
2062/// hard-fails on non-numeric/non-finite values regardless of quality, exactly like [`read_f32`],
2063/// since that's a data-shape problem no quality policy can excuse.
2064#[cfg(test)]
2065async fn read_pv_sample(
2066    driver: &dyn Driver,
2067    tag: &str,
2068) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
2069    read_numeric_sample(driver, tag).await
2070}
2071
2072async fn read_numeric_sample(
2073    driver: &dyn Driver,
2074    tag: &str,
2075) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
2076    let values = driver.read(&[tag.to_string()]).await?;
2077    let value = values
2078        .into_iter()
2079        .next()
2080        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
2081    let numeric: f32 = value
2082        .value
2083        .trim()
2084        .parse::<f32>()
2085        .map_err(|_| anyhow::anyhow!("tag '{tag}' value '{}' is not a number", value.value))?;
2086    if !numeric.is_finite() {
2087        anyhow::bail!("tag '{tag}' value '{}' is not a finite number", value.value);
2088    }
2089    Ok((numeric, value.quality))
2090}
2091
2092async fn read_poll_batch(
2093    driver: &dyn Driver,
2094    pv_tag: &str,
2095    mv_tag: Option<&str>,
2096) -> anyhow::Result<HashMap<String, TagValue>> {
2097    let mut requested_tags = vec![pv_tag.to_string()];
2098    if let Some(mv_tag) = mv_tag
2099        && mv_tag != pv_tag
2100    {
2101        requested_tags.push(mv_tag.to_string());
2102    }
2103
2104    Ok(driver
2105        .read(&requested_tags)
2106        .await?
2107        .into_iter()
2108        .map(|value| (value.tag.clone(), value))
2109        .collect())
2110}
2111
2112fn read_numeric_from_batch(
2113    values: &HashMap<String, TagValue>,
2114    tag: &str,
2115) -> anyhow::Result<(f32, bhtune_driver::Quality)> {
2116    let value = values
2117        .get(tag)
2118        .ok_or_else(|| anyhow::anyhow!("driver returned no value for tag '{tag}'"))?;
2119    let numeric = parse_f32_value(tag, &value.value)?;
2120    Ok((numeric, value.quality))
2121}
2122
2123async fn write_raw(driver: &dyn Driver, tag: &str, value: String) -> anyhow::Result<()> {
2124    let outcome = driver.write(&tag.to_string(), TagWrite::Raw(value)).await?;
2125    if outcome.success {
2126        Ok(())
2127    } else {
2128        anyhow::bail!(
2129            "write to '{tag}' was rejected: {}",
2130            outcome
2131                .error_message
2132                .unwrap_or_else(|| "unknown reason".to_string())
2133        )
2134    }
2135}
2136
2137async fn write_value(driver: &dyn Driver, tag: &str, value: f32) -> anyhow::Result<()> {
2138    let outcome = driver
2139        .write(&tag.to_string(), TagWrite::Float(value))
2140        .await?;
2141    if outcome.success {
2142        Ok(())
2143    } else {
2144        anyhow::bail!(
2145            "write to '{tag}' was rejected: {}",
2146            outcome
2147                .error_message
2148                .unwrap_or_else(|| "unknown reason".to_string())
2149        )
2150    }
2151}
2152
2153/// Pure port of `ReadInitialOPCvalues`: everything read before any mode transition.
2154async fn read_initial_values(
2155    driver: &dyn Driver,
2156    tags: &LoopTags,
2157    template: &DcsTemplate,
2158    allow_uncertain: bool,
2159) -> anyhow::Result<InitialState> {
2160    let mut requested_tags = Vec::new();
2161    let mut seen_tags = HashSet::new();
2162    let mut request_tag = |tag: &str| {
2163        if seen_tags.insert(tag.to_string()) {
2164            requested_tags.push(tag.to_string());
2165        }
2166    };
2167
2168    request_tag(&tags.process_variable);
2169    request_tag(&tags.manipulated_variable);
2170    if let Some(tag) = &tags.controller_mode {
2171        request_tag(tag);
2172    }
2173    if let Some(tag) = &tags.mode_attribute {
2174        request_tag(tag);
2175    }
2176    if let TagOrValue::Tag(tag) = &tags.controller_direction {
2177        request_tag(tag.as_str());
2178    }
2179    for tag_or_value in [
2180        &tags.upper_pv_range,
2181        &tags.lower_pv_range,
2182        &tags.upper_mv_range,
2183        &tags.lower_mv_range,
2184    ] {
2185        if let TagOrValue::Tag(tag) = tag_or_value {
2186            request_tag(tag.as_str());
2187        }
2188    }
2189
2190    let values_by_tag: HashMap<String, TagValue> = driver
2191        .read(&requested_tags)
2192        .await?
2193        .into_iter()
2194        .map(|value| (value.tag.clone(), value))
2195        .collect();
2196
2197    let pv_ini = read_batch_f32(&values_by_tag, &tags.process_variable, allow_uncertain)?;
2198    let mv_ini = read_batch_f32(&values_by_tag, &tags.manipulated_variable, allow_uncertain)?;
2199
2200    let mode_raw = match &tags.controller_mode {
2201        Some(tag) => Some(read_batch_raw(&values_by_tag, tag, allow_uncertain)?),
2202        None => None,
2203    };
2204    let mode_attribute_raw = match &tags.mode_attribute {
2205        Some(tag) => Some(read_batch_raw(&values_by_tag, tag, allow_uncertain)?),
2206        None => None,
2207    };
2208
2209    // Captured here, before any mutation, whenever the loop's original mode is Auto -- see
2210    // `InitialState::setpoint_ini`'s doc comment for why this read is hoisted out of
2211    // `transition_to_manual`, where the legacy app captures the analogous `SvValueIni`.
2212    let setpoint_ini = match (&tags.setpoint_variable, &mode_raw) {
2213        (Some(sv_tag), Some(mode_raw)) if mode_raw == &template.mode_auto_value => {
2214            Some(read_f32(driver, sv_tag, allow_uncertain).await?)
2215        }
2216        _ => None,
2217    };
2218
2219    let direction = resolve_direction_from_batch(
2220        driver,
2221        &values_by_tag,
2222        &tags.controller_direction,
2223        template,
2224        allow_uncertain,
2225    )
2226    .await?;
2227    let pv_range_high = resolve_f32_from_batch(
2228        driver,
2229        &values_by_tag,
2230        &tags.upper_pv_range,
2231        allow_uncertain,
2232    )
2233    .await?;
2234    let pv_range_low = resolve_f32_from_batch(
2235        driver,
2236        &values_by_tag,
2237        &tags.lower_pv_range,
2238        allow_uncertain,
2239    )
2240    .await?;
2241    let mv_range_high = resolve_f32_from_batch(
2242        driver,
2243        &values_by_tag,
2244        &tags.upper_mv_range,
2245        allow_uncertain,
2246    )
2247    .await?;
2248    let mv_range_low = resolve_f32_from_batch(
2249        driver,
2250        &values_by_tag,
2251        &tags.lower_mv_range,
2252        allow_uncertain,
2253    )
2254    .await?;
2255
2256    Ok(InitialState {
2257        pv_ini,
2258        mv_ini,
2259        pv_range_high,
2260        pv_range_low,
2261        mv_range_high,
2262        mv_range_low,
2263        direction,
2264        mode_raw,
2265        mode_attribute_raw,
2266        setpoint_ini,
2267    })
2268}
2269
2270/// The single choke point validating an `InitialState` -- from live driver tags and/or CLI
2271/// flag overrides alike -- before any mutation of the loop happens (i.e. called between
2272/// `read_initial_values` and `transition_to_manual` in `execute`, never after). `read_f32`/
2273/// `resolve_f32` already reject a non-finite individual value as it's read; this additionally
2274/// checks values *together*: range ordering, zero span, and that the initial MV actually
2275/// lies inside its own reported range. Closes finding 4 of the live-plant safety review --
2276/// see AGENTS.md's "Live-plant safety hardening" section.
2277fn validate_initial_state(initial: &InitialState) -> anyhow::Result<()> {
2278    PvRange::new(initial.pv_range_high, initial.pv_range_low)
2279        .map_err(|e| anyhow::anyhow!("invalid PV range: {e}"))?;
2280    let mv_range = MvRange::new(initial.mv_range_high, initial.mv_range_low)
2281        .map_err(|e| anyhow::anyhow!("invalid MV range: {e}"))?;
2282    if !mv_range.contains(initial.mv_ini) {
2283        anyhow::bail!(
2284            "initial MV {} is outside the MV range [{}, {}]",
2285            initial.mv_ini,
2286            initial.mv_range_low,
2287            initial.mv_range_high
2288        );
2289    }
2290    Ok(())
2291}
2292
2293/// Pure port of `ChangeControllerModeToMan`. No-ops entirely when `tags.controller_mode` and
2294/// `tags.mode_attribute` are both `None` (the simulator's case). `guard`'s flags are armed
2295/// immediately before each write is attempted, not after it succeeds -- see
2296/// [`MutationGuard`]'s doc comment for why that ordering matters -- so a caller still knows
2297/// exactly what was attempted even if this function returns partway through with an error.
2298async fn transition_to_manual(
2299    driver: &dyn Driver,
2300    tags: &LoopTags,
2301    template: &DcsTemplate,
2302    initial: &InitialState,
2303    guard: &mut MutationGuard,
2304) -> anyhow::Result<()> {
2305    if let (Some(attr_tag), Some(program_value)) =
2306        (&tags.mode_attribute, &template.mode_attribute_program_value)
2307    {
2308        guard.mode_attribute_written = true;
2309        write_raw(driver, attr_tag, program_value.clone()).await?;
2310        tokio::time::sleep(Duration::from_millis(1000)).await;
2311    }
2312
2313    if let Some(mode_tag) = &tags.controller_mode {
2314        let mode_raw = initial.mode_raw.as_deref().unwrap_or_default();
2315        if mode_raw != template.mode_manual_value {
2316            guard.mode_written = true;
2317            write_raw(driver, mode_tag, template.mode_manual_value.clone()).await?;
2318        }
2319    }
2320
2321    Ok(())
2322}
2323
2324/// One step of a [`RestoreReport`]. `NotNeeded` means the step's precondition wasn't met --
2325/// the loop wasn't in a state requiring reverting that aspect, or the corresponding mutation
2326/// was never attempted per [`MutationGuard`] -- not that it failed.
2327#[derive(Debug, Clone, PartialEq, Default)]
2328enum RestoreStepOutcome {
2329    #[default]
2330    NotNeeded,
2331    Succeeded,
2332    Failed(String),
2333}
2334
2335/// The result of one [`restore`] call: each of the (up to) four independent revert steps,
2336/// attempted regardless of whether an earlier one failed (`safety-restore-guard`, finding 3
2337/// of the live-plant safety review, "aggregated best-effort restore") -- so a rejected MV
2338/// write, say, can never prevent the mode from also being put back.
2339#[derive(Debug, Clone, Default)]
2340struct RestoreReport {
2341    mv: RestoreStepOutcome,
2342    mode: RestoreStepOutcome,
2343    setpoint: RestoreStepOutcome,
2344    mode_attribute: RestoreStepOutcome,
2345}
2346
2347impl RestoreReport {
2348    /// `true` only if every step that was attempted succeeded -- a step that was
2349    /// [`RestoreStepOutcome::NotNeeded`] doesn't count against this, since nothing needed
2350    /// doing there in the first place.
2351    fn all_succeeded(&self) -> bool {
2352        [&self.mv, &self.mode, &self.setpoint, &self.mode_attribute]
2353            .into_iter()
2354            .all(|step| !matches!(step, RestoreStepOutcome::Failed(_)))
2355    }
2356
2357    /// A human-readable summary of every step that failed, or `None` if none did. Used both
2358    /// for [`bhtune_db::models::RestoreStatus::Incomplete`]'s persisted `detail` and the
2359    /// operator-facing warning.
2360    fn failure_summary(&self) -> Option<String> {
2361        let labelled = [
2362            ("MV", &self.mv),
2363            ("mode", &self.mode),
2364            ("setpoint", &self.setpoint),
2365            ("mode attribute", &self.mode_attribute),
2366        ];
2367        let failures: Vec<String> = labelled
2368            .into_iter()
2369            .filter_map(|(label, step)| match step {
2370                RestoreStepOutcome::Failed(e) => Some(format!("{label}: {e}")),
2371                _ => None,
2372            })
2373            .collect();
2374        if failures.is_empty() {
2375            None
2376        } else {
2377            Some(failures.join("; "))
2378        }
2379    }
2380}
2381
2382/// Pure port of `ResetOPC` (minus the dead Python-model branch, which is not being ported —
2383/// see AGENTS.md), restructured for `safety-restore-guard` (finding 3 of the live-plant
2384/// safety review): every step is attempted independently, via a per-step `match` rather than
2385/// `?`, so one step failing can never prevent the others from being tried. The MV write-back
2386/// is unconditional and never gated by `guard` at all -- proven safe by the fact that a
2387/// no-op MV write-back is always harmless (idempotent if the pre-test value is already
2388/// there, or safely rejected by the DCS if the loop never actually left Auto) -- while the
2389/// mode/setpoint/mode-attribute reverts are each gated by both their original value-based
2390/// condition (as before) *and* the matching `guard` flag, so nothing is "restored" that was
2391/// never actually mutated in the first place.
2392async fn restore_after_mv(
2393    driver: &dyn Driver,
2394    tags: &LoopTags,
2395    template: &DcsTemplate,
2396    initial: &InitialState,
2397    guard: &MutationGuard,
2398    mv: RestoreStepOutcome,
2399) -> RestoreReport {
2400    let mode = restore_mode_step(driver, tags, template, initial, guard).await;
2401    let setpoint = restore_setpoint_step(driver, tags, template, initial, guard).await;
2402    let mode_attribute = restore_mode_attribute_step(driver, tags, template, initial, guard).await;
2403
2404    RestoreReport {
2405        mv,
2406        mode,
2407        setpoint,
2408        mode_attribute,
2409    }
2410}
2411
2412async fn restore_value_step(driver: &dyn Driver, tag: &str, value: f32) -> RestoreStepOutcome {
2413    match write_value(driver, tag, value).await {
2414        Ok(()) => RestoreStepOutcome::Succeeded,
2415        Err(e) => RestoreStepOutcome::Failed(e.to_string()),
2416    }
2417}
2418
2419#[cfg(test)]
2420async fn restore(
2421    driver: &dyn Driver,
2422    tags: &LoopTags,
2423    template: &DcsTemplate,
2424    initial: &InitialState,
2425    guard: &MutationGuard,
2426) -> RestoreReport {
2427    let mv = restore_value_step(driver, &tags.manipulated_variable, initial.mv_ini).await;
2428    tokio::time::sleep(Duration::from_millis(1000)).await;
2429    restore_after_mv(driver, tags, template, initial, guard, mv).await
2430}
2431
2432async fn restore_raw_step(driver: &dyn Driver, tag: &str, value: &str) -> RestoreStepOutcome {
2433    match write_raw(driver, tag, value.to_string()).await {
2434        Ok(()) => RestoreStepOutcome::Succeeded,
2435        Err(e) => RestoreStepOutcome::Failed(e.to_string()),
2436    }
2437}
2438
2439async fn restore_mode_step(
2440    driver: &dyn Driver,
2441    tags: &LoopTags,
2442    template: &DcsTemplate,
2443    initial: &InitialState,
2444    guard: &MutationGuard,
2445) -> RestoreStepOutcome {
2446    let Some(mode_tag) = &tags.controller_mode else {
2447        return RestoreStepOutcome::NotNeeded;
2448    };
2449    let mode_raw = initial.mode_raw.as_deref().unwrap_or_default();
2450    if !guard.mode_written || !template.revert_mode || mode_raw == template.mode_manual_value {
2451        return RestoreStepOutcome::NotNeeded;
2452    }
2453    restore_raw_step(driver, mode_tag, mode_raw).await
2454}
2455
2456async fn restore_setpoint_step(
2457    driver: &dyn Driver,
2458    tags: &LoopTags,
2459    template: &DcsTemplate,
2460    initial: &InitialState,
2461    guard: &MutationGuard,
2462) -> RestoreStepOutcome {
2463    if !guard.mode_written || !template.revert_mode {
2464        return RestoreStepOutcome::NotNeeded;
2465    }
2466    let (Some(sv_tag), Some(sv_ini)) = (&tags.setpoint_variable, initial.setpoint_ini) else {
2467        return RestoreStepOutcome::NotNeeded;
2468    };
2469    tokio::time::sleep(Duration::from_millis(1000)).await;
2470    restore_value_step(driver, sv_tag, sv_ini).await
2471}
2472
2473async fn restore_mode_attribute_step(
2474    driver: &dyn Driver,
2475    tags: &LoopTags,
2476    template: &DcsTemplate,
2477    initial: &InitialState,
2478    guard: &MutationGuard,
2479) -> RestoreStepOutcome {
2480    let Some(attr_tag) = &tags.mode_attribute else {
2481        return RestoreStepOutcome::NotNeeded;
2482    };
2483    let attr_raw = initial.mode_attribute_raw.as_deref().unwrap_or_default();
2484    let program_value = template
2485        .mode_attribute_program_value
2486        .as_deref()
2487        .unwrap_or_default();
2488    if !guard.mode_attribute_written || attr_raw == program_value {
2489        return RestoreStepOutcome::NotNeeded;
2490    }
2491    restore_raw_step(driver, attr_tag, attr_raw).await
2492}
2493
2494/// The outcome of racing one driver call (a poll batch or [`write_value`], during a poll tick)
2495/// against Ctrl+C and `[tuning].op_timeout_secs` -- see [`bounded_driver_call`]. Distinct from a
2496/// genuine `Err` from the call itself (a rejected write, a malformed value, a transport error),
2497/// which [`bounded_driver_call`] still propagates via `?` rather than wrapping here, since those
2498/// are real failures, not "gave up waiting".
2499#[derive(Debug)]
2500enum TickOperation<T> {
2501    /// `fut` resolved before either interrupt source.
2502    Completed(T),
2503    /// Ctrl+C (or a second Ctrl+C) fired first; `fut` was dropped, abandoning it in flight.
2504    Cancelled,
2505    /// `[tuning].op_timeout_secs` elapsed first; `fut` was dropped, abandoning it in flight.
2506    TimedOut,
2507}
2508
2509/// Races one driver call against `ctrl_c` and a fresh `op_timeout_secs` sleep, so a single
2510/// stalled read/write (gateway down, DCOM wedged, network black-holed) can never make the
2511/// polling loop -- or the restore, via [`attempt_restore_with_actuation`] -- uninterruptible.
2512/// This is what
2513/// fixes finding 2 of the live-plant safety review: previously, `run_polling_loop`'s Ctrl+C
2514/// and `[tuning].timeout_secs` listeners only ran *between* tick-body awaits, so a hung call inside
2515/// one was invisible to both. `fut` is taken by value (not `&mut`) and is simply dropped,
2516/// abandoning the in-flight operation, on the losing branches -- there is no cancellation
2517/// signal sent to the driver itself, only to this call's own wait for it. A genuine `Err`
2518/// from `fut` resolving still propagates through the `?` here, distinct from either
2519/// [`TickOperation`] interrupt case.
2520async fn bounded_driver_call<T>(
2521    op_timeout_secs: u64,
2522    ctrl_c: &mut CtrlC,
2523    fut: impl Future<Output = anyhow::Result<T>>,
2524) -> anyhow::Result<TickOperation<T>> {
2525    tokio::select! {
2526        result = fut => result.map(TickOperation::Completed),
2527        () = ctrl_c.signalled() => Ok(TickOperation::Cancelled),
2528        () = tokio::time::sleep(Duration::from_secs(op_timeout_secs)) => Ok(TickOperation::TimedOut),
2529    }
2530}
2531
2532fn actuation_abort_reason(
2533    tag: &str,
2534    pending: &PendingMvActuation,
2535    readback: Option<f32>,
2536    now: Instant,
2537) -> AbortReason {
2538    let elapsed_ms = now
2539        .saturating_duration_since(pending.accepted_instant)
2540        .as_millis()
2541        .min(u128::from(u64::MAX)) as u64;
2542    AbortReason::MvActuationUnconfirmed {
2543        tag: tag.to_string(),
2544        target: pending.target,
2545        readback,
2546        tolerance: pending.tolerance,
2547        elapsed_ms,
2548        deadline_secs: MV_ACTUATION_CONFIRMATION_SECS,
2549    }
2550}
2551
2552fn actuation_matches(target: f32, readback: f32, tolerance: f32) -> bool {
2553    (target - readback).abs() <= tolerance
2554}
2555
2556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2557enum MvVerificationTrigger {
2558    Scheduled,
2559    Deadline,
2560}
2561
2562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2563enum ActuationAuditPolicy {
2564    Required,
2565    BestEffort,
2566}
2567
2568#[derive(Debug, Clone, Copy)]
2569enum MvVerificationCallLimit {
2570    None,
2571    Restore(Instant),
2572}
2573
2574#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2575enum MvVerificationLimitKind {
2576    Confirmation,
2577    Deadline,
2578    Restore,
2579}
2580
2581async fn record_actuation_observation(
2582    pool: &SqlitePool,
2583    pending: &PendingMvActuation,
2584    checked_at: DateTime<Utc>,
2585    readback: Option<f32>,
2586    quality: Option<SampleQuality>,
2587    policy: ActuationAuditPolicy,
2588) -> anyhow::Result<Option<i64>> {
2589    let Some(id) = pending.id else {
2590        return Ok(None);
2591    };
2592    match TuneMvActuationRow::record_observation(pool, id, checked_at, readback, quality).await {
2593        Ok(row) => Ok(Some(row.attempt_count)),
2594        Err(error) if policy == ActuationAuditPolicy::BestEffort => {
2595            tracing::error!(
2596                actuation_id = id,
2597                error = %error,
2598                "failed to record MV verification observation; continuing physical restore"
2599            );
2600            Ok(None)
2601        }
2602        Err(error) => Err(error.into()),
2603    }
2604}
2605
2606#[allow(clippy::too_many_arguments)]
2607async fn record_final_actuation_observation(
2608    pool: &SqlitePool,
2609    pending: &PendingMvActuation,
2610    checked_at: DateTime<Utc>,
2611    readback: Option<f32>,
2612    quality: Option<SampleQuality>,
2613    status: MvActuationStatus,
2614    detail: &str,
2615) -> Option<i64> {
2616    let id = pending.id?;
2617    match TuneMvActuationRow::record_final_observation(
2618        pool,
2619        id,
2620        checked_at,
2621        readback,
2622        quality,
2623        status,
2624        (!detail.is_empty()).then_some(detail),
2625    )
2626    .await
2627    {
2628        Ok(row) => Some(row.attempt_count),
2629        Err(error) => {
2630            tracing::error!(
2631                actuation_id = id,
2632                error = %error,
2633                "failed to record terminal MV verification observation"
2634            );
2635            None
2636        }
2637    }
2638}
2639
2640async fn finalize_actuation_best_effort(
2641    pool: &SqlitePool,
2642    pending: &PendingMvActuation,
2643    status: MvActuationStatus,
2644    detail: &str,
2645) {
2646    let Some(id) = pending.id else {
2647        return;
2648    };
2649    if let Err(error) = TuneMvActuationRow::finalize(pool, id, status, Some(detail)).await {
2650        tracing::error!(
2651            actuation_id = id,
2652            error = %error,
2653            "failed to finalize MV actuation audit row"
2654        );
2655    }
2656}
2657
2658async fn reject_replacement_for_pending_actuation(
2659    pool: &SqlitePool,
2660    tag: &str,
2661    tracker: &mut MvActuationTracker,
2662) -> anyhow::Result<AbortReason> {
2663    let pending = tracker
2664        .pending
2665        .take()
2666        .expect("called only when an MV actuation is pending");
2667    let (status, detail) = if pending.last_readback.is_some() {
2668        (
2669            MvActuationStatus::Failed,
2670            "a replacement relay command was requested while the prior MV readback remained outside tolerance",
2671        )
2672    } else {
2673        (
2674            MvActuationStatus::Unverified,
2675            "a replacement relay command was requested before an acceptable prior MV readback was available",
2676        )
2677    };
2678    finalize_actuation_best_effort(pool, &pending, status, detail).await;
2679    Ok(actuation_abort_reason(
2680        tag,
2681        &pending,
2682        pending.last_readback,
2683        Instant::now(),
2684    ))
2685}
2686
2687fn verification_trigger(
2688    pending: &PendingMvActuation,
2689    now: Instant,
2690) -> Option<MvVerificationTrigger> {
2691    if now >= pending.deadline {
2692        Some(MvVerificationTrigger::Deadline)
2693    } else if pending.last_readback.is_none() && now >= pending.first_check_at {
2694        Some(MvVerificationTrigger::Scheduled)
2695    } else {
2696        None
2697    }
2698}
2699
2700async fn wait_for_mv_verification(wakeup: Option<Instant>) {
2701    match wakeup {
2702        Some(wakeup) => tokio::time::sleep_until(wakeup).await,
2703        None => std::future::pending::<()>().await,
2704    }
2705}
2706
2707fn mv_verification_read_limit(
2708    trigger: MvVerificationTrigger,
2709    pending: &PendingMvActuation,
2710    call_limit: MvVerificationCallLimit,
2711) -> (Instant, MvVerificationLimitKind) {
2712    let external = match call_limit {
2713        MvVerificationCallLimit::None => None,
2714        MvVerificationCallLimit::Restore(deadline) => {
2715            Some((deadline, MvVerificationLimitKind::Restore))
2716        }
2717    };
2718    if trigger == MvVerificationTrigger::Deadline {
2719        let deadline_read_limit = (
2720            Instant::now() + MV_ACTUATION_DEADLINE_READ_MAX,
2721            MvVerificationLimitKind::Deadline,
2722        );
2723        return match external {
2724            Some(external) if external.0 < deadline_read_limit.0 => external,
2725            _ => deadline_read_limit,
2726        };
2727    }
2728    match external {
2729        Some(external @ (deadline, _)) if deadline < pending.deadline => external,
2730        _ => (pending.deadline, MvVerificationLimitKind::Confirmation),
2731    }
2732}
2733
2734/// Checks the accepted OPC DA MV command without producing a tune sample. Transport failures
2735/// remain ordinary failed-run errors, an individual operation timeout remains
2736/// [`AbortReason::OperationTimedOut`], and rejected quality remains [`AbortReason::PoorQuality`].
2737/// Only a finite, acceptable-quality mismatch can become
2738/// [`AbortReason::MvActuationUnconfirmed`]. Successful verification-read duration is optionally
2739/// recorded when this is called from the polling loop.
2740#[allow(clippy::too_many_arguments)]
2741async fn verify_pending_mv_actuation_with_timing(
2742    pool: &SqlitePool,
2743    _args: &TuneArgs,
2744    effective_timing: EffectiveTiming,
2745    tag: &str,
2746    driver: &dyn Driver,
2747    ctrl_c: &mut CtrlC,
2748    allow_uncertain_quality: bool,
2749    tracker: &mut MvActuationTracker,
2750    trigger: MvVerificationTrigger,
2751    call_limit: MvVerificationCallLimit,
2752    audit_policy: ActuationAuditPolicy,
2753    mut timing: Option<&mut PollTimingAccumulator>,
2754) -> anyhow::Result<Option<AbortReason>> {
2755    let Some(pending) = tracker.pending.as_ref() else {
2756        return Ok(None);
2757    };
2758    let now = Instant::now();
2759    if trigger == MvVerificationTrigger::Scheduled && now < pending.first_check_at {
2760        return Ok(None);
2761    }
2762
2763    match read_pending_mv_verification_with_timing(
2764        effective_timing,
2765        tag,
2766        driver,
2767        ctrl_c,
2768        tracker,
2769        trigger,
2770        call_limit,
2771    )
2772    .await?
2773    {
2774        PendingMvVerificationRead::DeadlineTimedOut {
2775            pending,
2776            checked_at,
2777            checked_instant,
2778        } => {
2779            record_final_actuation_observation(
2780                pool,
2781                &pending,
2782                checked_at,
2783                pending.last_readback,
2784                None,
2785                MvActuationStatus::Unverified,
2786                "the fresh MV read at the confirmation deadline did not finish within its bounded verification window",
2787            )
2788            .await;
2789            Ok(Some(actuation_abort_reason(
2790                tag,
2791                &pending,
2792                pending.last_readback,
2793                checked_instant,
2794            )))
2795        }
2796        PendingMvVerificationRead::RestoreTimedOut {
2797            pending,
2798            checked_at,
2799            checked_instant,
2800        } => {
2801            record_final_actuation_observation(
2802                pool,
2803                &pending,
2804                checked_at,
2805                pending.last_readback,
2806                None,
2807                MvActuationStatus::Unverified,
2808                "the restore timeout elapsed before MV confirmation completed",
2809            )
2810            .await;
2811            Ok(Some(actuation_abort_reason(
2812                tag,
2813                &pending,
2814                pending.last_readback,
2815                checked_instant,
2816            )))
2817        }
2818        PendingMvVerificationRead::Ready {
2819            operation,
2820            checked_at,
2821            checked_instant,
2822            read_duration,
2823        } => match resolve_pending_mv_read(
2824            pool,
2825            effective_timing,
2826            tag,
2827            tracker,
2828            operation,
2829            checked_at,
2830            checked_instant,
2831            allow_uncertain_quality,
2832        )
2833        .await?
2834        {
2835            PendingMvVerificationResult::Abort(reason) => Ok(Some(reason)),
2836            PendingMvVerificationResult::Value(value) => {
2837                if let Some(timing) = timing.as_mut() {
2838                    timing.observe_mv_verification(read_duration);
2839                }
2840                finalize_pending_mv_verification(pool, tag, tracker, value, trigger, audit_policy)
2841                    .await
2842            }
2843        },
2844    }
2845}
2846
2847#[cfg(test)]
2848#[allow(clippy::too_many_arguments)]
2849async fn verify_pending_mv_actuation_with(
2850    pool: &SqlitePool,
2851    args: &TuneArgs,
2852    tag: &str,
2853    driver: &dyn Driver,
2854    ctrl_c: &mut CtrlC,
2855    allow_uncertain_quality: bool,
2856    tracker: &mut MvActuationTracker,
2857    trigger: MvVerificationTrigger,
2858    call_limit: MvVerificationCallLimit,
2859    audit_policy: ActuationAuditPolicy,
2860) -> anyhow::Result<Option<AbortReason>> {
2861    verify_pending_mv_actuation_with_timing(
2862        pool,
2863        args,
2864        test_effective_timing(args),
2865        tag,
2866        driver,
2867        ctrl_c,
2868        allow_uncertain_quality,
2869        tracker,
2870        trigger,
2871        call_limit,
2872        audit_policy,
2873        None,
2874    )
2875    .await
2876}
2877
2878enum PendingMvVerificationRead {
2879    Ready {
2880        operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
2881        checked_at: DateTime<Utc>,
2882        checked_instant: Instant,
2883        read_duration: Duration,
2884    },
2885    DeadlineTimedOut {
2886        pending: PendingMvActuation,
2887        checked_at: DateTime<Utc>,
2888        checked_instant: Instant,
2889    },
2890    RestoreTimedOut {
2891        pending: PendingMvActuation,
2892        checked_at: DateTime<Utc>,
2893        checked_instant: Instant,
2894    },
2895}
2896
2897struct PendingMvVerificationValue {
2898    readback: f32,
2899    sample_quality: SampleQuality,
2900    checked_at: DateTime<Utc>,
2901    checked_instant: Instant,
2902}
2903
2904enum PendingMvVerificationResult {
2905    Value(PendingMvVerificationValue),
2906    Abort(AbortReason),
2907}
2908
2909fn pending_verification_ready(
2910    tracker: &MvActuationTracker,
2911    operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
2912    read_duration: Duration,
2913) -> anyhow::Result<PendingMvVerificationRead> {
2914    let checked_instant = Instant::now();
2915    let pending = tracker
2916        .pending
2917        .as_ref()
2918        .expect("pending actuation existed after the bounded read");
2919    Ok(PendingMvVerificationRead::Ready {
2920        operation,
2921        checked_at: checked_at_for_pending(pending, checked_instant)?,
2922        checked_instant,
2923        read_duration,
2924    })
2925}
2926
2927#[allow(clippy::too_many_arguments)]
2928async fn read_pending_mv_verification_with_timing(
2929    effective_timing: EffectiveTiming,
2930    tag: &str,
2931    driver: &dyn Driver,
2932    ctrl_c: &mut CtrlC,
2933    tracker: &mut MvActuationTracker,
2934    mut trigger: MvVerificationTrigger,
2935    call_limit: MvVerificationCallLimit,
2936) -> anyhow::Result<PendingMvVerificationRead> {
2937    loop {
2938        let limit = {
2939            let pending = tracker
2940                .pending
2941                .as_ref()
2942                .expect("pending actuation existed before the bounded read");
2943            mv_verification_read_limit(trigger, pending, call_limit)
2944        };
2945        let read_started = Instant::now();
2946        let read = bounded_driver_call(
2947            effective_timing.op_timeout_secs,
2948            ctrl_c,
2949            read_numeric_sample(driver, tag),
2950        );
2951        let (deadline, limit_kind) = limit;
2952        match tokio::time::timeout_at(deadline, read).await {
2953            Ok(operation) => {
2954                return pending_verification_ready(tracker, operation, read_started.elapsed());
2955            }
2956            Err(_) => match limit_kind {
2957                MvVerificationLimitKind::Confirmation => {
2958                    trigger = MvVerificationTrigger::Deadline;
2959                }
2960
2961                MvVerificationLimitKind::Deadline => {
2962                    let pending = tracker
2963                        .pending
2964                        .take()
2965                        .expect("pending actuation existed before the deadline read");
2966                    let checked_instant = Instant::now();
2967                    let checked_at = checked_at_for_pending(&pending, checked_instant)?;
2968                    return Ok(PendingMvVerificationRead::DeadlineTimedOut {
2969                        pending,
2970                        checked_at,
2971                        checked_instant,
2972                    });
2973                }
2974                MvVerificationLimitKind::Restore => {
2975                    let pending = tracker
2976                        .pending
2977                        .take()
2978                        .expect("pending actuation existed before the bounded read");
2979                    let checked_instant = Instant::now();
2980                    let checked_at = checked_at_for_pending(&pending, checked_instant)?;
2981                    return Ok(PendingMvVerificationRead::RestoreTimedOut {
2982                        pending,
2983                        checked_at,
2984                        checked_instant,
2985                    });
2986                }
2987            },
2988        }
2989    }
2990}
2991
2992#[allow(clippy::too_many_arguments)]
2993async fn resolve_pending_mv_read(
2994    pool: &SqlitePool,
2995    effective_timing: EffectiveTiming,
2996    tag: &str,
2997    tracker: &mut MvActuationTracker,
2998    operation: anyhow::Result<TickOperation<(f32, bhtune_driver::Quality)>>,
2999    checked_at: DateTime<Utc>,
3000    checked_instant: Instant,
3001    allow_uncertain_quality: bool,
3002) -> anyhow::Result<PendingMvVerificationResult> {
3003    let operation = match operation {
3004        Ok(operation) => operation,
3005        Err(error) => {
3006            let pending = tracker
3007                .pending
3008                .take()
3009                .expect("pending actuation existed before the verification read");
3010            let detail = format!("MV verification read failed: {error}");
3011            record_final_actuation_observation(
3012                pool,
3013                &pending,
3014                checked_at,
3015                None,
3016                None,
3017                MvActuationStatus::Unverified,
3018                &detail,
3019            )
3020            .await;
3021            return Err(error);
3022        }
3023    };
3024
3025    let (readback, quality) = match operation {
3026        TickOperation::Completed(value) => value,
3027        TickOperation::Cancelled => {
3028            let pending = tracker
3029                .pending
3030                .take()
3031                .expect("pending actuation existed before the verification read");
3032            finalize_actuation_best_effort(
3033                pool,
3034                &pending,
3035                MvActuationStatus::Unverified,
3036                "MV verification was interrupted before confirmation completed",
3037            )
3038            .await;
3039            return Ok(PendingMvVerificationResult::Abort(
3040                AbortReason::UserInterrupt,
3041            ));
3042        }
3043        TickOperation::TimedOut => {
3044            let pending = tracker
3045                .pending
3046                .take()
3047                .expect("pending actuation existed before the verification read");
3048            let detail = format!(
3049                "MV verification read did not complete within {} seconds",
3050                effective_timing.op_timeout_secs
3051            );
3052            record_final_actuation_observation(
3053                pool,
3054                &pending,
3055                checked_at,
3056                None,
3057                None,
3058                MvActuationStatus::Unverified,
3059                &detail,
3060            )
3061            .await;
3062            return Ok(PendingMvVerificationResult::Abort(
3063                AbortReason::OperationTimedOut {
3064                    tag: tag.to_string(),
3065                    op_timeout_secs: effective_timing.op_timeout_secs,
3066                },
3067            ));
3068        }
3069    };
3070    let sample_quality = sample_quality_from_driver(quality);
3071    if check_quality(tag, quality, allow_uncertain_quality).is_err() {
3072        let pending = tracker
3073            .pending
3074            .take()
3075            .expect("pending actuation existed before the verification read");
3076        let detail = format!("MV verification read reported OPC quality {quality:?}");
3077        record_final_actuation_observation(
3078            pool,
3079            &pending,
3080            checked_at,
3081            Some(readback),
3082            Some(sample_quality),
3083            MvActuationStatus::Unverified,
3084            &detail,
3085        )
3086        .await;
3087        return Ok(PendingMvVerificationResult::Abort(
3088            AbortReason::PoorQuality {
3089                tag: tag.to_string(),
3090                quality,
3091            },
3092        ));
3093    }
3094
3095    Ok(PendingMvVerificationResult::Value(
3096        PendingMvVerificationValue {
3097            readback,
3098            sample_quality,
3099            checked_at,
3100            checked_instant,
3101        },
3102    ))
3103}
3104
3105#[allow(clippy::too_many_arguments)]
3106async fn resolve_pending_mv_poll(
3107    pool: &SqlitePool,
3108    effective_timing: EffectiveTiming,
3109    pv_mv_values: TickOperation<HashMap<String, TagValue>>,
3110    mv_tag: &str,
3111    checked_at: DateTime<Utc>,
3112    checked_instant: Instant,
3113    read_duration: Duration,
3114    allow_uncertain_quality: bool,
3115    tracker: &mut MvActuationTracker,
3116    timing: &mut PollTimingAccumulator,
3117) -> anyhow::Result<(Option<AbortReason>, bool)> {
3118    let operation = match pv_mv_values {
3119        TickOperation::Completed(values) => match read_numeric_from_batch(&values, mv_tag) {
3120            Ok(value) => Ok(TickOperation::Completed(value)),
3121            Err(error) => Err(error),
3122        },
3123        TickOperation::Cancelled => Ok(TickOperation::Cancelled),
3124        TickOperation::TimedOut => Ok(TickOperation::TimedOut),
3125    };
3126
3127    match resolve_pending_mv_read(
3128        pool,
3129        effective_timing,
3130        mv_tag,
3131        tracker,
3132        operation,
3133        checked_at,
3134        checked_instant,
3135        allow_uncertain_quality,
3136    )
3137    .await?
3138    {
3139        PendingMvVerificationResult::Abort(reason) => Ok((Some(reason), false)),
3140        PendingMvVerificationResult::Value(value) => {
3141            timing.observe_mv_verification(read_duration);
3142            let outcome = finalize_pending_mv_verification(
3143                pool,
3144                mv_tag,
3145                tracker,
3146                value,
3147                MvVerificationTrigger::Scheduled,
3148                ActuationAuditPolicy::Required,
3149            )
3150            .await?;
3151            Ok((outcome, true))
3152        }
3153    }
3154}
3155
3156#[allow(clippy::too_many_arguments)]
3157async fn confirm_pending_mv_actuation(
3158    pool: &SqlitePool,
3159    tracker: &mut MvActuationTracker,
3160    pending: PendingMvActuation,
3161    checked_at: DateTime<Utc>,
3162    readback: f32,
3163    sample_quality: SampleQuality,
3164    audit_policy: ActuationAuditPolicy,
3165) -> anyhow::Result<()> {
3166    let recorded_attempts = match pending.id {
3167        Some(id) => {
3168            let result = TuneMvActuationRow::record_final_observation(
3169                pool,
3170                id,
3171                checked_at,
3172                Some(readback),
3173                Some(sample_quality),
3174                MvActuationStatus::Confirmed,
3175                None,
3176            )
3177            .await;
3178            match result {
3179                Ok(row) => Some(row.attempt_count),
3180                Err(error) if audit_policy == ActuationAuditPolicy::BestEffort => {
3181                    tracing::error!(
3182                        actuation_id = id,
3183                        error = %error,
3184                        "failed to record confirmed MV restore observation"
3185                    );
3186                    None
3187                }
3188                Err(error) => {
3189                    tracker.pending = Some(pending);
3190                    return Err(error.into());
3191                }
3192            }
3193        }
3194        None => None,
3195    };
3196    if let Some(attempt_count) = recorded_attempts.filter(|attempt_count| *attempt_count > 1) {
3197        tracing::warn!(
3198            actuation_id = ?pending.id,
3199            attempt_count,
3200            target = pending.target,
3201            readback,
3202            tolerance = pending.tolerance,
3203            "MV actuation confirmed after earlier unsuccessful observations"
3204        );
3205    }
3206    tracker.confirmed_mv = Some(pending.target);
3207    Ok(())
3208}
3209
3210async fn finalize_pending_mv_verification(
3211    pool: &SqlitePool,
3212    tag: &str,
3213    tracker: &mut MvActuationTracker,
3214    value: PendingMvVerificationValue,
3215    trigger: MvVerificationTrigger,
3216    audit_policy: ActuationAuditPolicy,
3217) -> anyhow::Result<Option<AbortReason>> {
3218    let pending = tracker
3219        .pending
3220        .take()
3221        .expect("pending actuation existed before the verification result");
3222    if value.checked_instant > pending.deadline {
3223        let detail = if actuation_matches(pending.target, value.readback, pending.tolerance) {
3224            "MV readback matched the target only after the confirmation deadline"
3225        } else {
3226            "MV readback remained outside tolerance after the confirmation deadline"
3227        };
3228        record_final_actuation_observation(
3229            pool,
3230            &pending,
3231            value.checked_at,
3232            Some(value.readback),
3233            Some(value.sample_quality),
3234            MvActuationStatus::Failed,
3235            detail,
3236        )
3237        .await;
3238        return Ok(Some(actuation_abort_reason(
3239            tag,
3240            &pending,
3241            Some(value.readback),
3242            value.checked_instant,
3243        )));
3244    }
3245    if actuation_matches(pending.target, value.readback, pending.tolerance) {
3246        confirm_pending_mv_actuation(
3247            pool,
3248            tracker,
3249            pending,
3250            value.checked_at,
3251            value.readback,
3252            value.sample_quality,
3253            audit_policy,
3254        )
3255        .await?;
3256        return Ok(None);
3257    }
3258
3259    let deadline_reached =
3260        trigger == MvVerificationTrigger::Deadline || value.checked_instant >= pending.deadline;
3261    if deadline_reached {
3262        record_final_actuation_observation(
3263            pool,
3264            &pending,
3265            value.checked_at,
3266            Some(value.readback),
3267            Some(value.sample_quality),
3268            MvActuationStatus::Failed,
3269            "MV readback remained outside tolerance at the confirmation deadline",
3270        )
3271        .await;
3272        return Ok(Some(actuation_abort_reason(
3273            tag,
3274            &pending,
3275            Some(value.readback),
3276            value.checked_instant,
3277        )));
3278    }
3279
3280    let attempt_count = record_actuation_observation(
3281        pool,
3282        &pending,
3283        value.checked_at,
3284        Some(value.readback),
3285        Some(value.sample_quality),
3286        audit_policy,
3287    )
3288    .await?;
3289    tracing::warn!(
3290        actuation_id = ?pending.id,
3291        attempt_count,
3292        target = pending.target,
3293        readback = value.readback,
3294        tolerance = pending.tolerance,
3295        "MV readback is outside tolerance; confirmation remains pending"
3296    );
3297    let mut pending = pending;
3298    pending.last_readback = Some(value.readback);
3299    tracker.pending = Some(pending);
3300    Ok(None)
3301}
3302
3303#[cfg(test)]
3304#[allow(clippy::too_many_arguments)]
3305async fn verify_pending_mv_actuation(
3306    pool: &SqlitePool,
3307    args: &TuneArgs,
3308    tag: &str,
3309    driver: &dyn Driver,
3310    ctrl_c: &mut CtrlC,
3311    allow_uncertain_quality: bool,
3312    tracker: &mut MvActuationTracker,
3313    call_deadline: Option<Instant>,
3314) -> anyhow::Result<Option<AbortReason>> {
3315    let trigger = tracker
3316        .pending
3317        .as_ref()
3318        .and_then(|pending| verification_trigger(pending, Instant::now()))
3319        .unwrap_or(MvVerificationTrigger::Scheduled);
3320    verify_pending_mv_actuation_with_timing(
3321        pool,
3322        args,
3323        test_effective_timing(args),
3324        tag,
3325        driver,
3326        ctrl_c,
3327        allow_uncertain_quality,
3328        tracker,
3329        trigger,
3330        call_deadline.map_or(
3331            MvVerificationCallLimit::None,
3332            MvVerificationCallLimit::Restore,
3333        ),
3334        ActuationAuditPolicy::Required,
3335        None,
3336    )
3337    .await
3338}
3339
3340async fn finalize_pending_for_run_best_effort(pool: &SqlitePool, run_id: i64, detail: &str) {
3341    if let Err(error) = TuneMvActuationRow::finalize_pending_for_run(
3342        pool,
3343        run_id,
3344        MvActuationStatus::Unverified,
3345        Some(detail),
3346    )
3347    .await
3348    {
3349        tracing::error!(
3350            run_id,
3351            error = %error,
3352            "failed to finalize pending MV actuation rows"
3353        );
3354    }
3355}
3356
3357async fn supersede_pending_actuation_best_effort(
3358    pool: &SqlitePool,
3359    tracker: &mut MvActuationTracker,
3360    detail: &str,
3361) {
3362    let Some(pending) = tracker.pending.take() else {
3363        return;
3364    };
3365    finalize_actuation_best_effort(pool, &pending, MvActuationStatus::Superseded, detail).await;
3366}
3367
3368async fn record_handoff_observation_best_effort(
3369    pool: &SqlitePool,
3370    pending: &PendingMvActuation,
3371    checked_at: DateTime<Utc>,
3372    readback: Option<f32>,
3373    quality: Option<SampleQuality>,
3374    detail: &str,
3375) {
3376    record_final_actuation_observation(
3377        pool,
3378        pending,
3379        checked_at,
3380        readback,
3381        quality,
3382        MvActuationStatus::Superseded,
3383        detail,
3384    )
3385    .await;
3386}
3387
3388fn checked_at_for_pending(
3389    pending: &PendingMvActuation,
3390    checked_instant: Instant,
3391) -> anyhow::Result<DateTime<Utc>> {
3392    Ok(pending.switch_tick
3393        + chrono::Duration::from_std(
3394            checked_instant.saturating_duration_since(pending.switch_instant),
3395        )
3396        .map_err(|_| anyhow::anyhow!("MV actuation observation time exceeded chrono's range"))?)
3397}
3398
3399fn utc_after_elapsed(now: DateTime<Utc>, elapsed: Duration) -> anyhow::Result<DateTime<Utc>> {
3400    Ok(now
3401        + chrono::Duration::from_std(elapsed)
3402            .map_err(|_| anyhow::anyhow!("MV command time exceeded chrono's range"))?)
3403}
3404
3405/// The outcome of [`attempt_restore_with_actuation`] -- whether the restore was confirmed to run
3406/// every applicable step to completion, or was abandoned/only partially successful because a
3407/// second Ctrl+C arrived, `[tuning].restore_timeout_secs` elapsed, or one or more individual restore
3408/// steps themselves failed.
3409enum RestoreAttempt {
3410    /// The restore ran to completion and [`RestoreReport::all_succeeded`] was `true`. A
3411    /// per-step failure is not a separate `Err` case -- it is folded into
3412    /// [`RestoreAttempt::Incomplete`]
3413    /// below via the report's own failure summary.
3414    Confirmed,
3415    /// The restore could not be confirmed: a second Ctrl+C arrived, `[tuning].restore_timeout_secs`
3416    /// elapsed, or one or more restore steps failed. `reason` is a
3417    /// human-readable description of which, for composing into the final
3418    /// [`RunOutcome::RestoreIncomplete`] message and the stderr warning already printed by
3419    /// [`warn_restore_incomplete`] before this variant is returned.
3420    Incomplete { reason: String },
3421}
3422
3423enum RestoreMvOutcome {
3424    Continue(RestoreStepOutcome),
3425    Interrupted(String),
3426}
3427
3428fn restore_mv_outcome_or_failed(result: anyhow::Result<RestoreMvOutcome>) -> RestoreMvOutcome {
3429    match result {
3430        Ok(outcome) => outcome,
3431        Err(error) => RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(error.to_string())),
3432    }
3433}
3434
3435enum RestoreHandoffOutcome {
3436    Confirmed,
3437    Rewrite,
3438    Interrupted(String),
3439}
3440
3441#[allow(clippy::too_many_arguments)]
3442async fn try_confirm_final_snapback_handoff_with_timing(
3443    pool: &SqlitePool,
3444    _args: &TuneArgs,
3445    effective_timing: EffectiveTiming,
3446    driver: &dyn Driver,
3447    tag: &str,
3448    initial_mv: f32,
3449    allow_uncertain_quality: bool,
3450    ctrl_c: &mut CtrlC,
3451    tracker: &mut MvActuationTracker,
3452    restore_deadline: Instant,
3453) -> anyhow::Result<Option<RestoreHandoffOutcome>> {
3454    let is_final_snapback = tracker.pending.as_ref().is_some_and(|pending| {
3455        pending.kind == MvActuationKind::Relay && pending.target == initial_mv
3456    });
3457    if !is_final_snapback {
3458        return Ok(None);
3459    }
3460
3461    let pending = tracker
3462        .pending
3463        .take()
3464        .expect("the final-snapback predicate required a pending actuation");
3465    let now = Instant::now();
3466    let reserved_restore_window = Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS);
3467    let latest_handoff_finish = restore_deadline
3468        .checked_sub(reserved_restore_window)
3469        .unwrap_or(now);
3470    if now >= latest_handoff_finish {
3471        finalize_actuation_best_effort(
3472            pool,
3473            &pending,
3474            MvActuationStatus::Superseded,
3475            "the authoritative restore skipped the final-snapback handoff read to preserve its full MV confirmation budget",
3476        )
3477        .await;
3478        return Ok(Some(RestoreHandoffOutcome::Rewrite));
3479    }
3480    let handoff_deadline = (now + MV_RESTORE_HANDOFF_READ_MAX).min(latest_handoff_finish);
3481    let read = tokio::time::timeout_at(
3482        handoff_deadline,
3483        bounded_driver_call(
3484            effective_timing.op_timeout_secs,
3485            ctrl_c,
3486            read_numeric_sample(driver, tag),
3487        ),
3488    )
3489    .await;
3490    let checked_instant = Instant::now();
3491    let checked_at = checked_at_for_pending(&pending, checked_instant)?;
3492
3493    let operation = match read {
3494        Err(_) => {
3495            finalize_actuation_best_effort(
3496                pool,
3497                &pending,
3498                MvActuationStatus::Superseded,
3499                "the authoritative restore superseded the final MRFT snapback when its tightly bounded handoff read did not finish promptly",
3500            )
3501            .await;
3502            return Ok(Some(RestoreHandoffOutcome::Rewrite));
3503        }
3504        Ok(Ok(operation)) => operation,
3505        Ok(Err(error)) => {
3506            let detail = format!(
3507                "the authoritative restore superseded the final MRFT snapback after its handoff read failed: {error}"
3508            );
3509            record_handoff_observation_best_effort(pool, &pending, checked_at, None, None, &detail)
3510                .await;
3511            tracing::warn!(error = %error, "final MRFT snapback handoff read failed; issuing authoritative restore write");
3512            return Ok(Some(RestoreHandoffOutcome::Rewrite));
3513        }
3514    };
3515
3516    let (readback, quality) = match operation {
3517        TickOperation::Completed(value) => value,
3518        TickOperation::Cancelled => {
3519            tracker.pending = Some(pending);
3520            return Ok(Some(RestoreHandoffOutcome::Interrupted(
3521                "a second Ctrl+C was received while confirming the final MRFT snapback".to_string(),
3522            )));
3523        }
3524        TickOperation::TimedOut => {
3525            let detail = format!(
3526                "the authoritative restore superseded the final MRFT snapback after its handoff read exceeded the {}s operation timeout",
3527                effective_timing.op_timeout_secs
3528            );
3529            record_handoff_observation_best_effort(pool, &pending, checked_at, None, None, &detail)
3530                .await;
3531            return Ok(Some(RestoreHandoffOutcome::Rewrite));
3532        }
3533    };
3534    let sample_quality = sample_quality_from_driver(quality);
3535    if check_quality(tag, quality, allow_uncertain_quality).is_err() {
3536        let detail = format!(
3537            "the authoritative restore superseded the final MRFT snapback after its handoff read reported OPC quality {quality:?}"
3538        );
3539        record_handoff_observation_best_effort(
3540            pool,
3541            &pending,
3542            checked_at,
3543            Some(readback),
3544            Some(sample_quality),
3545            &detail,
3546        )
3547        .await;
3548        return Ok(Some(RestoreHandoffOutcome::Rewrite));
3549    }
3550
3551    if actuation_matches(pending.target, readback, pending.tolerance) {
3552        record_handoff_observation_best_effort(
3553            pool,
3554            &pending,
3555            checked_at,
3556            Some(readback),
3557            Some(sample_quality),
3558            "the authoritative restore adopted and confirmed the final MRFT snapback; no duplicate MV write was issued",
3559        )
3560        .await;
3561        tracker.confirmed_mv = Some(initial_mv);
3562        return Ok(Some(RestoreHandoffOutcome::Confirmed));
3563    }
3564
3565    record_handoff_observation_best_effort(
3566        pool,
3567        &pending,
3568        checked_at,
3569        Some(readback),
3570        Some(sample_quality),
3571        "the authoritative restore superseded an unconfirmed final MRFT snapback and issued a replacement MV write",
3572    )
3573    .await;
3574    Ok(Some(RestoreHandoffOutcome::Rewrite))
3575}
3576
3577#[allow(clippy::too_many_arguments)]
3578async fn restore_mv_with_verification_with_timing(
3579    pool: &SqlitePool,
3580    run_id: i64,
3581    args: &TuneArgs,
3582    effective_timing: EffectiveTiming,
3583    driver: &dyn Driver,
3584    tag: &str,
3585    initial_mv: f32,
3586    allow_uncertain_quality: bool,
3587    ctrl_c: &mut CtrlC,
3588    tracker: &mut Option<MvActuationTracker>,
3589    restore_deadline: &mut Instant,
3590) -> anyhow::Result<RestoreMvOutcome> {
3591    let Some(tracker) = tracker.as_mut() else {
3592        let write = tokio::time::timeout_at(
3593            *restore_deadline,
3594            bounded_driver_call(
3595                effective_timing.op_timeout_secs,
3596                ctrl_c,
3597                write_value(driver, tag, initial_mv),
3598            ),
3599        )
3600        .await;
3601        return Ok(match write {
3602            Err(_) => RestoreMvOutcome::Interrupted(format!(
3603                "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3604                effective_timing.restore_timeout_secs
3605            )),
3606            Ok(Err(error)) => {
3607                RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(error.to_string()))
3608            }
3609            Ok(Ok(operation)) => RestoreMvOutcome::Continue(match operation {
3610                TickOperation::Completed(()) => RestoreStepOutcome::Succeeded,
3611                TickOperation::Cancelled => {
3612                    return Ok(RestoreMvOutcome::Interrupted(
3613                        "a second Ctrl+C was received while restoring the MV".to_string(),
3614                    ));
3615                }
3616                TickOperation::TimedOut => RestoreStepOutcome::Failed(format!(
3617                    "MV restore write did not complete within {}s",
3618                    effective_timing.op_timeout_secs
3619                )),
3620            }),
3621        });
3622    };
3623
3624    if tracker.pending.is_none() && tracker.confirmed_mv == Some(initial_mv) {
3625        return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded));
3626    }
3627
3628    match try_confirm_final_snapback_handoff_with_timing(
3629        pool,
3630        args,
3631        effective_timing,
3632        driver,
3633        tag,
3634        initial_mv,
3635        allow_uncertain_quality,
3636        ctrl_c,
3637        tracker,
3638        *restore_deadline,
3639    )
3640    .await?
3641    {
3642        Some(RestoreHandoffOutcome::Confirmed) => {
3643            return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded));
3644        }
3645        Some(RestoreHandoffOutcome::Interrupted(reason)) => {
3646            return Ok(RestoreMvOutcome::Interrupted(reason));
3647        }
3648        Some(RestoreHandoffOutcome::Rewrite) | None => {}
3649    }
3650
3651    let tolerance =
3652        mv_actuation_uncapped_tolerance(initial_mv, tracker.previous_commanded_mv, tracker.mv_span);
3653    let write = tokio::time::timeout_at(
3654        *restore_deadline,
3655        bounded_driver_call(
3656            effective_timing.op_timeout_secs,
3657            ctrl_c,
3658            write_value(driver, tag, initial_mv),
3659        ),
3660    )
3661    .await;
3662    match write {
3663        Err(_) => {
3664            return Ok(RestoreMvOutcome::Interrupted(format!(
3665                "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3666                effective_timing.restore_timeout_secs
3667            )));
3668        }
3669        Ok(Ok(TickOperation::Completed(()))) => {}
3670        Ok(Ok(TickOperation::Cancelled)) => {
3671            return Ok(RestoreMvOutcome::Interrupted(
3672                "a second Ctrl+C was received while restoring the MV".to_string(),
3673            ));
3674        }
3675        Ok(Ok(TickOperation::TimedOut)) => {
3676            return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3677                format!(
3678                    "MV restore write did not complete within {}s",
3679                    effective_timing.op_timeout_secs
3680                ),
3681            )));
3682        }
3683        Ok(Err(error)) => {
3684            return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3685                error.to_string(),
3686            )));
3687        }
3688    }
3689
3690    if tracker.pending.is_some() {
3691        supersede_pending_actuation_best_effort(
3692            pool,
3693            tracker,
3694            "the authoritative restore write replaced this pending relay command",
3695        )
3696        .await;
3697    }
3698    let accepted_instant = Instant::now();
3699    let accepted_at = Utc::now();
3700    *restore_deadline = (*restore_deadline)
3701        .max(accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS));
3702    tracker
3703        .record_restore_accepted_best_effort(
3704            pool,
3705            run_id,
3706            initial_mv,
3707            accepted_at,
3708            accepted_instant,
3709            tolerance,
3710        )
3711        .await;
3712
3713    loop {
3714        let trigger = tracker
3715            .pending
3716            .as_ref()
3717            .and_then(|pending| verification_trigger(pending, Instant::now()))
3718            .unwrap_or(MvVerificationTrigger::Scheduled);
3719        let verification = verify_pending_mv_actuation_with_timing(
3720            pool,
3721            args,
3722            effective_timing,
3723            tag,
3724            driver,
3725            ctrl_c,
3726            allow_uncertain_quality,
3727            tracker,
3728            trigger,
3729            MvVerificationCallLimit::Restore(*restore_deadline),
3730            ActuationAuditPolicy::BestEffort,
3731            None,
3732        )
3733        .await;
3734        match verification {
3735            Ok(None) if tracker.pending.is_none() => {
3736                return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded));
3737            }
3738            Ok(None) => {
3739                let pending = tracker
3740                    .pending
3741                    .as_ref()
3742                    .expect("pending state was checked above");
3743                let remaining_confirmation =
3744                    pending.deadline.saturating_duration_since(Instant::now());
3745                let remaining_restore =
3746                    (*restore_deadline).saturating_duration_since(Instant::now());
3747                tokio::time::sleep(
3748                    MV_ACTUATION_RETRY_INTERVAL
3749                        .min(remaining_confirmation)
3750                        .min(remaining_restore),
3751                )
3752                .await;
3753            }
3754            Ok(Some(AbortReason::UserInterrupt)) => {
3755                return Ok(RestoreMvOutcome::Interrupted(
3756                    "a second Ctrl+C was received while confirming the restored MV".to_string(),
3757                ));
3758            }
3759            Ok(Some(_)) if Instant::now() >= *restore_deadline => {
3760                return Ok(RestoreMvOutcome::Interrupted(format!(
3761                    "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3762                    effective_timing.restore_timeout_secs
3763                )));
3764            }
3765            Ok(Some(reason)) => {
3766                return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3767                    format!("MV restore could not be confirmed: {reason:?}"),
3768                )));
3769            }
3770            Err(error) => {
3771                return Ok(RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(
3772                    format!("MV restore verification failed: {error}"),
3773                )));
3774            }
3775        }
3776    }
3777}
3778
3779#[cfg(test)]
3780#[allow(clippy::too_many_arguments)]
3781async fn try_confirm_final_snapback_handoff(
3782    pool: &SqlitePool,
3783    args: &TuneArgs,
3784    driver: &dyn Driver,
3785    tag: &str,
3786    initial_mv: f32,
3787    allow_uncertain_quality: bool,
3788    ctrl_c: &mut CtrlC,
3789    tracker: &mut MvActuationTracker,
3790    restore_deadline: Instant,
3791) -> anyhow::Result<Option<RestoreHandoffOutcome>> {
3792    try_confirm_final_snapback_handoff_with_timing(
3793        pool,
3794        args,
3795        test_effective_timing(args),
3796        driver,
3797        tag,
3798        initial_mv,
3799        allow_uncertain_quality,
3800        ctrl_c,
3801        tracker,
3802        restore_deadline,
3803    )
3804    .await
3805}
3806
3807#[cfg(test)]
3808#[allow(clippy::too_many_arguments)]
3809async fn restore_mv_with_verification(
3810    pool: &SqlitePool,
3811    run_id: i64,
3812    args: &TuneArgs,
3813    driver: &dyn Driver,
3814    tag: &str,
3815    initial_mv: f32,
3816    allow_uncertain_quality: bool,
3817    ctrl_c: &mut CtrlC,
3818    tracker: &mut Option<MvActuationTracker>,
3819    restore_deadline: Instant,
3820) -> anyhow::Result<RestoreMvOutcome> {
3821    let mut restore_deadline = restore_deadline;
3822    restore_mv_with_verification_with_timing(
3823        pool,
3824        run_id,
3825        args,
3826        test_effective_timing(args),
3827        driver,
3828        tag,
3829        initial_mv,
3830        allow_uncertain_quality,
3831        ctrl_c,
3832        tracker,
3833        &mut restore_deadline,
3834    )
3835    .await
3836}
3837
3838/// Restores the loop, bounded by `restore_timeout_secs` and a second Ctrl+C, so a restore
3839/// that itself hangs (the same class of stalled-driver-call risk `bounded_driver_call`
3840/// guards the polling loop against) can never block the process indefinitely. Unlike
3841/// [`bounded_driver_call`], this takes `restore`'s exact parameters directly rather than a
3842/// generic `impl Future` -- there is only one real call shape (one `restore(...)` call per
3843/// run), so a generic signature would add no value. Infallible: [`restore`] itself no longer
3844/// returns a `Result` (every step is now attempted independently and reported via
3845/// [`RestoreReport`] instead of short-circuiting), so this function's only remaining
3846/// "failure" shapes are the two abandonment cases already covered by
3847/// [`RestoreAttempt::Incomplete`], plus a completed-but-not-fully-successful report, which
3848/// maps to that same variant. On [`RestoreAttempt::Incomplete`], calls
3849/// [`warn_restore_incomplete`] before returning, so the operator-facing warning is printed
3850/// exactly once, at the one place that decides a restore could not be confirmed -- callers
3851/// only need to fold the returned `reason` into their own context (e.g. the original
3852/// [`AbortReason`], if any) for the final [`RunOutcome`].
3853#[allow(clippy::too_many_arguments)]
3854async fn attempt_restore_with_actuation_with_timing(
3855    pool: &SqlitePool,
3856    run_id: i64,
3857    args: &TuneArgs,
3858    effective_timing: EffectiveTiming,
3859    driver: &dyn Driver,
3860    tags: &LoopTags,
3861    template: &DcsTemplate,
3862    initial: &InitialState,
3863    guard: &MutationGuard,
3864    allow_uncertain_quality: bool,
3865    ctrl_c: &mut CtrlC,
3866    mv_actuations: &mut Option<MvActuationTracker>,
3867) -> RestoreAttempt {
3868    let mut restore_deadline =
3869        Instant::now() + Duration::from_secs(effective_timing.restore_timeout_secs);
3870    let mv = match restore_mv_outcome_or_failed(
3871        restore_mv_with_verification_with_timing(
3872            pool,
3873            run_id,
3874            args,
3875            effective_timing,
3876            driver,
3877            &tags.manipulated_variable,
3878            initial.mv_ini,
3879            allow_uncertain_quality,
3880            ctrl_c,
3881            mv_actuations,
3882            &mut restore_deadline,
3883        )
3884        .await,
3885    ) {
3886        RestoreMvOutcome::Continue(outcome) => outcome,
3887        RestoreMvOutcome::Interrupted(reason) => {
3888            let _ = warn_restore_incomplete(tags, initial, &reason);
3889            return RestoreAttempt::Incomplete { reason };
3890        }
3891    };
3892
3893    tokio::select! {
3894        report = restore_after_mv(driver, tags, template, initial, guard, mv) => {
3895            if report.all_succeeded() {
3896                RestoreAttempt::Confirmed
3897            } else {
3898                let reason = report
3899                    .failure_summary()
3900                    .unwrap_or_else(|| "one or more restore steps failed".to_string());
3901                let _ = warn_restore_incomplete(tags, initial, &reason);
3902                RestoreAttempt::Incomplete { reason }
3903            }
3904        }
3905        () = ctrl_c.signalled() => {
3906            let reason = "a second Ctrl+C was received while restoring the loop".to_string();
3907            let _ = warn_restore_incomplete(tags, initial, &reason);
3908            RestoreAttempt::Incomplete { reason }
3909        }
3910        () = tokio::time::sleep_until(restore_deadline) => {
3911            let reason = format!(
3912                "the restore did not complete within the {}s [tuning].restore_timeout_secs limit",
3913                effective_timing.restore_timeout_secs
3914            );
3915            let _ = warn_restore_incomplete(tags, initial, &reason);
3916            RestoreAttempt::Incomplete { reason }
3917        }
3918    }
3919}
3920
3921#[cfg(test)]
3922#[allow(clippy::too_many_arguments)]
3923async fn attempt_restore_with_actuation(
3924    pool: &SqlitePool,
3925    run_id: i64,
3926    args: &TuneArgs,
3927    driver: &dyn Driver,
3928    tags: &LoopTags,
3929    template: &DcsTemplate,
3930    initial: &InitialState,
3931    guard: &MutationGuard,
3932    allow_uncertain_quality: bool,
3933    ctrl_c: &mut CtrlC,
3934    mv_actuations: &mut Option<MvActuationTracker>,
3935) -> RestoreAttempt {
3936    attempt_restore_with_actuation_with_timing(
3937        pool,
3938        run_id,
3939        args,
3940        test_effective_timing(args),
3941        driver,
3942        tags,
3943        template,
3944        initial,
3945        guard,
3946        allow_uncertain_quality,
3947        ctrl_c,
3948        mv_actuations,
3949    )
3950    .await
3951}
3952
3953/// Prints a loud, operator-facing warning (to stderr, so it survives `--output json` and any
3954/// stdout redirection) naming the MV tag and the pre-test value it may not have been
3955/// restored to, plus a matching `tracing::error!` for anyone mining logs rather than watching
3956/// the terminal. The loop's mode may also not have been reverted -- see `restore`'s own
3957/// mode/setpoint/mode-attribute steps -- but the MV is called out specifically since it is
3958/// the one value every template has and the one most directly consequential if left at a
3959/// relay-test extreme.
3960fn restore_incomplete_warning_message(
3961    tags: &LoopTags,
3962    initial: &InitialState,
3963    reason: &str,
3964) -> String {
3965    format!(
3966        "WARNING: could not confirm the loop was fully restored ({reason}). Tag '{}' may still be at its last relay-test value instead of its pre-test value {}. Check it -- and the loop's mode -- by hand.",
3967        tags.manipulated_variable, initial.mv_ini
3968    )
3969}
3970
3971fn warn_restore_incomplete(tags: &LoopTags, initial: &InitialState, reason: &str) -> String {
3972    let message = restore_incomplete_warning_message(tags, initial, reason);
3973    eprintln!("{message}");
3974    tracing::error!(
3975        mv_tag = %tags.manipulated_variable,
3976        mv_ini = initial.mv_ini,
3977        reason,
3978        "loop restore could not be confirmed"
3979    );
3980    message
3981}
3982
3983/// Best-effort records a restore attempt's outcome on the run (`safety-restore-guard`,
3984/// finding 3 of the live-plant safety review) -- logs and swallows its own failure rather
3985/// than propagating, since failing to *record* that a restore was attempted must never
3986/// itself change what error (if any) a run reports.
3987async fn record_restore_status_best_effort(
3988    pool: &SqlitePool,
3989    run_id: i64,
3990    attempt: &RestoreAttempt,
3991) {
3992    let (status, detail) = match attempt {
3993        RestoreAttempt::Confirmed => (bhtune_db::models::RestoreStatus::Confirmed, None),
3994        RestoreAttempt::Incomplete { reason } => (
3995            bhtune_db::models::RestoreStatus::Incomplete,
3996            Some(reason.as_str()),
3997        ),
3998    };
3999    if let Err(e) = TuneRunRow::record_restore_status(pool, run_id, status, detail).await {
4000        tracing::error!(run_id, error = %e, "failed to record restore status");
4001    }
4002}
4003
4004fn completed_oscillation_period_ms(
4005    poll_result: &anyhow::Result<PollOutcome>,
4006    direction: ControllerDirection,
4007    config: LoopConfig,
4008    pv_range: PvRange,
4009) -> Option<f64> {
4010    let Ok(PollOutcome::Completed(Action::Complete {
4011        peaks,
4012        troughs,
4013        switch_times,
4014        mv_sign_init,
4015    })) = poll_result
4016    else {
4017        return None;
4018    };
4019
4020    let oscillation = measure_oscillation(
4021        peaks,
4022        troughs,
4023        switch_times,
4024        *mv_sign_init,
4025        direction,
4026        config,
4027        pv_range,
4028        TuningMathCompat::default(),
4029    );
4030    Some(f64::from(oscillation.period_minutes) * 60_000.0)
4031}
4032
4033fn warn_on_missed_poll_opportunities(run_id: i64, metrics: &TimingMetrics) {
4034    if metrics.basis != TimingBasis::LiveMonotonic || metrics.missed_poll_opportunity_count == 0 {
4035        return;
4036    }
4037
4038    tracing::warn!(
4039        run_id,
4040        requested_interval_ms = metrics.requested_interval_ms,
4041        sample_gap_count = metrics.sample_gap_count,
4042        mean_sample_gap_ms = metrics.mean_sample_gap_ms,
4043        max_sample_gap_ms = metrics.max_sample_gap_ms,
4044        missed_poll_opportunity_count = metrics.missed_poll_opportunity_count,
4045        "live tune missed at least one complete polling opportunity"
4046    );
4047}
4048
4049/// Timing diagnostics are observational: every call site defers this database write until
4050/// after the safety-critical restore attempt, and a failure here must never replace the
4051/// tune's actual completion, abort, or driver-error outcome.
4052async fn record_timing_metrics_best_effort(pool: &SqlitePool, run_id: i64, metrics: TimingMetrics) {
4053    if let Err(e) = TuneRunRow::record_timing_metrics(pool, run_id, metrics).await {
4054        tracing::error!(run_id, error = %e, "failed to record tune timing metrics");
4055    }
4056}
4057
4058async fn record_timing_metrics_if_present(
4059    pool: &SqlitePool,
4060    run_id: i64,
4061    metrics: Option<TimingMetrics>,
4062) {
4063    let Some(metrics) = metrics else { return };
4064    record_timing_metrics_best_effort(pool, run_id, metrics).await;
4065}
4066
4067/// Attempts a best-effort restore, records its outcome, then returns `err` **unchanged** --
4068/// the single choke point every early-return error path in `execute` funnels through, so a
4069/// partial mutation is never left un-restored just because the step that failed came before
4070/// `attempt_restore` was reached (`safety-restore-guard`, finding 3 of the live-plant safety
4071/// review, fixing three such gaps: a failed `transition_to_manual`, a failed
4072/// `record_initial_readings`/`persist_results` after a successful test, and any other hard
4073/// failure from `run_polling_loop` itself). Always returns the *original* `err`:
4074/// neither an incomplete restore nor a failure recording its status should ever mask the
4075/// real reason the run is failing.
4076#[allow(clippy::too_many_arguments)]
4077async fn restore_best_effort_then_propagate_with_timing(
4078    pool: &SqlitePool,
4079    run_id: i64,
4080    driver: &dyn Driver,
4081    tags: &LoopTags,
4082    template: &DcsTemplate,
4083    initial: &InitialState,
4084    guard: &MutationGuard,
4085    args: &TuneArgs,
4086    effective_timing: EffectiveTiming,
4087    allow_uncertain_quality: bool,
4088    ctrl_c: &mut CtrlC,
4089    mv_actuations: &mut Option<MvActuationTracker>,
4090    err: anyhow::Error,
4091) -> anyhow::Error {
4092    let attempt = attempt_restore_with_actuation_with_timing(
4093        pool,
4094        run_id,
4095        args,
4096        effective_timing,
4097        driver,
4098        tags,
4099        template,
4100        initial,
4101        guard,
4102        allow_uncertain_quality,
4103        ctrl_c,
4104        mv_actuations,
4105    )
4106    .await;
4107    record_restore_status_best_effort(pool, run_id, &attempt).await;
4108    if let Err(finalize_error) = TuneMvActuationRow::finalize_pending_for_run(
4109        pool,
4110        run_id,
4111        MvActuationStatus::Unverified,
4112        Some("the run failed before MV confirmation completed"),
4113    )
4114    .await
4115    {
4116        tracing::error!(
4117            run_id,
4118            error = %finalize_error,
4119            "failed to finalize pending MV actuation rows"
4120        );
4121    }
4122    err
4123}
4124
4125#[cfg(test)]
4126#[allow(clippy::too_many_arguments)]
4127async fn restore_best_effort_then_propagate(
4128    pool: &SqlitePool,
4129    run_id: i64,
4130    driver: &dyn Driver,
4131    tags: &LoopTags,
4132    template: &DcsTemplate,
4133    initial: &InitialState,
4134    guard: &MutationGuard,
4135    args: &TuneArgs,
4136    allow_uncertain_quality: bool,
4137    ctrl_c: &mut CtrlC,
4138    mv_actuations: &mut Option<MvActuationTracker>,
4139    err: anyhow::Error,
4140) -> anyhow::Error {
4141    restore_best_effort_then_propagate_with_timing(
4142        pool,
4143        run_id,
4144        driver,
4145        tags,
4146        template,
4147        initial,
4148        guard,
4149        args,
4150        test_effective_timing(args),
4151        allow_uncertain_quality,
4152        ctrl_c,
4153        mv_actuations,
4154        err,
4155    )
4156    .await
4157}
4158
4159/// Distinguishes *why* [`run_polling_loop`] ended without a normal engine completion, so
4160/// `execute` can record and report the right [`AbortReason`].
4161enum PollOutcome {
4162    /// The engine reported [`Action::Complete`] and any post-completion
4163    /// `[tuning].mrft_delay_secs`
4164    /// padding has elapsed.
4165    Completed(Action),
4166    /// Ctrl+C, `[tuning].timeout_secs`, `[tuning].op_timeout_secs`, or a poor-quality PV sample ended the
4167    /// run before that.
4168    Aborted(AbortReason),
4169}
4170
4171async fn insert_tune_sample_with_timing(
4172    pool: &SqlitePool,
4173    run_id: i64,
4174    tick_index: i64,
4175    tick: Tick,
4176    state: bhtune_core::MrftState,
4177    sample_quality: SampleQuality,
4178    timing: &mut PollTimingAccumulator,
4179) -> anyhow::Result<()> {
4180    let started = Instant::now();
4181    TuneSampleRow::insert(pool, run_id, tick_index, tick, state, sample_quality).await?;
4182    timing.observe_sample_persist(started.elapsed());
4183    Ok(())
4184}
4185
4186/// Polls the driver on the frozen global polling interval, driving `engine` once the pre-test
4187/// `[tuning].mrft_delay_secs` padding period has elapsed, and continuing to record (but not evaluate)
4188/// samples for the same padding period after completion. Returns `Ok(PollOutcome::Completed`
4189/// on a normal finish, `Ok(PollOutcome::Aborted)` if interrupted by Ctrl+C, by
4190/// the frozen whole-run timeout elapsing, or by a single driver call exceeding the frozen
4191/// operation timeout -- the last of these via [`bounded_driver_call`], which wraps
4192/// every driver read/write in the tick body so a stalled call is abandoned rather than
4193/// awaited forever, keeping Ctrl+C and both timeouts effective even mid-hung-read/write. The
4194/// outer `tokio::select!` below still separately covers the *idle* wait between ticks (via
4195/// `ctrl_c`, shared with every `bounded_driver_call` inside the winning tick body -- see
4196/// that function's doc comment for why reusing it across nested `select!`s is safe) and the
4197/// whole-run `[tuning].timeout_secs` deadline.
4198#[allow(clippy::too_many_arguments)]
4199async fn run_polling_loop_with_timing(
4200    pool: &SqlitePool,
4201    run_id: i64,
4202    args: &TuneArgs,
4203    effective_timing: EffectiveTiming,
4204    tags: &LoopTags,
4205    driver: &dyn Driver,
4206    engine: &mut MrftEngine,
4207    time_anchor: RunTimeAnchor,
4208    ctrl_c: &mut CtrlC,
4209    guard: &mut MutationGuard,
4210    allow_uncertain_quality: bool,
4211    timing: &mut PollTimingAccumulator,
4212    mv_actuations: &mut Option<MvActuationTracker>,
4213    config: LoopConfig,
4214) -> anyhow::Result<PollOutcome> {
4215    let start_time = time_anchor.utc();
4216    let mut tick_time =
4217        TickTimeSource::for_driver(args.driver, time_anchor, effective_timing.poll_interval_ms)?;
4218    let poll_interval = Duration::from_millis(effective_timing.poll_interval_ms);
4219    let mut next_poll_at = Instant::now();
4220
4221    let pre_delay_end =
4222        start_time + chrono::Duration::seconds(i64::from(effective_timing.mrft_delay_secs));
4223    let mut tick_index: i64 = 0;
4224    let mut completion: Option<Action> = None;
4225    let mut post_delay_end: Option<DateTime<Utc>> = None;
4226
4227    // A mandatory safety net for unattended operation: an unattended run must never be able
4228    // to perturb a live process indefinitely (a stuck relay, a misconfigured tag mapping that
4229    // never crosses hysteresis, a stalled driver read). Created once and raced via
4230    // `tokio::select!` on every iteration below, rather than checked only after each
4231    // completed tick, so it fires even if a single `read_f32` call itself hangs.
4232    let timeout = tokio::time::sleep(Duration::from_secs(effective_timing.timeout_secs));
4233    tokio::pin!(timeout);
4234
4235    loop {
4236        let verification_wakeup = mv_actuations
4237            .as_ref()
4238            .and_then(MvActuationTracker::next_verification_wakeup);
4239        tokio::select! {
4240            biased;
4241            _ = wait_for_mv_verification(verification_wakeup) => {
4242                let trigger = mv_actuations
4243                    .as_ref()
4244                    .and_then(|tracker| tracker.pending.as_ref())
4245                    .and_then(|pending| verification_trigger(pending, Instant::now()))
4246                    .expect("a verification wakeup requires a due pending actuation");
4247                let tracker = mv_actuations
4248                    .as_mut()
4249                    .expect("a verification trigger requires an OPC DA tracker");
4250                let reason = verify_pending_mv_actuation_with_timing(
4251                    pool,
4252                    args,
4253                    effective_timing,
4254                    &tags.manipulated_variable,
4255                    driver,
4256                    ctrl_c,
4257                    allow_uncertain_quality,
4258                    tracker,
4259                    trigger,
4260                    MvVerificationCallLimit::None,
4261                    ActuationAuditPolicy::Required,
4262                    Some(timing),
4263                )
4264                .await?;
4265                return Ok(match reason {
4266                    Some(reason) => PollOutcome::Aborted(reason),
4267                    None => continue,
4268                });
4269            }
4270            _ = tokio::time::sleep_until(next_poll_at) => {
4271                let tick_started = Instant::now();
4272                next_poll_at = Instant::now() + poll_interval;
4273                let pending_actuation = mv_actuations
4274                    .as_ref()
4275                    .is_some_and(|tracker| tracker.pending.is_some());
4276                let pv_read_started = Instant::now();
4277                let (pv, quality, poll_provided_mv_evidence, batched_mv_abort_reason) = match bounded_driver_call(
4278                    effective_timing.op_timeout_secs,
4279                    ctrl_c,
4280                    read_poll_batch(
4281                        driver,
4282                        &tags.process_variable,
4283                        pending_actuation.then_some(tags.manipulated_variable.as_str()),
4284                    ),
4285                )
4286                .await?
4287                {
4288                    TickOperation::Completed(values) => {
4289                        timing.observe_pv_read(pv_read_started.elapsed());
4290                        let completed_at = Instant::now();
4291                        let (batched_mv_abort_reason, poll_provided_mv_evidence) =
4292                            if pending_actuation {
4293                                let pending = mv_actuations
4294                                    .as_ref()
4295                                    .and_then(|tracker| tracker.pending.as_ref())
4296                                    .expect("pending actuation existed for the batched poll");
4297                                let checked_at = checked_at_for_pending(pending, completed_at)?;
4298                                let tracker = mv_actuations
4299                                    .as_mut()
4300                                    .expect("pending actuation requires an OPC DA tracker");
4301                                resolve_pending_mv_poll(
4302                                    pool,
4303                                    effective_timing,
4304                                    TickOperation::Completed(values.clone()),
4305                                    &tags.manipulated_variable,
4306                                    checked_at,
4307                                    completed_at,
4308                                    pv_read_started.elapsed(),
4309                                    allow_uncertain_quality,
4310                                    tracker,
4311                                    timing,
4312                                )
4313                                .await?
4314                            } else {
4315                                (None, false)
4316                            };
4317                        let (pv, quality) = read_numeric_from_batch(&values, &tags.process_variable)?;
4318                        (
4319                            pv,
4320                            quality,
4321                            poll_provided_mv_evidence,
4322                            batched_mv_abort_reason,
4323                        )
4324                    }
4325                    TickOperation::Cancelled => {
4326                        if let Some(tracker) = mv_actuations.as_mut()
4327                            && tracker.pending.is_some()
4328                        {
4329                            let completed_at = Instant::now();
4330                            let pending = tracker
4331                                .pending
4332                                .as_ref()
4333                                .expect("pending actuation existed for the cancelled poll");
4334                            let checked_at = checked_at_for_pending(pending, completed_at)?;
4335                            let (reason, _) = resolve_pending_mv_poll(
4336                                pool,
4337                                effective_timing,
4338                                TickOperation::Cancelled,
4339                                &tags.manipulated_variable,
4340                                checked_at,
4341                                completed_at,
4342                                pv_read_started.elapsed(),
4343                                allow_uncertain_quality,
4344                                tracker,
4345                                timing,
4346                            )
4347                            .await?;
4348                            let reason =
4349                                reason.expect("a cancelled pending MV poll must abort the run");
4350                            return Ok(PollOutcome::Aborted(reason));
4351                        }
4352                        tracing::warn!(run_id, tick_index, "Ctrl+C received while reading the PV; aborting run");
4353                        return Ok(PollOutcome::Aborted(AbortReason::UserInterrupt));
4354                    }
4355                    TickOperation::TimedOut => {
4356                        if let Some(tracker) = mv_actuations.as_mut()
4357                            && tracker.pending.is_some()
4358                        {
4359                            let completed_at = Instant::now();
4360                            let pending = tracker
4361                                .pending
4362                                .as_ref()
4363                                .expect("pending actuation existed for the timed-out poll");
4364                            let checked_at = checked_at_for_pending(pending, completed_at)?;
4365                            let (reason, _) = resolve_pending_mv_poll(
4366                                pool,
4367                                effective_timing,
4368                                TickOperation::TimedOut,
4369                                &tags.manipulated_variable,
4370                                checked_at,
4371                                completed_at,
4372                                pv_read_started.elapsed(),
4373                                allow_uncertain_quality,
4374                                tracker,
4375                                timing,
4376                            )
4377                            .await?;
4378                            let reason =
4379                                reason.expect("a timed-out pending MV poll must abort the run");
4380                            return Ok(PollOutcome::Aborted(reason));
4381                        }
4382                        tracing::warn!(
4383                            run_id,
4384                            tick_index,
4385                            op_timeout_secs = effective_timing.op_timeout_secs,
4386                            tag = %tags.process_variable,
4387                            "[tuning].op_timeout_secs elapsed reading the PV; aborting run"
4388                        );
4389                        return Ok(PollOutcome::Aborted(AbortReason::OperationTimedOut {
4390                            tag: tags.process_variable.clone(),
4391                            op_timeout_secs: effective_timing.op_timeout_secs,
4392                        }));
4393                    }
4394                };
4395                // Timestamp the value after it is actually read. For OPC DA this includes the
4396                // read's real monotonic latency; for the simulator it advances the logical
4397                // process clock by exactly one fixed poll step per successful PV sample.
4398                let tick_observed_instant = Instant::now();
4399                let now = tick_time.next_timestamp()?;
4400                timing.observe(now)?;
4401                let tick = Tick { time: now, pv };
4402                let sample_quality = sample_quality_from_driver(quality);
4403
4404                if let Err(e) = check_quality(&tags.process_variable, quality, allow_uncertain_quality) {
4405                    tracing::warn!(
4406                        run_id,
4407                        tick_index,
4408                        tag = %tags.process_variable,
4409                        quality = ?quality,
4410                        error = %e,
4411                        "PV quality check failed; aborting run"
4412                    );
4413                    // Record the triggering sample (with its real quality) before aborting, so
4414                    // the history explorer can show exactly what was seen when the run gave up.
4415                    insert_tune_sample_with_timing(
4416                        pool,
4417                        run_id,
4418                        tick_index,
4419                        tick,
4420                        engine.state(),
4421                        sample_quality,
4422                        timing,
4423                    )
4424                    .await?;
4425                    timing.observe_tick_work(tick_started.elapsed());
4426                    return Ok(PollOutcome::Aborted(AbortReason::PoorQuality {
4427                        tag: tags.process_variable.clone(),
4428                        quality,
4429                    }));
4430                }
4431
4432                if let Some(reason) = batched_mv_abort_reason {
4433                    insert_tune_sample_with_timing(
4434                        pool,
4435                        run_id,
4436                        tick_index,
4437                        tick,
4438                        engine.state(),
4439                        sample_quality,
4440                        timing,
4441                    )
4442                    .await?;
4443                    timing.observe_tick_work(tick_started.elapsed());
4444                    return Ok(PollOutcome::Aborted(reason));
4445                }
4446
4447                if completion.is_none() && now < pre_delay_end {
4448                    insert_tune_sample_with_timing(
4449                        pool,
4450                        run_id,
4451                        tick_index,
4452                        tick,
4453                        engine.state(),
4454                        sample_quality,
4455                        timing,
4456                    )
4457                    .await?;
4458                    timing.observe_tick_work(tick_started.elapsed());
4459                    tick_index += 1;
4460                    continue;
4461                }
4462
4463                // When an earlier command is still pending, evaluate this tick against a
4464                // clone. The real engine is committed only if the preview does not request a
4465                // replacement command. This keeps the engine's counters/switch timestamp and
4466                // final-completion state causally behind physical MV confirmation.
4467                let state_before_step = engine.state();
4468                let actions = if mv_actuations
4469                    .as_ref()
4470                    .is_some_and(|tracker| tracker.pending.is_some())
4471                {
4472                    let mut preview = engine.clone();
4473                    let actions = preview.step(tick);
4474                    if actions.iter().any(|action| matches!(action, Action::WriteMv(_))) {
4475                        let tracker = mv_actuations
4476                            .as_mut()
4477                            .expect("a pending actuation requires an OPC DA tracker");
4478                        assert!(
4479                            poll_provided_mv_evidence,
4480                            "a pending batched poll must provide MV evidence before replacement preview"
4481                        );
4482                        let reason = reject_replacement_for_pending_actuation(
4483                            pool,
4484                            &tags.manipulated_variable,
4485                            tracker,
4486                        )
4487                        .await?;
4488                        insert_tune_sample_with_timing(
4489                            pool,
4490                            run_id,
4491                            tick_index,
4492                            tick,
4493                            state_before_step,
4494                            sample_quality,
4495                            timing,
4496                        )
4497                        .await?;
4498                        timing.observe_tick_work(tick_started.elapsed());
4499                        return Ok(PollOutcome::Aborted(reason));
4500                    }
4501                    *engine = preview;
4502                    actions
4503                } else {
4504                    engine.step(tick)
4505                };
4506                for action in actions {
4507                    match action {
4508                        Action::WriteMv(v) => {
4509                            let tolerance = mv_actuations
4510                                .as_ref()
4511                                .map(|tracker| {
4512                                    mv_actuation_tolerance(
4513                                        MvActuationKind::Relay,
4514                                        v,
4515                                        tracker.previous_commanded_mv,
4516                                        tracker.mv_span,
4517                                    )
4518                                })
4519                                .transpose()?;
4520                            guard.mv_written = true;
4521                                let mv_write_started = Instant::now();
4522                                match bounded_driver_call(
4523                                effective_timing.op_timeout_secs,
4524                                ctrl_c,
4525                                write_value(driver, &tags.manipulated_variable, v),
4526                            )
4527                            .await?
4528                            {
4529                                TickOperation::Completed(()) => {
4530                                    timing.observe_mv_write(mv_write_started.elapsed());
4531                                    if let (Some(tracker), Some(tolerance)) =
4532                                        (mv_actuations.as_mut(), tolerance)
4533                                    {
4534                                        let commanded_instant = Instant::now();
4535                                        record_relay_actuation(
4536                                            tracker,
4537                                            pool,
4538                                            run_id,
4539                                            v,
4540                                            now,
4541                                            tick_observed_instant,
4542                                            tick_observed_instant
4543                                                + Duration::from_secs(u64::from(
4544                                                    config.noise_protection_secs,
4545                                                )),
4546                                            commanded_instant
4547                                                .saturating_duration_since(tick_observed_instant),
4548                                            commanded_instant,
4549                                            tolerance,
4550                                        )
4551                                        .await?;
4552                                    }
4553                                }
4554                                TickOperation::Cancelled => {
4555                                    // A valid sample/tick is already in hand for this iteration
4556                                    // (unlike the PV-read timeout/cancel case above), so record
4557                                    // it before aborting -- same rationale as the quality-check
4558                                    // abort above.
4559                                    insert_tune_sample_with_timing(
4560                                        pool,
4561                                        run_id,
4562                                        tick_index,
4563                                        tick,
4564                                        state_before_step,
4565                                        sample_quality,
4566                                        timing,
4567                                    )
4568                                    .await?;
4569                                    timing.observe_tick_work(tick_started.elapsed());
4570                                    tracing::warn!(run_id, tick_index, "Ctrl+C received while writing the MV; aborting run");
4571                                    return Ok(PollOutcome::Aborted(AbortReason::UserInterrupt));
4572                                }
4573                                TickOperation::TimedOut => {
4574                                    insert_tune_sample_with_timing(
4575                                        pool,
4576                                        run_id,
4577                                        tick_index,
4578                                        tick,
4579                                        engine.state(),
4580                                        sample_quality,
4581                                        timing,
4582                                    )
4583                                    .await?;
4584                                    timing.observe_tick_work(tick_started.elapsed());
4585                                    tracing::warn!(
4586                                        run_id,
4587                                        tick_index,
4588                                        op_timeout_secs = effective_timing.op_timeout_secs,
4589                                        tag = %tags.manipulated_variable,
4590                                        "[tuning].op_timeout_secs elapsed writing the MV; aborting run"
4591                                    );
4592                                    return Ok(PollOutcome::Aborted(AbortReason::OperationTimedOut {
4593                                        tag: tags.manipulated_variable.clone(),
4594                                        op_timeout_secs: effective_timing.op_timeout_secs,
4595                                    }));
4596                                }
4597                            }
4598                        }
4599                        Action::Complete { .. } => {
4600                            tracing::info!(
4601                                run_id,
4602                                tick_index,
4603                                "MRFT engine reported completion; recording post-test padding"
4604                            );
4605                            completion = Some(action);
4606                            post_delay_end = Some(
4607                                now + chrono::Duration::seconds(i64::from(
4608                                    effective_timing.mrft_delay_secs,
4609                                )),
4610                            );
4611                        }
4612                    }
4613                }
4614                tracing::trace!(run_id, tick_index, pv, "recorded tune sample");
4615                insert_tune_sample_with_timing(
4616                    pool,
4617                    run_id,
4618                    tick_index,
4619                    tick,
4620                    engine.state(),
4621                    sample_quality,
4622                    timing,
4623                )
4624                .await?;
4625                timing.observe_tick_work(tick_started.elapsed());
4626                tick_index += 1;
4627
4628                if let Some(end) = post_delay_end
4629                    && now >= end
4630                {
4631                    break;
4632                }
4633            }
4634            () = ctrl_c.signalled() => {
4635                tracing::warn!(run_id, tick_index, "Ctrl+C received; aborting run");
4636                return Ok(PollOutcome::Aborted(AbortReason::UserInterrupt));
4637            }
4638            _ = &mut timeout => {
4639                tracing::warn!(
4640                    run_id,
4641                    tick_index,
4642                    timeout_secs = effective_timing.timeout_secs,
4643                    "[tuning].timeout_secs elapsed before completion; aborting run"
4644                );
4645                return Ok(PollOutcome::Aborted(AbortReason::Timeout {
4646                    timeout_secs: effective_timing.timeout_secs,
4647                }));
4648            }
4649        }
4650    }
4651
4652    Ok(PollOutcome::Completed(completion.expect(
4653        "the loop only `break`s after `completion` is set",
4654    )))
4655}
4656
4657#[cfg(test)]
4658#[allow(clippy::too_many_arguments)]
4659async fn run_polling_loop(
4660    pool: &SqlitePool,
4661    run_id: i64,
4662    args: &TuneArgs,
4663    tags: &LoopTags,
4664    driver: &dyn Driver,
4665    engine: &mut MrftEngine,
4666    time_anchor: RunTimeAnchor,
4667    ctrl_c: &mut CtrlC,
4668    guard: &mut MutationGuard,
4669    allow_uncertain_quality: bool,
4670    timing: &mut PollTimingAccumulator,
4671    mv_actuations: &mut Option<MvActuationTracker>,
4672    config: LoopConfig,
4673) -> anyhow::Result<PollOutcome> {
4674    run_polling_loop_with_timing(
4675        pool,
4676        run_id,
4677        args,
4678        test_effective_timing(args),
4679        tags,
4680        driver,
4681        engine,
4682        time_anchor,
4683        ctrl_c,
4684        guard,
4685        allow_uncertain_quality,
4686        timing,
4687        mv_actuations,
4688        config,
4689    )
4690    .await
4691}
4692
4693async fn persist_results(
4694    pool: &SqlitePool,
4695    run_id: i64,
4696    action: Action,
4697    direction: ControllerDirection,
4698    config: LoopConfig,
4699    pv_range: PvRange,
4700    template: &DcsTemplate,
4701) -> anyhow::Result<()> {
4702    let Action::Complete {
4703        peaks,
4704        troughs,
4705        switch_times,
4706        mv_sign_init,
4707    } = action
4708    else {
4709        anyhow::bail!("internal error: persist_results called with a non-Complete action");
4710    };
4711
4712    let results = calculate_all_checked(
4713        &peaks,
4714        &troughs,
4715        &switch_times,
4716        mv_sign_init,
4717        direction,
4718        config,
4719        pv_range,
4720        template,
4721        TuningMathCompat::default(),
4722    );
4723
4724    for result in results {
4725        let row = TuneResultRow::from_checked(run_id, result);
4726        TuneResultRow::insert(pool, &row).await?;
4727    }
4728
4729    Ok(())
4730}
4731
4732/// Reads the existing Proportional/Integral/Derivative values before any write is attempted
4733/// -- `safety-writeback-rollback`'s pre-read step. Reading all three is a hard stop on the
4734/// first failure, mirroring findings 4/5's "refuse before mutating" pattern, and has the
4735/// useful side effect of guaranteeing that a rollback, if one later turns out to be
4736/// necessary, always has a known-good value to roll back to. `pub(crate)`: also reused by
4737/// `commands::history::revert`, which needs the identical pre-read step before writing a
4738/// past run's recorded values back.
4739pub(crate) async fn read_previous_pid_values(
4740    driver: &dyn Driver,
4741    p_tag: &str,
4742    i_tag: &str,
4743    d_tag: &str,
4744    allow_uncertain: bool,
4745) -> anyhow::Result<WriteReadback> {
4746    let proportional = read_f32(driver, p_tag, allow_uncertain)
4747        .await
4748        .map_err(|e| anyhow::anyhow!("pre-read of Proportional tag '{p_tag}' failed: {e}"))?;
4749    let integral = read_f32(driver, i_tag, allow_uncertain)
4750        .await
4751        .map_err(|e| anyhow::anyhow!("pre-read of Integral tag '{i_tag}' failed: {e}"))?;
4752    let derivative = read_f32(driver, d_tag, allow_uncertain)
4753        .await
4754        .map_err(|e| anyhow::anyhow!("pre-read of Derivative tag '{d_tag}' failed: {e}"))?;
4755    Ok(WriteReadback {
4756        proportional,
4757        integral,
4758        derivative,
4759    })
4760}
4761
4762/// Whether a PID write-back's confirmation readback is close enough to `requested` to count
4763/// as confirmed. Combined absolute (1e-3) and relative (1%) tolerance rather than exact
4764/// equality, since a DCS's own internal unit conversion/precision means the readback of a
4765/// just-written float is not guaranteed to be bit-identical -- and a purely relative
4766/// tolerance breaks down for a requested value at or near zero (e.g. `D = 0` for a PI
4767/// controller).
4768fn pid_value_within_tolerance(requested: f32, actual: f32) -> bool {
4769    let tolerance = (1e-3_f32).max(0.01 * requested.abs());
4770    (actual - requested).abs() <= tolerance
4771}
4772
4773/// Writes `value` to `tag` and reads it back to confirm the DCS accepted it within
4774/// [`pid_value_within_tolerance`], reusing [`write_value`] (so a transport error and a
4775/// rejected write both surface the same way) and [`read_f32`] (so a poor-quality or
4776/// non-numeric readback is never mistaken for confirmation). `label` is only used to prefix
4777/// the error message so a caller writing several constants in sequence can tell which one
4778/// failed. `pub(crate)`: also reused by `commands::history::revert` for the identical
4779/// write-and-verify step against a run's recorded previous values.
4780pub(crate) async fn write_and_verify_pid_value(
4781    driver: &dyn Driver,
4782    label: &str,
4783    tag: &str,
4784    value: f32,
4785    allow_uncertain: bool,
4786) -> Result<f32, String> {
4787    write_value(driver, tag, value)
4788        .await
4789        .map_err(|e| format!("{label} write to '{tag}' failed: {e}"))?;
4790    let readback = read_f32(driver, tag, allow_uncertain)
4791        .await
4792        .map_err(|e| format!("{label} readback from '{tag}' failed: {e}"))?;
4793    if pid_value_within_tolerance(value, readback) {
4794        Ok(readback)
4795    } else {
4796        Err(format!(
4797            "{label} readback {readback} from '{tag}' is outside tolerance of requested {value}"
4798        ))
4799    }
4800}
4801
4802/// Best-effort rollback of whichever PID constants were confirmed written before a later one
4803/// failed -- mirroring `restore()`'s "attempt every step independently, don't short-circuit
4804/// on the first failure" philosophy (`safety-restore-guard`). `targets` is `(label, tag,
4805/// previous_value)` triples, in any order. Returns `Ok(())` only if every rollback write
4806/// succeeded; otherwise `Err` describing every one that did not.
4807async fn rollback_pid_writes(
4808    driver: &dyn Driver,
4809    targets: &[(&str, &str, f32)],
4810) -> Result<(), String> {
4811    let mut failures = Vec::new();
4812    for (label, tag, previous_value) in targets {
4813        if let Err(e) = write_value(driver, tag, *previous_value).await {
4814            failures.push(format!("{label} rollback write to '{tag}' failed: {e}"));
4815        }
4816    }
4817    if failures.is_empty() {
4818        Ok(())
4819    } else {
4820        Err(failures.join("; "))
4821    }
4822}
4823
4824/// The result of [`write_pid_values`] -- deliberately simpler than [`WriteBackOutcome`]
4825/// below, which layers CLI-only concerns (a `Skipped` variant covering unconfigured tags, no
4826/// recorded results, or an interactive skip) on top of this. `write_pid_values` only ever
4827/// runs once a specific, available target has already been chosen, so there is nothing left
4828/// to "skip" by the time it's called. Named distinctly from `bhtune_driver::WriteOutcome`
4829/// (that one describes a single raw tag write's own outcome; this one describes the full
4830/// pre-read/write/verify/rollback/audit sequence across all three PID constants).
4831#[derive(Debug, Clone, PartialEq)]
4832pub enum PidWriteOutcome {
4833    /// Every constant was written and confirmed within tolerance.
4834    Written,
4835    /// The pre-read, a write, or a readback failed. `detail` is a human-readable summary
4836    /// suitable for surfacing directly to a CLI user or an HTTP error body -- including
4837    /// whether/how rollback resolved, for a [`WriteKind::Write`] that failed partway
4838    /// through. A [`TuneWriteRow`] audit row was still inserted recording the same story in
4839    /// full detail; this is only ever a summary of it.
4840    Failed { detail: String },
4841}
4842
4843/// Pre-reads the existing Proportional/Integral/Derivative values, writes and verifies
4844/// `target` (Proportional then Integral then Derivative, stopping at the first failure),
4845/// rolls back to the pre-read values on partial failure (only for `kind =
4846/// `[`WriteKind::Write`]` -- [`WriteKind::Revert`] never does, so a revert can't chase its
4847/// own failure with a nested rollback; see [`WriteKind`]'s own doc comment), and records
4848/// exactly one [`TuneWriteRow`] audit row for the attempt, success or not
4849/// (`safety-writeback-rollback`, finding 6 of the live-plant safety review).
4850///
4851/// The one implementation of "pre-read, write, verify, roll back, audit" in the whole
4852/// workspace, shared by three callers: [`maybe_write_back`]'s in-run write-back,
4853/// `commands::history::revert`, and `bhtune-server`'s post-hoc `POST /api/runs/{id}/write`/
4854/// `.../revert` (`api-post-run-write`) -- `pub` (not `pub(crate)`) specifically so that
4855/// third, different-crate caller can reach it. `bhtune-server` calls only this function, not
4856/// the lower-level [`read_previous_pid_values`]/[`write_and_verify_pid_value`] helpers this
4857/// builds on -- those stay `pub(crate)`, since nothing outside `bhtune-cli` needs the
4858/// individual pre-read/write-single-value steps, only the complete audited sequence.
4859///
4860/// `target` is the caller-selected P/I/D values to write: freshly calculated parameters for a
4861/// [`WriteKind::Write`], or a past write's recorded `previous` values for a
4862/// [`WriteKind::Revert`]. Never propagates a driver/database error via `?` for an
4863/// operational failure -- a pre-read failure, a rejected write, a failed confirmation
4864/// readback, or a failed rollback all still produce their audit row and return
4865/// [`PidWriteOutcome::Failed`]; the `Err` case is reserved for the one thing that really is
4866/// exceptional here, [`TuneWriteRow::insert`] itself failing.
4867#[allow(clippy::too_many_arguments)]
4868pub async fn write_pid_values(
4869    pool: &SqlitePool,
4870    run_id: i64,
4871    driver: &dyn Driver,
4872    p_tag: &str,
4873    i_tag: &str,
4874    d_tag: &str,
4875    response_level: ResponseLevel,
4876    target: WriteReadback,
4877    kind: WriteKind,
4878    allow_uncertain: bool,
4879) -> anyhow::Result<PidWriteOutcome> {
4880    let written_at = Utc::now();
4881    let mut new_write = NewTuneWrite::new(response_level, written_at);
4882    new_write.kind = kind;
4883    new_write.allow_uncertain_quality = allow_uncertain;
4884
4885    let previous =
4886        match read_previous_pid_values(driver, p_tag, i_tag, d_tag, allow_uncertain).await {
4887            Ok(previous) => previous,
4888            Err(e) => {
4889                let message = e.to_string();
4890                new_write.error_message = Some(message.clone());
4891                TuneWriteRow::insert(pool, run_id, new_write).await?;
4892                tracing::error!(run_id, ?response_level, ?kind, %message, "PID pre-read failed");
4893                return Ok(PidWriteOutcome::Failed {
4894                    detail: format!("pre-read failed: {message}"),
4895                });
4896            }
4897        };
4898    new_write.previous = Some(previous);
4899
4900    // Write and verify Proportional, then Integral, then Derivative, stopping at the first
4901    // failure. `rollback_targets` accumulates only the constants confirmed written so far,
4902    // so a failure partway through knows exactly what needs rolling back (when rollback
4903    // applies at all -- see `kind` below).
4904    let steps: [(&str, &str, f32, f32); 3] = [
4905        (
4906            "Proportional",
4907            p_tag,
4908            target.proportional,
4909            previous.proportional,
4910        ),
4911        ("Integral", i_tag, target.integral, previous.integral),
4912        ("Derivative", d_tag, target.derivative, previous.derivative),
4913    ];
4914    let mut written_vals: [Option<f32>; 3] = [None; 3];
4915    let mut readback_vals: [Option<f32>; 3] = [None; 3];
4916    let mut rollback_targets: Vec<(&str, &str, f32)> = Vec::new();
4917    let mut failure: Option<String> = None;
4918
4919    for (i, (label, tag, value, previous_value)) in steps.into_iter().enumerate() {
4920        written_vals[i] = Some(value);
4921        match write_and_verify_pid_value(driver, label, tag, value, allow_uncertain).await {
4922            Ok(readback) => {
4923                readback_vals[i] = Some(readback);
4924                rollback_targets.push((label, tag, previous_value));
4925            }
4926            Err(e) => {
4927                failure = Some(e);
4928                break;
4929            }
4930        }
4931    }
4932
4933    new_write.proportional_written = written_vals[0];
4934    new_write.integral_written = written_vals[1];
4935    new_write.derivative_written = written_vals[2];
4936    new_write.proportional_readback = readback_vals[0];
4937    new_write.integral_readback = readback_vals[1];
4938    new_write.derivative_readback = readback_vals[2];
4939
4940    let Some(error_message) = failure else {
4941        new_write.success = true;
4942        TuneWriteRow::insert(pool, run_id, new_write).await?;
4943        tracing::info!(run_id, ?response_level, ?kind, "PID write succeeded");
4944        return Ok(PidWriteOutcome::Written);
4945    };
4946
4947    new_write.success = false;
4948    new_write.error_message = Some(error_message.clone());
4949
4950    // `WriteKind::Revert` never chases its own failure with a nested rollback (see that
4951    // variant's doc comment); neither does a `Write` that failed before confirming even one
4952    // constant, since there is nothing yet to roll back.
4953    if kind != WriteKind::Write || rollback_targets.is_empty() {
4954        TuneWriteRow::insert(pool, run_id, new_write).await?;
4955        tracing::error!(run_id, ?response_level, ?kind, %error_message, "PID write failed");
4956        return Ok(PidWriteOutcome::Failed {
4957            detail: error_message,
4958        });
4959    }
4960
4961    match rollback_pid_writes(driver, &rollback_targets).await {
4962        Ok(()) => {
4963            new_write.rollback_state = Some(RollbackState::Succeeded);
4964            TuneWriteRow::insert(pool, run_id, new_write).await?;
4965            tracing::error!(run_id, ?response_level, %error_message, "PID write failed partway through; rollback succeeded");
4966            Ok(PidWriteOutcome::Failed {
4967                detail: format!("{error_message} (rolled back)"),
4968            })
4969        }
4970        Err(rollback_error) => {
4971            new_write.rollback_state = Some(RollbackState::Failed);
4972            new_write.rollback_error = Some(rollback_error.clone());
4973            TuneWriteRow::insert(pool, run_id, new_write).await?;
4974            tracing::error!(
4975                run_id,
4976                ?response_level,
4977                %error_message,
4978                %rollback_error,
4979                "PID write failed partway through; rollback also failed"
4980            );
4981            Ok(PidWriteOutcome::Failed {
4982                detail: format!(
4983                    "{error_message}; rollback also failed: {rollback_error} -- the loop may \
4984                     hold a mismatched set of PID constants, see \
4985                     `bhtune history revert {run_id}`"
4986                ),
4987            })
4988        }
4989    }
4990}
4991
4992/// Represents the selected calculated PID parameters and any reason they were not written.
4993///
4994/// The write-back operation itself is documented on [`maybe_write_back`].
4995enum WriteBackSelection<'a> {
4996    Selected(&'a TuneResultRow),
4997    Skipped(String),
4998    Failed(String),
4999}
5000
5001/// Converts a persisted result into the exact PID values that may be written to a controller.
5002///
5003/// This is the single validity gate shared by the CLI and HTTP write paths. A result must be
5004/// explicitly valid and contain finite values for all three constants; malformed historical
5005/// rows are rejected rather than being allowed to reach a live driver.
5006pub fn pid_parameters_for_result(result: &TuneResultRow) -> anyhow::Result<PidParameters> {
5007    if result.status != TuningResultStatus::Valid {
5008        let reason = result
5009            .invalid_reason
5010            .map(|reason| reason.to_string())
5011            .unwrap_or_else(|| "no invalid reason was recorded".to_string());
5012        anyhow::bail!(
5013            "{:?} calculated result is invalid: {reason}",
5014            result.response_level
5015        );
5016    }
5017    if result.invalid_reason.is_some() {
5018        anyhow::bail!(
5019            "{:?} calculated result has an invalid reason despite being marked valid",
5020            result.response_level
5021        );
5022    }
5023
5024    let proportional = result.proportional.ok_or_else(|| {
5025        anyhow::anyhow!(
5026            "{:?} calculated result is missing its proportional value",
5027            result.response_level
5028        )
5029    })?;
5030    let integral = result.integral.ok_or_else(|| {
5031        anyhow::anyhow!(
5032            "{:?} calculated result is missing its integral value",
5033            result.response_level
5034        )
5035    })?;
5036    let derivative = result.derivative.ok_or_else(|| {
5037        anyhow::anyhow!(
5038            "{:?} calculated result is missing its derivative value",
5039            result.response_level
5040        )
5041    })?;
5042    if !proportional.is_finite() || !integral.is_finite() || !derivative.is_finite() {
5043        anyhow::bail!(
5044            "{:?} calculated result contains a non-finite PID value",
5045            result.response_level
5046        );
5047    }
5048
5049    Ok(PidParameters {
5050        response_level: result.response_level,
5051        proportional,
5052        integral,
5053        derivative,
5054    })
5055}
5056
5057fn result_write_back_error(result: &TuneResultRow) -> Option<String> {
5058    pid_parameters_for_result(result)
5059        .err()
5060        .map(|error| error.to_string())
5061}
5062
5063fn select_named_write_back_result<'a>(
5064    results: &'a [TuneResultRow],
5065    level: ResponseLevel,
5066    output: OutputFormat,
5067) -> WriteBackSelection<'a> {
5068    match results.iter().find(|r| r.response_level == level) {
5069        Some(result) => {
5070            if let Some(detail) = result_write_back_error(result) {
5071                if prints_table_output(output) {
5072                    println!(
5073                        "Calculated {level:?} result is invalid; skipping write-back: {detail}"
5074                    );
5075                }
5076                return WriteBackSelection::Failed(detail);
5077            }
5078            if prints_table_output(output) {
5079                println!(
5080                    "Non-interactively writing {level:?} PID parameters back to the DCS (--write-pid)."
5081                );
5082            }
5083            WriteBackSelection::Selected(result)
5084        }
5085        None => {
5086            let detail = format!("no calculated result recorded for response level {level:?}");
5087            if prints_table_output(output) {
5088                println!(
5089                    "No calculated result recorded for response level {level:?}; skipping write-back."
5090                );
5091            }
5092            WriteBackSelection::Failed(detail)
5093        }
5094    }
5095}
5096
5097fn select_interactive_write_back_result<'a>(
5098    results: &'a [TuneResultRow],
5099    reader: &mut impl std::io::BufRead,
5100) -> WriteBackSelection<'a> {
5101    eprintln!("\nCalculated PID parameters:");
5102    for (i, result) in results.iter().enumerate() {
5103        match pid_parameters_for_result(result) {
5104            Ok(pid) => eprintln!(
5105                "  {}. {:?}: P={:.4} I={:.4} D={:.4}",
5106                i + 1,
5107                result.response_level,
5108                pid.proportional,
5109                pid.integral,
5110                pid.derivative
5111            ),
5112            Err(error) => eprintln!(
5113                "  {}. {:?}: INVALID ({error})",
5114                i + 1,
5115                result.response_level
5116            ),
5117        }
5118    }
5119    eprintln!(
5120        "Write which response level's PID parameters back to the DCS? [1-{}, or Enter/n to skip]:",
5121        results.len()
5122    );
5123
5124    let mut input = String::new();
5125    let bytes_read = reader.read_line(&mut input).unwrap_or(0);
5126    let input = input.trim();
5127    if bytes_read == 0 || input.is_empty() || input.eq_ignore_ascii_case("n") {
5128        eprintln!("Skipping PID write-back.");
5129        return WriteBackSelection::Skipped(
5130            "skipped interactively (no selection made)".to_string(),
5131        );
5132    }
5133
5134    match input.parse::<usize>() {
5135        Ok(n) if n >= 1 && n <= results.len() => {
5136            let result = &results[n - 1];
5137            match result_write_back_error(result) {
5138                Some(detail) => {
5139                    eprintln!("Selected result is invalid; skipping PID write-back: {detail}");
5140                    WriteBackSelection::Failed(detail)
5141                }
5142                None => WriteBackSelection::Selected(result),
5143            }
5144        }
5145        _ => {
5146            eprintln!("Invalid selection; skipping PID write-back.");
5147            WriteBackSelection::Skipped("invalid response level selection".to_string())
5148        }
5149    }
5150}
5151
5152fn select_write_back_result<'a>(
5153    results: &'a [TuneResultRow],
5154    write_pid: Option<ResponseLevel>,
5155    output: OutputFormat,
5156    reader: &mut impl std::io::BufRead,
5157) -> WriteBackSelection<'a> {
5158    match write_pid {
5159        Some(level) => select_named_write_back_result(results, level, output),
5160        None if skips_interactive_prompt(write_pid, output) => WriteBackSelection::Skipped(
5161            "--output json was set without --write-pid; skipped the interactive \
5162             write-back prompt since there is no human present to answer it"
5163                .to_string(),
5164        ),
5165        None => select_interactive_write_back_result(results, reader),
5166    }
5167}
5168
5169fn finish_write_back(
5170    output: OutputFormat,
5171    response_level: ResponseLevel,
5172    outcome: PidWriteOutcome,
5173) -> (WriteBackOutcome, Option<String>) {
5174    match outcome {
5175        PidWriteOutcome::Written => {
5176            if prints_table_output(output) {
5177                println!("Wrote and confirmed {response_level:?} PID parameters.");
5178            }
5179            (WriteBackOutcome::Written { response_level }, None)
5180        }
5181        PidWriteOutcome::Failed { detail } => {
5182            if prints_table_output(output) {
5183                println!("PID write-back failed: {detail}");
5184            }
5185            (WriteBackOutcome::Failed, Some(detail))
5186        }
5187    }
5188}
5189
5190/// Writes back the calculated PID parameters for one response level -- chosen either
5191/// interactively (prompting on `reader`) or non-interactively via `write_pid`
5192/// (`--write-pid`; the caller has already validated `--yes` was also given before the tune
5193/// even started). Skips with an informational message (rather than prompting/writing)
5194/// whenever any of the three PID constant tags is unconfigured — true for the simulator
5195/// driver, and also a sane guard for any real template missing one — or when no results
5196/// were recorded at all. `reader` is injected (rather than reading `std::io::stdin()`
5197/// directly) so tests can supply a fixed `Cursor` in place of the process's real stdin; it
5198/// is never read from at all when `write_pid` is `Some`, or when `output` is
5199/// [`OutputFormat::Json`] (see below).
5200///
5201/// Pre-reads the existing constants, writes and verifies Proportional then Integral then
5202/// Derivative in sequence (stopping at the first failure), and rolls back whatever was
5203/// already confirmed if a later constant fails -- `safety-writeback-rollback` (finding 6).
5204/// Every attempt, including a pre-read failure or a transport error mid-write, produces
5205/// exactly one [`TuneWriteRow`] audit row.
5206///
5207/// `output` makes this function format-aware (`safety-json-contract`, finding 8):
5208///
5209/// - Under [`OutputFormat::Table`], status/result lines print with `println!` exactly as
5210///   before, and the interactive listing/menu print with `eprintln!` -- a prompt has no
5211///   business on stdout in *any* format, since a caller piping stdout elsewhere shouldn't
5212///   see it interleaved with the tune's actual result.
5213/// - Under [`OutputFormat::Json`], none of those `println!`s fire at all. The reason for
5214///   every `Skipped`/`Failed` outcome is instead returned as the second element of the
5215///   tuple -- a human-readable detail string -- so `print_summary`'s JSON branch can fold
5216///   it into the one JSON object this whole run must still emit on stdout. Without this,
5217///   the interactive prompt or a plain status line would print ahead of that object and
5218///   break `--output json` for every scripted/scheduled caller trying to parse stdout.
5219/// - When `output` is `Json` and `write_pid` is `None` (no response level was named
5220///   non-interactively), the interactive prompt is skipped entirely -- `reader` (real
5221///   stdin outside tests) is never touched, since there is no human present to answer it
5222///   and a scripted caller could otherwise hang waiting on input that will never arrive.
5223#[allow(clippy::too_many_arguments)]
5224async fn maybe_write_back(
5225    pool: &SqlitePool,
5226    run_id: i64,
5227    tags: &LoopTags,
5228    template: &DcsTemplate,
5229    driver: &dyn Driver,
5230    config: LoopConfig,
5231    write_pid: Option<ResponseLevel>,
5232    output: OutputFormat,
5233    allow_uncertain: bool,
5234    reader: &mut impl std::io::BufRead,
5235) -> anyhow::Result<(WriteBackOutcome, Option<String>)> {
5236    let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
5237        &tags.proportional_constant,
5238        &tags.integral_constant,
5239        &tags.derivative_constant,
5240    ) else {
5241        let detail = "no PID constant tags configured for this run's driver/template";
5242        if prints_table_output(output) {
5243            println!(
5244                "No PID constant tags configured for this run's driver/template; skipping write-back."
5245            );
5246        }
5247        return Ok((WriteBackOutcome::Skipped, Some(detail.to_string())));
5248    };
5249
5250    let results = TuneResultRow::list_for_run(pool, run_id).await?;
5251    if results.is_empty() {
5252        return Ok((
5253            WriteBackOutcome::Skipped,
5254            Some("no calculated results were recorded for this run".to_string()),
5255        ));
5256    }
5257
5258    let selected = match select_write_back_result(&results, write_pid, output, reader) {
5259        WriteBackSelection::Selected(result) => result,
5260        WriteBackSelection::Skipped(detail) => {
5261            return Ok((WriteBackOutcome::Skipped, Some(detail)));
5262        }
5263        WriteBackSelection::Failed(detail) => {
5264            return Ok((WriteBackOutcome::Failed, Some(detail)));
5265        }
5266    };
5267
5268    let pid = pid_parameters_for_result(selected)?;
5269    let response_level = pid.response_level;
5270    let written = opc_write_values(pid, config.controller_type, template.integral_type);
5271    let target = WriteReadback {
5272        proportional: written.proportional,
5273        integral: written.integral,
5274        derivative: written.derivative,
5275    };
5276
5277    let outcome = write_pid_values(
5278        pool,
5279        run_id,
5280        driver,
5281        p_tag,
5282        i_tag,
5283        d_tag,
5284        response_level,
5285        target,
5286        WriteKind::Write,
5287        allow_uncertain,
5288    )
5289    .await?;
5290
5291    Ok(finish_write_back(output, response_level, outcome))
5292}
5293
5294fn prints_table_output(output: OutputFormat) -> bool {
5295    matches!(output, OutputFormat::Table)
5296}
5297
5298fn skips_interactive_prompt(write_pid: Option<ResponseLevel>, output: OutputFormat) -> bool {
5299    write_pid.is_none() && matches!(output, OutputFormat::Json)
5300}
5301
5302#[cfg(test)]
5303mod tests {
5304    use super::*;
5305    use crate::args::{ControllerTypeArg, DirectionArg, ProcessTypeArg};
5306    use bhtune_db::models::{SamplingAdequacy, TemplateOrigin};
5307
5308    async fn seeded_pool() -> SqlitePool {
5309        let pool = bhtune_db::connect_in_memory().await.unwrap();
5310        bhtune_db::seed_builtin_templates(&pool, Utc::now())
5311            .await
5312            .unwrap();
5313        pool
5314    }
5315
5316    async fn start_opc_test_run(
5317        pool: &SqlitePool,
5318        name: &str,
5319    ) -> (i64, LoopConfig, DcsTemplate, LoopTags) {
5320        let mut args = fast_simulator_args();
5321        args.driver = DriverKindArg::Opcda;
5322        let config = build_loop_config(&args).unwrap();
5323        let template = honeywell_template();
5324        let tags = honeywell_tags();
5325        let run = TuneRunRow::start(
5326            pool,
5327            None,
5328            name,
5329            TuneDriver::Opcda,
5330            config,
5331            TemplateOrigin::Builtin,
5332            &template,
5333            &tags,
5334            Utc::now(),
5335        )
5336        .await
5337        .unwrap();
5338        (run.id, config, template, tags)
5339    }
5340
5341    /// Keep the shared execution tests fast now that timing values come from global
5342    /// configuration rather than per-run arguments.
5343    fn test_config() -> crate::config::BhtuneConfig {
5344        crate::config::BhtuneConfig {
5345            tuning: crate::config::TuningConfig {
5346                mrft_delay_secs: Some(0),
5347                poll_interval_ms: Some(5),
5348                timeout_secs: Some(5),
5349                op_timeout_secs: Some(30),
5350                restore_timeout_secs: Some(30),
5351            },
5352            ..crate::config::BhtuneConfig::default()
5353        }
5354    }
5355
5356    fn time_anchor_at(utc: DateTime<Utc>) -> RunTimeAnchor {
5357        RunTimeAnchor::from_parts(utc, tokio::time::Instant::now())
5358    }
5359
5360    fn timing_for_args(args: &TuneArgs) -> PollTimingAccumulator {
5361        let basis = match args.driver {
5362            DriverKindArg::Opcda => TimingBasis::LiveMonotonic,
5363            DriverKindArg::Simulator => TimingBasis::SimulatedFixedStep,
5364        };
5365        PollTimingAccumulator::new(basis, args.poll_interval_ms)
5366    }
5367
5368    #[test]
5369    fn simulator_driver_requires_every_fixed_range_and_direction_value() {
5370        let template = bhtune_core::built_in_templates().remove(0);
5371        for (field, clear) in [
5372            (
5373                "pv_range_high",
5374                (|args: &mut TuneArgs| args.pv_range_high = None) as fn(&mut TuneArgs),
5375            ),
5376            ("pv_range_low", |args: &mut TuneArgs| {
5377                args.pv_range_low = None
5378            }),
5379            ("mv_range_high", |args: &mut TuneArgs| {
5380                args.mv_range_high = None
5381            }),
5382            ("mv_range_low", |args: &mut TuneArgs| {
5383                args.mv_range_low = None
5384            }),
5385            ("direction", |args: &mut TuneArgs| args.direction = None),
5386        ] {
5387            let mut args = fast_simulator_args();
5388            clear(&mut args);
5389            let error = build_loop_tags(&args, &template).unwrap_err();
5390            assert!(error.to_string().contains(&field.replace('_', "-")));
5391        }
5392    }
5393
5394    #[test]
5395    fn restore_mv_errors_become_failed_restore_steps() {
5396        let outcome = restore_mv_outcome_or_failed(Err(anyhow::anyhow!("restore failed")));
5397
5398        assert!(matches!(
5399            outcome,
5400            RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(detail))
5401                if detail == "restore failed"
5402        ));
5403    }
5404
5405    #[test]
5406    fn json_summary_renderer_has_a_displayable_fallback_for_an_encoding_failure() {
5407        let rendered = render_json_summary(&serde_json::json!({"run_id": 1}), |_| {
5408            Err::<String, _>("injected encoding failure")
5409        });
5410        assert_eq!(rendered, r#"{"error": "injected encoding failure"}"#);
5411    }
5412
5413    fn valid_pid_result_row() -> TuneResultRow {
5414        TuneResultRow {
5415            id: 0,
5416            run_id: 1,
5417            response_level: ResponseLevel::Moderate,
5418            kp: Some(2.0),
5419            ti_minutes: Some(4.0),
5420            td_minutes: Some(0.5),
5421            proportional: Some(2.0),
5422            integral: Some(4.0),
5423            derivative: Some(0.5),
5424            status: TuningResultStatus::Valid,
5425            invalid_reason: None,
5426        }
5427    }
5428
5429    #[test]
5430    fn pid_result_validation_rejects_every_malformed_shape() {
5431        let mut result = valid_pid_result_row();
5432        result.status = TuningResultStatus::Invalid;
5433        result.invalid_reason =
5434            Some(bhtune_core::TuningResultInvalidReason::NonPositivePvAmplitude);
5435        let error = pid_parameters_for_result(&result).unwrap_err();
5436        assert!(error.to_string().contains("PV amplitude is not positive"));
5437
5438        let mut result = valid_pid_result_row();
5439        result.invalid_reason = Some(bhtune_core::TuningResultInvalidReason::NonFiniteKp);
5440        let error = pid_parameters_for_result(&result).unwrap_err();
5441        assert!(
5442            error
5443                .to_string()
5444                .contains("has an invalid reason despite being marked valid")
5445        );
5446
5447        let mut proportional_missing = valid_pid_result_row();
5448        proportional_missing.proportional = None;
5449        let mut integral_missing = valid_pid_result_row();
5450        integral_missing.integral = None;
5451        let mut derivative_missing = valid_pid_result_row();
5452        derivative_missing.derivative = None;
5453        for (result, expected) in [
5454            (proportional_missing, "missing its proportional value"),
5455            (integral_missing, "missing its integral value"),
5456            (derivative_missing, "missing its derivative value"),
5457        ] {
5458            assert!(
5459                pid_parameters_for_result(&result)
5460                    .unwrap_err()
5461                    .to_string()
5462                    .contains(expected)
5463            );
5464        }
5465
5466        let mut result = valid_pid_result_row();
5467        result.integral = Some(f32::NAN);
5468        let error = pid_parameters_for_result(&result).unwrap_err();
5469        assert!(error.to_string().contains("non-finite PID value"));
5470
5471        let valid = pid_parameters_for_result(&valid_pid_result_row()).unwrap();
5472        assert_eq!(valid.response_level, ResponseLevel::Moderate);
5473        assert_eq!(valid.proportional, 2.0);
5474    }
5475
5476    #[test]
5477    fn write_back_selection_rejects_invalid_results_in_named_and_interactive_paths() {
5478        let mut invalid = valid_pid_result_row();
5479        invalid.status = TuningResultStatus::Invalid;
5480        invalid.invalid_reason =
5481            Some(bhtune_core::TuningResultInvalidReason::NonPositivePvAmplitude);
5482        let results = vec![invalid];
5483
5484        assert!(matches!(
5485            select_named_write_back_result(&results, ResponseLevel::Moderate, OutputFormat::Table),
5486            WriteBackSelection::Failed(detail) if detail.contains("PV amplitude is not positive")
5487        ));
5488        assert!(matches!(
5489            select_named_write_back_result(&results, ResponseLevel::Moderate, OutputFormat::Json),
5490            WriteBackSelection::Failed(detail) if detail.contains("PV amplitude is not positive")
5491        ));
5492
5493        let mut reader = std::io::Cursor::new(b"1\n");
5494        assert!(matches!(
5495            select_interactive_write_back_result(&results, &mut reader),
5496            WriteBackSelection::Failed(detail) if detail.contains("PV amplitude is not positive")
5497        ));
5498    }
5499
5500    #[test]
5501    fn utc_after_elapsed_rejects_a_duration_outside_chrono_range() {
5502        let error = utc_after_elapsed(Utc::now(), Duration::MAX).unwrap_err();
5503
5504        assert!(
5505            error
5506                .to_string()
5507                .contains("MV command time exceeded chrono's range")
5508        );
5509    }
5510
5511    #[tokio::test]
5512    async fn relay_actuation_timestamp_conversion_error_is_propagated() {
5513        let pool = seeded_pool().await;
5514        let mut args = fast_simulator_args();
5515        args.driver = DriverKindArg::Opcda;
5516        let initial = sample_initial_state();
5517        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
5518        let now = Utc::now();
5519        let instant = Instant::now();
5520
5521        let error = record_relay_actuation(
5522            &mut tracker,
5523            &pool,
5524            0,
5525            55.0,
5526            now,
5527            instant,
5528            instant,
5529            Duration::MAX,
5530            instant,
5531            1.0,
5532        )
5533        .await
5534        .unwrap_err();
5535
5536        assert!(
5537            error
5538                .to_string()
5539                .contains("MV command time exceeded chrono's range")
5540        );
5541        assert!(tracker.pending.is_none());
5542    }
5543
5544    fn delayed_live_timing_metrics() -> TimingMetrics {
5545        TimingMetrics {
5546            basis: TimingBasis::LiveMonotonic,
5547            requested_interval_ms: 800,
5548            sample_gap_count: 2,
5549            mean_sample_gap_ms: Some(1_200.0),
5550            max_sample_gap_ms: Some(1_600.0),
5551            missed_poll_opportunity_count: 1,
5552            measured_oscillation_period_ms: None,
5553            approximate_samples_per_period: None,
5554            sampling_adequacy: SamplingAdequacy::NotAssessed,
5555            poll_latency: None,
5556        }
5557    }
5558
5559    /// A fast-converging simulator tune: proportionally scaled down from
5560    /// `bhtune-driver`'s own proven `FopdtConfig::new(1.0, 2.0, 5.0, 1.0)` E2E fixture (2
5561    /// ticks of lag, 5 ticks of dead time) so the whole test — which polls on a real
5562    /// `tokio::time::interval`, unlike that lower-level test's manually driven ticks —
5563    /// finishes in well under a second of real wall-clock time.
5564    fn fast_simulator_args() -> TuneArgs {
5565        TuneArgs {
5566            tagname: "ignored-for-simulator".to_string(),
5567            template: "Yokogawa CentumVP".to_string(),
5568            process_type: ProcessTypeArg::Flow,
5569            controller_type: ControllerTypeArg::Pi,
5570            relay_amp: 10.0,
5571            cycles_skip: Some(1),
5572            cycles_count: Some(2),
5573            noise_protection_secs: Some(0),
5574            mrft_delay: 0,
5575            driver: DriverKindArg::Simulator,
5576            bridge_host: None,
5577            server: None,
5578            sim_gain: 1.0,
5579            sim_tau: 0.01,
5580            sim_dead_time: 0.025,
5581            sim_noise: 0.0,
5582            sim_seed: 0,
5583            sim_initial_pv: 50.0,
5584            sim_initial_mv: 50.0,
5585            pv_range_high: Some(100.0),
5586            pv_range_low: Some(0.0),
5587            mv_range_high: Some(100.0),
5588            mv_range_low: Some(0.0),
5589            direction: Some(DirectionArg::Reverse),
5590            tag_overrides: None,
5591            poll_interval_ms: 5,
5592            // Keep ordinary tune tests bounded even when a mutation prevents the
5593            // simulator from completing. The dedicated timeout test overrides this.
5594            timeout_secs: 5,
5595            op_timeout_secs: 30,
5596            restore_timeout_secs: 30,
5597            notes: Some("test note".to_string()),
5598            yes: false,
5599            write_pid: None,
5600            output: OutputFormat::Table,
5601        }
5602    }
5603
5604    #[tokio::test]
5605    async fn a_full_simulator_tune_completes_and_persists_results() {
5606        let pool = seeded_pool().await;
5607        run(&pool, fast_simulator_args(), &test_config())
5608            .await
5609            .unwrap();
5610
5611        let runs = TuneRunRow::list(
5612            &pool,
5613            &bhtune_db::models::TuneRunFilter::default(),
5614            bhtune_db::models::Pagination::first(10),
5615        )
5616        .await
5617        .unwrap();
5618        assert_eq!(runs.len(), 1);
5619        assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Completed);
5620        assert_eq!(runs[0].loop_name, "ignored-for-simulator");
5621        assert_eq!(runs[0].notes.as_deref(), Some("test note"));
5622        assert!(runs[0].initial_readings.is_some());
5623        let timing = runs[0]
5624            .timing_metrics
5625            .expect("completed simulator run should record timing diagnostics");
5626        assert_eq!(timing.basis, TimingBasis::SimulatedFixedStep);
5627        assert_eq!(timing.requested_interval_ms, 5);
5628        assert!(timing.sample_gap_count > 0);
5629        assert_eq!(timing.mean_sample_gap_ms, Some(5.0));
5630        assert_eq!(timing.max_sample_gap_ms, Some(5.0));
5631        assert_eq!(timing.missed_poll_opportunity_count, 0);
5632        assert!(
5633            timing
5634                .measured_oscillation_period_ms
5635                .is_some_and(|period| period > 0.0)
5636        );
5637        assert!(
5638            timing
5639                .approximate_samples_per_period
5640                .is_some_and(|samples| samples > 1.0)
5641        );
5642
5643        // A simulator run has no OPC DA connection at all -- `db-run-request-snapshot`
5644        // requires both to be `None` here regardless of whatever `--bridge-host` default
5645        // `prepare` resolved internally, since the driver never actually contacted a
5646        // gateway.
5647        assert_eq!(runs[0].opc_server, None);
5648        assert_eq!(runs[0].bridge_host, None);
5649
5650        // The submitted request is snapshotted verbatim (pre-resolution), so a field the
5651        // test left unset (`server`) stays absent/null rather than showing a resolved
5652        // default.
5653        let request: serde_json::Value = serde_json::from_str(&runs[0].request_json).unwrap();
5654        assert_eq!(request["tagname"], "ignored-for-simulator");
5655        assert_eq!(request["driver"], "simulator");
5656        assert_eq!(request["server"], serde_json::Value::Null);
5657        assert_eq!(request["notes"], "test note");
5658        for timing_field in [
5659            "mrft_delay",
5660            "mrft_delay_secs",
5661            "poll_interval_ms",
5662            "timeout_secs",
5663            "op_timeout_secs",
5664            "restore_timeout_secs",
5665        ] {
5666            assert!(
5667                request.get(timing_field).is_none(),
5668                "{timing_field} must not be part of the per-run request snapshot"
5669            );
5670        }
5671        assert_eq!(
5672            runs[0].effective_tuning,
5673            Some(EffectiveTuning {
5674                mrft_delay_secs: 0,
5675                poll_interval_ms: 5,
5676                timeout_secs: 5,
5677                op_timeout_secs: 30,
5678                restore_timeout_secs: 30,
5679            })
5680        );
5681        assert!(
5682            TuneMvActuationRow::list_for_run(&pool, runs[0].id)
5683                .await
5684                .unwrap()
5685                .is_empty(),
5686            "simulator runs must not create OPC DA MV actuation audit rows"
5687        );
5688
5689        let results = TuneResultRow::list_for_run(&pool, runs[0].id)
5690            .await
5691            .unwrap();
5692        assert_eq!(results.len(), 3);
5693
5694        let samples = TuneSampleRow::list_for_run(&pool, runs[0].id)
5695            .await
5696            .unwrap();
5697        assert!(!samples.is_empty());
5698
5699        // The simulator driver has no PID constant tags, so write-back must have been
5700        // skipped entirely rather than hanging on stdin.
5701        let writes = TuneWriteRow::list_for_run(&pool, runs[0].id).await.unwrap();
5702        assert!(writes.is_empty());
5703    }
5704
5705    #[tokio::test]
5706    async fn prepared_tune_run_id_matches_the_persisted_run_id() {
5707        let pool = seeded_pool().await;
5708        let first = prepare(&pool, fast_simulator_args(), &test_config())
5709            .await
5710            .unwrap();
5711        let second = prepare(&pool, fast_simulator_args(), &test_config())
5712            .await
5713            .unwrap();
5714
5715        assert!(first.run_id() > 0);
5716        assert_eq!(second.run_id(), first.run_id() + 1);
5717    }
5718
5719    #[tokio::test]
5720    async fn prepare_rejects_invalid_tag_overrides_before_creating_a_run() {
5721        let pool = seeded_pool().await;
5722        let mut args = fast_simulator_args();
5723        args.tag_overrides = Some(TagOverrides {
5724            process_variable: Some("bad\0tag".to_string()),
5725            ..TagOverrides::default()
5726        });
5727
5728        let result = prepare(&pool, args, &test_config()).await;
5729        assert!(result.is_err());
5730        let err = result.err().unwrap();
5731        assert!(err.to_string().contains("process_variable"));
5732        assert!(
5733            TuneRunRow::list(
5734                &pool,
5735                &bhtune_db::models::TuneRunFilter::default(),
5736                bhtune_db::models::Pagination::first(10),
5737            )
5738            .await
5739            .unwrap()
5740            .is_empty()
5741        );
5742    }
5743
5744    #[tokio::test]
5745    async fn prepare_owned_rejects_a_non_simulator_before_creating_a_run() {
5746        let pool = seeded_pool().await;
5747        let mut args = fast_simulator_args();
5748        args.driver = DriverKindArg::Opcda;
5749
5750        let error = prepare_owned(&pool, args, &test_config(), 1)
5751            .await
5752            .err()
5753            .expect("demo preparation must reject a non-simulator driver");
5754
5755        assert!(
5756            error
5757                .to_string()
5758                .contains("demo sessions may only start simulator runs")
5759        );
5760        assert!(
5761            TuneRunRow::list(
5762                &pool,
5763                &bhtune_db::models::TuneRunFilter::default(),
5764                bhtune_db::models::Pagination::first(10),
5765            )
5766            .await
5767            .unwrap()
5768            .is_empty(),
5769            "the demo driver guard must run before the tune_runs insert"
5770        );
5771    }
5772
5773    #[tokio::test]
5774    async fn drive_completes_a_prepared_simulator_run() {
5775        let pool = seeded_pool().await;
5776        let prepared = prepare(&pool, fast_simulator_args(), &test_config())
5777            .await
5778            .unwrap();
5779        let run_id = prepared.run_id();
5780
5781        let outcome = drive(&pool, prepared, &mut CtrlC::never()).await.unwrap();
5782
5783        assert_eq!(outcome, TuneOutcome::Completed);
5784        assert_eq!(
5785            TuneRunRow::get(&pool, run_id)
5786                .await
5787                .unwrap()
5788                .unwrap()
5789                .outcome,
5790            bhtune_db::models::TuneOutcome::Completed
5791        );
5792    }
5793
5794    #[tokio::test]
5795    async fn drive_marks_a_prepared_run_failed_when_execution_errors() {
5796        let pool = seeded_pool().await;
5797        let mut prepared = prepare(&pool, fast_simulator_args(), &test_config())
5798            .await
5799            .unwrap();
5800        let run_id = prepared.run_id();
5801        prepared.driver = Box::new(MockDriver::default().empty_read(SIMULATOR_PV_TAG));
5802
5803        let err = drive(&pool, prepared, &mut CtrlC::never())
5804            .await
5805            .unwrap_err();
5806
5807        assert!(err.to_string().contains("no value"));
5808        let run = TuneRunRow::get(&pool, run_id).await.unwrap().unwrap();
5809        assert_eq!(run.outcome, bhtune_db::models::TuneOutcome::Failed);
5810        assert!(run.failure_reason.is_some());
5811    }
5812
5813    /// Every range/direction override is CLI-supplied below, so `read_initial_values` never
5814    /// reads them from the driver; the mock only ever needs to answer for `pv_ini`/`mv_ini`
5815    /// and (for the Yokogawa template) has no mode/mode-attribute tags to read either. Fails
5816    /// starting at the 2nd `read` RPC call — after the one batched setup read — so the
5817    /// failure always lands on the first polling tick's PV read, deep inside
5818    /// `run_polling_loop`, not during setup. Also covers `db-run-request-snapshot`'s opcda
5819    /// path: `prepare` records the resolved connection and the request snapshot before the
5820    /// polling loop ever runs, so both must already be persisted on the row even though this
5821    /// run goes on to fail.
5822    #[tokio::test]
5823    async fn run_with_opcda_driver_fails_mid_poll_and_marks_the_run_failed() {
5824        use crate::test_support::{MockBridgeService, start_mock_server};
5825        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue, WriteResponse};
5826
5827        let (host, server) = start_mock_server(
5828            MockBridgeService {
5829                read_response: ReadResponse {
5830                    values: vec![ProtoTagValue {
5831                        tag_id: "ignored".to_string(),
5832                        value: "50".to_string(),
5833                        quality: "Good".to_string(),
5834                        timestamp: "2024-01-15 10:23:45".to_string(),
5835                    }],
5836                },
5837                write_response: WriteResponse {
5838                    tag_id: "ignored".to_string(),
5839                    success: true,
5840                    error: None,
5841                },
5842                ..Default::default()
5843            }
5844            .failing_read_from_call(2),
5845        )
5846        .await;
5847
5848        let pool = seeded_pool().await;
5849        let mut args = fast_simulator_args();
5850        args.driver = DriverKindArg::Opcda;
5851        args.tagname = "Unit1.LIC101.PV".to_string();
5852        args.bridge_host = Some(host.clone());
5853        args.server = Some("MockServer".to_string());
5854
5855        let err = run(&pool, args, &test_config()).await.unwrap_err();
5856        assert!(err.to_string().contains("driver operation failed"));
5857
5858        let runs = TuneRunRow::list(
5859            &pool,
5860            &bhtune_db::models::TuneRunFilter::default(),
5861            bhtune_db::models::Pagination::first(10),
5862        )
5863        .await
5864        .unwrap();
5865        assert_eq!(runs.len(), 1);
5866        assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Failed);
5867        assert_eq!(runs[0].driver, bhtune_db::models::TuneDriver::Opcda);
5868        assert!(
5869            runs[0]
5870                .failure_reason
5871                .as_deref()
5872                .unwrap()
5873                .contains("driver operation failed")
5874        );
5875
5876        // `record_connection` runs inside `prepare`, before the polling loop that goes on
5877        // to fail -- so the resolved connection and the submitted request must already be
5878        // persisted even though the run itself never completes (`db-run-request-snapshot`).
5879        assert_eq!(runs[0].opc_server.as_deref(), Some("MockServer"));
5880        assert_eq!(runs[0].bridge_host.as_deref(), Some(host.as_str()));
5881        let request: serde_json::Value = serde_json::from_str(&runs[0].request_json).unwrap();
5882        assert_eq!(request["tagname"], "Unit1.LIC101.PV");
5883        assert_eq!(request["driver"], "opcda");
5884        assert_eq!(request["server"], "MockServer");
5885
5886        server.shutdown().await;
5887    }
5888
5889    /// Proves `run()` actually resolves `bridge_host`/`server` from `app_config` (not just
5890    /// from `TuneArgs`) by leaving both CLI-facing fields unset and supplying them only via
5891    /// the config -- if resolution didn't happen, `driver::build` would either fail fast
5892    /// with "no OPC server specified" (server never resolved) or try to dial
5893    /// `DEFAULT_BRIDGE_HOST` instead of the mock (bridge_host never resolved), producing a
5894    /// different failure than the one asserted below. The mock is configured to fail
5895    /// starting on its very first `read` call so this stays a fast, deterministic setup
5896    /// failure -- there is no wall-clock timeout in `run_polling_loop` yet (that lands in
5897    /// `cli-safety`), so a config-resolution bug that instead let the run reach a real
5898    /// polling loop against a frozen PV value would hang this test forever rather than
5899    /// fail cleanly.
5900    #[tokio::test]
5901    async fn run_resolves_bridge_host_and_server_from_config_when_cli_flags_are_unset() {
5902        use crate::test_support::{MockBridgeService, start_mock_server};
5903        use opcda_bridge_proto::bridge::{ReadResponse, TagValue as ProtoTagValue};
5904
5905        let (host, server) = start_mock_server(
5906            MockBridgeService {
5907                read_response: ReadResponse {
5908                    values: vec![ProtoTagValue {
5909                        tag_id: "ignored".to_string(),
5910                        value: "50".to_string(),
5911                        quality: "Good".to_string(),
5912                        timestamp: "2024-01-15 10:23:45".to_string(),
5913                    }],
5914                },
5915                ..Default::default()
5916            }
5917            .failing_read_from_call(1),
5918        )
5919        .await;
5920
5921        let pool = seeded_pool().await;
5922        let mut args = fast_simulator_args();
5923        args.driver = DriverKindArg::Opcda;
5924        args.tagname = "Unit1.LIC101.PV".to_string();
5925        args.bridge_host = None;
5926        args.server = None;
5927
5928        let app_config = crate::config::BhtuneConfig {
5929            bridge_host: Some(host),
5930            server: Some("MockServer".to_string()),
5931            ..Default::default()
5932        };
5933
5934        // A "driver operation failed" error (rather than "no OPC server specified" or a
5935        // connection error against the unresolved default host) proves setup got as far as
5936        // issuing a real RPC against the config-resolved mock server.
5937        let err = run(&pool, args, &app_config).await.unwrap_err();
5938        assert!(err.to_string().contains("driver operation failed"));
5939
5940        server.shutdown().await;
5941    }
5942
5943    #[tokio::test]
5944    async fn run_errors_when_opcda_server_is_unset_in_both_cli_and_config() {
5945        let pool = seeded_pool().await;
5946        let mut args = fast_simulator_args();
5947        args.driver = DriverKindArg::Opcda;
5948        args.server = None;
5949
5950        let err = run(&pool, args, &test_config()).await.unwrap_err();
5951        assert!(err.to_string().contains("no OPC server specified"));
5952    }
5953
5954    /// `[tuning].mrft_delay_secs` is whole seconds (the smallest non-zero value costs ~1s of logical
5955    /// simulator time both before switching and after completion). Fixed-step timestamps make
5956    /// the result deterministic, but the polling interval still paces those ticks in real time;
5957    /// the SQLite-backed test cannot use Tokio's paused clock without also expiring sqlx's own
5958    /// pool timers.
5959    #[tokio::test]
5960    async fn mrft_delay_pads_the_run_with_extra_recorded_samples() {
5961        let pool = seeded_pool().await;
5962        let args = fast_simulator_args();
5963        // This test intentionally consumes about two seconds before ordinary MRFT work. Keep
5964        // its safety budget independent of a loaded CI host while retaining the real timeout.
5965        let mut config = test_config();
5966        config.tuning.mrft_delay_secs = Some(1);
5967        config.tuning.timeout_secs = Some(30);
5968        run(&pool, args, &config).await.unwrap();
5969
5970        let runs = TuneRunRow::list(
5971            &pool,
5972            &bhtune_db::models::TuneRunFilter::default(),
5973            bhtune_db::models::Pagination::first(10),
5974        )
5975        .await
5976        .unwrap();
5977        assert_eq!(runs.len(), 1);
5978        assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Completed);
5979
5980        // ~1s of pre-test padding plus ~1s of post-test padding at a 5ms poll interval is on
5981        // the order of 400 padding ticks alone, dwarfing the handful of ticks the actual
5982        // (near-instant) MRFT switching test itself takes -- so a generous lower bound
5983        // safely distinguishes "padding samples were recorded" from "they weren't".
5984        let samples = TuneSampleRow::list_for_run(&pool, runs[0].id)
5985            .await
5986            .unwrap();
5987        assert!(samples.len() > 100);
5988    }
5989
5990    #[tokio::test]
5991    async fn mrft_delay_keeps_the_engine_idle_during_pre_test_padding() {
5992        let pool = seeded_pool().await;
5993        let template = honeywell_template();
5994        let tags = honeywell_tags();
5995        let mut args = fast_simulator_args();
5996        args.mrft_delay = 1;
5997        args.cycles_count = Some(1_000);
5998        let config = build_loop_config(&args).unwrap();
5999        let started_at = Utc::now();
6000        let run = TuneRunRow::start(
6001            &pool,
6002            None,
6003            "pre-delay",
6004            TuneDriver::Opcda,
6005            config,
6006            TemplateOrigin::Builtin,
6007            &template,
6008            &tags,
6009            started_at,
6010        )
6011        .await
6012        .unwrap();
6013        let driver = honeywell_driver_auto();
6014        let initial = sample_initial_state();
6015        let mut engine = MrftEngine::new(
6016            config,
6017            initial.direction,
6018            lookup(
6019                config.process_type,
6020                config.controller_type,
6021                ResponseLevel::Aggressive,
6022            )
6023            .beta,
6024            InitialReadings {
6025                pv_ini: initial.pv_ini,
6026                mv_ini: initial.mv_ini,
6027                mv_range_low: initial.mv_range_low,
6028                mv_range_high: initial.mv_range_high,
6029            },
6030            started_at,
6031            MrftCompat::default(),
6032        );
6033        let (mut ctrl_c, tx) = CtrlC::test_pair();
6034        tokio::spawn(async move {
6035            tokio::time::sleep(Duration::from_millis(50)).await;
6036            let _ = tx.send(1);
6037        });
6038
6039        let mut timing = timing_for_args(&args);
6040        let outcome = run_polling_loop(
6041            &pool,
6042            run.id,
6043            &args,
6044            &tags,
6045            &driver,
6046            &mut engine,
6047            time_anchor_at(started_at),
6048            &mut ctrl_c,
6049            &mut MutationGuard::default(),
6050            true,
6051            &mut timing,
6052            &mut None,
6053            build_loop_config(&args).unwrap(),
6054        )
6055        .await
6056        .unwrap();
6057
6058        assert!(matches!(
6059            outcome,
6060            PollOutcome::Aborted(AbortReason::UserInterrupt)
6061        ));
6062        assert!(
6063            !TuneSampleRow::list_for_run(&pool, run.id)
6064                .await
6065                .unwrap()
6066                .is_empty()
6067        );
6068        assert!(
6069            driver.write_log().is_empty(),
6070            "MRFT writes must not occur during pre-test padding"
6071        );
6072    }
6073
6074    #[tokio::test]
6075    async fn unknown_template_is_a_clean_error() {
6076        let pool = seeded_pool().await;
6077        let mut args = fast_simulator_args();
6078        args.template = "Does Not Exist".to_string();
6079        let err = run(&pool, args, &test_config()).await.unwrap_err();
6080        assert!(err.to_string().contains("Does Not Exist"));
6081    }
6082
6083    #[test]
6084    fn build_loop_config_rejects_pid_for_a_non_temperature_process_type() {
6085        let mut args = fast_simulator_args();
6086        args.controller_type = ControllerTypeArg::Pid;
6087        args.process_type = ProcessTypeArg::Flow;
6088        let err = build_loop_config(&args).unwrap_err();
6089        assert!(err.to_string().contains("Pid"));
6090    }
6091
6092    #[test]
6093    fn build_loop_config_uses_process_type_defaults_when_unset() {
6094        let mut args = fast_simulator_args();
6095        args.cycles_skip = None;
6096        args.cycles_count = None;
6097        args.noise_protection_secs = None;
6098        let config = build_loop_config(&args).unwrap();
6099        assert_eq!(
6100            config.num_cycles_skip,
6101            ProcessType::Flow.default_cycles_skip()
6102        );
6103        assert_eq!(
6104            config.num_cycles_count,
6105            ProcessType::Flow.default_cycles_test()
6106        );
6107        assert_eq!(
6108            config.noise_protection_secs,
6109            ProcessType::Flow.default_noise_protection_secs()
6110        );
6111    }
6112
6113    #[test]
6114    fn build_loop_config_rejects_an_out_of_range_relay_amp_before_any_driver_or_db_io() {
6115        // Mirrors the `--write-pid`-requires-`--yes` fail-fast precedent: a bad
6116        // `--relay-amp` (including a leftover legacy debug-code magic number like 2014) must
6117        // be caught by `LoopConfig::validate` here, at construction time, not discovered
6118        // later against a live driver.
6119        let mut args = fast_simulator_args();
6120        args.relay_amp = 2014.0;
6121        let err = build_loop_config(&args).unwrap_err();
6122        assert!(err.to_string().contains("relay amplitude 2014"));
6123        assert!(err.to_string().contains("out of range"));
6124    }
6125
6126    #[test]
6127    fn build_loop_config_rejects_a_relay_amp_below_the_minimum() {
6128        let mut args = fast_simulator_args();
6129        args.relay_amp = 0.0;
6130        let err = build_loop_config(&args).unwrap_err();
6131        assert!(err.to_string().contains("out of range"));
6132    }
6133
6134    /// The reproduced panic: `--cycles-count 0` used to reach
6135    /// `bhtune_core::measure_oscillation`'s internal `assert!` and panic mid-run, after the
6136    /// loop had already been switched to manual and stroked. `LoopConfig::validate` (called
6137    /// from `build_loop_config`, before any driver or DB I/O) must reject it cleanly
6138    /// instead. The clap-level `positive_u32` parser (see `args.rs`) also rejects `0` for
6139    /// this flag before it ever reaches here, but this test exercises the model-level
6140    /// guarantee directly, independent of how the value arrived.
6141    #[test]
6142    fn build_loop_config_rejects_zero_cycles_count_before_any_driver_or_db_io() {
6143        let mut args = fast_simulator_args();
6144        args.cycles_count = Some(0);
6145        let err = build_loop_config(&args).unwrap_err();
6146        assert!(err.to_string().contains("at least 1"));
6147    }
6148
6149    #[test]
6150    fn build_loop_tags_simulator_requires_all_overrides() {
6151        let template = bhtune_core::built_in_templates().remove(0);
6152        let mut args = fast_simulator_args();
6153        args.pv_range_high = None;
6154        let err = build_loop_tags(&args, &template).unwrap_err();
6155        assert!(err.to_string().contains("--pv-range-high"));
6156    }
6157
6158    #[test]
6159    fn build_loop_tags_simulator_requires_every_override_individually() {
6160        // Each of the 4 remaining mandatory simulator overrides has its own `ok_or_else`
6161        // error message; clearing exactly one at a time (rather than just the first, as
6162        // above) exercises each closure and confirms the flag name in every message.
6163        type ClearFn = fn(&mut TuneArgs);
6164        let template = bhtune_core::built_in_templates().remove(0);
6165        let cases: &[(&str, ClearFn)] = &[
6166            ("--pv-range-low", |a| a.pv_range_low = None),
6167            ("--mv-range-high", |a| a.mv_range_high = None),
6168            ("--mv-range-low", |a| a.mv_range_low = None),
6169            ("--direction", |a| a.direction = None),
6170        ];
6171        for (flag, clear) in cases {
6172            let mut args = fast_simulator_args();
6173            clear(&mut args);
6174            let err = build_loop_tags(&args, &template).unwrap_err();
6175            assert!(
6176                err.to_string().contains(flag),
6177                "expected error for missing {flag}, got: {err}"
6178            );
6179        }
6180    }
6181
6182    #[test]
6183    fn build_loop_tags_simulator_uses_fixed_tag_names() {
6184        let template = bhtune_core::built_in_templates().remove(0);
6185        let args = fast_simulator_args();
6186        let tags = build_loop_tags(&args, &template).unwrap();
6187        assert_eq!(tags.process_variable, SIMULATOR_PV_TAG);
6188        assert_eq!(tags.manipulated_variable, SIMULATOR_MV_TAG);
6189        assert!(tags.controller_mode.is_none());
6190        assert!(tags.proportional_constant.is_none());
6191    }
6192
6193    #[test]
6194    fn build_loop_tags_opcda_derives_and_applies_overrides() {
6195        let template = bhtune_core::built_in_templates().remove(0);
6196        let mut args = fast_simulator_args();
6197        args.driver = DriverKindArg::Opcda;
6198        args.tagname = "Unit1.LIC101.PV".to_string();
6199        args.direction = Some(DirectionArg::Direct);
6200        args.tag_overrides = Some(TagOverrides {
6201            manipulated_variable: Some("Unit1.LIC101.PY".to_string()),
6202            proportional_constant: Some("Unit1.LIC101.PB".to_string()),
6203            ..TagOverrides::default()
6204        });
6205        let tags = build_loop_tags(&args, &template).unwrap();
6206        assert!(tags.process_variable.starts_with("Unit1.LIC101"));
6207        assert_eq!(tags.manipulated_variable, "Unit1.LIC101.PY");
6208        assert_eq!(
6209            tags.proportional_constant,
6210            Some("Unit1.LIC101.PB".to_string())
6211        );
6212        assert_eq!(
6213            tags.controller_direction,
6214            TagOrValue::Value(ControllerDirection::Direct)
6215        );
6216        assert_eq!(tags.upper_pv_range, TagOrValue::Value(100.0));
6217    }
6218
6219    #[tokio::test]
6220    async fn a_ctrl_c_style_abort_restores_and_records_aborted() {
6221        // Exercises the DB/restore shape of an abort directly -- calling `restore` +
6222        // `TuneRunRow::abort` exactly as the real Ctrl+C path does -- rather than going
6223        // through a full `run_polling_loop`/`run_with_ctrl_c` cycle. `CtrlC::test_pair()` can
6224        // and does fake a real signal end to end elsewhere (see
6225        // `a_stalled_mv_write_during_a_tick_is_cancelled_and_still_records_the_sample` and
6226        // `run_with_ctrl_c_aborts_the_run_when_signalled_during_the_poll`, both further down
6227        // in this module); this test is kept as a narrower, cheaper check of just the
6228        // resulting database row shape.
6229        let pool = seeded_pool().await;
6230        let template = bhtune_core::built_in_templates().remove(0);
6231        let args = fast_simulator_args();
6232        let config = build_loop_config(&args).unwrap();
6233        let tags = build_loop_tags(&args, &template).unwrap();
6234        let driver = crate::driver::build(&args).await.unwrap();
6235
6236        let started_at = Utc::now();
6237        let run = TuneRunRow::start(
6238            &pool,
6239            None,
6240            "abort-test",
6241            TuneDriver::Simulator,
6242            config,
6243            TemplateOrigin::Builtin,
6244            &template,
6245            &tags,
6246            started_at,
6247        )
6248        .await
6249        .unwrap();
6250
6251        let initial = read_initial_values(driver.as_ref(), &tags, &template, false)
6252            .await
6253            .unwrap();
6254        let mut guard = MutationGuard::default();
6255        transition_to_manual(driver.as_ref(), &tags, &template, &initial, &mut guard)
6256            .await
6257            .unwrap();
6258        let report = restore(driver.as_ref(), &tags, &template, &initial, &guard).await;
6259        assert!(report.all_succeeded());
6260        TuneRunRow::abort(&pool, run.id, Utc::now()).await.unwrap();
6261
6262        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
6263        assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
6264    }
6265
6266    /// Unlike Ctrl+C/timeout (which need a real signal or elapsed wall-clock time and so are
6267    /// only exercised indirectly, see the test above), finding 5's `PoorQuality` abort is
6268    /// purely data-driven -- the driver just has to report a non-`Good` reading -- so this
6269    /// test drives `run_polling_loop` for real and checks its returned `PollOutcome`
6270    /// directly, then confirms `restore` leaves the loop in the same consistent state
6271    /// `execute`'s `Aborted` branch would.
6272    #[tokio::test]
6273    async fn poor_quality_pv_during_polling_aborts_records_the_sample_and_restores() {
6274        let pool = seeded_pool().await;
6275        let template = honeywell_template();
6276        let tags = honeywell_tags();
6277        let driver = honeywell_driver_auto()
6278            .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
6279        let args = fast_simulator_args();
6280        let config = build_loop_config(&args).unwrap();
6281
6282        let started_at = Utc::now();
6283        let run = TuneRunRow::start(
6284            &pool,
6285            None,
6286            "poor-quality-poll",
6287            TuneDriver::Opcda,
6288            config,
6289            TemplateOrigin::Builtin,
6290            &template,
6291            &tags,
6292            started_at,
6293        )
6294        .await
6295        .unwrap();
6296
6297        // Built directly (bypassing `read_initial_values`) using `honeywell_driver_auto()`'s
6298        // own fixture values (see `sample_initial_state`, defined below), because
6299        // `read_initial_values` itself enforces finding 5 on this very same PV tag and would
6300        // hard-fail before ever reaching the polling loop this test targets.
6301        let initial = sample_initial_state();
6302        let beta = lookup(
6303            config.process_type,
6304            config.controller_type,
6305            ResponseLevel::Aggressive,
6306        )
6307        .beta;
6308        let mut engine = MrftEngine::new(
6309            config,
6310            initial.direction,
6311            beta,
6312            InitialReadings {
6313                pv_ini: initial.pv_ini,
6314                mv_ini: initial.mv_ini,
6315                mv_range_low: initial.mv_range_low,
6316                mv_range_high: initial.mv_range_high,
6317            },
6318            started_at,
6319            MrftCompat::default(),
6320        );
6321
6322        let mut timing = timing_for_args(&args);
6323        let outcome = run_polling_loop(
6324            &pool,
6325            run.id,
6326            &args,
6327            &tags,
6328            &driver,
6329            &mut engine,
6330            time_anchor_at(started_at),
6331            &mut CtrlC::never(),
6332            &mut MutationGuard::default(),
6333            false,
6334            &mut timing,
6335            &mut None,
6336            build_loop_config(&args).unwrap(),
6337        )
6338        .await
6339        .unwrap();
6340
6341        assert!(matches!(
6342            outcome,
6343            PollOutcome::Aborted(AbortReason::PoorQuality { ref tag, quality })
6344                if tag == &tags.process_variable && quality == bhtune_driver::Quality::Bad
6345        ));
6346
6347        // The triggering sample was recorded (with its real, poor quality) before the abort
6348        // -- finding 5 explicitly requires the operator can see exactly what was seen when
6349        // the run gave up, not just that it gave up.
6350        let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6351        assert_eq!(samples.len(), 1);
6352        assert_eq!(samples[0].pv_quality, SampleQuality::Bad);
6353
6354        // Same DB-shape check the Ctrl+C-style test above uses: a `PoorQuality` abort must
6355        // be indistinguishable in its cleanup guarantees from any other abort reason. Uses a
6356        // bare default `MutationGuard` -- `transition_to_manual` was never called on this
6357        // path -- which is exactly what proves `restore`'s MV-revert step is unconditional
6358        // and never gated by the guard: it still succeeds and writes the MV back even though
6359        // `guard.mv_written` is `false`, while the guard-gated mode/setpoint/mode-attribute
6360        // steps correctly report `NotNeeded` since nothing was ever attempted for them.
6361        let guard = MutationGuard::default();
6362        let report = restore(&driver, &tags, &template, &initial, &guard).await;
6363        assert_eq!(report.mv, RestoreStepOutcome::Succeeded);
6364        assert_eq!(report.mode, RestoreStepOutcome::NotNeeded);
6365        assert_eq!(report.setpoint, RestoreStepOutcome::NotNeeded);
6366        assert_eq!(report.mode_attribute, RestoreStepOutcome::NotNeeded);
6367        TuneRunRow::abort(&pool, run.id, Utc::now()).await.unwrap();
6368
6369        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
6370        assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
6371
6372        // The MV was actually written back to its initial value during restore.
6373        assert!(
6374            driver
6375                .write_log()
6376                .iter()
6377                .any(|(tag, value)| tag == &tags.manipulated_variable && value == "45")
6378        );
6379    }
6380
6381    #[tokio::test]
6382    async fn live_sample_timestamp_includes_driver_read_delay() {
6383        let pool = seeded_pool().await;
6384        let template = honeywell_template();
6385        let tags = honeywell_tags();
6386        let driver = honeywell_driver_auto()
6387            .delaying_read(&tags.process_variable, Duration::from_millis(50))
6388            .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
6389        let mut args = fast_simulator_args();
6390        args.driver = DriverKindArg::Opcda;
6391        let config = build_loop_config(&args).unwrap();
6392        let started_at = DateTime::UNIX_EPOCH;
6393        let time_anchor = time_anchor_at(started_at);
6394        let run = TuneRunRow::start(
6395            &pool,
6396            None,
6397            "delayed-live-read",
6398            TuneDriver::Opcda,
6399            config,
6400            TemplateOrigin::Builtin,
6401            &template,
6402            &tags,
6403            started_at,
6404        )
6405        .await
6406        .unwrap();
6407        let initial = sample_initial_state();
6408        let mut engine = MrftEngine::new(
6409            config,
6410            initial.direction,
6411            lookup(
6412                config.process_type,
6413                config.controller_type,
6414                ResponseLevel::Aggressive,
6415            )
6416            .beta,
6417            InitialReadings {
6418                pv_ini: initial.pv_ini,
6419                mv_ini: initial.mv_ini,
6420                mv_range_low: initial.mv_range_low,
6421                mv_range_high: initial.mv_range_high,
6422            },
6423            started_at,
6424            MrftCompat::default(),
6425        );
6426
6427        let mut timing = timing_for_args(&args);
6428        let outcome = run_polling_loop(
6429            &pool,
6430            run.id,
6431            &args,
6432            &tags,
6433            &driver,
6434            &mut engine,
6435            time_anchor,
6436            &mut CtrlC::never(),
6437            &mut MutationGuard::default(),
6438            false,
6439            &mut timing,
6440            &mut None,
6441            build_loop_config(&args).unwrap(),
6442        )
6443        .await
6444        .unwrap();
6445
6446        assert!(matches!(
6447            outcome,
6448            PollOutcome::Aborted(AbortReason::PoorQuality { .. })
6449        ));
6450        let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6451        assert_eq!(samples.len(), 1);
6452        assert!(
6453            samples[0].sample.time - started_at >= chrono::Duration::milliseconds(45),
6454            "live sample timestamp should include the driver's 50 ms read delay"
6455        );
6456    }
6457
6458    // --- safety-cancellation: `[tuning].op_timeout_secs` / mid-tick Ctrl+C via `bounded_driver_call`
6459
6460    /// Proves the wiring, not just the mechanism (see the dedicated `bounded_driver_call`
6461    /// unit tests below for that): a PV read that never resolves at all -- the gateway is
6462    /// down, DCOM is wedged, the network is black-holed -- must abort the run via
6463    /// `[tuning].op_timeout_secs` rather than hang the poll loop forever, exactly the scenario
6464    /// finding 2 of the live-plant safety review names as the most severe of the three
6465    /// consequences of the pre-`safety-cancellation` design. Real (unpaused) time, paying a
6466    /// real ~1s wall-clock cost: `start_paused` interacts badly with the real sqlx
6467    /// `SqlitePool` this test also creates (it fast-forwards the pool's own internal
6468    /// connection-acquire timeout too), matching the documented precedent in
6469    /// `run_times_out_and_aborts_when_timeout_secs_elapses_before_completion` below.
6470    #[tokio::test]
6471    async fn a_stalled_pv_read_aborts_the_poll_loop_via_op_timeout_secs() {
6472        let pool = seeded_pool().await;
6473        let template = honeywell_template();
6474        let tags = honeywell_tags();
6475        let driver = honeywell_driver_auto().hanging_read(&tags.process_variable);
6476        let mut args = fast_simulator_args();
6477        args.op_timeout_secs = 1;
6478        let config = build_loop_config(&args).unwrap();
6479
6480        let started_at = Utc::now();
6481        let run = TuneRunRow::start(
6482            &pool,
6483            None,
6484            "stalled-pv-read",
6485            TuneDriver::Opcda,
6486            config,
6487            TemplateOrigin::Builtin,
6488            &template,
6489            &tags,
6490            started_at,
6491        )
6492        .await
6493        .unwrap();
6494
6495        let initial = sample_initial_state();
6496        let beta = lookup(
6497            config.process_type,
6498            config.controller_type,
6499            ResponseLevel::Aggressive,
6500        )
6501        .beta;
6502        let mut engine = MrftEngine::new(
6503            config,
6504            initial.direction,
6505            beta,
6506            InitialReadings {
6507                pv_ini: initial.pv_ini,
6508                mv_ini: initial.mv_ini,
6509                mv_range_low: initial.mv_range_low,
6510                mv_range_high: initial.mv_range_high,
6511            },
6512            started_at,
6513            MrftCompat::default(),
6514        );
6515
6516        let mut timing = timing_for_args(&args);
6517        let outcome = run_polling_loop(
6518            &pool,
6519            run.id,
6520            &args,
6521            &tags,
6522            &driver,
6523            &mut engine,
6524            time_anchor_at(started_at),
6525            &mut CtrlC::never(),
6526            &mut MutationGuard::default(),
6527            false,
6528            &mut timing,
6529            &mut None,
6530            build_loop_config(&args).unwrap(),
6531        )
6532        .await
6533        .unwrap();
6534
6535        assert!(matches!(
6536            outcome,
6537            PollOutcome::Aborted(AbortReason::OperationTimedOut {
6538                ref tag,
6539                op_timeout_secs,
6540            }) if tag == &tags.process_variable && op_timeout_secs == 1
6541        ));
6542
6543        // Unlike the poor-quality/mid-write-cancellation cases, the PV read itself is what
6544        // stalled -- there is no valid tick/sample to record for this iteration at all.
6545        let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6546        assert!(samples.is_empty());
6547    }
6548
6549    /// The write-side counterpart of the read-stall test above, and also the one integration
6550    /// test exercising a mid-tick Ctrl+C (as opposed to the pre-existing idle-between-ticks
6551    /// coverage in `a_ctrl_c_style_abort_restores_and_records_aborted`): the PV read for tick
6552    /// 1 succeeds normally (so a real `tick`/`sample_quality` is in hand), the engine's very
6553    /// first `step` call always emits `Action::WriteMv` (see `MrftEngine::switch_is_needed`:
6554    /// `hysteresis` is still zero and `counter_all_switches == 0` on tick 1), and that write
6555    /// then hangs forever via `hanging_write`. A background task -- standing in for a human
6556    /// pressing Ctrl+C mid-write, which a unit test can't do with a real signal without
6557    /// hitting every other concurrently running test (see `tests/ctrlc_abort.rs`'s doc
6558    /// comment) -- sends the cancellation a short real delay later. Deliberately *not*
6559    /// `start_paused`: the delay has to be observed as "genuinely still in flight" by a
6560    /// concurrently running task, which paused virtual time (where nothing advances until
6561    /// every task is parked) can't model as naturally as a small real sleep.
6562    #[tokio::test]
6563    async fn a_stalled_mv_write_during_a_tick_is_cancelled_and_still_records_the_sample() {
6564        let pool = seeded_pool().await;
6565        let template = honeywell_template();
6566        let tags = honeywell_tags();
6567        let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
6568        let mut args = fast_simulator_args();
6569        // Large enough that the op-timeout branch never wins the race against the
6570        // background task's much shorter real delay below.
6571        args.op_timeout_secs = 30;
6572        let config = build_loop_config(&args).unwrap();
6573
6574        let started_at = Utc::now();
6575        let run = TuneRunRow::start(
6576            &pool,
6577            None,
6578            "stalled-mv-write",
6579            TuneDriver::Opcda,
6580            config,
6581            TemplateOrigin::Builtin,
6582            &template,
6583            &tags,
6584            started_at,
6585        )
6586        .await
6587        .unwrap();
6588
6589        let initial = sample_initial_state();
6590        let beta = lookup(
6591            config.process_type,
6592            config.controller_type,
6593            ResponseLevel::Aggressive,
6594        )
6595        .beta;
6596        let mut engine = MrftEngine::new(
6597            config,
6598            initial.direction,
6599            beta,
6600            InitialReadings {
6601                pv_ini: initial.pv_ini,
6602                mv_ini: initial.mv_ini,
6603                mv_range_low: initial.mv_range_low,
6604                mv_range_high: initial.mv_range_high,
6605            },
6606            started_at,
6607            MrftCompat::default(),
6608        );
6609
6610        let (mut ctrl_c, tx) = CtrlC::test_pair();
6611        tokio::spawn(async move {
6612            tokio::time::sleep(Duration::from_millis(50)).await;
6613            let _ = tx.send(1);
6614        });
6615
6616        let mut timing = timing_for_args(&args);
6617        let outcome = run_polling_loop(
6618            &pool,
6619            run.id,
6620            &args,
6621            &tags,
6622            &driver,
6623            &mut engine,
6624            time_anchor_at(started_at),
6625            &mut ctrl_c,
6626            &mut MutationGuard::default(),
6627            true,
6628            &mut timing,
6629            &mut None,
6630            build_loop_config(&args).unwrap(),
6631        )
6632        .await
6633        .unwrap();
6634
6635        assert!(matches!(
6636            outcome,
6637            PollOutcome::Aborted(AbortReason::UserInterrupt)
6638        ));
6639
6640        // Unlike the read-stall case, a valid sample for this tick was already in hand
6641        // before the write was attempted, so it must still be recorded before aborting.
6642        let samples = TuneSampleRow::list_for_run(&pool, run.id).await.unwrap();
6643        assert_eq!(samples.len(), 1);
6644
6645        // The hung write never actually completed -- the loop was left at its relay-test MV,
6646        // matching `warn_restore_incomplete`'s premise for why the operator warning names
6647        // the MV specifically.
6648        assert!(driver.write_log().is_empty());
6649    }
6650
6651    // --- opcda-style mode-transition/restore/write-back coverage ---------------------------
6652    //
6653    // The tests above all use the simulator driver, whose `LoopTags` has no
6654    // setpoint/mode/mode-attribute/PID-constant tags at all (see `build_loop_tags`), so they
6655    // never exercise `transition_to_manual`/`restore`/`maybe_write_back`'s real opcda-style
6656    // logic. `MockDriver` below is a minimal in-memory `Driver` double with a configurable
6657    // tag/value map, used together with the real "Honeywell Experion" built-in template
6658    // (which has every optional tag suffix configured) to drive that logic directly.
6659
6660    /// A minimal, fully in-memory [`Driver`] test double with a fixed tag-value map, plus
6661    /// the ability to inject specific-tag read/write failures. `std::sync::Mutex`, not
6662    /// `tokio::sync::Mutex` — matching `SimulatorDriver`'s own precedent — since no
6663    /// `.await` point is ever held across the lock.
6664    #[derive(Default)]
6665    struct MockDriver {
6666        values: std::sync::Mutex<std::collections::HashMap<String, String>>,
6667        read_sequences:
6668            std::sync::Mutex<std::collections::HashMap<String, std::collections::VecDeque<String>>>,
6669        read_batches: std::sync::Mutex<Vec<Vec<String>>>,
6670        reverse_read_results: bool,
6671        writes: std::sync::Mutex<Vec<(String, String)>>,
6672        reject_writes: std::collections::HashSet<String>,
6673        error_reads: std::collections::HashSet<String>,
6674        error_writes: std::collections::HashSet<String>,
6675        empty_reads: std::collections::HashSet<String>,
6676        /// Tags whose `read`/`write` never resolves (`.await`s `std::future::pending`
6677        /// forever), simulating a stalled OPC DA call (gateway down, DCOM wedged, network
6678        /// black-holed) so `[tuning].op_timeout_secs`/Ctrl+C-during-a-tick can actually be exercised.
6679        /// Finite delays are configured separately below; both delay forms await before any
6680        /// mutex guard is acquired.
6681        hang_reads: std::collections::HashSet<String>,
6682        hang_writes: std::collections::HashSet<String>,
6683        /// Per-tag finite read latency, used to prove that live monotonic sample timestamps
6684        /// include the time spent awaiting the driver rather than being captured before it.
6685        read_delays: std::collections::HashMap<String, Duration>,
6686        /// Tags whose configured finite read delay was cancelled before it completed.
6687        cancelled_delayed_reads: std::sync::Mutex<std::collections::HashSet<String>>,
6688        /// Per-tag finite write latency, used to exercise restore-budget behavior when the
6689        /// authoritative MV write is accepted near the initial restore deadline.
6690        write_delays: std::collections::HashMap<String, Duration>,
6691        /// Tags whose configured finite write delay was cancelled before it completed.
6692        cancelled_delayed_writes: std::sync::Mutex<std::collections::HashSet<String>>,
6693        /// Per-tag OPC quality override, defaulting to `Quality::Good` for any tag not
6694        /// listed -- matching a healthy real driver and letting most tests ignore quality
6695        /// entirely while a handful exercise finding 5's enforcement via `with_quality`.
6696        qualities: std::sync::Mutex<std::collections::HashMap<String, bhtune_driver::Quality>>,
6697        /// Per-tag: reports the tag's ordinarily-configured quality (from `qualities`,
6698        /// defaulting to `Good`) for the tag's first `usize` reads, then switches to the
6699        /// paired [`bhtune_driver::Quality`] for every read after that. Lets a test put a
6700        /// tag's *initial* read (before any mutation is attempted, subject to finding 5 the
6701        /// same as every other read) in good standing while still forcing quality to
6702        /// degrade partway through polling -- deterministically, with no reliance on real
6703        /// elapsed time or a Ctrl+C race, unlike `[tuning].timeout_secs`/
6704        /// `[tuning].op_timeout_secs`-driven
6705        /// aborts.
6706        degrade_quality_after: std::collections::HashMap<String, (usize, bhtune_driver::Quality)>,
6707        /// Tracks how many times each tag has been read so far, for
6708        /// `degrade_quality_after`/`erroring_read_after`.
6709        read_counts: std::sync::Mutex<std::collections::HashMap<String, usize>>,
6710        /// Per-tag: the tag's first `usize` reads resolve normally, then every read after
6711        /// that returns a transport-level error -- the same "succeeds at first, degrades
6712        /// partway through" shape as `degrade_quality_after`, but a hard read error rather
6713        /// than a quality downgrade, so `safety-writeback-rollback`'s pre-read-succeeds/
6714        /// verify-readback-errors path can be exercised distinctly from the
6715        /// verify-readback-reports-poor-quality path.
6716        error_reads_after: std::collections::HashMap<String, usize>,
6717        /// Tags whose float writes are silently perturbed by a fixed offset before being
6718        /// stored, simulating a DCS that clamps/rounds a written value rather than accepting
6719        /// it exactly -- lets a test exercise `pid_value_within_tolerance`'s rejection path
6720        /// deterministically.
6721        write_offsets: std::collections::HashMap<String, f32>,
6722        /// Per-tag prefix of float writes to perturb before later writes resume normal
6723        /// behavior, allowing an unconfirmed relay command followed by a healthy restore.
6724        prefix_write_offsets: std::collections::HashMap<String, (usize, f32)>,
6725        /// Per-tag: the tag's first `usize` writes are accepted normally, then every write
6726        /// after that is rejected (mirrors `reject_writes`'s `WriteOutcome::failure`, not a
6727        /// transport error). Lets a test make a constant's *forward* write succeed while its
6728        /// later *rollback* write (the same tag, written a second time with the previous
6729        /// value) is rejected -- exercising `rollback_state = Failed`.
6730        reject_writes_after: std::collections::HashMap<String, usize>,
6731        /// Tracks how many times each tag has been written so far, for
6732        /// `reject_writes_after`.
6733        write_counts: std::sync::Mutex<std::collections::HashMap<String, usize>>,
6734    }
6735
6736    impl MockDriver {
6737        fn new(values: &[(&str, &str)]) -> MockDriver {
6738            MockDriver {
6739                values: std::sync::Mutex::new(
6740                    values
6741                        .iter()
6742                        .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
6743                        .collect(),
6744                ),
6745                ..Default::default()
6746            }
6747        }
6748
6749        fn rejecting_write(mut self, tag: &str) -> MockDriver {
6750            self.reject_writes.insert(tag.to_string());
6751            self
6752        }
6753
6754        fn erroring_read(mut self, tag: &str) -> MockDriver {
6755            self.error_reads.insert(tag.to_string());
6756            self
6757        }
6758
6759        fn erroring_write(mut self, tag: &str) -> MockDriver {
6760            self.error_writes.insert(tag.to_string());
6761            self
6762        }
6763
6764        fn empty_read(mut self, tag: &str) -> MockDriver {
6765            self.empty_reads.insert(tag.to_string());
6766            self
6767        }
6768
6769        /// Makes reading `tag` hang forever -- see the `hang_reads` field doc comment.
6770        fn hanging_read(mut self, tag: &str) -> MockDriver {
6771            self.hang_reads.insert(tag.to_string());
6772            self
6773        }
6774
6775        /// Makes writing `tag` hang forever -- see the `hang_writes` field doc comment.
6776        fn hanging_write(mut self, tag: &str) -> MockDriver {
6777            self.hang_writes.insert(tag.to_string());
6778            self
6779        }
6780
6781        fn delaying_read(mut self, tag: &str, delay: Duration) -> MockDriver {
6782            self.read_delays.insert(tag.to_string(), delay);
6783            self
6784        }
6785
6786        fn delaying_write(mut self, tag: &str, delay: Duration) -> MockDriver {
6787            self.write_delays.insert(tag.to_string(), delay);
6788            self
6789        }
6790
6791        /// Returns multi-tag reads in reverse order, proving callers map responses by tag
6792        /// rather than relying on the driver's request-order convention.
6793        fn reversing_read_results(mut self) -> MockDriver {
6794            self.reverse_read_results = true;
6795            self
6796        }
6797
6798        async fn apply_read_delay(&self, tags: &[String]) {
6799            if let Some(delay) = tags
6800                .iter()
6801                .filter_map(|tag| self.read_delays.get(tag))
6802                .max()
6803            {
6804                let delayed_tags = tags
6805                    .iter()
6806                    .filter(|tag| self.read_delays.contains_key(*tag))
6807                    .cloned()
6808                    .collect();
6809                let mut observer = DelayedReadObserver {
6810                    driver: self,
6811                    tags: delayed_tags,
6812                    completed: false,
6813                };
6814                tokio::time::sleep(*delay).await;
6815                observer.completed = true;
6816            }
6817        }
6818
6819        async fn apply_write_delay(&self, tag: &str) {
6820            let Some(delay) = self.write_delays.get(tag).copied() else {
6821                return;
6822            };
6823            let mut observer = DelayedWriteObserver {
6824                driver: self,
6825                tag: tag.to_string(),
6826                completed: false,
6827            };
6828            tokio::time::sleep(delay).await;
6829            observer.completed = true;
6830        }
6831
6832        /// Overrides a single tag's fixture value -- e.g. to make an otherwise-valid
6833        /// baseline driver (like `honeywell_driver_auto()`) report one bad reading.
6834        fn with_value(self, tag: &str, value: &str) -> MockDriver {
6835            self.values
6836                .lock()
6837                .unwrap()
6838                .insert(tag.to_string(), value.to_string());
6839            self
6840        }
6841
6842        fn with_read_sequence(self, tag: &str, values: &[&str]) -> MockDriver {
6843            self.read_sequences.lock().unwrap().insert(
6844                tag.to_string(),
6845                values.iter().map(|value| (*value).to_string()).collect(),
6846            );
6847            self
6848        }
6849
6850        /// Overrides a single tag's reported [`bhtune_driver::Quality`] -- every other tag
6851        /// keeps reporting `Quality::Good`, matching a healthy real driver.
6852        fn with_quality(self, tag: &str, quality: bhtune_driver::Quality) -> MockDriver {
6853            self.qualities
6854                .lock()
6855                .unwrap()
6856                .insert(tag.to_string(), quality);
6857            self
6858        }
6859
6860        /// See the `degrade_quality_after` field doc comment: `tag`'s first `good_reads`
6861        /// reads keep reporting `Good` (or whatever `with_quality` set), then every read
6862        /// after that reports `degraded` instead.
6863        fn degrade_quality_after(
6864            mut self,
6865            tag: &str,
6866            good_reads: usize,
6867            degraded: bhtune_driver::Quality,
6868        ) -> MockDriver {
6869            self.degrade_quality_after
6870                .insert(tag.to_string(), (good_reads, degraded));
6871            self
6872        }
6873
6874        /// See the `error_reads_after` field doc comment: `tag`'s first `good_reads` reads
6875        /// succeed normally, then every read after that returns a transport-level error.
6876        fn erroring_read_after(mut self, tag: &str, good_reads: usize) -> MockDriver {
6877            self.error_reads_after.insert(tag.to_string(), good_reads);
6878            self
6879        }
6880
6881        /// See the `write_offsets` field doc comment: writing a float to `tag` silently
6882        /// stores `value + offset` instead of `value`, so a subsequent readback observes a
6883        /// value that differs from what was requested.
6884        fn distorting_write(mut self, tag: &str, offset: f32) -> MockDriver {
6885            self.write_offsets.insert(tag.to_string(), offset);
6886            self
6887        }
6888
6889        fn distorting_first_writes(
6890            mut self,
6891            tag: &str,
6892            write_count: usize,
6893            offset: f32,
6894        ) -> MockDriver {
6895            self.prefix_write_offsets
6896                .insert(tag.to_string(), (write_count, offset));
6897            self
6898        }
6899
6900        /// See the `reject_writes_after` field doc comment: `tag`'s first `good_writes`
6901        /// writes are accepted normally, then every write after that is rejected.
6902        fn rejecting_write_after(mut self, tag: &str, good_writes: usize) -> MockDriver {
6903            self.reject_writes_after
6904                .insert(tag.to_string(), good_writes);
6905            self
6906        }
6907
6908        fn value_of(&self, tag: &str) -> Option<String> {
6909            self.values.lock().unwrap().get(tag).cloned()
6910        }
6911
6912        fn write_log(&self) -> Vec<(String, String)> {
6913            self.writes.lock().unwrap().clone()
6914        }
6915
6916        fn read_batches(&self) -> Vec<Vec<String>> {
6917            self.read_batches.lock().unwrap().clone()
6918        }
6919
6920        fn next_read_count(&self, tag: &str) -> usize {
6921            let mut counts = self.read_counts.lock().unwrap();
6922            let count = counts.entry(tag.to_string()).or_insert(0);
6923            *count += 1;
6924            *count
6925        }
6926
6927        fn quality_for_read(&self, tag: &str, count: usize) -> bhtune_driver::Quality {
6928            let baseline_quality = self
6929                .qualities
6930                .lock()
6931                .unwrap()
6932                .get(tag)
6933                .copied()
6934                .unwrap_or(bhtune_driver::Quality::Good);
6935            self.degrade_quality_after.get(tag).map_or(
6936                baseline_quality,
6937                |(good_reads, degraded)| {
6938                    if count > *good_reads {
6939                        *degraded
6940                    } else {
6941                        baseline_quality
6942                    }
6943                },
6944            )
6945        }
6946
6947        fn read_tag(
6948            &self,
6949            tag: &str,
6950            store: &std::collections::HashMap<String, String>,
6951            sequences: &mut std::collections::HashMap<String, std::collections::VecDeque<String>>,
6952        ) -> bhtune_driver::DriverResult<Option<bhtune_driver::TagValue>> {
6953            if self.error_reads.contains(tag) {
6954                return Err(bhtune_driver::DriverError::Operation(Box::new(
6955                    std::io::Error::other("mock read error"),
6956                )));
6957            }
6958            if self.empty_reads.contains(tag) {
6959                return Ok(None);
6960            }
6961
6962            let count = self.next_read_count(tag);
6963            if self
6964                .error_reads_after
6965                .get(tag)
6966                .is_some_and(|good_reads| count > *good_reads)
6967            {
6968                return Err(bhtune_driver::DriverError::Operation(Box::new(
6969                    std::io::Error::other("mock read error after good reads"),
6970                )));
6971            }
6972
6973            let quality = self.quality_for_read(tag, count);
6974            let value = sequences
6975                .get_mut(tag)
6976                .and_then(std::collections::VecDeque::pop_front)
6977                .or_else(|| store.get(tag).cloned())
6978                .unwrap_or_default();
6979            Ok(Some(bhtune_driver::TagValue {
6980                tag: tag.to_string(),
6981                value,
6982                quality,
6983                timestamp: None,
6984            }))
6985        }
6986
6987        fn delayed_read_was_cancelled(&self, tag: &str) -> bool {
6988            self.cancelled_delayed_reads.lock().unwrap().contains(tag)
6989        }
6990
6991        fn delayed_write_was_cancelled(&self, tag: &str) -> bool {
6992            self.cancelled_delayed_writes.lock().unwrap().contains(tag)
6993        }
6994    }
6995
6996    struct DelayedReadObserver<'a> {
6997        driver: &'a MockDriver,
6998        tags: Vec<String>,
6999        completed: bool,
7000    }
7001
7002    impl Drop for DelayedReadObserver<'_> {
7003        fn drop(&mut self) {
7004            if !self.completed {
7005                self.driver
7006                    .cancelled_delayed_reads
7007                    .lock()
7008                    .unwrap()
7009                    .extend(self.tags.iter().cloned());
7010            }
7011        }
7012    }
7013
7014    struct DelayedWriteObserver<'a> {
7015        driver: &'a MockDriver,
7016        tag: String,
7017        completed: bool,
7018    }
7019
7020    impl Drop for DelayedWriteObserver<'_> {
7021        fn drop(&mut self) {
7022            if !self.completed {
7023                self.driver
7024                    .cancelled_delayed_writes
7025                    .lock()
7026                    .unwrap()
7027                    .insert(self.tag.clone());
7028            }
7029        }
7030    }
7031
7032    #[test]
7033    fn delayed_write_observer_records_cancellation_only_when_incomplete() {
7034        let driver = MockDriver::default();
7035        {
7036            let _observer = DelayedWriteObserver {
7037                driver: &driver,
7038                tag: "MV".into(),
7039                completed: false,
7040            };
7041        }
7042        assert!(driver.delayed_write_was_cancelled("MV"));
7043
7044        {
7045            let _observer = DelayedWriteObserver {
7046                driver: &driver,
7047                tag: "completed".into(),
7048                completed: true,
7049            };
7050        }
7051        assert!(!driver.delayed_write_was_cancelled("completed"));
7052    }
7053
7054    #[async_trait::async_trait]
7055    impl Driver for MockDriver {
7056        async fn read(
7057            &self,
7058            tags: &[String],
7059        ) -> bhtune_driver::DriverResult<Vec<bhtune_driver::TagValue>> {
7060            self.read_batches.lock().unwrap().push(tags.to_vec());
7061            if tags.iter().any(|tag| self.hang_reads.contains(tag)) {
7062                std::future::pending::<()>().await;
7063            }
7064            self.apply_read_delay(tags).await;
7065            let store = self.values.lock().unwrap();
7066            let mut sequences = self.read_sequences.lock().unwrap();
7067            let mut out = Vec::new();
7068            for tag in tags {
7069                if let Some(value) = self.read_tag(tag, &store, &mut sequences)? {
7070                    out.push(value);
7071                }
7072            }
7073            if self.reverse_read_results {
7074                out.reverse();
7075            }
7076            Ok(out)
7077        }
7078
7079        async fn write(
7080            &self,
7081            tag: &String,
7082            value: TagWrite,
7083        ) -> bhtune_driver::DriverResult<bhtune_driver::WriteOutcome> {
7084            if self.hang_writes.contains(tag) {
7085                std::future::pending::<()>().await;
7086            }
7087            self.apply_write_delay(tag).await;
7088            if self.error_writes.contains(tag) {
7089                return Err(bhtune_driver::DriverError::Operation(Box::new(
7090                    std::io::Error::other("mock write error"),
7091                )));
7092            }
7093            let text = match &value {
7094                TagWrite::Float(f) => f.to_string(),
7095                TagWrite::Raw(s) => s.clone(),
7096            };
7097            self.writes
7098                .lock()
7099                .unwrap()
7100                .push((tag.clone(), text.clone()));
7101            let write_count = {
7102                let mut counts = self.write_counts.lock().unwrap();
7103                let count = counts.entry(tag.clone()).or_insert(0);
7104                *count += 1;
7105                *count
7106            };
7107            if self.reject_writes.contains(tag) {
7108                return Ok(bhtune_driver::WriteOutcome::failure("mock rejected write"));
7109            }
7110            if let Some(good_writes) = self.reject_writes_after.get(tag)
7111                && write_count > *good_writes
7112            {
7113                return Ok(bhtune_driver::WriteOutcome::failure(
7114                    "mock rejected write after good writes",
7115                ));
7116            }
7117            // Store the (possibly silently distorted) value that a subsequent read would
7118            // observe, while `writes` above kept a log of what was actually requested -- see
7119            // the `write_offsets` field doc comment.
7120            let stored = if let TagWrite::Float(f) = value {
7121                let prefix_offset = self
7122                    .prefix_write_offsets
7123                    .get(tag)
7124                    .filter(|(prefix, _)| write_count <= *prefix)
7125                    .map_or(0.0, |(_, offset)| *offset);
7126                (f + self
7127                    .write_offsets
7128                    .get(tag)
7129                    .copied()
7130                    .unwrap_or(prefix_offset))
7131                .to_string()
7132            } else {
7133                text
7134            };
7135            self.values.lock().unwrap().insert(tag.clone(), stored);
7136            Ok(bhtune_driver::WriteOutcome::success())
7137        }
7138
7139        async fn browse(
7140            &self,
7141            _request: bhtune_driver::BrowsePageRequest,
7142        ) -> bhtune_driver::DriverResult<bhtune_driver::BrowsePage> {
7143            Err(bhtune_driver::DriverError::Unsupported {
7144                operation: "browse",
7145            })
7146        }
7147    }
7148
7149    #[tokio::test]
7150    async fn mock_driver_browse_is_unsupported() {
7151        // `tune`'s own logic never calls `Driver::browse` -- this only exists so
7152        // `MockDriver` satisfies the trait -- but it should still honor the same
7153        // "unsupported, not a panic" convention real drivers document for it.
7154        let err = MockDriver::new(&[])
7155            .browse(bhtune_driver::BrowsePageRequest::root(20))
7156            .await
7157            .unwrap_err();
7158        assert!(matches!(
7159            err,
7160            bhtune_driver::DriverError::Unsupported {
7161                operation: "browse"
7162            }
7163        ));
7164    }
7165
7166    #[test]
7167    fn relay_actuation_tolerance_uses_span_allowance_and_step_cap() {
7168        let uncapped = mv_actuation_tolerance(MvActuationKind::Relay, 60.0, 50.0, 100.0).unwrap();
7169        assert!(uncapped > 0.1);
7170        assert!(uncapped < 0.101);
7171
7172        let capped = mv_actuation_tolerance(MvActuationKind::Relay, 50.2, 50.0, 100.0).unwrap();
7173        assert!((capped - 0.05).abs() < 1e-6);
7174
7175        let restore = mv_actuation_tolerance(MvActuationKind::Restore, 50.0, 50.2, 100.0).unwrap();
7176        assert!(restore > capped);
7177    }
7178
7179    #[test]
7180    fn relay_actuation_tolerance_rejects_a_step_below_the_f32_floor() {
7181        let error =
7182            mv_actuation_tolerance(MvActuationKind::Relay, 50.0 + f32::EPSILON, 50.0, 100.0)
7183                .unwrap_err();
7184        assert!(error.to_string().contains("too small to verify safely"));
7185        assert!(mv_actuation_tolerance(MvActuationKind::Relay, 50.005, 50.0, 100.0).is_err());
7186    }
7187
7188    #[test]
7189    fn relay_actuation_tolerance_rejects_large_magnitude_step_below_precision_floor() {
7190        let previous = 100_000_000.0_f32;
7191        let target = previous + 8.0;
7192        assert_eq!(target - previous, 8.0);
7193        let error =
7194            mv_actuation_tolerance(MvActuationKind::Relay, target, previous, 100.0).unwrap_err();
7195        assert!(error.to_string().contains("too small to verify safely"));
7196    }
7197
7198    fn pending_actuation(
7199        id: Option<i64>,
7200        kind: MvActuationKind,
7201        target: f32,
7202        first_check_at: Instant,
7203        deadline: Instant,
7204        last_readback: Option<f32>,
7205    ) -> PendingMvActuation {
7206        let now = Instant::now();
7207        PendingMvActuation {
7208            id,
7209            kind,
7210            target,
7211            tolerance: 0.1,
7212            switch_tick: Utc::now(),
7213            switch_instant: now,
7214            accepted_instant: now,
7215            first_check_at,
7216            deadline,
7217            last_readback,
7218        }
7219    }
7220
7221    fn tracker_with_pending(pending: PendingMvActuation) -> MvActuationTracker {
7222        MvActuationTracker {
7223            next_sequence: 1,
7224            previous_commanded_mv: 55.0,
7225            confirmed_mv: None,
7226            pending: Some(pending),
7227            mv_span: 100.0,
7228        }
7229    }
7230
7231    fn batched_mv_value(tag: &str, value: &str, quality: bhtune_driver::Quality) -> TagValue {
7232        TagValue {
7233            tag: tag.to_string(),
7234            value: value.to_string(),
7235            quality,
7236            timestamp: None,
7237        }
7238    }
7239
7240    fn pending_poll_test_state() -> (
7241        SqlitePool,
7242        EffectiveTiming,
7243        MvActuationTracker,
7244        PollTimingAccumulator,
7245    ) {
7246        let args = {
7247            let mut args = fast_simulator_args();
7248            args.driver = DriverKindArg::Opcda;
7249            args
7250        };
7251        let now = Instant::now();
7252        let pending = pending_actuation(
7253            None,
7254            MvActuationKind::Relay,
7255            55.0,
7256            now,
7257            now + Duration::from_secs(10),
7258            None,
7259        );
7260        (
7261            SqlitePool::connect_lazy("sqlite::memory:").unwrap(),
7262            test_effective_timing(&args),
7263            tracker_with_pending(pending),
7264            timing_for_args(&args),
7265        )
7266    }
7267
7268    #[tokio::test]
7269    async fn resolve_pending_mv_poll_reports_missing_mv_data() {
7270        let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7271        let error = resolve_pending_mv_poll(
7272            &pool,
7273            effective_timing,
7274            TickOperation::Completed(HashMap::new()),
7275            "Unit1.LIC101.OP",
7276            Utc::now(),
7277            Instant::now(),
7278            Duration::from_millis(1),
7279            false,
7280            &mut tracker,
7281            &mut timing,
7282        )
7283        .await
7284        .unwrap_err();
7285
7286        assert!(error.to_string().contains("no value for tag"));
7287        assert!(tracker.pending.is_none());
7288    }
7289
7290    #[tokio::test]
7291    async fn resolve_pending_mv_poll_reports_malformed_mv_data() {
7292        let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7293        let values = HashMap::from([(
7294            "Unit1.LIC101.OP".to_string(),
7295            batched_mv_value(
7296                "Unit1.LIC101.OP",
7297                "not-a-number",
7298                bhtune_driver::Quality::Good,
7299            ),
7300        )]);
7301        let error = resolve_pending_mv_poll(
7302            &pool,
7303            effective_timing,
7304            TickOperation::Completed(values),
7305            "Unit1.LIC101.OP",
7306            Utc::now(),
7307            Instant::now(),
7308            Duration::from_millis(1),
7309            false,
7310            &mut tracker,
7311            &mut timing,
7312        )
7313        .await
7314        .unwrap_err();
7315
7316        assert!(error.to_string().contains("not a number"));
7317        assert!(tracker.pending.is_none());
7318    }
7319
7320    #[tokio::test]
7321    async fn resolve_pending_mv_poll_preserves_a_cancelled_operation() {
7322        let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7323        let (reason, provided_evidence) = resolve_pending_mv_poll(
7324            &pool,
7325            effective_timing,
7326            TickOperation::Cancelled,
7327            "Unit1.LIC101.OP",
7328            Utc::now(),
7329            Instant::now(),
7330            Duration::from_millis(1),
7331            false,
7332            &mut tracker,
7333            &mut timing,
7334        )
7335        .await
7336        .unwrap();
7337
7338        assert_eq!(reason, Some(AbortReason::UserInterrupt));
7339        assert!(!provided_evidence);
7340        assert!(tracker.pending.is_none());
7341    }
7342
7343    #[tokio::test]
7344    async fn resolve_pending_mv_poll_preserves_a_timed_out_operation() {
7345        let (pool, effective_timing, mut tracker, mut timing) = pending_poll_test_state();
7346        let (reason, provided_evidence) = resolve_pending_mv_poll(
7347            &pool,
7348            effective_timing,
7349            TickOperation::TimedOut,
7350            "Unit1.LIC101.OP",
7351            Utc::now(),
7352            Instant::now(),
7353            Duration::from_millis(1),
7354            false,
7355            &mut tracker,
7356            &mut timing,
7357        )
7358        .await
7359        .unwrap();
7360
7361        assert!(matches!(
7362            reason,
7363            Some(AbortReason::OperationTimedOut {
7364                tag,
7365                op_timeout_secs: 30,
7366            }) if tag == "Unit1.LIC101.OP"
7367        ));
7368        assert!(!provided_evidence);
7369        assert!(tracker.pending.is_none());
7370    }
7371
7372    #[test]
7373    fn mv_actuation_abort_format_falls_back_for_other_abort_reasons() {
7374        assert_eq!(
7375            format_mv_actuation_abort_reason(&AbortReason::UserInterrupt),
7376            "UserInterrupt"
7377        );
7378    }
7379
7380    #[tokio::test]
7381    async fn audit_helpers_skip_rows_without_an_audit_id_or_pending_actuation() {
7382        let pool = seeded_pool().await;
7383        let now = Instant::now();
7384        let pending = pending_actuation(
7385            None,
7386            MvActuationKind::Restore,
7387            45.0,
7388            now,
7389            now + Duration::from_secs(1),
7390            None,
7391        );
7392
7393        assert_eq!(
7394            record_actuation_observation(
7395                &pool,
7396                &pending,
7397                Utc::now(),
7398                Some(45.0),
7399                Some(SampleQuality::Good),
7400                ActuationAuditPolicy::Required,
7401            )
7402            .await
7403            .unwrap(),
7404            None
7405        );
7406        assert_eq!(
7407            record_final_actuation_observation(
7408                &pool,
7409                &pending,
7410                Utc::now(),
7411                Some(45.0),
7412                Some(SampleQuality::Good),
7413                MvActuationStatus::Confirmed,
7414                "",
7415            )
7416            .await,
7417            None
7418        );
7419        finalize_actuation_best_effort(
7420            &pool,
7421            &pending,
7422            MvActuationStatus::Superseded,
7423            "no audit row",
7424        )
7425        .await;
7426
7427        let mut args = fast_simulator_args();
7428        args.driver = DriverKindArg::Opcda;
7429        let mut tracker = MvActuationTracker::for_run(&args, &sample_initial_state()).unwrap();
7430        supersede_pending_actuation_best_effort(&pool, &mut tracker, "nothing pending").await;
7431        assert!(tracker.pending.is_none());
7432    }
7433
7434    #[tokio::test]
7435    async fn audit_helpers_apply_required_and_best_effort_failure_policies() {
7436        let pool = seeded_pool().await;
7437        let now = Instant::now();
7438        let pending = pending_actuation(
7439            Some(i64::MAX),
7440            MvActuationKind::Restore,
7441            45.0,
7442            now,
7443            now + Duration::from_secs(1),
7444            None,
7445        );
7446        pool.close().await;
7447
7448        assert_eq!(
7449            record_actuation_observation(
7450                &pool,
7451                &pending,
7452                Utc::now(),
7453                Some(45.0),
7454                Some(SampleQuality::Good),
7455                ActuationAuditPolicy::BestEffort,
7456            )
7457            .await
7458            .unwrap(),
7459            None
7460        );
7461        assert!(
7462            record_actuation_observation(
7463                &pool,
7464                &pending,
7465                Utc::now(),
7466                Some(45.0),
7467                Some(SampleQuality::Good),
7468                ActuationAuditPolicy::Required,
7469            )
7470            .await
7471            .is_err()
7472        );
7473        assert_eq!(
7474            record_final_actuation_observation(
7475                &pool,
7476                &pending,
7477                Utc::now(),
7478                Some(45.0),
7479                Some(SampleQuality::Good),
7480                MvActuationStatus::Confirmed,
7481                "closed pool",
7482            )
7483            .await,
7484            None
7485        );
7486        finalize_actuation_best_effort(
7487            &pool,
7488            &pending,
7489            MvActuationStatus::Superseded,
7490            "closed pool",
7491        )
7492        .await;
7493    }
7494
7495    #[tokio::test]
7496    async fn replacement_before_any_readback_is_finalized_as_unverified() {
7497        let pool = seeded_pool().await;
7498        let now = Instant::now();
7499        let pending = pending_actuation(
7500            None,
7501            MvActuationKind::Relay,
7502            55.0,
7503            now,
7504            now + Duration::from_secs(1),
7505            None,
7506        );
7507        let mut tracker = tracker_with_pending(pending);
7508
7509        let reason =
7510            reject_replacement_for_pending_actuation(&pool, "Unit1.LIC101.OP", &mut tracker)
7511                .await
7512                .unwrap();
7513
7514        assert!(matches!(
7515            reason,
7516            AbortReason::MvActuationUnconfirmed { readback: None, .. }
7517        ));
7518        assert!(tracker.pending.is_none());
7519    }
7520
7521    #[tokio::test]
7522    async fn verification_without_pending_work_or_before_first_check_is_a_noop() {
7523        let pool = seeded_pool().await;
7524        let driver = honeywell_driver_auto();
7525        let mut args = fast_simulator_args();
7526        args.driver = DriverKindArg::Opcda;
7527        let mut tracker = MvActuationTracker::for_run(&args, &sample_initial_state()).unwrap();
7528
7529        assert_eq!(
7530            verify_pending_mv_actuation_with(
7531                &pool,
7532                &args,
7533                "Unit1.LIC101.OP",
7534                &driver,
7535                &mut CtrlC::never(),
7536                false,
7537                &mut tracker,
7538                MvVerificationTrigger::Scheduled,
7539                MvVerificationCallLimit::None,
7540                ActuationAuditPolicy::Required,
7541            )
7542            .await
7543            .unwrap(),
7544            None
7545        );
7546
7547        let now = Instant::now();
7548        tracker.pending = Some(pending_actuation(
7549            None,
7550            MvActuationKind::Relay,
7551            55.0,
7552            now + Duration::from_secs(1),
7553            now + Duration::from_secs(2),
7554            None,
7555        ));
7556        assert_eq!(
7557            verify_pending_mv_actuation_with(
7558                &pool,
7559                &args,
7560                "Unit1.LIC101.OP",
7561                &driver,
7562                &mut CtrlC::never(),
7563                false,
7564                &mut tracker,
7565                MvVerificationTrigger::Scheduled,
7566                MvVerificationCallLimit::None,
7567                ActuationAuditPolicy::Required,
7568            )
7569            .await
7570            .unwrap(),
7571            None
7572        );
7573        assert!(driver.read_batches().is_empty());
7574    }
7575
7576    #[tokio::test]
7577    async fn explicit_deadline_trigger_rejects_a_mismatch_before_the_clock_deadline() {
7578        let pool = seeded_pool().await;
7579        let (run_id, _config, _template, tags) =
7580            start_opc_test_run(&pool, "actuation-explicit-deadline").await;
7581        let driver = honeywell_driver_auto();
7582        let mut args = fast_simulator_args();
7583        args.driver = DriverKindArg::Opcda;
7584        let now = Instant::now();
7585        let mut tracker = MvActuationTracker::for_run(&args, &sample_initial_state()).unwrap();
7586        tracker
7587            .record_accepted(
7588                &pool,
7589                run_id,
7590                MvActuationKind::Relay,
7591                55.0,
7592                now,
7593                Utc::now(),
7594                now,
7595                0.1,
7596            )
7597            .await
7598            .unwrap();
7599
7600        let outcome = verify_pending_mv_actuation_with(
7601            &pool,
7602            &args,
7603            &tags.manipulated_variable,
7604            &driver,
7605            &mut CtrlC::never(),
7606            false,
7607            &mut tracker,
7608            MvVerificationTrigger::Deadline,
7609            MvVerificationCallLimit::None,
7610            ActuationAuditPolicy::Required,
7611        )
7612        .await
7613        .unwrap();
7614
7615        assert!(matches!(
7616            outcome,
7617            Some(AbortReason::MvActuationUnconfirmed {
7618                readback: Some(45.0),
7619                ..
7620            })
7621        ));
7622        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7623            .await
7624            .unwrap();
7625        assert_eq!(rows[0].status, MvActuationStatus::Failed);
7626    }
7627
7628    #[tokio::test]
7629    async fn first_verification_uses_the_switch_tick_even_when_write_acceptance_is_late() {
7630        let pool = seeded_pool().await;
7631        let (run_id, _config, _template, _tags) =
7632            start_opc_test_run(&pool, "actuation-switch-causality").await;
7633        let mut args = fast_simulator_args();
7634        args.driver = DriverKindArg::Opcda;
7635        let initial = sample_initial_state();
7636        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7637        let switch_instant = Instant::now();
7638        let switch_tick = DateTime::UNIX_EPOCH + chrono::Duration::seconds(12);
7639        let accepted_instant = switch_instant + Duration::from_secs(3);
7640        let accepted_at = switch_tick + chrono::Duration::seconds(3);
7641        let first_check_at = switch_instant + Duration::from_secs(2);
7642
7643        tracker
7644            .record_accepted_at_switch(
7645                &pool,
7646                run_id,
7647                MvActuationKind::Relay,
7648                55.0,
7649                switch_tick,
7650                switch_instant,
7651                first_check_at,
7652                accepted_at,
7653                accepted_instant,
7654                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7655            )
7656            .await
7657            .unwrap();
7658
7659        let pending = tracker.pending.as_ref().unwrap();
7660        assert_eq!(pending.switch_tick, switch_tick);
7661        assert_eq!(pending.switch_instant, switch_instant);
7662        assert_eq!(pending.accepted_instant, accepted_instant);
7663        assert_eq!(pending.first_check_at, first_check_at);
7664        assert_eq!(
7665            pending.deadline,
7666            accepted_instant + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS)
7667        );
7668    }
7669
7670    #[tokio::test]
7671    async fn accepted_mv_command_is_confirmed_without_waiting_when_readback_matches() {
7672        let pool = seeded_pool().await;
7673        let (run_id, _config, _template, tags) =
7674            start_opc_test_run(&pool, "actuation-confirmed").await;
7675        let driver = honeywell_driver_auto();
7676        let mut args = fast_simulator_args();
7677        args.driver = DriverKindArg::Opcda;
7678        let initial = sample_initial_state();
7679        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7680        let target = 55.0;
7681        write_value(&driver, &tags.manipulated_variable, target)
7682            .await
7683            .unwrap();
7684        let commanded_instant = Instant::now();
7685        let commanded_at = Utc::now();
7686        let tolerance =
7687            mv_actuation_tolerance(MvActuationKind::Relay, target, initial.mv_ini, 100.0).unwrap();
7688        tracker
7689            .record_accepted(
7690                &pool,
7691                run_id,
7692                MvActuationKind::Relay,
7693                target,
7694                commanded_instant,
7695                commanded_at,
7696                commanded_instant,
7697                tolerance,
7698            )
7699            .await
7700            .unwrap();
7701
7702        let outcome = verify_pending_mv_actuation(
7703            &pool,
7704            &args,
7705            &tags.manipulated_variable,
7706            &driver,
7707            &mut CtrlC::never(),
7708            false,
7709            &mut tracker,
7710            None,
7711        )
7712        .await
7713        .unwrap();
7714
7715        assert_eq!(outcome, None);
7716        assert!(tracker.pending.is_none());
7717        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7718            .await
7719            .unwrap();
7720        assert_eq!(rows.len(), 1);
7721        assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
7722        assert_eq!(rows[0].attempt_count, 1);
7723        assert_eq!(rows[0].readback_mv, Some(target));
7724    }
7725
7726    #[tokio::test]
7727    async fn later_retry_can_confirm_after_an_earlier_mismatch() {
7728        let pool = seeded_pool().await;
7729        let (run_id, _config, _template, tags) =
7730            start_opc_test_run(&pool, "actuation-late-confirmation").await;
7731        let driver =
7732            honeywell_driver_auto().with_read_sequence(&tags.manipulated_variable, &["50", "55"]);
7733        let mut args = fast_simulator_args();
7734        args.driver = DriverKindArg::Opcda;
7735        let initial = sample_initial_state();
7736        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7737        let commanded_instant = Instant::now();
7738        let commanded_at = Utc::now();
7739        tracker
7740            .record_accepted(
7741                &pool,
7742                run_id,
7743                MvActuationKind::Relay,
7744                55.0,
7745                commanded_instant,
7746                commanded_at,
7747                commanded_instant,
7748                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7749            )
7750            .await
7751            .unwrap();
7752
7753        for _ in 0..2 {
7754            assert_eq!(
7755                verify_pending_mv_actuation(
7756                    &pool,
7757                    &args,
7758                    &tags.manipulated_variable,
7759                    &driver,
7760                    &mut CtrlC::never(),
7761                    false,
7762                    &mut tracker,
7763                    None,
7764                )
7765                .await
7766                .unwrap(),
7767                None
7768            );
7769        }
7770
7771        assert!(tracker.pending.is_none());
7772        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7773            .await
7774            .unwrap();
7775        assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
7776        assert_eq!(rows[0].attempt_count, 2);
7777        assert_eq!(rows[0].readback_mv, Some(55.0));
7778    }
7779
7780    #[tokio::test]
7781    async fn early_mismatch_stays_pending_but_blocks_a_replacement_relay() {
7782        let pool = seeded_pool().await;
7783        let (run_id, _config, _template, tags) =
7784            start_opc_test_run(&pool, "actuation-mismatch").await;
7785        let driver = honeywell_driver_auto().distorting_write(&tags.manipulated_variable, -5.0);
7786        let mut args = fast_simulator_args();
7787        args.driver = DriverKindArg::Opcda;
7788        let initial = sample_initial_state();
7789        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7790        let target = 55.0;
7791        write_value(&driver, &tags.manipulated_variable, target)
7792            .await
7793            .unwrap();
7794        let commanded_instant = Instant::now();
7795        let commanded_at = Utc::now();
7796        let tolerance =
7797            mv_actuation_tolerance(MvActuationKind::Relay, target, initial.mv_ini, 100.0).unwrap();
7798        tracker
7799            .record_accepted(
7800                &pool,
7801                run_id,
7802                MvActuationKind::Relay,
7803                target,
7804                commanded_instant,
7805                commanded_at,
7806                commanded_instant,
7807                tolerance,
7808            )
7809            .await
7810            .unwrap();
7811
7812        let first = verify_pending_mv_actuation(
7813            &pool,
7814            &args,
7815            &tags.manipulated_variable,
7816            &driver,
7817            &mut CtrlC::never(),
7818            false,
7819            &mut tracker,
7820            None,
7821        )
7822        .await
7823        .unwrap();
7824        assert_eq!(first, None);
7825        assert!(tracker.pending.is_some());
7826
7827        let forced = reject_replacement_for_pending_actuation(
7828            &pool,
7829            &tags.manipulated_variable,
7830            &mut tracker,
7831        )
7832        .await
7833        .unwrap();
7834        assert!(matches!(
7835            forced,
7836            AbortReason::MvActuationUnconfirmed {
7837                target: 55.0,
7838                readback: Some(50.0),
7839                ..
7840            }
7841        ));
7842        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7843            .await
7844            .unwrap();
7845        assert_eq!(rows[0].status, MvActuationStatus::Failed);
7846        assert_eq!(rows[0].attempt_count, 1);
7847    }
7848
7849    #[tokio::test]
7850    async fn later_check_reads_fresh_instead_of_failing_from_an_earlier_mismatch() {
7851        let pool = seeded_pool().await;
7852        let (run_id, _config, _template, tags) =
7853            start_opc_test_run(&pool, "actuation-deadline").await;
7854        let driver =
7855            honeywell_driver_auto().with_read_sequence(&tags.manipulated_variable, &["50", "55"]);
7856        let mut args = fast_simulator_args();
7857        args.driver = DriverKindArg::Opcda;
7858        let initial = sample_initial_state();
7859        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7860        write_value(&driver, &tags.manipulated_variable, 55.0)
7861            .await
7862            .unwrap();
7863        let commanded_instant = Instant::now();
7864        let commanded_at = Utc::now();
7865        tracker
7866            .record_accepted(
7867                &pool,
7868                run_id,
7869                MvActuationKind::Relay,
7870                55.0,
7871                commanded_instant,
7872                commanded_at,
7873                commanded_instant,
7874                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7875            )
7876            .await
7877            .unwrap();
7878        assert_eq!(
7879            verify_pending_mv_actuation(
7880                &pool,
7881                &args,
7882                &tags.manipulated_variable,
7883                &driver,
7884                &mut CtrlC::never(),
7885                false,
7886                &mut tracker,
7887                None,
7888            )
7889            .await
7890            .unwrap(),
7891            None
7892        );
7893        // Trigger another verification without crossing the real confirmation deadline. The
7894        // second read must be evaluated on its own rather than inheriting the first mismatch.
7895        tracker.pending.as_mut().unwrap().deadline = Instant::now() + Duration::from_secs(1);
7896
7897        let outcome = verify_pending_mv_actuation(
7898            &pool,
7899            &args,
7900            &tags.manipulated_variable,
7901            &driver,
7902            &mut CtrlC::never(),
7903            false,
7904            &mut tracker,
7905            None,
7906        )
7907        .await
7908        .unwrap();
7909
7910        assert_eq!(outcome, None);
7911        assert!(tracker.pending.is_none());
7912        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7913            .await
7914            .unwrap();
7915        assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
7916        assert_eq!(rows[0].attempt_count, 2);
7917        assert_eq!(rows[0].readback_mv, Some(55.0));
7918    }
7919
7920    #[tokio::test]
7921    async fn predeadline_read_is_dropped_and_late_matching_readback_still_fails() {
7922        let pool = seeded_pool().await;
7923        let (run_id, _config, _template, tags) =
7924            start_opc_test_run(&pool, "actuation-predeadline-bound").await;
7925        let driver = honeywell_driver_auto()
7926            .with_value(&tags.manipulated_variable, "55")
7927            .delaying_read(&tags.manipulated_variable, Duration::from_millis(50));
7928        let mut args = fast_simulator_args();
7929        args.driver = DriverKindArg::Opcda;
7930        args.op_timeout_secs = 30;
7931        let initial = sample_initial_state();
7932        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7933        let accepted_instant = Instant::now();
7934        tracker
7935            .record_accepted(
7936                &pool,
7937                run_id,
7938                MvActuationKind::Relay,
7939                55.0,
7940                accepted_instant,
7941                Utc::now(),
7942                accepted_instant,
7943                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
7944            )
7945            .await
7946            .unwrap();
7947        tracker.pending.as_mut().unwrap().deadline = Instant::now() + Duration::from_millis(25);
7948
7949        let outcome = verify_pending_mv_actuation(
7950            &pool,
7951            &args,
7952            &tags.manipulated_variable,
7953            &driver,
7954            &mut CtrlC::never(),
7955            false,
7956            &mut tracker,
7957            None,
7958        )
7959        .await
7960        .unwrap();
7961
7962        assert!(matches!(
7963            outcome,
7964            Some(AbortReason::MvActuationUnconfirmed {
7965                readback: Some(55.0),
7966                ..
7967            })
7968        ));
7969        assert!(tracker.pending.is_none());
7970        assert_eq!(
7971            driver.read_batches(),
7972            vec![
7973                vec![tags.manipulated_variable.clone()],
7974                vec![tags.manipulated_variable.clone()]
7975            ],
7976            "the read cancelled at the deadline must not be reused as deadline evidence"
7977        );
7978        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
7979            .await
7980            .unwrap();
7981        assert_eq!(rows[0].status, MvActuationStatus::Failed);
7982        assert_eq!(rows[0].attempt_count, 1);
7983    }
7984
7985    #[tokio::test]
7986    async fn fresh_deadline_read_is_tightly_bounded_below_the_operation_timeout() {
7987        let pool = seeded_pool().await;
7988        let (run_id, _config, _template, tags) =
7989            start_opc_test_run(&pool, "actuation-deadline-read-bound").await;
7990        let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
7991        let mut args = fast_simulator_args();
7992        args.driver = DriverKindArg::Opcda;
7993        args.op_timeout_secs = 30;
7994        let initial = sample_initial_state();
7995        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
7996        let accepted_instant = Instant::now();
7997        tracker
7998            .record_accepted(
7999                &pool,
8000                run_id,
8001                MvActuationKind::Relay,
8002                55.0,
8003                accepted_instant,
8004                Utc::now(),
8005                accepted_instant,
8006                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8007            )
8008            .await
8009            .unwrap();
8010        tracker.pending.as_mut().unwrap().deadline = Instant::now();
8011
8012        let started = Instant::now();
8013        let outcome = verify_pending_mv_actuation(
8014            &pool,
8015            &args,
8016            &tags.manipulated_variable,
8017            &driver,
8018            &mut CtrlC::never(),
8019            false,
8020            &mut tracker,
8021            None,
8022        )
8023        .await
8024        .unwrap();
8025
8026        assert!(started.elapsed() < Duration::from_secs(2));
8027        assert!(matches!(
8028            outcome,
8029            Some(AbortReason::MvActuationUnconfirmed { readback: None, .. })
8030        ));
8031        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8032            .await
8033            .unwrap();
8034        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8035        assert_eq!(rows[0].attempt_count, 1);
8036    }
8037
8038    #[tokio::test]
8039    async fn stalled_shared_pv_mv_poll_is_cancelled_without_recording_a_sample() {
8040        let pool = seeded_pool().await;
8041        let (run_id, config, _template, tags) =
8042            start_opc_test_run(&pool, "actuation-shared-poll-cancel").await;
8043        let driver = honeywell_driver_auto()
8044            .delaying_read(&tags.manipulated_variable, Duration::from_secs(2))
8045            .degrade_quality_after(&tags.process_variable, 1, bhtune_driver::Quality::Bad);
8046        let mut args = fast_simulator_args();
8047        args.driver = DriverKindArg::Opcda;
8048        args.mrft_delay = 10;
8049        args.timeout_secs = 3;
8050        let initial = sample_initial_state();
8051        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8052        let commanded_instant = Instant::now();
8053        let commanded_at = Utc::now();
8054        tracker
8055            .record_accepted(
8056                &pool,
8057                run_id,
8058                MvActuationKind::Relay,
8059                55.0,
8060                commanded_instant + Duration::from_secs(1),
8061                commanded_at,
8062                commanded_instant,
8063                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8064            )
8065            .await
8066            .unwrap();
8067        let mut tracker = Some(tracker);
8068        let mut engine = MrftEngine::new(
8069            config,
8070            initial.direction,
8071            lookup(
8072                config.process_type,
8073                config.controller_type,
8074                ResponseLevel::Aggressive,
8075            )
8076            .beta,
8077            InitialReadings {
8078                pv_ini: initial.pv_ini,
8079                mv_ini: initial.mv_ini,
8080                mv_range_low: initial.mv_range_low,
8081                mv_range_high: initial.mv_range_high,
8082            },
8083            Utc::now(),
8084            MrftCompat::default(),
8085        );
8086        let (mut ctrl_c, tx) = CtrlC::test_pair();
8087        tokio::spawn(async move {
8088            tokio::time::sleep(Duration::from_millis(50)).await;
8089            let _ = tx.send(1);
8090        });
8091        let mut timing = timing_for_args(&args);
8092
8093        let outcome = run_polling_loop(
8094            &pool,
8095            run_id,
8096            &args,
8097            &tags,
8098            &driver,
8099            &mut engine,
8100            RunTimeAnchor::now(),
8101            &mut ctrl_c,
8102            &mut MutationGuard::default(),
8103            false,
8104            &mut timing,
8105            &mut tracker,
8106            config,
8107        )
8108        .await
8109        .unwrap();
8110
8111        assert!(matches!(
8112            outcome,
8113            PollOutcome::Aborted(AbortReason::UserInterrupt)
8114        ));
8115        assert!(
8116            driver.delayed_read_was_cancelled(&tags.manipulated_variable),
8117            "the shared PV/MV read must be dropped when Ctrl+C cancels the operation"
8118        );
8119        assert_eq!(
8120            driver.read_batches(),
8121            vec![vec![
8122                tags.process_variable.clone(),
8123                tags.manipulated_variable.clone()
8124            ]]
8125        );
8126        assert!(
8127            TuneSampleRow::list_for_run(&pool, run_id)
8128                .await
8129                .unwrap()
8130                .is_empty(),
8131            "a cancelled shared read has no valid PV sample to persist"
8132        );
8133        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8134            .await
8135            .unwrap();
8136        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8137        assert_eq!(rows[0].attempt_count, 0);
8138    }
8139
8140    #[tokio::test]
8141    async fn stalled_shared_pv_mv_poll_times_out_without_recording_a_sample() {
8142        let pool = seeded_pool().await;
8143        let (run_id, config, _template, tags) =
8144            start_opc_test_run(&pool, "actuation-shared-poll-timeout").await;
8145        let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
8146        let mut args = fast_simulator_args();
8147        args.driver = DriverKindArg::Opcda;
8148        args.mrft_delay = 10;
8149        args.op_timeout_secs = 0;
8150        let initial = sample_initial_state();
8151        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8152        let commanded_instant = Instant::now();
8153        let commanded_at = Utc::now();
8154        tracker
8155            .record_accepted(
8156                &pool,
8157                run_id,
8158                MvActuationKind::Relay,
8159                55.0,
8160                commanded_instant,
8161                commanded_at,
8162                commanded_instant,
8163                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8164            )
8165            .await
8166            .unwrap();
8167        let mut tracker = Some(tracker);
8168        let started_at = Utc::now();
8169        let mut engine = MrftEngine::new(
8170            config,
8171            initial.direction,
8172            lookup(
8173                config.process_type,
8174                config.controller_type,
8175                ResponseLevel::Aggressive,
8176            )
8177            .beta,
8178            InitialReadings {
8179                pv_ini: initial.pv_ini,
8180                mv_ini: initial.mv_ini,
8181                mv_range_low: initial.mv_range_low,
8182                mv_range_high: initial.mv_range_high,
8183            },
8184            started_at,
8185            MrftCompat::default(),
8186        );
8187        let mut timing = timing_for_args(&args);
8188
8189        let outcome = run_polling_loop(
8190            &pool,
8191            run_id,
8192            &args,
8193            &tags,
8194            &driver,
8195            &mut engine,
8196            time_anchor_at(started_at),
8197            &mut CtrlC::never(),
8198            &mut MutationGuard::default(),
8199            false,
8200            &mut timing,
8201            &mut tracker,
8202            config,
8203        )
8204        .await
8205        .unwrap();
8206
8207        assert!(matches!(
8208            outcome,
8209            PollOutcome::Aborted(AbortReason::OperationTimedOut {
8210                ref tag,
8211                op_timeout_secs: 0,
8212            }) if tag == &tags.manipulated_variable
8213        ));
8214        assert_eq!(
8215            driver.read_batches(),
8216            vec![vec![
8217                tags.process_variable.clone(),
8218                tags.manipulated_variable.clone()
8219            ]]
8220        );
8221        assert!(
8222            TuneSampleRow::list_for_run(&pool, run_id)
8223                .await
8224                .unwrap()
8225                .is_empty(),
8226            "a timed-out shared read has no valid PV sample to persist"
8227        );
8228        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8229            .await
8230            .unwrap();
8231        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8232        assert_eq!(rows[0].attempt_count, 1);
8233        assert!(tracker.is_none() || tracker.as_ref().unwrap().pending.is_none());
8234    }
8235
8236    #[tokio::test]
8237    async fn scheduled_mv_verification_keeps_an_early_mismatch_pending() {
8238        let pool = seeded_pool().await;
8239        let (run_id, config, _template, tags) =
8240            start_opc_test_run(&pool, "actuation-early-mismatch").await;
8241        let driver = honeywell_driver_auto();
8242        let mut args = fast_simulator_args();
8243        args.driver = DriverKindArg::Opcda;
8244        args.poll_interval_ms = 10_000;
8245        args.mrft_delay = 10;
8246        args.timeout_secs = 1;
8247        let initial = sample_initial_state();
8248        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8249        let accepted_instant = Instant::now();
8250        tracker
8251            .record_accepted(
8252                &pool,
8253                run_id,
8254                MvActuationKind::Relay,
8255                55.0,
8256                accepted_instant,
8257                Utc::now(),
8258                accepted_instant,
8259                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8260            )
8261            .await
8262            .unwrap();
8263        let pending = tracker.pending.as_mut().unwrap();
8264        pending.first_check_at = accepted_instant;
8265        pending.deadline = accepted_instant + Duration::from_millis(200);
8266        let mut tracker = Some(tracker);
8267        let started_at = Utc::now();
8268        let mut engine = MrftEngine::new(
8269            config,
8270            initial.direction,
8271            lookup(
8272                config.process_type,
8273                config.controller_type,
8274                ResponseLevel::Aggressive,
8275            )
8276            .beta,
8277            InitialReadings {
8278                pv_ini: initial.pv_ini,
8279                mv_ini: initial.mv_ini,
8280                mv_range_low: initial.mv_range_low,
8281                mv_range_high: initial.mv_range_high,
8282            },
8283            started_at,
8284            MrftCompat::default(),
8285        );
8286        let mut timing = timing_for_args(&args);
8287
8288        let outcome = run_polling_loop(
8289            &pool,
8290            run_id,
8291            &args,
8292            &tags,
8293            &driver,
8294            &mut engine,
8295            time_anchor_at(started_at),
8296            &mut CtrlC::never(),
8297            &mut MutationGuard::default(),
8298            false,
8299            &mut timing,
8300            &mut tracker,
8301            config,
8302        )
8303        .await
8304        .unwrap();
8305
8306        assert!(matches!(
8307            outcome,
8308            PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
8309        ));
8310        assert_eq!(
8311            driver.read_batches(),
8312            vec![
8313                vec![tags.manipulated_variable.clone()],
8314                vec![
8315                    tags.process_variable.clone(),
8316                    tags.manipulated_variable.clone()
8317                ],
8318                vec![tags.manipulated_variable.clone()]
8319            ]
8320        );
8321        assert_eq!(
8322            TuneSampleRow::list_for_run(&pool, run_id)
8323                .await
8324                .unwrap()
8325                .len(),
8326            1
8327        );
8328        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8329            .await
8330            .unwrap();
8331        assert_eq!(rows[0].status, MvActuationStatus::Failed);
8332        assert_eq!(rows[0].attempt_count, 3);
8333    }
8334
8335    #[tokio::test]
8336    async fn verification_deadline_wakes_without_waiting_for_a_long_poll_interval() {
8337        let pool = seeded_pool().await;
8338        let (run_id, config, _template, tags) =
8339            start_opc_test_run(&pool, "actuation-deadline-wakeup").await;
8340        let driver = honeywell_driver_auto();
8341        let mut args = fast_simulator_args();
8342        args.driver = DriverKindArg::Opcda;
8343        args.poll_interval_ms = 10_000;
8344        args.mrft_delay = 10;
8345        args.timeout_secs = 2;
8346        let initial = sample_initial_state();
8347        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8348        let accepted_instant = Instant::now();
8349        tracker
8350            .record_accepted(
8351                &pool,
8352                run_id,
8353                MvActuationKind::Relay,
8354                55.0,
8355                accepted_instant,
8356                Utc::now(),
8357                accepted_instant,
8358                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8359            )
8360            .await
8361            .unwrap();
8362        let deadline = Instant::now() + Duration::from_millis(25);
8363        let pending = tracker.pending.as_mut().unwrap();
8364        pending.first_check_at = deadline;
8365        pending.deadline = deadline;
8366        let mut tracker = Some(tracker);
8367        let started_at = Utc::now();
8368        let mut engine = MrftEngine::new(
8369            config,
8370            initial.direction,
8371            lookup(
8372                config.process_type,
8373                config.controller_type,
8374                ResponseLevel::Aggressive,
8375            )
8376            .beta,
8377            InitialReadings {
8378                pv_ini: initial.pv_ini,
8379                mv_ini: initial.mv_ini,
8380                mv_range_low: initial.mv_range_low,
8381                mv_range_high: initial.mv_range_high,
8382            },
8383            started_at,
8384            MrftCompat::default(),
8385        );
8386        let mut timing = timing_for_args(&args);
8387
8388        let started = Instant::now();
8389        let outcome = run_polling_loop(
8390            &pool,
8391            run_id,
8392            &args,
8393            &tags,
8394            &driver,
8395            &mut engine,
8396            time_anchor_at(started_at),
8397            &mut CtrlC::never(),
8398            &mut MutationGuard::default(),
8399            false,
8400            &mut timing,
8401            &mut tracker,
8402            config,
8403        )
8404        .await
8405        .unwrap();
8406
8407        assert!(started.elapsed() < Duration::from_secs(1));
8408        assert!(matches!(
8409            outcome,
8410            PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
8411        ));
8412        let reads = driver.read_batches();
8413        assert_eq!(
8414            reads[0],
8415            vec![
8416                tags.process_variable.clone(),
8417                tags.manipulated_variable.clone()
8418            ]
8419        );
8420        assert_eq!(reads[1], vec![tags.manipulated_variable.clone()]);
8421        assert_eq!(
8422            TuneSampleRow::list_for_run(&pool, run_id)
8423                .await
8424                .unwrap()
8425                .len(),
8426            1
8427        );
8428    }
8429
8430    #[tokio::test]
8431    async fn due_mv_verification_precedes_a_due_pv_poll() {
8432        let pool = seeded_pool().await;
8433        let (run_id, config, _template, tags) =
8434            start_opc_test_run(&pool, "actuation-before-due-poll").await;
8435        let driver = honeywell_driver_auto()
8436            .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
8437        let mut args = fast_simulator_args();
8438        args.driver = DriverKindArg::Opcda;
8439        args.poll_interval_ms = 10_000;
8440        args.mrft_delay = 10;
8441        args.timeout_secs = 2;
8442        let initial = sample_initial_state();
8443        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8444        let accepted_instant = Instant::now();
8445        tracker
8446            .record_accepted(
8447                &pool,
8448                run_id,
8449                MvActuationKind::Relay,
8450                55.0,
8451                accepted_instant,
8452                Utc::now(),
8453                accepted_instant,
8454                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8455            )
8456            .await
8457            .unwrap();
8458        tracker.pending.as_mut().unwrap().deadline = Instant::now();
8459        let mut tracker = Some(tracker);
8460        let started_at = Utc::now();
8461        let mut engine = MrftEngine::new(
8462            config,
8463            initial.direction,
8464            lookup(
8465                config.process_type,
8466                config.controller_type,
8467                ResponseLevel::Aggressive,
8468            )
8469            .beta,
8470            InitialReadings {
8471                pv_ini: initial.pv_ini,
8472                mv_ini: initial.mv_ini,
8473                mv_range_low: initial.mv_range_low,
8474                mv_range_high: initial.mv_range_high,
8475            },
8476            started_at,
8477            MrftCompat::default(),
8478        );
8479        let mut timing = timing_for_args(&args);
8480
8481        let outcome = run_polling_loop(
8482            &pool,
8483            run_id,
8484            &args,
8485            &tags,
8486            &driver,
8487            &mut engine,
8488            time_anchor_at(started_at),
8489            &mut CtrlC::never(),
8490            &mut MutationGuard::default(),
8491            false,
8492            &mut timing,
8493            &mut tracker,
8494            config,
8495        )
8496        .await
8497        .unwrap();
8498
8499        assert!(matches!(
8500            outcome,
8501            PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
8502        ));
8503        assert_eq!(
8504            driver.read_batches(),
8505            vec![vec![tags.manipulated_variable.clone()]],
8506            "a due verification deadline must be handled before the due PV poll"
8507        );
8508        assert!(
8509            TuneSampleRow::list_for_run(&pool, run_id)
8510                .await
8511                .unwrap()
8512                .is_empty()
8513        );
8514    }
8515
8516    #[tokio::test]
8517    async fn replacement_preview_uses_deadline_verification_and_records_the_abort_sample() {
8518        let pool = seeded_pool().await;
8519        let (run_id, config, _template, tags) =
8520            start_opc_test_run(&pool, "deadline-preview-verification").await;
8521        let driver = honeywell_driver_auto();
8522        let mut args = fast_simulator_args();
8523        args.driver = DriverKindArg::Opcda;
8524        args.timeout_secs = 1;
8525        let initial = sample_initial_state();
8526        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8527        let now = Instant::now();
8528        tracker.pending = Some(pending_actuation(
8529            None,
8530            MvActuationKind::Relay,
8531            35.0,
8532            now + Duration::from_secs(1),
8533            now,
8534            None,
8535        ));
8536        let mut tracker = Some(tracker);
8537        let started_at = Utc::now();
8538        let mut engine = MrftEngine::new(
8539            config,
8540            initial.direction,
8541            lookup(
8542                config.process_type,
8543                config.controller_type,
8544                ResponseLevel::Aggressive,
8545            )
8546            .beta,
8547            InitialReadings {
8548                pv_ini: initial.pv_ini,
8549                mv_ini: initial.mv_ini,
8550                mv_range_low: initial.mv_range_low,
8551                mv_range_high: initial.mv_range_high,
8552            },
8553            started_at,
8554            MrftCompat::default(),
8555        );
8556        let state_before = engine.state();
8557        let mut timing = timing_for_args(&args);
8558
8559        let outcome = run_polling_loop(
8560            &pool,
8561            run_id,
8562            &args,
8563            &tags,
8564            &driver,
8565            &mut engine,
8566            time_anchor_at(started_at),
8567            &mut CtrlC::never(),
8568            &mut MutationGuard::default(),
8569            false,
8570            &mut timing,
8571            &mut tracker,
8572            config,
8573        )
8574        .await
8575        .unwrap();
8576
8577        assert!(matches!(
8578            outcome,
8579            PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
8580                readback: Some(45.0),
8581                ..
8582            })
8583        ));
8584        assert_eq!(engine.state(), state_before);
8585        assert_eq!(
8586            TuneSampleRow::list_for_run(&pool, run_id)
8587                .await
8588                .unwrap()
8589                .len(),
8590            1
8591        );
8592    }
8593
8594    #[tokio::test]
8595    async fn replacement_preview_commits_after_confirming_the_prior_command() {
8596        let pool = seeded_pool().await;
8597        let (run_id, config, _template, tags) =
8598            start_opc_test_run(&pool, "confirmed-preview-replacement").await;
8599        let driver = honeywell_driver_auto().degrade_quality_after(
8600            &tags.process_variable,
8601            1,
8602            bhtune_driver::Quality::Bad,
8603        );
8604        let mut args = fast_simulator_args();
8605        args.driver = DriverKindArg::Opcda;
8606        args.timeout_secs = 1;
8607        let initial = sample_initial_state();
8608        let now = Instant::now();
8609        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8610        tracker.pending = Some(pending_actuation(
8611            None,
8612            MvActuationKind::Relay,
8613            initial.mv_ini,
8614            now + Duration::from_secs(1),
8615            now + Duration::from_secs(2),
8616            None,
8617        ));
8618        let mut tracker = Some(tracker);
8619        let started_at = Utc::now();
8620        let mut engine = MrftEngine::new(
8621            config,
8622            initial.direction,
8623            lookup(
8624                config.process_type,
8625                config.controller_type,
8626                ResponseLevel::Aggressive,
8627            )
8628            .beta,
8629            InitialReadings {
8630                pv_ini: initial.pv_ini,
8631                mv_ini: initial.mv_ini,
8632                mv_range_low: initial.mv_range_low,
8633                mv_range_high: initial.mv_range_high,
8634            },
8635            started_at,
8636            MrftCompat::default(),
8637        );
8638        let state_before = engine.state();
8639        let mut timing = timing_for_args(&args);
8640
8641        let outcome = run_polling_loop(
8642            &pool,
8643            run_id,
8644            &args,
8645            &tags,
8646            &driver,
8647            &mut engine,
8648            time_anchor_at(started_at),
8649            &mut CtrlC::never(),
8650            &mut MutationGuard::default(),
8651            false,
8652            &mut timing,
8653            &mut tracker,
8654            config,
8655        )
8656        .await
8657        .unwrap();
8658
8659        assert!(matches!(
8660            outcome,
8661            PollOutcome::Aborted(AbortReason::PoorQuality { .. })
8662        ));
8663        assert_ne!(engine.state(), state_before);
8664        assert_eq!(driver.write_log().len(), 1);
8665    }
8666
8667    #[tokio::test]
8668    async fn pending_actuation_preview_does_not_commit_or_write_a_replacement_relay() {
8669        let pool = seeded_pool().await;
8670        let (run_id, config, _template, tags) =
8671            start_opc_test_run(&pool, "actuation-preview").await;
8672        let driver = honeywell_driver_auto();
8673        let mut args = fast_simulator_args();
8674        args.driver = DriverKindArg::Opcda;
8675        args.timeout_secs = 1;
8676        let initial = sample_initial_state();
8677        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8678        let accepted_instant = Instant::now();
8679        tracker
8680            .record_accepted(
8681                &pool,
8682                run_id,
8683                MvActuationKind::Relay,
8684                35.0,
8685                accepted_instant,
8686                Utc::now(),
8687                accepted_instant,
8688                mv_actuation_tolerance(MvActuationKind::Relay, 35.0, 45.0, 100.0).unwrap(),
8689            )
8690            .await
8691            .unwrap();
8692        assert_eq!(
8693            verify_pending_mv_actuation(
8694                &pool,
8695                &args,
8696                &tags.manipulated_variable,
8697                &driver,
8698                &mut CtrlC::never(),
8699                false,
8700                &mut tracker,
8701                None,
8702            )
8703            .await
8704            .unwrap(),
8705            None
8706        );
8707        assert_eq!(
8708            tracker.pending.as_ref().unwrap().last_readback,
8709            Some(initial.mv_ini)
8710        );
8711        let mut tracker = Some(tracker);
8712        let started_at = Utc::now();
8713        let mut engine = MrftEngine::new(
8714            config,
8715            initial.direction,
8716            lookup(
8717                config.process_type,
8718                config.controller_type,
8719                ResponseLevel::Aggressive,
8720            )
8721            .beta,
8722            InitialReadings {
8723                pv_ini: initial.pv_ini,
8724                mv_ini: initial.mv_ini,
8725                mv_range_low: initial.mv_range_low,
8726                mv_range_high: initial.mv_range_high,
8727            },
8728            started_at,
8729            MrftCompat::default(),
8730        );
8731        let state_before = engine.state();
8732        let mut timing = timing_for_args(&args);
8733
8734        let outcome = run_polling_loop(
8735            &pool,
8736            run_id,
8737            &args,
8738            &tags,
8739            &driver,
8740            &mut engine,
8741            time_anchor_at(started_at),
8742            &mut CtrlC::never(),
8743            &mut MutationGuard::default(),
8744            false,
8745            &mut timing,
8746            &mut tracker,
8747            config,
8748        )
8749        .await
8750        .unwrap();
8751
8752        assert!(matches!(
8753            outcome,
8754            PollOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
8755                readback: Some(45.0),
8756                ..
8757            })
8758        ));
8759        assert_eq!(engine.state(), state_before);
8760        assert!(driver.write_log().is_empty());
8761        assert_eq!(
8762            driver.read_batches(),
8763            vec![
8764                vec![tags.manipulated_variable.clone()],
8765                vec![
8766                    tags.process_variable.clone(),
8767                    tags.manipulated_variable.clone()
8768                ],
8769            ],
8770            "the replacement preview must use the fresh batched PV/MV read from the preview tick"
8771        );
8772        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8773            .await
8774            .unwrap();
8775        assert_eq!(rows[0].status, MvActuationStatus::Failed);
8776    }
8777
8778    #[tokio::test]
8779    async fn verification_operation_timeout_preserves_the_timed_out_outcome() {
8780        let pool = seeded_pool().await;
8781        let (run_id, _config, _template, tags) = start_opc_test_run(&pool, "actuation-hang").await;
8782        let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
8783        let mut args = fast_simulator_args();
8784        args.driver = DriverKindArg::Opcda;
8785        args.op_timeout_secs = 1;
8786        let initial = sample_initial_state();
8787        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8788        let commanded_instant = Instant::now();
8789        let commanded_at = Utc::now();
8790        tracker
8791            .record_accepted(
8792                &pool,
8793                run_id,
8794                MvActuationKind::Relay,
8795                55.0,
8796                commanded_instant,
8797                commanded_at,
8798                commanded_instant,
8799                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8800            )
8801            .await
8802            .unwrap();
8803        let started = Instant::now();
8804        let outcome = verify_pending_mv_actuation(
8805            &pool,
8806            &args,
8807            &tags.manipulated_variable,
8808            &driver,
8809            &mut CtrlC::never(),
8810            false,
8811            &mut tracker,
8812            None,
8813        )
8814        .await
8815        .unwrap();
8816
8817        assert!(started.elapsed() < Duration::from_secs(2));
8818        assert!(matches!(
8819            outcome,
8820            Some(AbortReason::OperationTimedOut {
8821                op_timeout_secs: 1,
8822                ..
8823            })
8824        ));
8825        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8826            .await
8827            .unwrap();
8828        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8829        assert_eq!(rows[0].attempt_count, 1);
8830    }
8831
8832    #[tokio::test]
8833    async fn ctrl_c_during_verification_preserves_pending_row_for_restore_handoff() {
8834        let pool = seeded_pool().await;
8835        let (run_id, _config, _template, tags) =
8836            start_opc_test_run(&pool, "actuation-ctrl-c").await;
8837        let hanging_driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
8838        let healthy_driver = honeywell_driver_auto();
8839        let mut args = fast_simulator_args();
8840        args.driver = DriverKindArg::Opcda;
8841        let initial = sample_initial_state();
8842        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8843        let commanded_instant = Instant::now();
8844        let commanded_at = Utc::now();
8845        tracker
8846            .record_accepted(
8847                &pool,
8848                run_id,
8849                MvActuationKind::Relay,
8850                55.0,
8851                commanded_instant,
8852                commanded_at,
8853                commanded_instant,
8854                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8855            )
8856            .await
8857            .unwrap();
8858        let (mut ctrl_c, tx) = CtrlC::test_pair();
8859        tx.send(1).unwrap();
8860
8861        let outcome = verify_pending_mv_actuation(
8862            &pool,
8863            &args,
8864            &tags.manipulated_variable,
8865            &hanging_driver,
8866            &mut ctrl_c,
8867            false,
8868            &mut tracker,
8869            None,
8870        )
8871        .await
8872        .unwrap();
8873        assert_eq!(outcome, Some(AbortReason::UserInterrupt));
8874        assert!(tracker.pending.is_none());
8875
8876        let mut tracker = Some(tracker);
8877        let restored = restore_mv_with_verification(
8878            &pool,
8879            run_id,
8880            &args,
8881            &healthy_driver,
8882            &tags.manipulated_variable,
8883            initial.mv_ini,
8884            false,
8885            &mut CtrlC::never(),
8886            &mut tracker,
8887            Instant::now() + Duration::from_secs(args.restore_timeout_secs),
8888        )
8889        .await
8890        .unwrap();
8891        assert!(matches!(
8892            restored,
8893            RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
8894        ));
8895        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8896            .await
8897            .unwrap();
8898        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8899        assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
8900    }
8901
8902    #[tokio::test]
8903    async fn poor_quality_verification_preserves_the_poor_quality_outcome() {
8904        let pool = seeded_pool().await;
8905        let (run_id, _config, _template, tags) =
8906            start_opc_test_run(&pool, "actuation-quality-retry").await;
8907        let driver = honeywell_driver_auto()
8908            .with_quality(&tags.manipulated_variable, bhtune_driver::Quality::Bad);
8909        let mut args = fast_simulator_args();
8910        args.driver = DriverKindArg::Opcda;
8911        let initial = sample_initial_state();
8912        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8913        let commanded_instant = Instant::now();
8914        let commanded_at = Utc::now();
8915        tracker
8916            .record_accepted(
8917                &pool,
8918                run_id,
8919                MvActuationKind::Relay,
8920                55.0,
8921                commanded_instant,
8922                commanded_at,
8923                commanded_instant,
8924                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8925            )
8926            .await
8927            .unwrap();
8928
8929        let outcome = verify_pending_mv_actuation(
8930            &pool,
8931            &args,
8932            &tags.manipulated_variable,
8933            &driver,
8934            &mut CtrlC::never(),
8935            false,
8936            &mut tracker,
8937            None,
8938        )
8939        .await
8940        .unwrap();
8941        assert!(matches!(
8942            outcome,
8943            Some(AbortReason::PoorQuality {
8944                quality: bhtune_driver::Quality::Bad,
8945                ..
8946            })
8947        ));
8948        assert!(tracker.pending.is_none());
8949        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8950            .await
8951            .unwrap();
8952        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
8953        assert_eq!(rows[0].readback_quality, Some(SampleQuality::Bad));
8954        assert_eq!(rows[0].attempt_count, 1);
8955    }
8956
8957    #[tokio::test]
8958    async fn verification_transport_error_is_an_ordinary_failed_run_error() {
8959        let pool = seeded_pool().await;
8960        let (run_id, _config, _template, tags) =
8961            start_opc_test_run(&pool, "actuation-timeout-retry").await;
8962        let driver = honeywell_driver_auto().erroring_read(&tags.manipulated_variable);
8963        let mut args = fast_simulator_args();
8964        args.driver = DriverKindArg::Opcda;
8965        let initial = sample_initial_state();
8966        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
8967        let commanded_instant = Instant::now();
8968        let commanded_at = Utc::now();
8969        tracker
8970            .record_accepted(
8971                &pool,
8972                run_id,
8973                MvActuationKind::Relay,
8974                55.0,
8975                commanded_instant,
8976                commanded_at,
8977                commanded_instant,
8978                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
8979            )
8980            .await
8981            .unwrap();
8982
8983        let error = verify_pending_mv_actuation(
8984            &pool,
8985            &args,
8986            &tags.manipulated_variable,
8987            &driver,
8988            &mut CtrlC::never(),
8989            false,
8990            &mut tracker,
8991            None,
8992        )
8993        .await
8994        .unwrap_err();
8995        assert!(error.to_string().contains("driver operation failed"));
8996        assert!(tracker.pending.is_none());
8997        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
8998            .await
8999            .unwrap();
9000        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
9001        assert_eq!(rows[0].attempt_count, 1);
9002        assert_eq!(rows[0].readback_mv, None);
9003    }
9004
9005    #[tokio::test]
9006    async fn restore_verification_timeout_finalizes_the_pending_row_as_unverified() {
9007        let pool = seeded_pool().await;
9008        let (run_id, _config, _template, tags) =
9009            start_opc_test_run(&pool, "actuation-restore-verification-timeout").await;
9010        let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
9011        let mut args = fast_simulator_args();
9012        args.driver = DriverKindArg::Opcda;
9013        let initial = sample_initial_state();
9014        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9015        let accepted_instant = Instant::now();
9016        tracker
9017            .record_accepted(
9018                &pool,
9019                run_id,
9020                MvActuationKind::Restore,
9021                initial.mv_ini,
9022                accepted_instant,
9023                Utc::now(),
9024                accepted_instant,
9025                mv_actuation_tolerance(MvActuationKind::Restore, initial.mv_ini, 55.0, 100.0)
9026                    .unwrap(),
9027            )
9028            .await
9029            .unwrap();
9030
9031        let outcome = verify_pending_mv_actuation_with(
9032            &pool,
9033            &args,
9034            &tags.manipulated_variable,
9035            &driver,
9036            &mut CtrlC::never(),
9037            false,
9038            &mut tracker,
9039            MvVerificationTrigger::Deadline,
9040            MvVerificationCallLimit::Restore(Instant::now()),
9041            ActuationAuditPolicy::BestEffort,
9042        )
9043        .await
9044        .unwrap();
9045
9046        assert!(matches!(
9047            outcome,
9048            Some(AbortReason::MvActuationUnconfirmed { readback: None, .. })
9049        ));
9050        assert!(tracker.pending.is_none());
9051        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9052            .await
9053            .unwrap();
9054        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
9055        assert_eq!(rows[0].attempt_count, 1);
9056    }
9057
9058    #[tokio::test]
9059    async fn required_confirmation_audit_failure_keeps_the_pending_state_for_retry() {
9060        let pool = seeded_pool().await;
9061        let (run_id, _config, _template, tags) =
9062            start_opc_test_run(&pool, "actuation-required-audit-failure").await;
9063        let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "55");
9064        let mut args = fast_simulator_args();
9065        args.driver = DriverKindArg::Opcda;
9066        let initial = sample_initial_state();
9067        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9068        let accepted_instant = Instant::now();
9069        tracker
9070            .record_accepted(
9071                &pool,
9072                run_id,
9073                MvActuationKind::Relay,
9074                55.0,
9075                accepted_instant,
9076                Utc::now(),
9077                accepted_instant,
9078                mv_actuation_tolerance(MvActuationKind::Relay, 55.0, 45.0, 100.0).unwrap(),
9079            )
9080            .await
9081            .unwrap();
9082        pool.close().await;
9083
9084        let error = verify_pending_mv_actuation_with(
9085            &pool,
9086            &args,
9087            &tags.manipulated_variable,
9088            &driver,
9089            &mut CtrlC::never(),
9090            false,
9091            &mut tracker,
9092            MvVerificationTrigger::Scheduled,
9093            MvVerificationCallLimit::None,
9094            ActuationAuditPolicy::Required,
9095        )
9096        .await
9097        .unwrap_err();
9098
9099        assert!(error.to_string().contains("pool"));
9100        assert!(tracker.pending.is_some());
9101        assert_eq!(tracker.confirmed_mv, None);
9102    }
9103
9104    #[tokio::test]
9105    async fn best_effort_confirmation_audit_failure_does_not_block_restore_state() {
9106        let pool = seeded_pool().await;
9107        let (run_id, _config, _template, tags) =
9108            start_opc_test_run(&pool, "actuation-best-effort-audit-failure").await;
9109        let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "55");
9110        let mut args = fast_simulator_args();
9111        args.driver = DriverKindArg::Opcda;
9112        let initial = sample_initial_state();
9113        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9114        let accepted_instant = Instant::now();
9115        tracker
9116            .record_accepted(
9117                &pool,
9118                run_id,
9119                MvActuationKind::Restore,
9120                55.0,
9121                accepted_instant,
9122                Utc::now(),
9123                accepted_instant,
9124                mv_actuation_tolerance(MvActuationKind::Restore, 55.0, 45.0, 100.0).unwrap(),
9125            )
9126            .await
9127            .unwrap();
9128        pool.close().await;
9129
9130        let outcome = verify_pending_mv_actuation_with(
9131            &pool,
9132            &args,
9133            &tags.manipulated_variable,
9134            &driver,
9135            &mut CtrlC::never(),
9136            false,
9137            &mut tracker,
9138            MvVerificationTrigger::Scheduled,
9139            MvVerificationCallLimit::None,
9140            ActuationAuditPolicy::BestEffort,
9141        )
9142        .await
9143        .unwrap();
9144
9145        assert_eq!(outcome, None);
9146        assert!(tracker.pending.is_none());
9147        assert_eq!(tracker.confirmed_mv, Some(55.0));
9148    }
9149
9150    #[tokio::test]
9151    async fn restore_supersedes_final_snapback_still_pending_during_padding() {
9152        let pool = seeded_pool().await;
9153        let (run_id, _config, _template, tags) =
9154            start_opc_test_run(&pool, "actuation-restore").await;
9155        let driver = honeywell_driver_auto();
9156        let mut args = fast_simulator_args();
9157        args.driver = DriverKindArg::Opcda;
9158        let initial = sample_initial_state();
9159        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9160        tracker.previous_commanded_mv = 55.0;
9161        write_value(&driver, &tags.manipulated_variable, initial.mv_ini)
9162            .await
9163            .unwrap();
9164        let commanded_instant = Instant::now();
9165        let commanded_at = Utc::now();
9166        tracker
9167            .record_accepted(
9168                &pool,
9169                run_id,
9170                MvActuationKind::Relay,
9171                initial.mv_ini,
9172                commanded_instant + Duration::from_secs(10),
9173                commanded_at,
9174                commanded_instant,
9175                mv_actuation_tolerance(MvActuationKind::Relay, initial.mv_ini, 55.0, 100.0)
9176                    .unwrap(),
9177            )
9178            .await
9179            .unwrap();
9180        let mut tracker = Some(tracker);
9181
9182        let outcome = restore_mv_with_verification(
9183            &pool,
9184            run_id,
9185            &args,
9186            &driver,
9187            &tags.manipulated_variable,
9188            initial.mv_ini,
9189            false,
9190            &mut CtrlC::never(),
9191            &mut tracker,
9192            Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9193        )
9194        .await
9195        .unwrap();
9196
9197        assert!(matches!(
9198            outcome,
9199            RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9200        ));
9201        assert_eq!(
9202            driver.write_log().len(),
9203            1,
9204            "restore must adopt a confirmed final snapback instead of writing MV again"
9205        );
9206        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9207            .await
9208            .unwrap();
9209        assert_eq!(rows.len(), 1);
9210        assert_eq!(rows[0].kind, MvActuationKind::Relay);
9211        assert_eq!(rows[0].status, MvActuationStatus::Superseded);
9212        assert_eq!(rows[0].attempt_count, 1);
9213        assert!(
9214            rows[0]
9215                .detail
9216                .as_deref()
9217                .is_some_and(|detail| detail.contains("no duplicate MV write"))
9218        );
9219    }
9220
9221    #[tokio::test]
9222    async fn restore_rewrites_an_unconfirmed_final_snapback_without_waiting_twice() {
9223        let pool = seeded_pool().await;
9224        let (run_id, _config, _template, tags) =
9225            start_opc_test_run(&pool, "actuation-restore-rewrite").await;
9226        let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "50");
9227        let mut args = fast_simulator_args();
9228        args.driver = DriverKindArg::Opcda;
9229        let initial = sample_initial_state();
9230        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9231        tracker.previous_commanded_mv = 55.0;
9232        let accepted_instant = Instant::now();
9233        tracker
9234            .record_accepted(
9235                &pool,
9236                run_id,
9237                MvActuationKind::Relay,
9238                initial.mv_ini,
9239                accepted_instant + Duration::from_secs(4),
9240                Utc::now(),
9241                accepted_instant,
9242                mv_actuation_tolerance(MvActuationKind::Relay, initial.mv_ini, 55.0, 100.0)
9243                    .unwrap(),
9244            )
9245            .await
9246            .unwrap();
9247        let mut tracker = Some(tracker);
9248
9249        let started = Instant::now();
9250        let outcome = restore_mv_with_verification(
9251            &pool,
9252            run_id,
9253            &args,
9254            &driver,
9255            &tags.manipulated_variable,
9256            initial.mv_ini,
9257            false,
9258            &mut CtrlC::never(),
9259            &mut tracker,
9260            Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9261        )
9262        .await
9263        .unwrap();
9264
9265        assert!(started.elapsed() < Duration::from_secs(1));
9266        assert!(matches!(
9267            outcome,
9268            RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9269        ));
9270        assert_eq!(
9271            driver.write_log(),
9272            vec![(
9273                tags.manipulated_variable.clone(),
9274                initial.mv_ini.to_string()
9275            )]
9276        );
9277        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9278            .await
9279            .unwrap();
9280        assert_eq!(rows.len(), 2);
9281        assert_eq!(rows[0].status, MvActuationStatus::Superseded);
9282        assert_eq!(rows[0].readback_mv, Some(50.0));
9283        assert_eq!(rows[1].kind, MvActuationKind::Restore);
9284        assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
9285    }
9286
9287    #[tokio::test]
9288    async fn slow_snapback_handoff_reserves_time_for_authoritative_restore() {
9289        let pool = seeded_pool().await;
9290        let (run_id, _config, _template, tags) =
9291            start_opc_test_run(&pool, "actuation-restore-budget").await;
9292        let driver = honeywell_driver_auto()
9293            .delaying_read(&tags.manipulated_variable, Duration::from_millis(1_100));
9294        let mut args = fast_simulator_args();
9295        args.driver = DriverKindArg::Opcda;
9296        args.restore_timeout_secs = MV_ACTUATION_CONFIRMATION_SECS + 1;
9297        let initial = sample_initial_state();
9298        let mut tracker = MvActuationTracker::for_run(&args, &initial).unwrap();
9299        tracker.previous_commanded_mv = 55.0;
9300        let accepted_instant = Instant::now();
9301        tracker
9302            .record_accepted(
9303                &pool,
9304                run_id,
9305                MvActuationKind::Relay,
9306                initial.mv_ini,
9307                accepted_instant + Duration::from_secs(4),
9308                Utc::now(),
9309                accepted_instant,
9310                mv_actuation_tolerance(MvActuationKind::Relay, initial.mv_ini, 55.0, 100.0)
9311                    .unwrap(),
9312            )
9313            .await
9314            .unwrap();
9315        let mut tracker = Some(tracker);
9316
9317        let started = Instant::now();
9318        let outcome = restore_mv_with_verification(
9319            &pool,
9320            run_id,
9321            &args,
9322            &driver,
9323            &tags.manipulated_variable,
9324            initial.mv_ini,
9325            false,
9326            &mut CtrlC::never(),
9327            &mut tracker,
9328            Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9329        )
9330        .await
9331        .unwrap();
9332
9333        assert!(started.elapsed() < Duration::from_secs(4));
9334        assert!(matches!(
9335            outcome,
9336            RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9337        ));
9338        assert_eq!(
9339            driver.write_log(),
9340            vec![(
9341                tags.manipulated_variable.clone(),
9342                initial.mv_ini.to_string()
9343            )],
9344            "the handoff read must fall back before consuming the restore write budget"
9345        );
9346        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9347            .await
9348            .unwrap();
9349        assert_eq!(rows.len(), 2);
9350        assert_eq!(rows[0].status, MvActuationStatus::Superseded);
9351        assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
9352    }
9353
9354    #[tokio::test]
9355    async fn snapback_handoff_skips_reads_that_would_consume_the_restore_budget() {
9356        let pool = seeded_pool().await;
9357        let driver = honeywell_driver_auto();
9358        let mut args = fast_simulator_args();
9359        args.driver = DriverKindArg::Opcda;
9360        let now = Instant::now();
9361
9362        for restore_deadline in [
9363            now + Duration::from_secs(1),
9364            Instant::now() + Duration::from_secs(MV_ACTUATION_CONFIRMATION_SECS),
9365        ] {
9366            let pending = pending_actuation(
9367                None,
9368                MvActuationKind::Relay,
9369                45.0,
9370                now,
9371                now + Duration::from_secs(10),
9372                None,
9373            );
9374            let mut tracker = tracker_with_pending(pending);
9375            let outcome = try_confirm_final_snapback_handoff(
9376                &pool,
9377                &args,
9378                &driver,
9379                "Unit1.LIC101.OP",
9380                45.0,
9381                false,
9382                &mut CtrlC::never(),
9383                &mut tracker,
9384                restore_deadline,
9385            )
9386            .await
9387            .unwrap();
9388
9389            assert!(matches!(outcome, Some(RestoreHandoffOutcome::Rewrite)));
9390            assert!(tracker.pending.is_none());
9391        }
9392        assert!(driver.read_batches().is_empty());
9393    }
9394
9395    #[tokio::test]
9396    async fn snapback_handoff_failures_fall_back_to_an_authoritative_rewrite() {
9397        let pool = seeded_pool().await;
9398        let mut args = fast_simulator_args();
9399        args.driver = DriverKindArg::Opcda;
9400        let restore_deadline = Instant::now() + Duration::from_secs(10);
9401
9402        let cases = [
9403            honeywell_driver_auto().erroring_read("Unit1.LIC101.OP"),
9404            honeywell_driver_auto()
9405                .hanging_read("Unit1.LIC101.OP")
9406                .with_quality("Unit1.LIC101.OP", bhtune_driver::Quality::Good),
9407            honeywell_driver_auto().with_quality("Unit1.LIC101.OP", bhtune_driver::Quality::Bad),
9408        ];
9409        for (index, driver) in cases.into_iter().enumerate() {
9410            let now = Instant::now();
9411            let pending = pending_actuation(
9412                None,
9413                MvActuationKind::Relay,
9414                45.0,
9415                now,
9416                now + Duration::from_secs(10),
9417                None,
9418            );
9419            let mut tracker = tracker_with_pending(pending);
9420            if index == 1 {
9421                args.op_timeout_secs = 0;
9422            } else {
9423                args.op_timeout_secs = 30;
9424            }
9425            let outcome = try_confirm_final_snapback_handoff(
9426                &pool,
9427                &args,
9428                &driver,
9429                "Unit1.LIC101.OP",
9430                45.0,
9431                false,
9432                &mut CtrlC::never(),
9433                &mut tracker,
9434                restore_deadline,
9435            )
9436            .await
9437            .unwrap();
9438
9439            assert!(matches!(outcome, Some(RestoreHandoffOutcome::Rewrite)));
9440            assert!(tracker.pending.is_none());
9441        }
9442    }
9443
9444    #[tokio::test]
9445    async fn snapback_handoff_preserves_pending_state_when_ctrl_c_interrupts_the_read() {
9446        let pool = seeded_pool().await;
9447        let driver = honeywell_driver_auto().hanging_read("Unit1.LIC101.OP");
9448        let mut args = fast_simulator_args();
9449        args.driver = DriverKindArg::Opcda;
9450        let now = Instant::now();
9451        let pending = pending_actuation(
9452            None,
9453            MvActuationKind::Relay,
9454            45.0,
9455            now,
9456            now + Duration::from_secs(10),
9457            None,
9458        );
9459        let mut tracker = tracker_with_pending(pending);
9460        let (mut ctrl_c, tx) = CtrlC::test_pair();
9461        tx.send(1).unwrap();
9462
9463        let outcome = try_confirm_final_snapback_handoff(
9464            &pool,
9465            &args,
9466            &driver,
9467            "Unit1.LIC101.OP",
9468            45.0,
9469            false,
9470            &mut ctrl_c,
9471            &mut tracker,
9472            Instant::now() + Duration::from_secs(10),
9473        )
9474        .await
9475        .unwrap();
9476
9477        assert!(matches!(
9478            outcome,
9479            Some(RestoreHandoffOutcome::Interrupted(_))
9480        ));
9481        assert!(tracker.pending.is_some());
9482    }
9483
9484    #[tokio::test]
9485    async fn restore_mv_propagates_an_interrupted_final_snapback_handoff() {
9486        let pool = seeded_pool().await;
9487        let driver = honeywell_driver_auto().hanging_read("Unit1.LIC101.OP");
9488        let mut args = fast_simulator_args();
9489        args.driver = DriverKindArg::Opcda;
9490        let now = Instant::now();
9491        let mut tracker = Some(tracker_with_pending(pending_actuation(
9492            None,
9493            MvActuationKind::Relay,
9494            45.0,
9495            now,
9496            now + Duration::from_secs(10),
9497            None,
9498        )));
9499        let (mut ctrl_c, tx) = CtrlC::test_pair();
9500        tx.send(1).unwrap();
9501
9502        let outcome = restore_mv_with_verification(
9503            &pool,
9504            0,
9505            &args,
9506            &driver,
9507            "Unit1.LIC101.OP",
9508            45.0,
9509            false,
9510            &mut ctrl_c,
9511            &mut tracker,
9512            Instant::now() + Duration::from_secs(10),
9513        )
9514        .await
9515        .unwrap();
9516
9517        assert!(matches!(
9518            outcome,
9519            RestoreMvOutcome::Interrupted(ref detail)
9520                if detail.contains("final MRFT snapback")
9521        ));
9522        assert!(tracker.as_ref().unwrap().pending.is_some());
9523    }
9524
9525    #[tokio::test]
9526    async fn restore_mv_without_a_tracker_reports_an_operation_timeout() {
9527        let pool = seeded_pool().await;
9528        let driver = honeywell_driver_auto().hanging_write("Unit1.LIC101.OP");
9529        let mut args = fast_simulator_args();
9530        args.op_timeout_secs = 0;
9531        let mut tracker = None;
9532
9533        let outcome = restore_mv_with_verification(
9534            &pool,
9535            0,
9536            &args,
9537            &driver,
9538            "Unit1.LIC101.OP",
9539            45.0,
9540            false,
9541            &mut CtrlC::never(),
9542            &mut tracker,
9543            Instant::now() + Duration::from_secs(1),
9544        )
9545        .await
9546        .unwrap();
9547
9548        assert!(matches!(
9549            outcome,
9550            RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(ref detail))
9551                if detail.contains("restore write did not complete")
9552        ));
9553    }
9554
9555    #[tokio::test]
9556    async fn tracked_restore_write_handles_deadline_cancel_and_operation_timeout() {
9557        let pool = seeded_pool().await;
9558        let driver = honeywell_driver_auto().hanging_write("Unit1.LIC101.OP");
9559        let initial = sample_initial_state();
9560
9561        let now = Instant::now();
9562        let mut deadline_tracker = Some(tracker_with_pending(pending_actuation(
9563            None,
9564            MvActuationKind::Relay,
9565            55.0,
9566            now,
9567            now + Duration::from_secs(1),
9568            None,
9569        )));
9570        let deadline_outcome = restore_mv_with_verification(
9571            &pool,
9572            0,
9573            &fast_simulator_args(),
9574            &driver,
9575            "Unit1.LIC101.OP",
9576            initial.mv_ini,
9577            false,
9578            &mut CtrlC::never(),
9579            &mut deadline_tracker,
9580            Instant::now(),
9581        )
9582        .await
9583        .unwrap();
9584        assert!(matches!(
9585            deadline_outcome,
9586            RestoreMvOutcome::Interrupted(ref detail)
9587                if detail.contains("[tuning].restore_timeout_secs")
9588        ));
9589
9590        let now = Instant::now();
9591        let mut cancel_tracker = Some(tracker_with_pending(pending_actuation(
9592            None,
9593            MvActuationKind::Relay,
9594            55.0,
9595            now,
9596            now + Duration::from_secs(1),
9597            None,
9598        )));
9599        let (mut ctrl_c, tx) = CtrlC::test_pair();
9600        tx.send(1).unwrap();
9601        let cancel_outcome = restore_mv_with_verification(
9602            &pool,
9603            0,
9604            &fast_simulator_args(),
9605            &driver,
9606            "Unit1.LIC101.OP",
9607            initial.mv_ini,
9608            false,
9609            &mut ctrl_c,
9610            &mut cancel_tracker,
9611            Instant::now() + Duration::from_secs(1),
9612        )
9613        .await
9614        .unwrap();
9615        assert!(matches!(
9616            cancel_outcome,
9617            RestoreMvOutcome::Interrupted(ref detail) if detail.contains("second Ctrl+C")
9618        ));
9619
9620        let now = Instant::now();
9621        let mut timeout_tracker = Some(tracker_with_pending(pending_actuation(
9622            None,
9623            MvActuationKind::Relay,
9624            55.0,
9625            now,
9626            now + Duration::from_secs(1),
9627            None,
9628        )));
9629        let mut timeout_args = fast_simulator_args();
9630        timeout_args.op_timeout_secs = 0;
9631        let timeout_outcome = restore_mv_with_verification(
9632            &pool,
9633            0,
9634            &timeout_args,
9635            &driver,
9636            "Unit1.LIC101.OP",
9637            initial.mv_ini,
9638            false,
9639            &mut CtrlC::never(),
9640            &mut timeout_tracker,
9641            Instant::now() + Duration::from_secs(1),
9642        )
9643        .await
9644        .unwrap();
9645        assert!(matches!(
9646            timeout_outcome,
9647            RestoreMvOutcome::Continue(RestoreStepOutcome::Failed(ref detail))
9648                if detail.contains("restore write did not complete")
9649        ));
9650    }
9651
9652    #[tokio::test]
9653    async fn restore_verification_retries_a_mismatch_then_confirms() {
9654        let pool = seeded_pool().await;
9655        let (run_id, _config, _template, tags) =
9656            start_opc_test_run(&pool, "restore-retry-confirm").await;
9657        let driver =
9658            honeywell_driver_auto().with_read_sequence(&tags.manipulated_variable, &["50", "45"]);
9659        let mut args = fast_simulator_args();
9660        args.driver = DriverKindArg::Opcda;
9661        let initial = sample_initial_state();
9662        let mut tracker = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
9663
9664        let outcome = restore_mv_with_verification(
9665            &pool,
9666            run_id,
9667            &args,
9668            &driver,
9669            &tags.manipulated_variable,
9670            initial.mv_ini,
9671            false,
9672            &mut CtrlC::never(),
9673            &mut tracker,
9674            Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9675        )
9676        .await
9677        .unwrap();
9678
9679        assert!(matches!(
9680            outcome,
9681            RestoreMvOutcome::Continue(RestoreStepOutcome::Succeeded)
9682        ));
9683        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9684            .await
9685            .unwrap();
9686        assert_eq!(rows[0].attempt_count, 2);
9687        assert_eq!(rows[0].status, MvActuationStatus::Confirmed);
9688    }
9689
9690    #[tokio::test]
9691    async fn ctrl_c_during_restore_verification_interrupts_after_the_write() {
9692        let pool = seeded_pool().await;
9693        let (run_id, _config, _template, tags) =
9694            start_opc_test_run(&pool, "restore-verification-cancel").await;
9695        let driver = honeywell_driver_auto()
9696            .delaying_read(&tags.manipulated_variable, Duration::from_millis(500));
9697        let mut args = fast_simulator_args();
9698        args.driver = DriverKindArg::Opcda;
9699        let initial = sample_initial_state();
9700        let mut tracker = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
9701        let (mut ctrl_c, tx) = CtrlC::test_pair();
9702        tokio::spawn(async move {
9703            tokio::time::sleep(Duration::from_millis(30)).await;
9704            let _ = tx.send(1);
9705        });
9706
9707        let outcome = restore_mv_with_verification(
9708            &pool,
9709            run_id,
9710            &args,
9711            &driver,
9712            &tags.manipulated_variable,
9713            initial.mv_ini,
9714            false,
9715            &mut ctrl_c,
9716            &mut tracker,
9717            Instant::now() + Duration::from_secs(args.restore_timeout_secs),
9718        )
9719        .await
9720        .unwrap();
9721
9722        assert!(matches!(
9723            outcome,
9724            RestoreMvOutcome::Interrupted(ref detail)
9725                if detail.contains("confirming the restored MV")
9726        ));
9727    }
9728
9729    #[tokio::test]
9730    async fn restore_verification_reports_the_expired_restore_deadline() {
9731        let pool = seeded_pool().await;
9732        let (run_id, _config, _template, tags) =
9733            start_opc_test_run(&pool, "restore-verification-deadline").await;
9734        let driver = honeywell_driver_auto().hanging_read(&tags.manipulated_variable);
9735        let mut args = fast_simulator_args();
9736        args.driver = DriverKindArg::Opcda;
9737        args.op_timeout_secs = 30;
9738        let initial = sample_initial_state();
9739        let mut tracker = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
9740
9741        let outcome = restore_mv_with_verification(
9742            &pool,
9743            run_id,
9744            &args,
9745            &driver,
9746            &tags.manipulated_variable,
9747            initial.mv_ini,
9748            false,
9749            &mut CtrlC::never(),
9750            &mut tracker,
9751            Instant::now() + Duration::from_millis(30),
9752        )
9753        .await
9754        .unwrap();
9755
9756        assert!(matches!(
9757            outcome,
9758            RestoreMvOutcome::Interrupted(ref detail)
9759                if detail.contains("[tuning].restore_timeout_secs")
9760        ));
9761    }
9762
9763    #[tokio::test]
9764    async fn restore_attempts_mode_setpoint_and_attribute_after_mv_quality_failure() {
9765        let pool = seeded_pool().await;
9766        let (run_id, _config, template, tags) =
9767            start_opc_test_run(&pool, "actuation-restore-quality").await;
9768        let driver = honeywell_driver_auto()
9769            .with_quality(&tags.manipulated_variable, bhtune_driver::Quality::Bad);
9770        let mut args = fast_simulator_args();
9771        args.driver = DriverKindArg::Opcda;
9772        let initial = sample_initial_state();
9773        let mut tracker = MvActuationTracker::for_run(&args, &initial);
9774        let guard = MutationGuard {
9775            mode_attribute_written: true,
9776            mode_written: true,
9777            mv_written: true,
9778        };
9779
9780        let outcome = attempt_restore_with_actuation(
9781            &pool,
9782            run_id,
9783            &args,
9784            &driver,
9785            &tags,
9786            &template,
9787            &initial,
9788            &guard,
9789            false,
9790            &mut CtrlC::never(),
9791            &mut tracker,
9792        )
9793        .await;
9794
9795        assert!(matches!(outcome, RestoreAttempt::Incomplete { .. }));
9796        let writes = driver.write_log();
9797        let mv_index = writes
9798            .iter()
9799            .position(|(tag, _)| tag == &tags.manipulated_variable)
9800            .unwrap();
9801        let mode_index = writes
9802            .iter()
9803            .position(|(tag, _)| Some(tag) == tags.controller_mode.as_ref())
9804            .unwrap();
9805        assert!(
9806            mv_index < mode_index,
9807            "the MV must be restored while the loop is still in Manual"
9808        );
9809        assert!(
9810            writes
9811                .iter()
9812                .any(|(tag, _)| Some(tag) == tags.setpoint_variable.as_ref())
9813        );
9814        assert!(
9815            writes
9816                .iter()
9817                .any(|(tag, _)| Some(tag) == tags.mode_attribute.as_ref())
9818        );
9819        let rows = TuneMvActuationRow::list_for_run(&pool, run_id)
9820            .await
9821            .unwrap();
9822        assert_eq!(rows.len(), 1);
9823        assert_eq!(rows[0].kind, MvActuationKind::Restore);
9824        assert_eq!(rows[0].status, MvActuationStatus::Unverified);
9825        assert_eq!(rows[0].readback_quality, Some(SampleQuality::Bad));
9826    }
9827
9828    #[tokio::test]
9829    async fn restore_audit_failure_does_not_prevent_any_physical_restore_step() {
9830        let pool = seeded_pool().await;
9831        let (run_id, _config, template, tags) =
9832            start_opc_test_run(&pool, "actuation-restore-audit-failure").await;
9833        pool.close().await;
9834        let driver = honeywell_driver_auto();
9835        let mut args = fast_simulator_args();
9836        args.driver = DriverKindArg::Opcda;
9837        let initial = sample_initial_state();
9838        let mut tracker = MvActuationTracker::for_run(&args, &initial);
9839        let guard = MutationGuard {
9840            mode_attribute_written: true,
9841            mode_written: true,
9842            mv_written: true,
9843        };
9844
9845        let outcome = attempt_restore_with_actuation(
9846            &pool,
9847            run_id,
9848            &args,
9849            &driver,
9850            &tags,
9851            &template,
9852            &initial,
9853            &guard,
9854            false,
9855            &mut CtrlC::never(),
9856            &mut tracker,
9857        )
9858        .await;
9859
9860        assert!(matches!(outcome, RestoreAttempt::Confirmed));
9861        let writes = driver.write_log();
9862        assert!(
9863            writes
9864                .iter()
9865                .any(|(tag, _)| tag == &tags.manipulated_variable)
9866        );
9867        assert!(
9868            writes
9869                .iter()
9870                .any(|(tag, _)| Some(tag) == tags.controller_mode.as_ref())
9871        );
9872        assert!(
9873            writes
9874                .iter()
9875                .any(|(tag, _)| Some(tag) == tags.setpoint_variable.as_ref())
9876        );
9877        assert!(
9878            writes
9879                .iter()
9880                .any(|(tag, _)| Some(tag) == tags.mode_attribute.as_ref())
9881        );
9882    }
9883
9884    #[tokio::test]
9885    async fn completed_opc_run_audits_final_snapback_through_post_test_padding() {
9886        let pool = seeded_pool().await;
9887        let mut args = fast_simulator_args();
9888        args.driver = DriverKindArg::Opcda;
9889        args.cycles_skip = Some(0);
9890        args.cycles_count = Some(1);
9891        args.mrft_delay = 1;
9892        args.poll_interval_ms = 20;
9893        args.timeout_secs = 5;
9894        let config = build_loop_config(&args).unwrap();
9895        let template = honeywell_template();
9896        let tags = honeywell_tags();
9897        let run = TuneRunRow::start(
9898            &pool,
9899            None,
9900            "actuation-padding-snapback",
9901            TuneDriver::Opcda,
9902            config,
9903            TemplateOrigin::Builtin,
9904            &template,
9905            &tags,
9906            Utc::now(),
9907        )
9908        .await
9909        .unwrap();
9910        let driver = honeywell_driver_auto()
9911            .with_read_sequence(&tags.process_variable, &["50", "55", "45", "55"]);
9912
9913        let outcome = execute(
9914            &pool,
9915            run.id,
9916            &args,
9917            &template,
9918            &tags,
9919            &driver,
9920            config,
9921            RunTimeAnchor::now(),
9922            None,
9923            false,
9924            &mut CtrlC::never(),
9925            &mut std::io::empty(),
9926        )
9927        .await
9928        .unwrap();
9929
9930        assert!(matches!(outcome, RunOutcome::Completed { .. }));
9931        let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
9932            .await
9933            .unwrap();
9934        assert_eq!(rows.len(), 3);
9935        assert_eq!(rows[2].kind, MvActuationKind::Relay);
9936        assert_eq!(rows[2].target_mv, sample_initial_state().mv_ini);
9937        assert_eq!(rows[2].status, MvActuationStatus::Confirmed);
9938        assert!(
9939            TuneSampleRow::list_for_run(&pool, run.id)
9940                .await
9941                .unwrap()
9942                .len()
9943                > 3,
9944            "post-test padding must continue recording PV samples"
9945        );
9946    }
9947
9948    #[tokio::test]
9949    async fn execute_rejects_a_zero_effective_relay_step_before_any_mutation() {
9950        let pool = seeded_pool().await;
9951        let (run_id, config, template, tags) =
9952            start_opc_test_run(&pool, "actuation-zero-step").await;
9953        let driver = honeywell_driver_auto().with_value(&tags.manipulated_variable, "100");
9954        let mut args = fast_simulator_args();
9955        args.driver = DriverKindArg::Opcda;
9956
9957        let error = execute(
9958            &pool,
9959            run_id,
9960            &args,
9961            &template,
9962            &tags,
9963            &driver,
9964            config,
9965            RunTimeAnchor::now(),
9966            None,
9967            false,
9968            &mut CtrlC::never(),
9969            &mut std::io::empty(),
9970        )
9971        .await
9972        .unwrap_err();
9973
9974        assert!(error.to_string().contains("too small to verify safely"));
9975        assert!(driver.write_log().is_empty());
9976    }
9977
9978    #[test]
9979    fn shared_restore_timeout_policy_keeps_simulator_positive_only() {
9980        assert!(validate_restore_timeout_secs(DriverKindArg::Simulator, 0).is_err());
9981        assert!(validate_restore_timeout_secs(DriverKindArg::Simulator, 1).is_ok());
9982        assert!(
9983            validate_restore_timeout_secs(DriverKindArg::Opcda, MV_ACTUATION_CONFIRMATION_SECS - 1)
9984                .is_err()
9985        );
9986        assert!(
9987            validate_restore_timeout_secs(DriverKindArg::Opcda, MV_ACTUATION_CONFIRMATION_SECS)
9988                .is_ok()
9989        );
9990    }
9991
9992    #[tokio::test]
9993    async fn prepare_rejects_short_opcda_restore_timeout_before_driver_or_database_mutation() {
9994        let pool = seeded_pool().await;
9995        let mut args = fast_simulator_args();
9996        args.driver = DriverKindArg::Opcda;
9997        args.server = Some("Mock.Server".to_string());
9998        args.bridge_host = Some("127.0.0.1:1".to_string());
9999        let mut config = test_config();
10000        config.tuning.restore_timeout_secs = Some(MV_ACTUATION_CONFIRMATION_SECS - 1);
10001
10002        let error = prepare(&pool, args, &config)
10003            .await
10004            .err()
10005            .expect("short OPC DA restore timeout must be rejected");
10006
10007        assert!(error.to_string().contains("tuning.restore_timeout_secs"));
10008        assert!(
10009            TuneRunRow::list(
10010                &pool,
10011                &bhtune_db::models::TuneRunFilter::default(),
10012                bhtune_db::models::Pagination::first(10),
10013            )
10014            .await
10015            .unwrap()
10016            .is_empty(),
10017            "validation must run before the tune_runs insert"
10018        );
10019    }
10020
10021    async fn assert_prepare_metadata_failure_is_terminal(column: &str) {
10022        let pool = seeded_pool().await;
10023        let trigger_name = format!("fail_{column}");
10024        let trigger = format!(
10025            "CREATE TRIGGER {trigger_name} \
10026             BEFORE UPDATE OF {column} ON tune_runs
10027             BEGIN SELECT RAISE(ABORT, 'injected metadata failure'); END"
10028        );
10029        sqlx::query(sqlx::AssertSqlSafe(trigger.as_str()))
10030            .execute(&pool)
10031            .await
10032            .unwrap();
10033
10034        let error = prepare(&pool, fast_simulator_args(), &test_config())
10035            .await
10036            .err()
10037            .expect("the injected metadata failure should abort preparation");
10038        assert!(error.to_string().contains("injected metadata failure"));
10039
10040        let runs = TuneRunRow::list(
10041            &pool,
10042            &bhtune_db::models::TuneRunFilter::default(),
10043            bhtune_db::models::Pagination::first(10),
10044        )
10045        .await
10046        .unwrap();
10047        assert_eq!(runs.len(), 1);
10048        assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Failed);
10049        assert!(
10050            runs[0]
10051                .failure_reason
10052                .as_deref()
10053                .is_some_and(|reason| reason.contains("injected metadata failure"))
10054        );
10055    }
10056
10057    #[tokio::test]
10058    async fn every_prepare_metadata_failure_marks_the_run_terminal() {
10059        for column in [
10060            "effective_tuning_json",
10061            "allow_uncertain_quality",
10062            "request_json",
10063            "notes",
10064        ] {
10065            assert_prepare_metadata_failure_is_terminal(column).await;
10066        }
10067    }
10068
10069    #[tokio::test]
10070    async fn prepare_deletes_the_run_when_terminalization_also_fails() {
10071        let pool = seeded_pool().await;
10072        sqlx::query(
10073            "CREATE TRIGGER fail_effective_tuning_update \
10074             BEFORE UPDATE OF effective_tuning_json ON tune_runs
10075             BEGIN SELECT RAISE(ABORT, 'injected metadata failure'); END",
10076        )
10077        .execute(&pool)
10078        .await
10079        .unwrap();
10080        sqlx::query(
10081            "CREATE TRIGGER fail_terminal_update \
10082             BEFORE UPDATE OF outcome ON tune_runs
10083             BEGIN SELECT RAISE(ABORT, 'injected terminalization failure'); END",
10084        )
10085        .execute(&pool)
10086        .await
10087        .unwrap();
10088
10089        let error = prepare(&pool, fast_simulator_args(), &test_config())
10090            .await
10091            .err()
10092            .expect("the injected metadata failure should abort preparation");
10093        assert!(error.to_string().contains("injected metadata failure"));
10094
10095        let runs = TuneRunRow::list(
10096            &pool,
10097            &bhtune_db::models::TuneRunFilter::default(),
10098            bhtune_db::models::Pagination::first(10),
10099        )
10100        .await
10101        .unwrap();
10102        assert!(
10103            runs.is_empty(),
10104            "the row must be removed when its failed outcome cannot be persisted"
10105        );
10106    }
10107
10108    #[tokio::test]
10109    async fn finalize_preparation_failure_reports_a_failed_cleanup() {
10110        let pool = seeded_pool().await;
10111        pool.close().await;
10112
10113        finalize_preparation_failure(&pool, 1, "injected preparation failure").await;
10114    }
10115
10116    #[tokio::test]
10117    async fn execute_aborts_and_records_reason_when_a_relay_command_is_unconfirmed() {
10118        let pool = seeded_pool().await;
10119        let mut args = fast_simulator_args();
10120        args.driver = DriverKindArg::Opcda;
10121        args.noise_protection_secs = Some(0);
10122        let config = build_loop_config(&args).unwrap();
10123        let template = honeywell_template();
10124        let tags = honeywell_tags();
10125        let run = TuneRunRow::start(
10126            &pool,
10127            None,
10128            "actuation-abort",
10129            TuneDriver::Opcda,
10130            config,
10131            TemplateOrigin::Builtin,
10132            &template,
10133            &tags,
10134            Utc::now(),
10135        )
10136        .await
10137        .unwrap();
10138        let driver =
10139            honeywell_driver_auto().distorting_first_writes(&tags.manipulated_variable, 1, -5.0);
10140
10141        let outcome = execute(
10142            &pool,
10143            run.id,
10144            &args,
10145            &template,
10146            &tags,
10147            &driver,
10148            config,
10149            RunTimeAnchor::now(),
10150            None,
10151            false,
10152            &mut CtrlC::never(),
10153            &mut std::io::empty(),
10154        )
10155        .await
10156        .unwrap();
10157
10158        assert!(matches!(
10159            outcome,
10160            RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed { .. })
10161        ));
10162        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10163        assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
10164        assert!(
10165            stored
10166                .failure_reason
10167                .as_deref()
10168                .is_some_and(|reason| reason.contains("MV actuation unconfirmed"))
10169        );
10170        let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
10171            .await
10172            .unwrap();
10173        assert_eq!(rows.len(), 2);
10174        assert_eq!(rows[0].kind, MvActuationKind::Relay);
10175        assert_eq!(rows[0].status, MvActuationStatus::Failed);
10176        assert_eq!(rows[1].kind, MvActuationKind::Restore);
10177        assert_eq!(rows[1].status, MvActuationStatus::Confirmed);
10178    }
10179
10180    #[tokio::test]
10181    async fn execute_finalizes_pending_actuation_when_sample_persistence_fails() {
10182        let pool = seeded_pool().await;
10183        let mut args = fast_simulator_args();
10184        args.driver = DriverKindArg::Opcda;
10185        args.noise_protection_secs = Some(10);
10186        let config = build_loop_config(&args).unwrap();
10187        let template = honeywell_template();
10188        let tags = honeywell_tags();
10189        let time_anchor = RunTimeAnchor::now();
10190        let run = TuneRunRow::start(
10191            &pool,
10192            None,
10193            "actuation-db-failure",
10194            TuneDriver::Opcda,
10195            config,
10196            TemplateOrigin::Builtin,
10197            &template,
10198            &tags,
10199            time_anchor.utc(),
10200        )
10201        .await
10202        .unwrap();
10203        let initial = sample_initial_state();
10204        let engine = MrftEngine::new(
10205            config,
10206            initial.direction,
10207            lookup(
10208                config.process_type,
10209                config.controller_type,
10210                ResponseLevel::Aggressive,
10211            )
10212            .beta,
10213            InitialReadings {
10214                pv_ini: initial.pv_ini,
10215                mv_ini: initial.mv_ini,
10216                mv_range_low: initial.mv_range_low,
10217                mv_range_high: initial.mv_range_high,
10218            },
10219            time_anchor.utc(),
10220            MrftCompat::default(),
10221        );
10222        TuneSampleRow::insert(
10223            &pool,
10224            run.id,
10225            0,
10226            Tick {
10227                time: time_anchor.utc(),
10228                pv: initial.pv_ini,
10229            },
10230            engine.state(),
10231            SampleQuality::Good,
10232        )
10233        .await
10234        .unwrap();
10235
10236        let error = execute(
10237            &pool,
10238            run.id,
10239            &args,
10240            &template,
10241            &tags,
10242            &honeywell_driver_auto(),
10243            config,
10244            time_anchor,
10245            None,
10246            false,
10247            &mut CtrlC::never(),
10248            &mut std::io::empty(),
10249        )
10250        .await
10251        .unwrap_err();
10252
10253        assert!(error.to_string().contains("UNIQUE constraint failed"));
10254        let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
10255            .await
10256            .unwrap();
10257        assert!(!rows.is_empty());
10258        assert!(
10259            rows.iter()
10260                .all(|row| row.status != MvActuationStatus::Pending)
10261        );
10262    }
10263
10264    #[tokio::test]
10265    async fn restore_incomplete_takes_precedence_over_actuation_failure() {
10266        let pool = seeded_pool().await;
10267        let mut args = fast_simulator_args();
10268        args.driver = DriverKindArg::Opcda;
10269        args.noise_protection_secs = Some(0);
10270        let config = build_loop_config(&args).unwrap();
10271        let template = honeywell_template();
10272        let tags = honeywell_tags();
10273        let run = TuneRunRow::start(
10274            &pool,
10275            None,
10276            "actuation-and-restore-fail",
10277            TuneDriver::Opcda,
10278            config,
10279            TemplateOrigin::Builtin,
10280            &template,
10281            &tags,
10282            Utc::now(),
10283        )
10284        .await
10285        .unwrap();
10286        let driver = honeywell_driver_auto()
10287            .distorting_first_writes(&tags.manipulated_variable, 1, -5.0)
10288            .rejecting_write_after(&tags.manipulated_variable, 1);
10289
10290        let outcome = execute(
10291            &pool,
10292            run.id,
10293            &args,
10294            &template,
10295            &tags,
10296            &driver,
10297            config,
10298            RunTimeAnchor::now(),
10299            None,
10300            false,
10301            &mut CtrlC::never(),
10302            &mut std::io::empty(),
10303        )
10304        .await
10305        .unwrap();
10306
10307        assert!(matches!(outcome, RunOutcome::RestoreIncomplete { .. }));
10308        assert_eq!(
10309            tune_outcome_for_run(&outcome),
10310            TuneOutcome::RestoreIncomplete
10311        );
10312        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10313        assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
10314        assert!(
10315            stored
10316                .failure_reason
10317                .as_deref()
10318                .is_some_and(|reason| reason.contains("MV actuation unconfirmed"))
10319        );
10320        let rows = TuneMvActuationRow::list_for_run(&pool, run.id)
10321            .await
10322            .unwrap();
10323        assert_eq!(rows.len(), 1);
10324        assert_eq!(rows[0].status, MvActuationStatus::Failed);
10325    }
10326
10327    #[tokio::test]
10328    async fn audit_cleanup_failure_does_not_override_restore_incomplete() {
10329        let pool = seeded_pool().await;
10330        let mut args = fast_simulator_args();
10331        args.driver = DriverKindArg::Opcda;
10332        let config = build_loop_config(&args).unwrap();
10333        let template = honeywell_template();
10334        let tags = honeywell_tags();
10335        let started_at = Utc::now();
10336        let run = TuneRunRow::start(
10337            &pool,
10338            None,
10339            "actuation-cleanup-failure",
10340            TuneDriver::Opcda,
10341            config,
10342            TemplateOrigin::Builtin,
10343            &template,
10344            &tags,
10345            started_at,
10346        )
10347        .await
10348        .unwrap();
10349        TuneMvActuationRow::insert_pending(
10350            &pool,
10351            run.id,
10352            NewTuneMvActuation {
10353                sequence: 0,
10354                kind: MvActuationKind::Relay,
10355                commanded_at: started_at,
10356                target_mv: 55.0,
10357                previous_commanded_mv: Some(45.0),
10358                tolerance: 0.1,
10359                confirmation_due_at: started_at + chrono::Duration::seconds(4),
10360            },
10361        )
10362        .await
10363        .unwrap();
10364        sqlx::query(
10365            "CREATE TRIGGER reject_actuation_cleanup \
10366             BEFORE UPDATE OF status ON tune_mv_actuations \
10367             WHEN OLD.status = 'pending' AND NEW.status = 'unverified' \
10368             BEGIN SELECT RAISE(FAIL, 'forced actuation cleanup failure'); END",
10369        )
10370        .execute(&pool)
10371        .await
10372        .unwrap();
10373        let driver = honeywell_driver_auto()
10374            .degrade_quality_after(&tags.process_variable, 1, bhtune_driver::Quality::Bad)
10375            .erroring_write(&tags.manipulated_variable);
10376
10377        let outcome = execute(
10378            &pool,
10379            run.id,
10380            &args,
10381            &template,
10382            &tags,
10383            &driver,
10384            config,
10385            RunTimeAnchor::now(),
10386            None,
10387            false,
10388            &mut CtrlC::never(),
10389            &mut std::io::empty(),
10390        )
10391        .await
10392        .unwrap();
10393
10394        assert!(matches!(outcome, RunOutcome::RestoreIncomplete { .. }));
10395        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10396        assert_eq!(stored.outcome, bhtune_db::models::TuneOutcome::Aborted);
10397        assert_eq!(
10398            stored.restore_status,
10399            Some(bhtune_db::models::RestoreStatus::Incomplete)
10400        );
10401    }
10402
10403    // --- `check_quality`: finding 5's single enforcement choke point ------------------------
10404
10405    #[test]
10406    fn check_quality_accepts_good_regardless_of_the_quality_policy() {
10407        assert!(check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Good, false).is_ok());
10408        assert!(check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Good, true).is_ok());
10409    }
10410
10411    #[test]
10412    fn check_quality_rejects_uncertain_unless_the_policy_allows_it() {
10413        let err =
10414            check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Uncertain, false).unwrap_err();
10415        assert!(err.to_string().contains("Uncertain"));
10416        assert!(err.to_string().contains("Unit1.LIC101.PV"));
10417        assert!(check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Uncertain, true).is_ok());
10418    }
10419
10420    #[test]
10421    fn check_quality_never_accepts_bad_regardless_of_the_policy() {
10422        let err_without_flag =
10423            check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Bad, false).unwrap_err();
10424        assert!(err_without_flag.to_string().contains("Bad"));
10425        let err_with_flag =
10426            check_quality("Unit1.LIC101.PV", bhtune_driver::Quality::Bad, true).unwrap_err();
10427        assert!(err_with_flag.to_string().contains("Bad"));
10428    }
10429
10430    // --- `pid_value_within_tolerance`: finding 6's write-back confirmation rule -------------
10431
10432    #[test]
10433    fn pid_value_within_tolerance_accepts_an_exact_match() {
10434        assert!(pid_value_within_tolerance(10.0, 10.0));
10435        assert!(pid_value_within_tolerance(0.0, 0.0));
10436    }
10437
10438    #[test]
10439    fn pid_value_within_tolerance_accepts_within_the_one_percent_relative_band() {
10440        // 1% of 10.0 is 0.1, so 10.09 is inside and 10.2 is outside.
10441        assert!(pid_value_within_tolerance(10.0, 10.09));
10442        assert!(pid_value_within_tolerance(10.0, 9.91));
10443        assert!(!pid_value_within_tolerance(10.0, 10.2));
10444        assert!(!pid_value_within_tolerance(10.0, 9.8));
10445    }
10446
10447    #[test]
10448    fn pid_value_within_tolerance_uses_the_absolute_floor_near_zero() {
10449        // 1% of a requested 0.0 (or anything smaller than 0.1) would be a tolerance under
10450        // 1e-3, which would reject even a harmless floating-point rounding difference -- the
10451        // absolute 1e-3 floor exists precisely for a requested `D = 0` on a PI controller.
10452        assert!(pid_value_within_tolerance(0.0, 0.0009));
10453        assert!(!pid_value_within_tolerance(0.0, 0.002));
10454    }
10455
10456    #[test]
10457    fn pid_value_within_tolerance_handles_negative_requested_values() {
10458        // The tolerance formula uses `requested.abs()`, so the relative band is symmetric
10459        // around a negative requested value too (relevant for reverse-acting controllers'
10460        // sign conventions).
10461        assert!(pid_value_within_tolerance(-10.0, -10.09));
10462        assert!(!pid_value_within_tolerance(-10.0, -10.2));
10463    }
10464
10465    /// "Honeywell Experion" is the one built-in template with every optional tag (setpoint,
10466    /// mode, mode attribute, PID constants) configured, making it the right fixture for
10467    /// exercising every opcda-style branch in one place.
10468    fn honeywell_template() -> DcsTemplate {
10469        bhtune_core::built_in_templates()
10470            .into_iter()
10471            .find(|t| t.name == "Honeywell Experion")
10472            .expect("Honeywell Experion is a built-in template")
10473    }
10474
10475    fn honeywell_tags() -> LoopTags {
10476        LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &honeywell_template())
10477    }
10478
10479    fn yokogawa_template() -> DcsTemplate {
10480        bhtune_core::built_in_templates()
10481            .into_iter()
10482            .find(|t| t.name == "Yokogawa CentumVP")
10483            .expect("Yokogawa CentumVP is a built-in template")
10484    }
10485
10486    fn yokogawa_tags() -> LoopTags {
10487        LoopTags::derive_from_pv_tag("Unit1.FIC101.PV", &yokogawa_template())
10488    }
10489
10490    /// A `MockDriver` pre-populated with every tag `honeywell_tags()` derives, using values
10491    /// that make the loop initially Auto (`MODE=1`) with its Mode Attribute not yet at the
10492    /// Program value (`MODEATTR=1`, program value is `"2"`) — the common starting point most
10493    /// of the tests below share before diverging.
10494    fn honeywell_driver_auto() -> MockDriver {
10495        MockDriver::new(&[
10496            ("Unit1.LIC101.PV", "50.0"),
10497            ("Unit1.LIC101.OP", "45.0"),
10498            ("Unit1.LIC101.MODE", "1"),
10499            ("Unit1.LIC101.MODEATTR", "1"),
10500            ("Unit1.LIC101.CTLACTN", "0"),
10501            ("Unit1.LIC101.PVEUHI", "100.0"),
10502            ("Unit1.LIC101.PVEULO", "0.0"),
10503            ("Unit1.LIC101.CVEUHI", "100.0"),
10504            ("Unit1.LIC101.CVEULO", "0.0"),
10505            ("Unit1.LIC101.SP", "55.0"),
10506            ("Unit1.LIC101.K", "10.0"),
10507            ("Unit1.LIC101.T1", "2.0"),
10508            ("Unit1.LIC101.T2", "0.5"),
10509        ])
10510    }
10511
10512    #[tokio::test]
10513    async fn read_initial_values_batches_the_opcda_tag_set_before_auto_setpoint() {
10514        let template = honeywell_template();
10515        let tags = honeywell_tags();
10516        let driver = honeywell_driver_auto();
10517
10518        let initial = read_initial_values(&driver, &tags, &template, false)
10519            .await
10520            .unwrap();
10521        assert_eq!(initial.pv_ini, 50.0);
10522        assert_eq!(initial.mv_ini, 45.0);
10523        assert_eq!(initial.pv_range_high, 100.0);
10524        assert_eq!(initial.pv_range_low, 0.0);
10525        assert_eq!(initial.mv_range_high, 100.0);
10526        assert_eq!(initial.mv_range_low, 0.0);
10527        assert_eq!(initial.direction, ControllerDirection::Direct);
10528        assert_eq!(initial.mode_raw.as_deref(), Some("1"));
10529        assert_eq!(initial.mode_attribute_raw.as_deref(), Some("1"));
10530        assert_eq!(
10531            driver.read_batches(),
10532            vec![
10533                vec![
10534                    "Unit1.LIC101.PV".to_string(),
10535                    "Unit1.LIC101.OP".to_string(),
10536                    "Unit1.LIC101.MODE".to_string(),
10537                    "Unit1.LIC101.MODEATTR".to_string(),
10538                    "Unit1.LIC101.CTLACTN".to_string(),
10539                    "Unit1.LIC101.PVEUHI".to_string(),
10540                    "Unit1.LIC101.PVEULO".to_string(),
10541                    "Unit1.LIC101.CVEUHI".to_string(),
10542                    "Unit1.LIC101.CVEULO".to_string(),
10543                ],
10544                vec!["Unit1.LIC101.SP".to_string()],
10545            ]
10546        );
10547    }
10548
10549    #[tokio::test]
10550    async fn read_initial_values_batches_manual_tags_without_reading_setpoint() {
10551        let template = yokogawa_template();
10552        let tags = yokogawa_tags();
10553        let driver = MockDriver::new(&[
10554            ("Unit1.FIC101.PV", "50.0"),
10555            ("Unit1.FIC101.MV", "45.0"),
10556            ("Unit1.FIC101.MODE", "MAN"),
10557            ("Unit1.FIC101.DR", "0"),
10558            ("Unit1.FIC101.SH", "100.0"),
10559            ("Unit1.FIC101.SL", "0.0"),
10560            ("Unit1.FIC101.MSH", "100.0"),
10561            ("Unit1.FIC101.MSL", "0.0"),
10562        ]);
10563
10564        let initial = read_initial_values(&driver, &tags, &template, false)
10565            .await
10566            .unwrap();
10567
10568        assert_eq!(initial.mode_raw.as_deref(), Some("MAN"));
10569        assert_eq!(initial.setpoint_ini, None);
10570        assert_eq!(
10571            driver.read_batches(),
10572            vec![vec![
10573                "Unit1.FIC101.PV".to_string(),
10574                "Unit1.FIC101.MV".to_string(),
10575                "Unit1.FIC101.MODE".to_string(),
10576                "Unit1.FIC101.DR".to_string(),
10577                "Unit1.FIC101.SH".to_string(),
10578                "Unit1.FIC101.SL".to_string(),
10579                "Unit1.FIC101.MSH".to_string(),
10580                "Unit1.FIC101.MSL".to_string(),
10581            ]]
10582        );
10583    }
10584
10585    #[tokio::test]
10586    async fn read_initial_values_deduplicates_tags_and_skips_fixed_overrides() {
10587        let template = honeywell_template();
10588        let mut tags = honeywell_tags();
10589        tags.manipulated_variable = tags.process_variable.clone();
10590        tags.controller_mode = None;
10591        tags.mode_attribute = None;
10592        tags.controller_direction = TagOrValue::Value(ControllerDirection::Reverse);
10593        tags.upper_pv_range = TagOrValue::Value(100.0);
10594        tags.lower_pv_range = TagOrValue::Value(0.0);
10595        tags.upper_mv_range = TagOrValue::Value(100.0);
10596        tags.lower_mv_range = TagOrValue::Value(0.0);
10597        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "50.0")]);
10598
10599        let initial = read_initial_values(&driver, &tags, &template, false)
10600            .await
10601            .unwrap();
10602
10603        assert_eq!(initial.pv_ini, 50.0);
10604        assert_eq!(initial.mv_ini, 50.0);
10605        assert_eq!(initial.direction, ControllerDirection::Reverse);
10606        assert_eq!(
10607            driver.read_batches(),
10608            vec![vec!["Unit1.LIC101.PV".to_string()]]
10609        );
10610    }
10611
10612    #[tokio::test]
10613    async fn read_initial_values_errors_when_a_tag_returns_no_value() {
10614        let template = honeywell_template();
10615        let tags = honeywell_tags();
10616        let driver = honeywell_driver_auto().empty_read("Unit1.LIC101.PV");
10617
10618        let err = read_initial_values(&driver, &tags, &template, false)
10619            .await
10620            .unwrap_err();
10621        assert!(err.to_string().contains("no value"));
10622    }
10623
10624    /// Matches `honeywell_driver_auto()`'s values -- a valid baseline for
10625    /// `validate_initial_state` tests to mutate one field at a time from. `setpoint_ini` is
10626    /// `Some(55.0)`, matching the fixture's `SP` tag, since `mode_raw` ("1") equals the
10627    /// template's `mode_auto_value` -- exactly the condition `read_initial_values` itself
10628    /// checks before reading the setpoint.
10629    fn sample_initial_state() -> InitialState {
10630        InitialState {
10631            pv_ini: 50.0,
10632            mv_ini: 45.0,
10633            pv_range_high: 100.0,
10634            pv_range_low: 0.0,
10635            mv_range_high: 100.0,
10636            mv_range_low: 0.0,
10637            direction: ControllerDirection::Direct,
10638            mode_raw: Some("1".to_string()),
10639            mode_attribute_raw: Some("1".to_string()),
10640            setpoint_ini: Some(55.0),
10641        }
10642    }
10643
10644    #[test]
10645    fn validate_initial_state_accepts_a_typical_reading() {
10646        assert!(validate_initial_state(&sample_initial_state()).is_ok());
10647    }
10648
10649    #[test]
10650    fn validate_initial_state_rejects_a_zero_span_pv_range() {
10651        let mut initial = sample_initial_state();
10652        initial.pv_range_high = 50.0;
10653        initial.pv_range_low = 50.0;
10654        let err = validate_initial_state(&initial).unwrap_err();
10655        assert!(err.to_string().contains("PV range"));
10656    }
10657
10658    #[test]
10659    fn validate_initial_state_rejects_an_mv_range_with_low_not_below_high() {
10660        let mut initial = sample_initial_state();
10661        initial.mv_range_high = 0.0;
10662        initial.mv_range_low = 100.0;
10663        let err = validate_initial_state(&initial).unwrap_err();
10664        assert!(err.to_string().contains("MV range"));
10665    }
10666
10667    #[test]
10668    fn validate_initial_state_rejects_equal_mv_range_bounds() {
10669        let mut initial = sample_initial_state();
10670        initial.mv_range_high = 50.0;
10671        initial.mv_range_low = 50.0;
10672        assert!(validate_initial_state(&initial).is_err());
10673    }
10674
10675    #[test]
10676    fn validate_initial_state_rejects_an_initial_mv_outside_the_mv_range() {
10677        let mut initial = sample_initial_state();
10678        initial.mv_ini = 150.0;
10679        let err = validate_initial_state(&initial).unwrap_err();
10680        assert!(err.to_string().contains("outside the MV range"));
10681    }
10682
10683    #[test]
10684    fn validate_initial_state_accepts_the_initial_mv_on_the_range_boundary() {
10685        let mut initial = sample_initial_state();
10686        initial.mv_ini = initial.mv_range_high;
10687        assert!(validate_initial_state(&initial).is_ok());
10688    }
10689
10690    // --- finding 5, end to end: a poor-quality initial reading must fail `execute` before --
10691    // --- any mutation of the loop, exactly like finding 4's invalid-range checks below -----
10692
10693    #[tokio::test]
10694    async fn execute_hard_fails_when_the_pv_tag_reports_bad_quality() {
10695        let pool = seeded_pool().await;
10696        let template = honeywell_template();
10697        let tags = honeywell_tags();
10698        let driver = honeywell_driver_auto()
10699            .with_quality(&tags.process_variable, bhtune_driver::Quality::Bad);
10700        let config = build_loop_config(&fast_simulator_args()).unwrap();
10701        let time_anchor = RunTimeAnchor::now();
10702        let run = TuneRunRow::start(
10703            &pool,
10704            None,
10705            "bad-quality-initial",
10706            TuneDriver::Opcda,
10707            config,
10708            TemplateOrigin::Builtin,
10709            &template,
10710            &tags,
10711            time_anchor.utc(),
10712        )
10713        .await
10714        .unwrap();
10715
10716        let err = execute(
10717            &pool,
10718            run.id,
10719            &fast_simulator_args(),
10720            &template,
10721            &tags,
10722            &driver,
10723            config,
10724            time_anchor,
10725            None,
10726            true,
10727            &mut CtrlC::never(),
10728            &mut std::io::empty(),
10729        )
10730        .await
10731        .unwrap_err();
10732
10733        assert!(err.to_string().contains(&tags.process_variable));
10734        assert!(err.to_string().contains("Bad"));
10735        // Nothing was mutated -- the mode transition never ran, matching the invalid-range
10736        // test's own safety assertion below.
10737        assert!(driver.write_log().is_empty());
10738    }
10739
10740    #[tokio::test]
10741    async fn execute_hard_fails_when_the_pv_tag_reports_uncertain_quality_policy_rejects_it() {
10742        let pool = seeded_pool().await;
10743        let template = honeywell_template();
10744        let tags = honeywell_tags();
10745        let driver = honeywell_driver_auto()
10746            .with_quality(&tags.process_variable, bhtune_driver::Quality::Uncertain);
10747        let config = build_loop_config(&fast_simulator_args()).unwrap();
10748        let time_anchor = RunTimeAnchor::now();
10749        let run = TuneRunRow::start(
10750            &pool,
10751            None,
10752            "uncertain-quality-initial",
10753            TuneDriver::Opcda,
10754            config,
10755            TemplateOrigin::Builtin,
10756            &template,
10757            &tags,
10758            time_anchor.utc(),
10759        )
10760        .await
10761        .unwrap();
10762
10763        let err = execute(
10764            &pool,
10765            run.id,
10766            &fast_simulator_args(),
10767            &template,
10768            &tags,
10769            &driver,
10770            config,
10771            time_anchor,
10772            None,
10773            false,
10774            &mut CtrlC::never(),
10775            &mut std::io::empty(),
10776        )
10777        .await
10778        .unwrap_err();
10779
10780        assert!(err.to_string().contains(&tags.process_variable));
10781        assert!(err.to_string().contains("Uncertain"));
10782        assert!(driver.write_log().is_empty());
10783    }
10784
10785    #[tokio::test]
10786    async fn read_initial_values_and_transition_accept_uncertain_pv_quality_when_policy_allows_it()
10787    {
10788        let template = honeywell_template();
10789        let tags = honeywell_tags();
10790        let driver = honeywell_driver_auto()
10791            .with_quality(&tags.process_variable, bhtune_driver::Quality::Uncertain);
10792
10793        let initial = read_initial_values(&driver, &tags, &template, true)
10794            .await
10795            .unwrap();
10796        assert_eq!(initial.pv_ini, 50.0);
10797
10798        let mut guard = MutationGuard::default();
10799        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
10800            .await
10801            .unwrap();
10802        // Proves the run actually proceeded to mutate the loop (the mode/mode-attribute
10803        // writes `transition_to_manual` performs), not just that `read_initial_values`
10804        // alone returned `Ok` -- the real proof the global quality policy has an effect,
10805        // not just that this specific error string disappeared.
10806        assert!(!driver.write_log().is_empty());
10807    }
10808
10809    #[tokio::test]
10810    async fn read_initial_values_hard_fails_when_the_setpoint_tag_reports_bad_quality() {
10811        // The Honeywell fixture starts in Auto (`MODE=1` == `mode_auto_value`), so
10812        // `read_initial_values` reads the setpoint tag as part of computing
10813        // `InitialState::setpoint_ini` -- finding 5 applies to that read exactly as it does
10814        // to every other tuning-critical read. This read was hoisted out of
10815        // `transition_to_manual` (see `InitialState::setpoint_ini`'s doc comment) so it can
10816        // be persisted before any mutation is attempted; this test moved with it.
10817        let template = honeywell_template();
10818        let tags = honeywell_tags();
10819        let sp_tag = tags.setpoint_variable.clone().unwrap();
10820        let driver = honeywell_driver_auto().with_quality(&sp_tag, bhtune_driver::Quality::Bad);
10821
10822        let err = read_initial_values(&driver, &tags, &template, false)
10823            .await
10824            .unwrap_err();
10825        assert!(err.to_string().contains(&sp_tag));
10826        assert!(err.to_string().contains("Bad"));
10827    }
10828
10829    /// The end-to-end proof that finding 4's fix closes the actual safety gap, not just the
10830    /// isolated unit: a driver reporting an MV range with `low >= high` must fail `execute`
10831    /// before `transition_to_manual`'s first write -- i.e. before the loop is touched at
10832    /// all, not merely before the tuning math runs.
10833    #[tokio::test]
10834    async fn execute_rejects_an_invalid_mv_range_before_any_mutation_of_the_loop() {
10835        let pool = seeded_pool().await;
10836        let template = honeywell_template();
10837        let tags = honeywell_tags();
10838        // Swaps CVEUHI/CVEULO so mv_range_high (0.0) < mv_range_low (100.0), violating
10839        // MvRange::new's low-strictly-below-high requirement.
10840        let driver = honeywell_driver_auto()
10841            .with_value("Unit1.LIC101.CVEUHI", "0.0")
10842            .with_value("Unit1.LIC101.CVEULO", "100.0");
10843        let config = build_loop_config(&fast_simulator_args()).unwrap();
10844        let time_anchor = RunTimeAnchor::now();
10845        let run = TuneRunRow::start(
10846            &pool,
10847            None,
10848            "invalid-mv-range",
10849            TuneDriver::Opcda,
10850            config,
10851            TemplateOrigin::Builtin,
10852            &template,
10853            &tags,
10854            time_anchor.utc(),
10855        )
10856        .await
10857        .unwrap();
10858
10859        let err = execute(
10860            &pool,
10861            run.id,
10862            &fast_simulator_args(),
10863            &template,
10864            &tags,
10865            &driver,
10866            config,
10867            time_anchor,
10868            None,
10869            true,
10870            &mut CtrlC::never(),
10871            &mut std::io::empty(),
10872        )
10873        .await
10874        .unwrap_err();
10875
10876        assert!(err.to_string().contains("MV range"));
10877        // The real safety property: no write ever reached the driver -- not the mode
10878        // attribute, not the mode, not the MV. `transition_to_manual` never ran.
10879        assert!(driver.write_log().is_empty());
10880    }
10881
10882    /// Covers `execute`'s first `restore_best_effort_then_propagate` call site: a failure
10883    /// from `transition_to_manual` itself, partway through (the mode-attribute write, the
10884    /// very first write it attempts) must still trigger a best-effort restore rather than
10885    /// propagating the error with the loop left half-mutated. `honeywell_driver_auto()`
10886    /// starts in Auto with `MODEATTR` not yet at the Program value, so `transition_to_manual`
10887    /// always attempts the mode-attribute write first.
10888    #[tokio::test]
10889    async fn execute_attempts_restore_when_transition_to_manual_fails_partway() {
10890        let pool = seeded_pool().await;
10891        let template = honeywell_template();
10892        let tags = honeywell_tags();
10893        let driver = honeywell_driver_auto().erroring_write("Unit1.LIC101.MODEATTR");
10894        let config = build_loop_config(&fast_simulator_args()).unwrap();
10895        let time_anchor = RunTimeAnchor::now();
10896        let run = TuneRunRow::start(
10897            &pool,
10898            None,
10899            "transition-to-manual-fails",
10900            TuneDriver::Opcda,
10901            config,
10902            TemplateOrigin::Builtin,
10903            &template,
10904            &tags,
10905            time_anchor.utc(),
10906        )
10907        .await
10908        .unwrap();
10909
10910        let err = execute(
10911            &pool,
10912            run.id,
10913            &fast_simulator_args(),
10914            &template,
10915            &tags,
10916            &driver,
10917            config,
10918            time_anchor,
10919            None,
10920            true,
10921            &mut CtrlC::never(),
10922            &mut std::io::empty(),
10923        )
10924        .await
10925        .unwrap_err();
10926
10927        // `restore_best_effort_then_propagate` always returns the *original* error
10928        // unchanged -- this is that original `transition_to_manual` failure, not some
10929        // restore-side error masking it.
10930        assert!(err.to_string().contains("driver operation failed"));
10931
10932        // `restore()`'s MV step is unconditional (never gated by the guard), so it still ran
10933        // despite `transition_to_manual` never getting anywhere near the MV -- proving the
10934        // restore was genuinely attempted, not skipped because "nothing was mutated yet".
10935        assert!(
10936            driver
10937                .write_log()
10938                .iter()
10939                .any(|(tag, _)| tag == "Unit1.LIC101.OP")
10940        );
10941        // `guard.mode_written` was correctly never armed: `transition_to_manual` failed on
10942        // the mode-attribute write, before it ever reached the mode write, so `restore()`
10943        // must not attempt to revert a mode change that was never made.
10944        assert!(
10945            driver
10946                .write_log()
10947                .iter()
10948                .all(|(tag, _)| tag != "Unit1.LIC101.MODE")
10949        );
10950
10951        // The mode-attribute *restore* step, unlike mode/setpoint, retries the same
10952        // permanently-erroring tag and fails again -- so the restore is recorded as
10953        // incomplete, naming exactly which step could not be confirmed.
10954        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
10955        assert_eq!(
10956            stored.restore_status,
10957            Some(bhtune_db::models::RestoreStatus::Incomplete)
10958        );
10959        assert!(
10960            stored
10961                .restore_detail
10962                .as_deref()
10963                .unwrap_or_default()
10964                .contains("mode attribute")
10965        );
10966    }
10967
10968    /// Covers `execute`'s second `restore_best_effort_then_propagate` call site: a failure in
10969    /// `persist_completed_results` (here, `persist_results` colliding with the
10970    /// `UNIQUE (run_id, response_level)` constraint) *after* a real, successful MRFT
10971    /// completion must still trigger a best-effort restore. Uses the real `SimulatorDriver`
10972    /// (via `crate::driver::build`, exactly like `a_ctrl_c_style_abort_restores_and_records_aborted`
10973    /// above) rather than a scripted `MockDriver`, since this needs an actual engine
10974    /// completion, not just a mocked one -- the simulator's `LoopTags` has no mode/setpoint/
10975    /// mode-attribute tags at all, so its restore only ever has the MV step to confirm.
10976    #[tokio::test]
10977    async fn execute_attempts_restore_when_persist_completed_results_fails() {
10978        let pool = seeded_pool().await;
10979        let template = bhtune_core::built_in_templates().remove(0);
10980        let args = fast_simulator_args();
10981        let config = build_loop_config(&args).unwrap();
10982        let tags = build_loop_tags(&args, &template).unwrap();
10983        let driver = crate::driver::build(&args).await.unwrap();
10984        let time_anchor = RunTimeAnchor::now();
10985        let run = TuneRunRow::start(
10986            &pool,
10987            None,
10988            "finish-completed-run-fails",
10989            TuneDriver::Simulator,
10990            config,
10991            TemplateOrigin::Builtin,
10992            &template,
10993            &tags,
10994            time_anchor.utc(),
10995        )
10996        .await
10997        .unwrap();
10998
10999        // Pre-inserts a conflicting row so `persist_results`'s own insert of the same
11000        // `(run_id, Aggressive)` pair -- once the simulator tune genuinely completes --
11001        // collides with the `UNIQUE (run_id, response_level)` constraint and fails
11002        // deterministically, without needing to fake or corrupt anything about the tune
11003        // itself.
11004        TuneResultRow::insert(
11005            &pool,
11006            &TuneResultRow {
11007                id: 0,
11008                run_id: run.id,
11009                response_level: ResponseLevel::Aggressive,
11010                kp: Some(1.0),
11011                ti_minutes: Some(1.0),
11012                td_minutes: Some(1.0),
11013                proportional: Some(1.0),
11014                integral: Some(1.0),
11015                derivative: Some(1.0),
11016                status: TuningResultStatus::Valid,
11017                invalid_reason: None,
11018            },
11019        )
11020        .await
11021        .unwrap();
11022
11023        let result = execute(
11024            &pool,
11025            run.id,
11026            &args,
11027            &template,
11028            &tags,
11029            driver.as_ref(),
11030            config,
11031            time_anchor,
11032            None,
11033            true,
11034            &mut CtrlC::never(),
11035            &mut std::io::empty(),
11036        )
11037        .await;
11038
11039        // Loose assertion by design, matching this codebase's existing convention for
11040        // constraint-violation tests (see `tests/schema.rs`): SQLite's exact wording for a
11041        // UNIQUE-constraint violation is version/implementation detail, not part of this
11042        // test's contract.
11043        assert!(result.is_err());
11044
11045        // The simulator driver has no mode/setpoint/mode-attribute tags, so the only
11046        // applicable restore step is the always-succeeding MV write -- confirming the
11047        // restore ran to completion despite the DB-level failure that follows it.
11048        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
11049        assert_eq!(
11050            stored.restore_status,
11051            Some(bhtune_db::models::RestoreStatus::Confirmed)
11052        );
11053        let timing = stored
11054            .timing_metrics
11055            .expect("cadence metrics should survive a post-poll persistence failure");
11056        assert!(timing.sample_gap_count > 0);
11057        assert_eq!(timing.measured_oscillation_period_ms, None);
11058        assert_eq!(timing.approximate_samples_per_period, None);
11059    }
11060
11061    #[derive(Debug)]
11062    struct RestoreFailingSimulator {
11063        inner: bhtune_driver::SimulatorDriver,
11064        writes: std::sync::Mutex<u32>,
11065        successful_writes: u32,
11066    }
11067
11068    #[async_trait::async_trait]
11069    impl Driver for RestoreFailingSimulator {
11070        async fn read(&self, tags: &[String]) -> bhtune_driver::DriverResult<Vec<TagValue>> {
11071            self.inner.read(tags).await
11072        }
11073
11074        async fn write(
11075            &self,
11076            tag: &String,
11077            value: TagWrite,
11078        ) -> bhtune_driver::DriverResult<bhtune_driver::WriteOutcome> {
11079            let reject = {
11080                let mut writes = self.writes.lock().unwrap();
11081                *writes += 1;
11082                *writes > self.successful_writes
11083            };
11084            if reject {
11085                Ok(bhtune_driver::WriteOutcome::failure(
11086                    "restore intentionally rejected",
11087                ))
11088            } else {
11089                self.inner.write(tag, value).await
11090            }
11091        }
11092
11093        async fn browse(
11094            &self,
11095            _request: bhtune_driver::BrowsePageRequest,
11096        ) -> bhtune_driver::DriverResult<bhtune_driver::BrowsePage> {
11097            Err(bhtune_driver::DriverError::Unsupported {
11098                operation: "browse",
11099            })
11100        }
11101    }
11102
11103    #[tokio::test]
11104    async fn execute_reports_restore_incomplete_after_a_completed_run_cannot_restore_mv() {
11105        let pool = seeded_pool().await;
11106        let args = fast_simulator_args();
11107        let template = bhtune_core::built_in_templates().remove(0);
11108        let tags = build_loop_tags(&args, &template).unwrap();
11109        let config = build_loop_config(&args).unwrap();
11110        let time_anchor = RunTimeAnchor::now();
11111        let run = TuneRunRow::start(
11112            &pool,
11113            None,
11114            "completed-restore-incomplete",
11115            TuneDriver::Simulator,
11116            config,
11117            TemplateOrigin::Builtin,
11118            &template,
11119            &tags,
11120            time_anchor.utc(),
11121        )
11122        .await
11123        .unwrap();
11124        let simulator = bhtune_driver::SimulatorDriver::new(
11125            SIMULATOR_PV_TAG,
11126            SIMULATOR_MV_TAG,
11127            bhtune_driver::FopdtConfig::new(
11128                args.sim_gain,
11129                args.sim_tau,
11130                args.sim_dead_time,
11131                args.poll_interval_ms as f32 / 1000.0,
11132            ),
11133            args.sim_initial_pv,
11134            args.sim_initial_mv,
11135            args.sim_seed,
11136        );
11137        let driver = RestoreFailingSimulator {
11138            inner: simulator,
11139            writes: std::sync::Mutex::new(0),
11140            successful_writes: 7,
11141        };
11142
11143        let outcome = execute(
11144            &pool,
11145            run.id,
11146            &args,
11147            &template,
11148            &tags,
11149            &driver,
11150            config,
11151            time_anchor,
11152            None,
11153            false,
11154            &mut CtrlC::never(),
11155            &mut std::io::empty(),
11156        )
11157        .await
11158        .unwrap();
11159
11160        assert!(matches!(outcome, RunOutcome::RestoreIncomplete { .. }));
11161        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
11162        assert_eq!(
11163            stored.restore_status,
11164            Some(bhtune_db::models::RestoreStatus::Incomplete)
11165        );
11166        assert!(matches!(
11167            driver
11168                .browse(bhtune_driver::BrowsePageRequest::root(20))
11169                .await,
11170            Err(bhtune_driver::DriverError::Unsupported {
11171                operation: "browse"
11172            })
11173        ));
11174    }
11175
11176    /// Covers the `Aborted` branch's `RestoreAttempt::Incomplete` mapping -- the sibling of
11177    /// the `Completed` branch's equivalent (deliberately not separately covered; see
11178    /// AGENTS.md's `safety-restore-guard` notes) -- with a real, deterministic abort: the PV
11179    /// tag's quality degrades to `Bad` starting on the very first poll tick (its one
11180    /// `read_initial_values` read stays `Good`, via `degrade_quality_after`), and the MV tag
11181    /// is error-injected so the subsequent restore's unconditional MV step fails while
11182    /// mode/setpoint/mode-attribute all succeed normally. Deliberately plain `#[tokio::test]`
11183    /// rather than `start_paused = true`: pairing a paused clock with `seeded_pool()`'s real
11184    /// `sqlx` connection pool reliably deadlocks (`PoolTimedOut`), so -- matching every other
11185    /// `execute`-level test in this module that needs a real pool -- this test pays the real
11186    /// wall-clock cost of `transition_to_manual`/`restore`'s inter-write pacing sleeps
11187    /// instead (~3s: one in `transition_to_manual`, two more in `restore`).
11188    #[tokio::test]
11189    async fn execute_reports_restore_incomplete_after_a_poor_quality_abort() {
11190        let pool = seeded_pool().await;
11191        let template = honeywell_template();
11192        let tags = honeywell_tags();
11193        let driver = honeywell_driver_auto()
11194            .erroring_write("Unit1.LIC101.OP")
11195            .degrade_quality_after(&tags.process_variable, 1, bhtune_driver::Quality::Bad);
11196        let config = build_loop_config(&fast_simulator_args()).unwrap();
11197        let time_anchor = RunTimeAnchor::now();
11198        let run = TuneRunRow::start(
11199            &pool,
11200            None,
11201            "poor-quality-abort-restore-incomplete",
11202            TuneDriver::Opcda,
11203            config,
11204            TemplateOrigin::Builtin,
11205            &template,
11206            &tags,
11207            time_anchor.utc(),
11208        )
11209        .await
11210        .unwrap();
11211
11212        let outcome = execute(
11213            &pool,
11214            run.id,
11215            &fast_simulator_args(),
11216            &template,
11217            &tags,
11218            &driver,
11219            config,
11220            time_anchor,
11221            None,
11222            true,
11223            &mut CtrlC::never(),
11224            &mut std::io::empty(),
11225        )
11226        .await
11227        .unwrap();
11228
11229        assert!(matches!(&outcome, RunOutcome::RestoreIncomplete { .. }));
11230        let outcome_text = format!("{outcome:?}");
11231        assert!(outcome_text.contains("run aborted"));
11232        assert!(outcome_text.contains("PoorQuality"));
11233        assert!(outcome_text.contains("MV"));
11234
11235        let stored = TuneRunRow::get(&pool, run.id).await.unwrap().unwrap();
11236        assert_eq!(
11237            stored.restore_status,
11238            Some(bhtune_db::models::RestoreStatus::Incomplete)
11239        );
11240    }
11241
11242    #[tokio::test]
11243    async fn read_f32_errors_on_a_non_numeric_value() {
11244        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "not-a-number")]);
11245        let err = read_f32(&driver, "Unit1.LIC101.PV", false)
11246            .await
11247            .unwrap_err();
11248        assert!(err.to_string().contains("not a number"));
11249    }
11250
11251    #[test]
11252    fn parse_f32_value_accepts_trimmed_finite_numbers() {
11253        assert_eq!(parse_f32_value("Unit1.LIC101.PV", " 42.5 ").unwrap(), 42.5);
11254    }
11255
11256    /// Rust's `f32::from_str` happily parses the literal strings `"nan"`/`"inf"` --
11257    /// confirming this gap is what motivated hardening `read_f32` (finding 4 of the
11258    /// live-plant safety review): a driver tag returning either string used to flow
11259    /// unchecked into the engine.
11260    #[tokio::test]
11261    async fn read_f32_rejects_nan() {
11262        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "nan")]);
11263        let err = read_f32(&driver, "Unit1.LIC101.PV", false)
11264            .await
11265            .unwrap_err();
11266        assert!(err.to_string().contains("finite"));
11267    }
11268
11269    #[tokio::test]
11270    async fn read_f32_rejects_infinity() {
11271        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "inf")]);
11272        let err = read_f32(&driver, "Unit1.LIC101.PV", false)
11273            .await
11274            .unwrap_err();
11275        assert!(err.to_string().contains("finite"));
11276    }
11277
11278    #[tokio::test]
11279    async fn resolve_f32_accepts_a_finite_tag_or_value() {
11280        let driver = MockDriver::new(&[]);
11281        let value = resolve_f32(&driver, &TagOrValue::Value(42.0), false)
11282            .await
11283            .unwrap();
11284        assert_eq!(value, 42.0);
11285    }
11286
11287    #[tokio::test]
11288    async fn resolve_f32_reads_a_tag_backed_value() {
11289        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "42.5")]);
11290        let value = resolve_f32(
11291            &driver,
11292            &TagOrValue::Tag("Unit1.LIC101.PV".to_string()),
11293            false,
11294        )
11295        .await
11296        .unwrap();
11297        assert_eq!(value, 42.5);
11298    }
11299
11300    #[tokio::test]
11301    async fn resolve_f32_from_batch_reads_a_tag_from_the_batch() {
11302        let values = HashMap::from([(
11303            "Unit1.LIC101.PV".to_string(),
11304            TagValue {
11305                tag: "Unit1.LIC101.PV".to_string(),
11306                value: "42.5".to_string(),
11307                quality: bhtune_driver::Quality::Good,
11308                timestamp: None,
11309            },
11310        )]);
11311        let value = resolve_f32_from_batch(
11312            &MockDriver::default(),
11313            &values,
11314            &TagOrValue::Tag("Unit1.LIC101.PV".to_string()),
11315            false,
11316        )
11317        .await
11318        .unwrap();
11319        assert_eq!(value, 42.5);
11320    }
11321
11322    #[tokio::test]
11323    async fn resolve_direction_from_batch_reads_and_maps_a_tag() {
11324        let template = honeywell_template();
11325        let tags = TagOrValue::Tag("Unit1.LIC101.CTLACTN".to_string());
11326        let direction_tag = "Unit1.LIC101.CTLACTN".to_string();
11327        let values = HashMap::from([(
11328            direction_tag.clone(),
11329            TagValue {
11330                tag: direction_tag,
11331                value: "0".to_string(),
11332                quality: bhtune_driver::Quality::Good,
11333                timestamp: None,
11334            },
11335        )]);
11336        let direction =
11337            resolve_direction_from_batch(&MockDriver::default(), &values, &tags, &template, false)
11338                .await
11339                .unwrap();
11340        assert_eq!(direction, ControllerDirection::Direct);
11341    }
11342
11343    #[tokio::test]
11344    async fn resolve_direction_reads_and_maps_a_tag_directly() {
11345        let template = honeywell_template();
11346        let driver = MockDriver::new(&[("Unit1.LIC101.CTLACTN", "0")]);
11347        let direction = resolve_direction(
11348            &driver,
11349            &TagOrValue::Tag("Unit1.LIC101.CTLACTN".to_string()),
11350            &template,
11351            false,
11352        )
11353        .await
11354        .unwrap();
11355        assert_eq!(direction, ControllerDirection::Direct);
11356    }
11357
11358    /// Defense in depth against a hypothetical future caller (e.g. a `bhtune-server` HTTP
11359    /// handler) constructing a `TagOrValue::Value` directly without going through clap's
11360    /// `finite_f32` parser at all -- see `args::finite_f32`.
11361    #[tokio::test]
11362    async fn resolve_f32_rejects_a_non_finite_direct_value() {
11363        let driver = MockDriver::new(&[]);
11364        let err = resolve_f32(&driver, &TagOrValue::Value(f32::NAN), false)
11365            .await
11366            .unwrap_err();
11367        assert!(err.to_string().contains("finite"));
11368    }
11369
11370    #[tokio::test]
11371    async fn read_pv_sample_rejects_non_finite_values() {
11372        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "nan")]);
11373        let err = read_pv_sample(&driver, "Unit1.LIC101.PV")
11374            .await
11375            .unwrap_err();
11376        assert!(err.to_string().contains("finite"));
11377    }
11378
11379    #[test]
11380    fn read_batch_f32_returns_a_good_numeric_value() {
11381        let values = HashMap::from([(
11382            "Unit1.LIC101.PV".to_string(),
11383            TagValue {
11384                tag: "Unit1.LIC101.PV".to_string(),
11385                value: "42.5".to_string(),
11386                quality: bhtune_driver::Quality::Good,
11387                timestamp: None,
11388            },
11389        )]);
11390
11391        assert_eq!(
11392            read_batch_f32(&values, "Unit1.LIC101.PV", false).unwrap(),
11393            42.5
11394        );
11395    }
11396
11397    #[tokio::test]
11398    async fn read_poll_batch_maps_reordered_responses_by_tag() {
11399        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "42.5"), ("Unit1.LIC101.OP", "60.0")])
11400            .reversing_read_results();
11401
11402        let values = read_poll_batch(&driver, "Unit1.LIC101.PV", Some("Unit1.LIC101.OP"))
11403            .await
11404            .unwrap();
11405
11406        assert_eq!(
11407            read_numeric_from_batch(&values, "Unit1.LIC101.PV")
11408                .unwrap()
11409                .0,
11410            42.5
11411        );
11412        assert_eq!(
11413            read_numeric_from_batch(&values, "Unit1.LIC101.OP")
11414                .unwrap()
11415                .0,
11416            60.0
11417        );
11418    }
11419
11420    #[test]
11421    fn sample_quality_mapping_covers_all_driver_qualities() {
11422        assert_eq!(
11423            sample_quality_from_driver(bhtune_driver::Quality::Good),
11424            SampleQuality::Good
11425        );
11426        assert_eq!(
11427            sample_quality_from_driver(bhtune_driver::Quality::Uncertain),
11428            SampleQuality::Uncertain
11429        );
11430        assert_eq!(
11431            sample_quality_from_driver(bhtune_driver::Quality::Bad),
11432            SampleQuality::Bad
11433        );
11434    }
11435
11436    #[test]
11437    fn completed_oscillation_period_is_reported_for_a_successful_poll_result() {
11438        let completion = PollOutcome::Completed(Action::Complete {
11439            peaks: vec![52.0, 48.0, 52.0],
11440            troughs: vec![46.0, 50.0],
11441            switch_times: vec![
11442                Utc::now(),
11443                Utc::now() + chrono::Duration::seconds(30),
11444                Utc::now() + chrono::Duration::seconds(60),
11445                Utc::now() + chrono::Duration::seconds(90),
11446                Utc::now() + chrono::Duration::seconds(120),
11447            ],
11448            mv_sign_init: 1,
11449        });
11450        let result = completed_oscillation_period_ms(
11451            &Ok(completion),
11452            ControllerDirection::Reverse,
11453            build_loop_config(&fast_simulator_args()).unwrap(),
11454            PvRange {
11455                high: 100.0,
11456                low: 0.0,
11457            },
11458        );
11459        assert!(result.is_some());
11460    }
11461
11462    #[tokio::test]
11463    async fn read_raw_and_write_raw_propagate_a_hard_driver_error() {
11464        // Distinct from a *rejected* write (`WriteOutcome::success == false`, handled by
11465        // `write_raw`/`write_value`'s own "was rejected" message): this is the driver call
11466        // itself failing (`DriverError::Operation`), which `?` should propagate as-is.
11467        let driver = MockDriver::new(&[("Unit1.LIC101.PV", "50.0")])
11468            .erroring_read("Unit1.LIC101.PV")
11469            .erroring_write("Unit1.LIC101.OP");
11470
11471        let read_err = read_raw(&driver, "Unit1.LIC101.PV", false)
11472            .await
11473            .unwrap_err();
11474        assert!(read_err.to_string().contains("driver operation failed"));
11475
11476        let write_err = write_value(&driver, "Unit1.LIC101.OP", 45.0)
11477            .await
11478            .unwrap_err();
11479        assert!(write_err.to_string().contains("driver operation failed"));
11480    }
11481
11482    #[tokio::test(start_paused = true)]
11483    async fn transition_to_manual_writes_program_value_and_mode_when_starting_in_auto() {
11484        let template = honeywell_template();
11485        let tags = honeywell_tags();
11486        let driver = honeywell_driver_auto();
11487        let initial = read_initial_values(&driver, &tags, &template, false)
11488            .await
11489            .unwrap();
11490        // The setpoint is captured here, in `read_initial_values`, before any mutation of
11491        // the loop -- see `InitialState::setpoint_ini`'s doc comment for why -- not during
11492        // `transition_to_manual` any more.
11493        assert_eq!(initial.setpoint_ini, Some(55.0));
11494
11495        let mut guard = MutationGuard::default();
11496        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11497            .await
11498            .unwrap();
11499
11500        assert_eq!(
11501            driver.value_of("Unit1.LIC101.MODEATTR").as_deref(),
11502            Some("2")
11503        );
11504        assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("0"));
11505        assert!(guard.mode_attribute_written);
11506        assert!(guard.mode_written);
11507        // Order matters (mode attribute unlocked before the mode itself is switched), per
11508        // `ChangeControllerModeToMan`.
11509        let log = driver.write_log();
11510        let attr_index = log
11511            .iter()
11512            .position(|(t, _)| t == "Unit1.LIC101.MODEATTR")
11513            .unwrap();
11514        let mode_index = log
11515            .iter()
11516            .position(|(t, _)| t == "Unit1.LIC101.MODE")
11517            .unwrap();
11518        assert!(attr_index < mode_index);
11519    }
11520
11521    #[tokio::test(start_paused = true)]
11522    async fn read_initial_values_skips_setpoint_capture_when_original_mode_is_not_auto() {
11523        let template = honeywell_template();
11524        let tags = honeywell_tags();
11525        // "2" is neither the manual ("0") nor auto ("1") raw value — e.g. Cascade.
11526        let driver = MockDriver::new(&[
11527            ("Unit1.LIC101.PV", "50.0"),
11528            ("Unit1.LIC101.OP", "45.0"),
11529            ("Unit1.LIC101.MODE", "2"),
11530            ("Unit1.LIC101.MODEATTR", "2"),
11531            ("Unit1.LIC101.CTLACTN", "0"),
11532            ("Unit1.LIC101.PVEUHI", "100.0"),
11533            ("Unit1.LIC101.PVEULO", "0.0"),
11534            ("Unit1.LIC101.CVEUHI", "100.0"),
11535            ("Unit1.LIC101.CVEULO", "0.0"),
11536            ("Unit1.LIC101.SP", "55.0"),
11537        ]);
11538        let initial = read_initial_values(&driver, &tags, &template, false)
11539            .await
11540            .unwrap();
11541
11542        assert_eq!(initial.setpoint_ini, None);
11543
11544        let mut guard = MutationGuard::default();
11545        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11546            .await
11547            .unwrap();
11548
11549        assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("0"));
11550    }
11551
11552    #[tokio::test(start_paused = true)]
11553    async fn transition_to_manual_does_not_rewrite_mode_when_already_manual() {
11554        let template = honeywell_template();
11555        let tags = honeywell_tags();
11556        let driver = MockDriver::new(&[
11557            ("Unit1.LIC101.PV", "50.0"),
11558            ("Unit1.LIC101.OP", "45.0"),
11559            ("Unit1.LIC101.MODE", "0"),
11560            ("Unit1.LIC101.MODEATTR", "2"),
11561            ("Unit1.LIC101.CTLACTN", "0"),
11562            ("Unit1.LIC101.PVEUHI", "100.0"),
11563            ("Unit1.LIC101.PVEULO", "0.0"),
11564            ("Unit1.LIC101.CVEUHI", "100.0"),
11565            ("Unit1.LIC101.CVEULO", "0.0"),
11566            ("Unit1.LIC101.SP", "55.0"),
11567        ]);
11568        let initial = read_initial_values(&driver, &tags, &template, false)
11569            .await
11570            .unwrap();
11571        assert_eq!(initial.setpoint_ini, None);
11572
11573        let mut guard = MutationGuard::default();
11574        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11575            .await
11576            .unwrap();
11577
11578        // The Mode Attribute write always fires unconditionally (there's no "already at the
11579        // program value" guard on it), but Mode itself is already Manual, so its own
11580        // conditional `write_raw` must not fire a second time.
11581        let log = driver.write_log();
11582        assert_eq!(
11583            log,
11584            vec![("Unit1.LIC101.MODEATTR".to_string(), "2".to_string())]
11585        );
11586        assert!(guard.mode_attribute_written);
11587        assert!(!guard.mode_written);
11588    }
11589
11590    #[tokio::test(start_paused = true)]
11591    async fn restore_reverts_mode_setpoint_and_mode_attribute() {
11592        let template = honeywell_template();
11593        let tags = honeywell_tags();
11594        let driver = honeywell_driver_auto();
11595        let initial = read_initial_values(&driver, &tags, &template, false)
11596            .await
11597            .unwrap();
11598        let mut guard = MutationGuard::default();
11599        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11600            .await
11601            .unwrap();
11602
11603        let report = restore(&driver, &tags, &template, &initial, &guard).await;
11604        assert!(report.all_succeeded());
11605
11606        assert_eq!(driver.value_of("Unit1.LIC101.OP").as_deref(), Some("45")); // mv_ini
11607        assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("1")); // original raw
11608        assert_eq!(driver.value_of("Unit1.LIC101.SP").as_deref(), Some("55")); // setpoint restored
11609        assert_eq!(
11610            driver.value_of("Unit1.LIC101.MODEATTR").as_deref(),
11611            Some("1")
11612        ); // reverted off the Program value
11613    }
11614
11615    #[tokio::test(start_paused = true)]
11616    async fn restore_skips_mode_revert_when_template_disables_it() {
11617        let mut template = honeywell_template();
11618        template.revert_mode = false;
11619        let tags = honeywell_tags();
11620        let driver = honeywell_driver_auto();
11621        let initial = read_initial_values(&driver, &tags, &template, false)
11622            .await
11623            .unwrap();
11624        let mut guard = MutationGuard::default();
11625        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11626            .await
11627            .unwrap();
11628        let writes_before_restore = driver.write_log().len();
11629
11630        let report = restore(&driver, &tags, &template, &initial, &guard).await;
11631        assert!(report.all_succeeded());
11632
11633        // MV is always written back regardless of `revert_mode`; Mode/Setpoint are not.
11634        assert_eq!(driver.value_of("Unit1.LIC101.OP").as_deref(), Some("45"));
11635        assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("0")); // untouched
11636        let new_writes = &driver.write_log()[writes_before_restore..];
11637        assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.MODE"));
11638        assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.SP"));
11639    }
11640
11641    #[tokio::test(start_paused = true)]
11642    async fn restore_skips_setpoint_revert_when_original_mode_was_not_auto() {
11643        let template = honeywell_template();
11644        let tags = honeywell_tags();
11645        let driver = MockDriver::new(&[
11646            ("Unit1.LIC101.PV", "50.0"),
11647            ("Unit1.LIC101.OP", "45.0"),
11648            ("Unit1.LIC101.MODE", "2"),
11649            ("Unit1.LIC101.MODEATTR", "2"),
11650            ("Unit1.LIC101.CTLACTN", "0"),
11651            ("Unit1.LIC101.PVEUHI", "100.0"),
11652            ("Unit1.LIC101.PVEULO", "0.0"),
11653            ("Unit1.LIC101.CVEUHI", "100.0"),
11654            ("Unit1.LIC101.CVEULO", "0.0"),
11655            ("Unit1.LIC101.SP", "55.0"),
11656        ]);
11657        let initial = read_initial_values(&driver, &tags, &template, false)
11658            .await
11659            .unwrap();
11660        let mut guard = MutationGuard::default();
11661        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11662            .await
11663            .unwrap();
11664        let writes_before_restore = driver.write_log().len();
11665
11666        let report = restore(&driver, &tags, &template, &initial, &guard).await;
11667        assert!(report.all_succeeded());
11668
11669        assert_eq!(driver.value_of("Unit1.LIC101.MODE").as_deref(), Some("2")); // reverted
11670        let new_writes = &driver.write_log()[writes_before_restore..];
11671        assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.SP"));
11672    }
11673
11674    #[tokio::test(start_paused = true)]
11675    async fn restore_skips_mode_attribute_revert_when_already_at_program_value() {
11676        let template = honeywell_template();
11677        let tags = honeywell_tags();
11678        let driver = MockDriver::new(&[
11679            ("Unit1.LIC101.PV", "50.0"),
11680            ("Unit1.LIC101.OP", "45.0"),
11681            ("Unit1.LIC101.MODE", "1"),
11682            ("Unit1.LIC101.MODEATTR", "2"), // already at the Program value
11683            ("Unit1.LIC101.CTLACTN", "0"),
11684            ("Unit1.LIC101.PVEUHI", "100.0"),
11685            ("Unit1.LIC101.PVEULO", "0.0"),
11686            ("Unit1.LIC101.CVEUHI", "100.0"),
11687            ("Unit1.LIC101.CVEULO", "0.0"),
11688            ("Unit1.LIC101.SP", "55.0"),
11689        ]);
11690        let initial = read_initial_values(&driver, &tags, &template, false)
11691            .await
11692            .unwrap();
11693        let mut guard = MutationGuard::default();
11694        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
11695            .await
11696            .unwrap();
11697        let writes_before_restore = driver.write_log().len();
11698
11699        let report = restore(&driver, &tags, &template, &initial, &guard).await;
11700        assert!(report.all_succeeded());
11701
11702        let new_writes = &driver.write_log()[writes_before_restore..];
11703        assert!(new_writes.iter().all(|(t, _)| t != "Unit1.LIC101.MODEATTR"));
11704    }
11705
11706    /// The heart of `safety-restore-guard`'s "aggregated best-effort restore" (Option C): one
11707    /// step failing must never prevent the others from being *attempted*, even in the
11708    /// pathological case where every single one of them also fails. Calls `restore` directly
11709    /// with a fully-armed `guard` (bypassing `transition_to_manual` entirely, since this test
11710    /// is only interested in `restore`'s own aggregation behavior in isolation, not in
11711    /// propagating any one mutation's own error -- that's covered by the `execute`-level
11712    /// tests around `restore_best_effort_then_propagate` instead).
11713    #[tokio::test(start_paused = true)]
11714    async fn restore_reports_each_step_failed_independently_without_short_circuiting() {
11715        let template = honeywell_template();
11716        let tags = honeywell_tags();
11717        let driver = honeywell_driver_auto()
11718            .erroring_write("Unit1.LIC101.OP")
11719            .erroring_write("Unit1.LIC101.MODE")
11720            .erroring_write("Unit1.LIC101.SP")
11721            .erroring_write("Unit1.LIC101.MODEATTR");
11722        let initial = read_initial_values(&driver, &tags, &template, false)
11723            .await
11724            .unwrap();
11725        let guard = MutationGuard {
11726            mode_attribute_written: true,
11727            mode_written: true,
11728            mv_written: true,
11729        };
11730
11731        let report = restore(&driver, &tags, &template, &initial, &guard).await;
11732
11733        assert!(!report.all_succeeded());
11734        assert!(matches!(report.mv, RestoreStepOutcome::Failed(_)));
11735        assert!(matches!(report.mode, RestoreStepOutcome::Failed(_)));
11736        assert!(matches!(report.setpoint, RestoreStepOutcome::Failed(_)));
11737        assert!(matches!(
11738            report.mode_attribute,
11739            RestoreStepOutcome::Failed(_)
11740        ));
11741
11742        // Every step's own failure is independently attributable in the summary -- an
11743        // operator reading this must be able to tell all four apart, not just "something
11744        // failed".
11745        let summary = report.failure_summary().unwrap();
11746        assert!(summary.contains("MV:"));
11747        assert!(summary.contains("mode:"));
11748        assert!(summary.contains("setpoint:"));
11749        assert!(summary.contains("mode attribute:"));
11750    }
11751
11752    #[test]
11753    fn restore_report_failure_summary_is_none_when_nothing_failed() {
11754        // The complement of the all-failed test above: a report where every step is
11755        // `NotNeeded` (the `Default`) has nothing to summarize.
11756        let report = RestoreReport::default();
11757        assert!(report.all_succeeded());
11758        assert!(report.failure_summary().is_none());
11759    }
11760
11761    #[tokio::test(start_paused = true)]
11762    async fn write_raw_and_write_value_error_when_the_driver_rejects_the_write() {
11763        let driver = MockDriver::new(&[("Unit1.LIC101.MODE", "1")])
11764            .rejecting_write("Unit1.LIC101.MODE")
11765            .rejecting_write("Unit1.LIC101.OP");
11766
11767        let raw_err = write_raw(&driver, "Unit1.LIC101.MODE", "0".to_string())
11768            .await
11769            .unwrap_err();
11770        assert!(raw_err.to_string().contains("rejected"));
11771
11772        let value_err = write_value(&driver, "Unit1.LIC101.OP", 45.0)
11773            .await
11774            .unwrap_err();
11775        assert!(value_err.to_string().contains("rejected"));
11776    }
11777
11778    #[tokio::test]
11779    async fn persist_results_bails_on_a_non_complete_action() {
11780        let pool = seeded_pool().await;
11781        let template = honeywell_template();
11782        let err = persist_results(
11783            &pool,
11784            1,
11785            Action::WriteMv(0.0),
11786            ControllerDirection::Direct,
11787            build_loop_config(&fast_simulator_args()).unwrap(),
11788            PvRange {
11789                high: 100.0,
11790                low: 0.0,
11791            },
11792            &template,
11793        )
11794        .await
11795        .unwrap_err();
11796        assert!(err.to_string().contains("internal error"));
11797    }
11798
11799    /// Sets up a run with 3 recorded `TuneResultRow`s (matching a real completed tune) using
11800    /// the Honeywell template/tags, whose PID constant tags are all configured — the
11801    /// precondition for `maybe_write_back` to prompt at all rather than skip immediately.
11802    async fn run_with_recorded_results() -> (SqlitePool, i64) {
11803        let pool = seeded_pool().await;
11804        let config = build_loop_config(&fast_simulator_args()).unwrap();
11805        let run = TuneRunRow::start(
11806            &pool,
11807            None,
11808            "write-back-test",
11809            TuneDriver::Opcda,
11810            config,
11811            TemplateOrigin::Builtin,
11812            &honeywell_template(),
11813            &honeywell_tags(),
11814            Utc::now(),
11815        )
11816        .await
11817        .unwrap();
11818        for (level, kp, ti, td, p, i, d) in [
11819            (ResponseLevel::Aggressive, 1.0, 0.5, 0.1, 10.0, 2.0, 0.5),
11820            (ResponseLevel::Moderate, 1.5, 0.7, 0.15, 12.0, 2.5, 0.6),
11821            (ResponseLevel::Sluggish, 2.0, 0.9, 0.2, 14.0, 3.0, 0.7),
11822        ] {
11823            TuneResultRow::insert(
11824                &pool,
11825                &TuneResultRow {
11826                    id: 0,
11827                    run_id: run.id,
11828                    response_level: level,
11829                    kp: Some(kp),
11830                    ti_minutes: Some(ti),
11831                    td_minutes: Some(td),
11832                    proportional: Some(p),
11833                    integral: Some(i),
11834                    derivative: Some(d),
11835                    status: TuningResultStatus::Valid,
11836                    invalid_reason: None,
11837                },
11838            )
11839            .await
11840            .unwrap();
11841        }
11842        (pool, run.id)
11843    }
11844
11845    #[tokio::test]
11846    async fn maybe_write_back_skips_when_no_pid_constant_tags_are_configured() {
11847        let (pool, run_id) = run_with_recorded_results().await;
11848        let template = honeywell_template();
11849        let mut tags = honeywell_tags();
11850        tags.proportional_constant = None;
11851        let driver = honeywell_driver_auto();
11852
11853        let (outcome, write_back_detail) = maybe_write_back(
11854            &pool,
11855            run_id,
11856            &tags,
11857            &template,
11858            &driver,
11859            build_loop_config(&fast_simulator_args()).unwrap(),
11860            None,
11861            OutputFormat::Table,
11862            false,
11863            &mut std::io::Cursor::new(b"1\n".as_slice()),
11864        )
11865        .await
11866        .unwrap();
11867
11868        assert_eq!(outcome, WriteBackOutcome::Skipped);
11869        assert_eq!(
11870            write_back_detail.as_deref(),
11871            Some("no PID constant tags configured for this run's driver/template")
11872        );
11873        assert!(
11874            TuneWriteRow::list_for_run(&pool, run_id)
11875                .await
11876                .unwrap()
11877                .is_empty()
11878        );
11879    }
11880
11881    #[tokio::test]
11882    async fn maybe_write_back_skips_when_no_results_were_recorded() {
11883        let pool = seeded_pool().await;
11884        let config = build_loop_config(&fast_simulator_args()).unwrap();
11885        let template = honeywell_template();
11886        let tags = honeywell_tags();
11887        let run = TuneRunRow::start(
11888            &pool,
11889            None,
11890            "no-results",
11891            TuneDriver::Opcda,
11892            config,
11893            TemplateOrigin::Builtin,
11894            &template,
11895            &tags,
11896            Utc::now(),
11897        )
11898        .await
11899        .unwrap();
11900        let driver = honeywell_driver_auto();
11901
11902        let (outcome, write_back_detail) = maybe_write_back(
11903            &pool,
11904            run.id,
11905            &tags,
11906            &template,
11907            &driver,
11908            config,
11909            None,
11910            OutputFormat::Table,
11911            false,
11912            &mut std::io::Cursor::new(b"1\n".as_slice()),
11913        )
11914        .await
11915        .unwrap();
11916
11917        assert_eq!(outcome, WriteBackOutcome::Skipped);
11918        assert_eq!(
11919            write_back_detail.as_deref(),
11920            Some("no calculated results were recorded for this run")
11921        );
11922        assert!(
11923            TuneWriteRow::list_for_run(&pool, run.id)
11924                .await
11925                .unwrap()
11926                .is_empty()
11927        );
11928    }
11929
11930    /// Runs `maybe_write_back` against `run_with_recorded_results()`'s fixture with the
11931    /// given stdin-equivalent input, returning both the outcome and the recorded
11932    /// write-back audit rows (0 or 1).
11933    async fn write_back_with_input(
11934        input: &[u8],
11935    ) -> (WriteBackOutcome, Vec<bhtune_db::models::TuneWriteRow>) {
11936        let (pool, run_id) = run_with_recorded_results().await;
11937        let template = honeywell_template();
11938        let tags = honeywell_tags();
11939        let driver = honeywell_driver_auto();
11940
11941        let (outcome, _write_back_detail) = maybe_write_back(
11942            &pool,
11943            run_id,
11944            &tags,
11945            &template,
11946            &driver,
11947            build_loop_config(&fast_simulator_args()).unwrap(),
11948            None,
11949            OutputFormat::Table,
11950            false,
11951            &mut std::io::Cursor::new(input),
11952        )
11953        .await
11954        .unwrap();
11955
11956        (
11957            outcome,
11958            TuneWriteRow::list_for_run(&pool, run_id).await.unwrap(),
11959        )
11960    }
11961
11962    #[tokio::test]
11963    async fn maybe_write_back_skips_on_eof() {
11964        let (outcome, writes) = write_back_with_input(b"").await;
11965        assert_eq!(outcome, WriteBackOutcome::Skipped);
11966        assert!(writes.is_empty());
11967    }
11968
11969    #[tokio::test]
11970    async fn maybe_write_back_skips_on_blank_input() {
11971        let (outcome, writes) = write_back_with_input(b"\n").await;
11972        assert_eq!(outcome, WriteBackOutcome::Skipped);
11973        assert!(writes.is_empty());
11974    }
11975
11976    #[tokio::test]
11977    async fn maybe_write_back_skips_on_n() {
11978        let (outcome, writes) = write_back_with_input(b"N\n").await;
11979        assert_eq!(outcome, WriteBackOutcome::Skipped);
11980        assert!(writes.is_empty());
11981    }
11982
11983    #[tokio::test]
11984    async fn maybe_write_back_skips_on_out_of_range_selection() {
11985        let (outcome, writes) = write_back_with_input(b"99\n").await;
11986        assert_eq!(outcome, WriteBackOutcome::Skipped);
11987        assert!(writes.is_empty());
11988    }
11989
11990    #[tokio::test]
11991    async fn maybe_write_back_skips_on_non_numeric_selection() {
11992        let (outcome, writes) = write_back_with_input(b"banana\n").await;
11993        assert_eq!(outcome, WriteBackOutcome::Skipped);
11994        assert!(writes.is_empty());
11995    }
11996
11997    #[tokio::test]
11998    async fn maybe_write_back_reports_blank_and_n_as_no_selection() {
11999        for input in [b"\n".as_slice(), b"N\n".as_slice()] {
12000            let (pool, run_id) = run_with_recorded_results().await;
12001            let template = honeywell_template();
12002            let tags = honeywell_tags();
12003            let driver = honeywell_driver_auto();
12004
12005            let (outcome, detail) = maybe_write_back(
12006                &pool,
12007                run_id,
12008                &tags,
12009                &template,
12010                &driver,
12011                build_loop_config(&fast_simulator_args()).unwrap(),
12012                None,
12013                OutputFormat::Table,
12014                false,
12015                &mut std::io::Cursor::new(input),
12016            )
12017            .await
12018            .unwrap();
12019
12020            assert_eq!(outcome, WriteBackOutcome::Skipped);
12021            assert_eq!(
12022                detail.as_deref(),
12023                Some("skipped interactively (no selection made)")
12024            );
12025            assert!(
12026                TuneWriteRow::list_for_run(&pool, run_id)
12027                    .await
12028                    .unwrap()
12029                    .is_empty()
12030            );
12031        }
12032    }
12033
12034    #[tokio::test]
12035    async fn maybe_write_back_writes_and_confirms_a_valid_selection() {
12036        let (outcome, writes) = write_back_with_input(b"2\n").await; // Moderate (index 1)
12037        assert_eq!(
12038            outcome,
12039            WriteBackOutcome::Written {
12040                response_level: ResponseLevel::Moderate
12041            }
12042        );
12043        assert_eq!(writes.len(), 1);
12044        let write = &writes[0];
12045        assert!(write.success);
12046        assert_eq!(write.response_level, ResponseLevel::Moderate);
12047        assert!(write.error_message.is_none());
12048        // A full success pre-reads, writes, and verifies all three constants, and never
12049        // rolls anything back.
12050        assert!(write.previous.is_some());
12051        assert!(write.proportional_written.is_some());
12052        assert!(write.integral_written.is_some());
12053        assert!(write.derivative_written.is_some());
12054        assert!(write.proportional_readback.is_some());
12055        assert!(write.integral_readback.is_some());
12056        assert!(write.derivative_readback.is_some());
12057        assert_eq!(write.rollback_state, None);
12058        assert!(write.rollback_error.is_none());
12059    }
12060
12061    #[tokio::test]
12062    async fn maybe_write_back_records_failure_when_the_pre_read_fails() {
12063        let (pool, run_id) = run_with_recorded_results().await;
12064        let template = honeywell_template();
12065        let tags = honeywell_tags();
12066        // Every read of the P tag fails, including the very first (pre-read) one -- nothing
12067        // is ever written.
12068        let driver = honeywell_driver_auto().erroring_read("Unit1.LIC101.K");
12069
12070        let (outcome, write_back_detail) = maybe_write_back(
12071            &pool,
12072            run_id,
12073            &tags,
12074            &template,
12075            &driver,
12076            build_loop_config(&fast_simulator_args()).unwrap(),
12077            None,
12078            OutputFormat::Table,
12079            false,
12080            &mut std::io::Cursor::new(b"1\n".as_slice()),
12081        )
12082        .await
12083        .unwrap();
12084
12085        assert_eq!(outcome, WriteBackOutcome::Failed);
12086        assert!(
12087            write_back_detail
12088                .as_deref()
12089                .unwrap_or_default()
12090                .starts_with("pre-read failed:")
12091        );
12092        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12093        assert_eq!(writes.len(), 1);
12094        let write = &writes[0];
12095        assert!(!write.success);
12096        // The pre-read never produced a known-good value, so there is nothing to record as
12097        // "previous", and nothing was ever attempted.
12098        assert!(write.previous.is_none());
12099        assert!(write.proportional_written.is_none());
12100        assert!(write.integral_written.is_none());
12101        assert!(write.derivative_written.is_none());
12102        assert!(
12103            write
12104                .error_message
12105                .as_deref()
12106                .unwrap_or_default()
12107                .starts_with("pre-read of Proportional")
12108        );
12109        assert_eq!(write.rollback_state, None);
12110        // Nothing was written to the driver at all -- confirms the pre-read is a genuine
12111        // hard stop, not just a reported failure alongside attempted writes.
12112        assert!(driver.write_log().is_empty());
12113    }
12114
12115    #[tokio::test]
12116    async fn maybe_write_back_rolls_back_a_confirmed_write_when_a_later_constant_fails() {
12117        let (pool, run_id) = run_with_recorded_results().await;
12118        let template = honeywell_template();
12119        let tags = honeywell_tags();
12120        // P writes and verifies successfully; I's write is then rejected. P was already
12121        // confirmed, so it must be rolled back to its pre-read value.
12122        let driver = honeywell_driver_auto().rejecting_write("Unit1.LIC101.T1");
12123
12124        let (outcome, write_back_detail) = maybe_write_back(
12125            &pool,
12126            run_id,
12127            &tags,
12128            &template,
12129            &driver,
12130            build_loop_config(&fast_simulator_args()).unwrap(),
12131            None,
12132            OutputFormat::Table,
12133            false,
12134            &mut std::io::Cursor::new(b"1\n".as_slice()),
12135        )
12136        .await
12137        .unwrap();
12138
12139        assert_eq!(outcome, WriteBackOutcome::Failed);
12140        assert!(
12141            write_back_detail
12142                .as_deref()
12143                .unwrap_or_default()
12144                .ends_with("(rolled back)")
12145        );
12146        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12147        assert_eq!(writes.len(), 1);
12148        let write = &writes[0];
12149        assert!(!write.success);
12150        assert!(write.previous.is_some());
12151        // P was confirmed (written + read back), I was attempted but never confirmed, D was
12152        // never attempted at all.
12153        assert!(write.proportional_written.is_some());
12154        assert!(write.proportional_readback.is_some());
12155        assert!(write.integral_written.is_some());
12156        assert!(write.integral_readback.is_none());
12157        assert!(write.derivative_written.is_none());
12158        assert!(write.derivative_readback.is_none());
12159        assert_eq!(write.rollback_state, Some(RollbackState::Succeeded));
12160        assert!(write.rollback_error.is_none());
12161        // The rollback actually put P's original value back on the driver, not just in the
12162        // audit row.
12163        let p_previous = write.previous.as_ref().unwrap().proportional;
12164        assert_eq!(
12165            driver
12166                .value_of("Unit1.LIC101.K")
12167                .and_then(|v| v.parse::<f32>().ok()),
12168            Some(p_previous)
12169        );
12170    }
12171
12172    #[tokio::test]
12173    async fn maybe_write_back_records_a_failed_rollback_when_the_rollback_write_is_also_rejected() {
12174        let (pool, run_id) = run_with_recorded_results().await;
12175        let template = honeywell_template();
12176        let tags = honeywell_tags();
12177        // P's forward write succeeds (1st write to the tag), but its rollback write (2nd
12178        // write to the same tag, once I fails) is rejected -- "wrote some and could not put
12179        // it back" must be a distinguishable, clearly reported outcome.
12180        let driver = honeywell_driver_auto()
12181            .rejecting_write("Unit1.LIC101.T1")
12182            .rejecting_write_after("Unit1.LIC101.K", 1);
12183
12184        let (outcome, write_back_detail) = maybe_write_back(
12185            &pool,
12186            run_id,
12187            &tags,
12188            &template,
12189            &driver,
12190            build_loop_config(&fast_simulator_args()).unwrap(),
12191            None,
12192            OutputFormat::Table,
12193            false,
12194            &mut std::io::Cursor::new(b"1\n".as_slice()),
12195        )
12196        .await
12197        .unwrap();
12198
12199        assert_eq!(outcome, WriteBackOutcome::Failed);
12200        let detail = write_back_detail.unwrap_or_default();
12201        assert!(detail.contains("rollback also failed"));
12202        assert!(detail.contains("history revert"));
12203        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12204        assert_eq!(writes.len(), 1);
12205        let write = &writes[0];
12206        assert!(!write.success);
12207        assert_eq!(write.rollback_state, Some(RollbackState::Failed));
12208        let rollback_error = write.rollback_error.as_deref().unwrap_or_default();
12209        assert!(rollback_error.contains("Proportional"));
12210        assert!(rollback_error.contains("rollback"));
12211    }
12212
12213    #[tokio::test]
12214    async fn maybe_write_back_records_failure_when_the_readback_is_outside_tolerance() {
12215        let (pool, run_id) = run_with_recorded_results().await;
12216        let template = honeywell_template();
12217        let tags = honeywell_tags();
12218        // The write itself succeeds and the readback parses fine at Good quality, but the
12219        // DCS silently stored a value far outside tolerance of what was requested -- a
12220        // distinct failure mode from an erroring or poor-quality readback.
12221        let driver = honeywell_driver_auto().distorting_write("Unit1.LIC101.K", 5.0);
12222
12223        let (outcome, _write_back_detail) = maybe_write_back(
12224            &pool,
12225            run_id,
12226            &tags,
12227            &template,
12228            &driver,
12229            build_loop_config(&fast_simulator_args()).unwrap(),
12230            None,
12231            OutputFormat::Table,
12232            false,
12233            &mut std::io::Cursor::new(b"1\n".as_slice()),
12234        )
12235        .await
12236        .unwrap();
12237
12238        assert_eq!(outcome, WriteBackOutcome::Failed);
12239        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12240        assert_eq!(writes.len(), 1);
12241        let write = &writes[0];
12242        assert!(!write.success);
12243        let message = write.error_message.as_deref().unwrap_or_default();
12244        assert!(message.contains("outside tolerance"));
12245        assert!(message.contains("Proportional"));
12246        // Nothing was confirmed before the tolerance rejection, so there is nothing to roll
12247        // back.
12248        assert_eq!(write.rollback_state, None);
12249    }
12250
12251    #[tokio::test]
12252    async fn maybe_write_back_records_failure_when_a_write_is_rejected() {
12253        let (pool, run_id) = run_with_recorded_results().await;
12254        let template = honeywell_template();
12255        let tags = honeywell_tags();
12256        let driver = honeywell_driver_auto().rejecting_write("Unit1.LIC101.K");
12257
12258        let (outcome, _write_back_detail) = maybe_write_back(
12259            &pool,
12260            run_id,
12261            &tags,
12262            &template,
12263            &driver,
12264            build_loop_config(&fast_simulator_args()).unwrap(),
12265            None,
12266            OutputFormat::Table,
12267            false,
12268            &mut std::io::Cursor::new(b"1\n".as_slice()),
12269        )
12270        .await
12271        .unwrap();
12272
12273        assert_eq!(outcome, WriteBackOutcome::Failed);
12274        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12275        assert_eq!(writes.len(), 1);
12276        assert!(!writes[0].success);
12277        assert!(writes[0].error_message.is_some());
12278    }
12279
12280    #[tokio::test]
12281    async fn maybe_write_back_records_failure_when_the_readback_fails() {
12282        let (pool, run_id) = run_with_recorded_results().await;
12283        let template = honeywell_template();
12284        let tags = honeywell_tags();
12285        // The pre-read of P succeeds (the tag's 1st read), the write itself succeeds, but
12286        // the confirmation re-read of the P tag (its 2nd read) then errors.
12287        let driver = honeywell_driver_auto().erroring_read_after("Unit1.LIC101.K", 1);
12288
12289        let (outcome, _write_back_detail) = maybe_write_back(
12290            &pool,
12291            run_id,
12292            &tags,
12293            &template,
12294            &driver,
12295            build_loop_config(&fast_simulator_args()).unwrap(),
12296            None,
12297            OutputFormat::Table,
12298            false,
12299            &mut std::io::Cursor::new(b"1\n".as_slice()),
12300        )
12301        .await
12302        .unwrap();
12303        assert_eq!(outcome, WriteBackOutcome::Failed);
12304        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12305
12306        assert_eq!(writes.len(), 1);
12307        assert!(!writes[0].success);
12308        // The pre-read succeeded, so the audit row still records the previous values, even
12309        // though the write-and-verify step for P failed.
12310        assert!(writes[0].previous.is_some());
12311        let message = writes[0].error_message.as_deref().unwrap_or_default();
12312        assert!(message.starts_with("Proportional readback from"));
12313        assert!(!message.starts_with("pre-read of"));
12314        // Nothing was confirmed before the failure, so there is nothing to roll back.
12315        assert_eq!(writes[0].rollback_state, None);
12316    }
12317
12318    #[tokio::test]
12319    async fn maybe_write_back_records_failure_when_the_readback_reports_poor_quality() {
12320        let (pool, run_id) = run_with_recorded_results().await;
12321        let template = honeywell_template();
12322        let tags = honeywell_tags();
12323        // The pre-read of P (its 1st read) succeeds at the default `Good` quality; only the
12324        // confirmation re-read after the write (its 2nd read) reports a poor OPC quality --
12325        // finding 5's rule applies to this readback exactly as it does to any other
12326        // tuning-critical read, so a stale/clamped value must not be mistaken for proof the
12327        // write actually landed.
12328        let driver = honeywell_driver_auto().degrade_quality_after(
12329            "Unit1.LIC101.K",
12330            1,
12331            bhtune_driver::Quality::Bad,
12332        );
12333
12334        let (outcome, _write_back_detail) = maybe_write_back(
12335            &pool,
12336            run_id,
12337            &tags,
12338            &template,
12339            &driver,
12340            build_loop_config(&fast_simulator_args()).unwrap(),
12341            None,
12342            OutputFormat::Table,
12343            false,
12344            &mut std::io::Cursor::new(b"1\n".as_slice()),
12345        )
12346        .await
12347        .unwrap();
12348        assert_eq!(outcome, WriteBackOutcome::Failed);
12349
12350        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12351        assert_eq!(writes.len(), 1);
12352        assert!(!writes[0].success);
12353        assert!(writes[0].previous.is_some());
12354        let message = writes[0].error_message.as_deref().unwrap_or_default();
12355        assert!(message.contains("quality"));
12356        assert!(message.contains("Unit1.LIC101.K"));
12357        // Confirms this is the *readback* failing, not the pre-read -- the pre-read's
12358        // message format is "pre-read of ... failed", distinct from "... readback from ...
12359        // failed", so an operator (or the history explorer) can tell a poor-quality
12360        // confirmation apart from a pre-read failure or an outright transport read failure.
12361        assert!(message.starts_with("Proportional readback from"));
12362        assert!(!message.starts_with("pre-read of"));
12363        assert_eq!(writes[0].rollback_state, None);
12364    }
12365
12366    #[tokio::test]
12367    async fn maybe_write_back_accepts_an_uncertain_readback_when_the_flag_is_set() {
12368        let (pool, run_id) = run_with_recorded_results().await;
12369        let template = honeywell_template();
12370        let tags = honeywell_tags();
12371        let driver = honeywell_driver_auto()
12372            .with_quality("Unit1.LIC101.K", bhtune_driver::Quality::Uncertain);
12373
12374        let (outcome, _write_back_detail) = maybe_write_back(
12375            &pool,
12376            run_id,
12377            &tags,
12378            &template,
12379            &driver,
12380            build_loop_config(&fast_simulator_args()).unwrap(),
12381            None,
12382            OutputFormat::Table,
12383            true, // allow_uncertain
12384            &mut std::io::Cursor::new(b"1\n".as_slice()),
12385        )
12386        .await
12387        .unwrap();
12388
12389        assert!(matches!(outcome, WriteBackOutcome::Written { .. }));
12390        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12391        assert_eq!(writes.len(), 1);
12392        assert!(writes[0].success);
12393    }
12394
12395    #[tokio::test]
12396    async fn maybe_write_back_writes_non_interactively_via_write_pid_without_touching_stdin() {
12397        let (pool, run_id) = run_with_recorded_results().await;
12398        let template = honeywell_template();
12399        let tags = honeywell_tags();
12400        let driver = honeywell_driver_auto();
12401
12402        // An empty reader would make the *interactive* path treat this as EOF-and-skip; a
12403        // `write_pid` request must never even try to read it.
12404        let (outcome, _write_back_detail) = maybe_write_back(
12405            &pool,
12406            run_id,
12407            &tags,
12408            &template,
12409            &driver,
12410            build_loop_config(&fast_simulator_args()).unwrap(),
12411            Some(ResponseLevel::Aggressive),
12412            OutputFormat::Table,
12413            false,
12414            &mut std::io::Cursor::new(b"".as_slice()),
12415        )
12416        .await
12417        .unwrap();
12418
12419        assert_eq!(
12420            outcome,
12421            WriteBackOutcome::Written {
12422                response_level: ResponseLevel::Aggressive
12423            }
12424        );
12425        let writes = TuneWriteRow::list_for_run(&pool, run_id).await.unwrap();
12426        assert_eq!(writes.len(), 1);
12427        assert!(writes[0].success);
12428        assert_eq!(writes[0].response_level, ResponseLevel::Aggressive);
12429    }
12430
12431    #[tokio::test]
12432    async fn maybe_write_back_fails_when_write_pid_names_a_level_with_no_recorded_result() {
12433        let pool = seeded_pool().await;
12434        let config = build_loop_config(&fast_simulator_args()).unwrap();
12435        let template = honeywell_template();
12436        let tags = honeywell_tags();
12437        let run = TuneRunRow::start(
12438            &pool,
12439            None,
12440            "partial-results",
12441            TuneDriver::Opcda,
12442            config,
12443            TemplateOrigin::Builtin,
12444            &template,
12445            &tags,
12446            Utc::now(),
12447        )
12448        .await
12449        .unwrap();
12450        // Deliberately only record Aggressive and Moderate -- Sluggish is missing, which
12451        // should never actually happen (`calculate_all` always computes all 3), but
12452        // `maybe_write_back` must still fail safely rather than panic on an out-of-bounds
12453        // index or silently write the wrong level.
12454        for (level, kp, ti, td, p, i, d) in [
12455            (ResponseLevel::Aggressive, 1.0, 0.5, 0.1, 10.0, 2.0, 0.5),
12456            (ResponseLevel::Moderate, 1.5, 0.7, 0.15, 12.0, 2.5, 0.6),
12457        ] {
12458            TuneResultRow::insert(
12459                &pool,
12460                &TuneResultRow {
12461                    id: 0,
12462                    run_id: run.id,
12463                    response_level: level,
12464                    kp: Some(kp),
12465                    ti_minutes: Some(ti),
12466                    td_minutes: Some(td),
12467                    proportional: Some(p),
12468                    integral: Some(i),
12469                    derivative: Some(d),
12470                    status: TuningResultStatus::Valid,
12471                    invalid_reason: None,
12472                },
12473            )
12474            .await
12475            .unwrap();
12476        }
12477        let driver = honeywell_driver_auto();
12478
12479        let (outcome, write_back_detail) = maybe_write_back(
12480            &pool,
12481            run.id,
12482            &tags,
12483            &template,
12484            &driver,
12485            config,
12486            Some(ResponseLevel::Sluggish),
12487            OutputFormat::Table,
12488            false,
12489            &mut std::io::Cursor::new(b"".as_slice()),
12490        )
12491        .await
12492        .unwrap();
12493
12494        assert_eq!(outcome, WriteBackOutcome::Failed);
12495        assert_eq!(
12496            write_back_detail.as_deref(),
12497            Some("no calculated result recorded for response level Sluggish")
12498        );
12499        // Nothing was attempted at the driver at all, so no audit row exists either.
12500        assert!(
12501            TuneWriteRow::list_for_run(&pool, run.id)
12502                .await
12503                .unwrap()
12504                .is_empty()
12505        );
12506    }
12507
12508    #[tokio::test]
12509    async fn maybe_write_back_skips_the_interactive_prompt_without_touching_stdin_when_json_output_is_set_without_write_pid()
12510     {
12511        // `safety-json-contract` (finding 8): under `--output json`, there is no human to
12512        // answer an interactive write-back prompt, so `write_pid: None` must skip straight
12513        // to `Skipped` -- without printing a prompt to stdout (which would break the JSON
12514        // contract) and, just as importantly, without reading a single byte from `reader`
12515        // (real stdin outside tests), since consuming input meant for something else, or
12516        // hanging a scripted caller waiting on input that will never arrive, would both be
12517        // real bugs. A `Cursor` that still reports a real value selection queued up proves
12518        // the byte was never consumed: if `maybe_write_back` had actually prompted and read
12519        // it, the outcome would be `Written`, not `Skipped`.
12520        let (pool, run_id) = run_with_recorded_results().await;
12521        let template = honeywell_template();
12522        let tags = honeywell_tags();
12523        let driver = honeywell_driver_auto();
12524        let mut reader = std::io::Cursor::new(b"1\n".as_slice());
12525
12526        let (outcome, write_back_detail) = maybe_write_back(
12527            &pool,
12528            run_id,
12529            &tags,
12530            &template,
12531            &driver,
12532            build_loop_config(&fast_simulator_args()).unwrap(),
12533            None,
12534            OutputFormat::Json,
12535            false,
12536            &mut reader,
12537        )
12538        .await
12539        .unwrap();
12540
12541        assert_eq!(outcome, WriteBackOutcome::Skipped);
12542        let detail = write_back_detail.unwrap_or_default();
12543        assert!(detail.contains("--output json"));
12544        assert!(detail.contains("--write-pid"));
12545        assert_eq!(reader.position(), 0, "reader must not be read from at all");
12546        assert!(
12547            TuneWriteRow::list_for_run(&pool, run_id)
12548                .await
12549                .unwrap()
12550                .is_empty()
12551        );
12552    }
12553
12554    #[test]
12555    fn output_predicates_match_table_and_json_write_back_modes() {
12556        assert!(prints_table_output(OutputFormat::Table));
12557        assert!(!prints_table_output(OutputFormat::Json));
12558        assert!(skips_interactive_prompt(None, OutputFormat::Json));
12559        assert!(!skips_interactive_prompt(
12560            Some(ResponseLevel::Aggressive),
12561            OutputFormat::Json
12562        ));
12563        assert!(!skips_interactive_prompt(None, OutputFormat::Table));
12564    }
12565
12566    #[test]
12567    fn delayed_live_timing_emits_the_observational_warning() {
12568        warn_on_missed_poll_opportunities(42, &delayed_live_timing_metrics());
12569    }
12570
12571    #[tokio::test]
12572    async fn timing_persistence_failure_does_not_replace_the_run_outcome() {
12573        let pool = seeded_pool().await;
12574        pool.close().await;
12575
12576        record_timing_metrics_best_effort(&pool, 42, delayed_live_timing_metrics()).await;
12577    }
12578
12579    #[tokio::test]
12580    async fn present_timing_metrics_are_persisted_by_the_optional_wrapper() {
12581        let (pool, run_id) = run_with_recorded_results().await;
12582        let metrics = delayed_live_timing_metrics();
12583
12584        record_timing_metrics_if_present(&pool, run_id, Some(metrics)).await;
12585
12586        let stored = TuneRunRow::get(&pool, run_id).await.unwrap().unwrap();
12587        assert_eq!(stored.timing_metrics, Some(metrics));
12588    }
12589
12590    #[test]
12591    fn restore_incomplete_warning_message_names_the_reason_and_mv_restore_target() {
12592        let message = restore_incomplete_warning_message(
12593            &honeywell_tags(),
12594            &sample_initial_state(),
12595            "restore timed out",
12596        );
12597
12598        assert!(message.contains("restore timed out"));
12599        assert!(message.contains("Unit1.LIC101.OP"));
12600        assert!(message.contains("45"));
12601        assert!(message.contains("loop's mode"));
12602    }
12603
12604    #[test]
12605    fn warn_restore_incomplete_returns_the_message_it_emits() {
12606        let tags = honeywell_tags();
12607        let initial = sample_initial_state();
12608        let message = warn_restore_incomplete(&tags, &initial, "restore timed out");
12609
12610        assert_eq!(
12611            message,
12612            restore_incomplete_warning_message(&tags, &initial, "restore timed out")
12613        );
12614    }
12615
12616    #[test]
12617    fn tune_outcome_for_run_maps_every_run_outcome_variant() {
12618        assert_eq!(
12619            tune_outcome_for_run(&RunOutcome::Completed {
12620                write_back: WriteBackOutcome::Skipped,
12621                write_back_detail: None,
12622            }),
12623            TuneOutcome::Completed
12624        );
12625        assert_eq!(
12626            tune_outcome_for_run(&RunOutcome::Completed {
12627                write_back: WriteBackOutcome::Written {
12628                    response_level: ResponseLevel::Moderate
12629                },
12630                write_back_detail: None,
12631            }),
12632            TuneOutcome::Completed
12633        );
12634        assert_eq!(
12635            tune_outcome_for_run(&RunOutcome::Completed {
12636                write_back: WriteBackOutcome::Failed,
12637                write_back_detail: None,
12638            }),
12639            TuneOutcome::WriteBackFailed
12640        );
12641        assert_eq!(
12642            tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::UserInterrupt)),
12643            TuneOutcome::Aborted
12644        );
12645        assert_eq!(
12646            tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::Timeout {
12647                timeout_secs: 3600
12648            })),
12649            TuneOutcome::TimedOut
12650        );
12651        assert_eq!(
12652            tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::PoorQuality {
12653                tag: "Unit1.LIC101.PV".to_string(),
12654                quality: bhtune_driver::Quality::Bad,
12655            })),
12656            TuneOutcome::PoorQuality
12657        );
12658        assert_eq!(
12659            tune_outcome_for_run(&RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
12660                tag: "Unit1.LIC101.OP".to_string(),
12661                target: 55.0,
12662                readback: Some(50.0),
12663                tolerance: 0.1,
12664                elapsed_ms: 4_000,
12665                deadline_secs: 4,
12666            })),
12667            TuneOutcome::ActuationFailed
12668        );
12669    }
12670
12671    #[test]
12672    fn print_summary_returns_the_tune_outcome_matching_the_run_outcome_in_every_output_format() {
12673        // Full 8 (RunOutcome shape) x 2 (OutputFormat) matrix, so every `println!` arm in
12674        // both `match output` branches is exercised directly here rather than relying on
12675        // incidental coverage from `run()`-level tests (which never reach `Written`/`Failed`
12676        // write-back outcomes -- see the module doc comment on why that's structurally hard
12677        // to drive end-to-end).
12678        assert_eq!(
12679            print_summary(
12680                1,
12681                &RunOutcome::Completed {
12682                    write_back: WriteBackOutcome::Skipped,
12683                    write_back_detail: None,
12684                },
12685                OutputFormat::Table
12686            ),
12687            TuneOutcome::Completed
12688        );
12689        assert_eq!(
12690            print_summary(
12691                1,
12692                &RunOutcome::Completed {
12693                    write_back: WriteBackOutcome::Skipped,
12694                    write_back_detail: None,
12695                },
12696                OutputFormat::Json
12697            ),
12698            TuneOutcome::Completed
12699        );
12700        assert_eq!(
12701            print_summary(
12702                1,
12703                &RunOutcome::Completed {
12704                    write_back: WriteBackOutcome::Written {
12705                        response_level: ResponseLevel::Aggressive
12706                    },
12707                    write_back_detail: None,
12708                },
12709                OutputFormat::Table
12710            ),
12711            TuneOutcome::Completed
12712        );
12713        assert_eq!(
12714            print_summary(
12715                1,
12716                &RunOutcome::Completed {
12717                    write_back: WriteBackOutcome::Written {
12718                        response_level: ResponseLevel::Aggressive
12719                    },
12720                    write_back_detail: None,
12721                },
12722                OutputFormat::Json
12723            ),
12724            TuneOutcome::Completed
12725        );
12726        assert_eq!(
12727            print_summary(
12728                1,
12729                &RunOutcome::Completed {
12730                    write_back: WriteBackOutcome::Failed,
12731                    write_back_detail: None,
12732                },
12733                OutputFormat::Table
12734            ),
12735            TuneOutcome::WriteBackFailed
12736        );
12737        assert_eq!(
12738            print_summary(
12739                1,
12740                &RunOutcome::Completed {
12741                    write_back: WriteBackOutcome::Failed,
12742                    write_back_detail: None,
12743                },
12744                OutputFormat::Json
12745            ),
12746            TuneOutcome::WriteBackFailed
12747        );
12748        assert_eq!(
12749            print_summary(
12750                1,
12751                &RunOutcome::Aborted(AbortReason::UserInterrupt),
12752                OutputFormat::Table
12753            ),
12754            TuneOutcome::Aborted
12755        );
12756        assert_eq!(
12757            print_summary(
12758                1,
12759                &RunOutcome::Aborted(AbortReason::UserInterrupt),
12760                OutputFormat::Json
12761            ),
12762            TuneOutcome::Aborted
12763        );
12764        assert_eq!(
12765            print_summary(
12766                1,
12767                &RunOutcome::Aborted(AbortReason::Timeout { timeout_secs: 3600 }),
12768                OutputFormat::Table
12769            ),
12770            TuneOutcome::TimedOut
12771        );
12772        assert_eq!(
12773            print_summary(
12774                1,
12775                &RunOutcome::Aborted(AbortReason::Timeout { timeout_secs: 3600 }),
12776                OutputFormat::Json
12777            ),
12778            TuneOutcome::TimedOut
12779        );
12780        assert_eq!(
12781            print_summary(
12782                1,
12783                &RunOutcome::Aborted(AbortReason::OperationTimedOut {
12784                    tag: "Unit1.LIC101.PV".to_string(),
12785                    op_timeout_secs: 30,
12786                }),
12787                OutputFormat::Table
12788            ),
12789            TuneOutcome::TimedOut
12790        );
12791        assert_eq!(
12792            print_summary(
12793                1,
12794                &RunOutcome::Aborted(AbortReason::OperationTimedOut {
12795                    tag: "Unit1.LIC101.PV".to_string(),
12796                    op_timeout_secs: 30,
12797                }),
12798                OutputFormat::Json
12799            ),
12800            TuneOutcome::TimedOut
12801        );
12802        assert_eq!(
12803            print_summary(
12804                1,
12805                &RunOutcome::Aborted(AbortReason::PoorQuality {
12806                    tag: "Unit1.LIC101.PV".to_string(),
12807                    quality: bhtune_driver::Quality::Uncertain,
12808                }),
12809                OutputFormat::Table
12810            ),
12811            TuneOutcome::PoorQuality
12812        );
12813        for output in [OutputFormat::Table, OutputFormat::Json] {
12814            assert_eq!(
12815                print_summary(
12816                    1,
12817                    &RunOutcome::Aborted(AbortReason::MvActuationUnconfirmed {
12818                        tag: "Unit1.LIC101.OP".to_string(),
12819                        target: 55.0,
12820                        readback: Some(50.0),
12821                        tolerance: 0.1,
12822                        elapsed_ms: 4_000,
12823                        deadline_secs: 4,
12824                    }),
12825                    output,
12826                ),
12827                TuneOutcome::ActuationFailed
12828            );
12829        }
12830        assert_eq!(
12831            print_summary(
12832                1,
12833                &RunOutcome::Aborted(AbortReason::PoorQuality {
12834                    tag: "Unit1.LIC101.PV".to_string(),
12835                    quality: bhtune_driver::Quality::Uncertain,
12836                }),
12837                OutputFormat::Json
12838            ),
12839            TuneOutcome::PoorQuality
12840        );
12841        assert_eq!(
12842            print_summary(
12843                1,
12844                &RunOutcome::RestoreIncomplete {
12845                    reason: "run aborted (UserInterrupt); a second Ctrl+C was received while \
12846                             restoring the loop"
12847                        .to_string(),
12848                },
12849                OutputFormat::Table
12850            ),
12851            TuneOutcome::RestoreIncomplete
12852        );
12853        assert_eq!(
12854            print_summary(
12855                1,
12856                &RunOutcome::RestoreIncomplete {
12857                    reason: "run aborted (UserInterrupt); a second Ctrl+C was received while \
12858                             restoring the loop"
12859                        .to_string(),
12860                },
12861                OutputFormat::Json
12862            ),
12863            TuneOutcome::RestoreIncomplete
12864        );
12865    }
12866
12867    #[tokio::test]
12868    async fn run_rejects_write_pid_without_yes_before_starting_the_tune() {
12869        let pool = seeded_pool().await;
12870        let mut args = fast_simulator_args();
12871        args.write_pid = Some(crate::args::ResponseLevelArg::Aggressive);
12872        args.yes = false;
12873
12874        let err = run(&pool, args, &test_config()).await.unwrap_err();
12875        assert!(err.to_string().contains("--write-pid requires --yes"));
12876
12877        // The check happens before any driver/database I/O, so no run row should exist.
12878        let runs = TuneRunRow::list(
12879            &pool,
12880            &bhtune_db::models::TuneRunFilter::default(),
12881            bhtune_db::models::Pagination::first(10),
12882        )
12883        .await
12884        .unwrap();
12885        assert!(runs.is_empty());
12886    }
12887
12888    #[tokio::test]
12889    async fn a_full_simulator_tune_with_write_pid_and_yes_still_skips_write_back() {
12890        // The built-in simulator driver has no PID constant tags at all (see
12891        // `build_loop_tags`), so `--write-pid`/`--yes` must be accepted but remain a no-op
12892        // -- not an error, and not `TuneOutcome::WriteBackFailed`.
12893        let pool = seeded_pool().await;
12894        let mut args = fast_simulator_args();
12895        args.write_pid = Some(crate::args::ResponseLevelArg::Aggressive);
12896        args.yes = true;
12897
12898        let outcome = run(&pool, args, &test_config()).await.unwrap();
12899        assert_eq!(outcome, TuneOutcome::Completed);
12900    }
12901
12902    #[tokio::test]
12903    async fn run_times_out_and_aborts_when_timeout_secs_elapses_before_completion() {
12904        // Real (unpaused) time: `start_paused` was tried here first but interacts badly with
12905        // the real sqlx `SqlitePool` -- pausing tokio's clock also fast-forwards the pool's
12906        // own internal connection-acquire timeout, which fires instantly and turns every
12907        // query into a spurious `PoolTimedOut` error. So this test pays a real ~1s wall-clock
12908        // cost instead, matching `tests/ctrlc_abort.rs`'s existing precedent of a similar
12909        // real-time cost for the same reason (an actual signal/timeout has to actually
12910        // elapse). `poll_interval_ms: 3` is not a divisor of `timeout_secs: 1`'s 1000ms, so
12911        // the timeout can never land exactly on a tick boundary. `cycles_count: 100_000`
12912        // makes it impossible for the MRFT test to legitimately finish within the handful of
12913        // ticks that occur in one real second at this poll rate (a real oscillation cycle
12914        // needs at least 2 ticks, so 100,000 cycles needs at least 200,000 -- nowhere near
12915        // reachable in ~333 ticks).
12916        let pool = seeded_pool().await;
12917        let mut args = fast_simulator_args();
12918        args.poll_interval_ms = 3;
12919        args.timeout_secs = 1;
12920        args.cycles_count = Some(100_000);
12921
12922        let outcome = run(&pool, args, &test_config()).await.unwrap();
12923        assert_eq!(outcome, TuneOutcome::TimedOut);
12924
12925        let runs = TuneRunRow::list(
12926            &pool,
12927            &bhtune_db::models::TuneRunFilter::default(),
12928            bhtune_db::models::Pagination::first(10),
12929        )
12930        .await
12931        .unwrap();
12932        assert_eq!(runs.len(), 1);
12933        // A timeout-triggered abort reuses the exact same DB outcome as Ctrl+C -- only the
12934        // CLI-level `TuneOutcome`/exit code/printed message distinguish *why*.
12935        assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Aborted);
12936
12937        // Ticks were actually recorded before the timeout fired, proving the loop really ran
12938        // rather than aborting instantly with nothing sampled.
12939        let samples = TuneSampleRow::list_for_run(&pool, runs[0].id)
12940            .await
12941            .unwrap();
12942        assert!(!samples.is_empty());
12943    }
12944
12945    // --- `bounded_driver_call` / `TickOperation`: the four possible race outcomes, tested --
12946    // --- directly and in isolation from the polling loop that's the only real caller --------
12947
12948    #[tokio::test]
12949    async fn bounded_driver_call_returns_completed_when_the_call_finishes_first() {
12950        let mut ctrl_c = CtrlC::never();
12951        let result = bounded_driver_call(30, &mut ctrl_c, async { Ok::<_, anyhow::Error>(42) })
12952            .await
12953            .unwrap();
12954        assert!(matches!(result, TickOperation::Completed(42)));
12955    }
12956
12957    #[tokio::test]
12958    async fn bounded_driver_call_propagates_a_genuine_error_from_the_call() {
12959        // A real failure from the call itself (a rejected write, a malformed value, a
12960        // transport error) must still propagate through `?` at the call site -- it is not
12961        // "gave up waiting", so it has no `TickOperation` variant of its own.
12962        let mut ctrl_c = CtrlC::never();
12963        let err = bounded_driver_call(30, &mut ctrl_c, async {
12964            Err::<(), _>(anyhow::anyhow!("boom"))
12965        })
12966        .await
12967        .unwrap_err();
12968        assert!(err.to_string().contains("boom"));
12969    }
12970
12971    #[tokio::test]
12972    async fn bounded_driver_call_returns_cancelled_when_ctrl_c_fires_first() {
12973        let (mut ctrl_c, tx) = CtrlC::test_pair();
12974        tx.send(1).unwrap();
12975        let result: TickOperation<()> = bounded_driver_call(
12976            30,
12977            &mut ctrl_c,
12978            std::future::pending::<anyhow::Result<()>>(),
12979        )
12980        .await
12981        .unwrap();
12982        assert!(matches!(result, TickOperation::Cancelled));
12983    }
12984
12985    #[tokio::test(start_paused = true)]
12986    async fn bounded_driver_call_returns_timed_out_when_the_driver_call_stalls() {
12987        // No `SqlitePool` involved here (unlike the `run_polling_loop`-level tests), so
12988        // `start_paused` is safe -- see the precedent/caveat noted on the timeout test above.
12989        let mut ctrl_c = CtrlC::never();
12990        let result: TickOperation<()> =
12991            bounded_driver_call(1, &mut ctrl_c, std::future::pending::<anyhow::Result<()>>())
12992                .await
12993                .unwrap();
12994        assert!(matches!(result, TickOperation::TimedOut));
12995    }
12996
12997    #[tokio::test]
12998    async fn a_stalled_pv_read_during_a_tick_is_cancelled_without_recording_a_sample() {
12999        let pool = seeded_pool().await;
13000        let template = honeywell_template();
13001        let tags = honeywell_tags();
13002        let driver = honeywell_driver_auto().hanging_read(&tags.process_variable);
13003        let mut args = fast_simulator_args();
13004        args.op_timeout_secs = 30;
13005        let config = build_loop_config(&args).unwrap();
13006        let started_at = Utc::now();
13007        let run = TuneRunRow::start(
13008            &pool,
13009            None,
13010            "stalled-pv-read",
13011            TuneDriver::Opcda,
13012            config,
13013            TemplateOrigin::Builtin,
13014            &template,
13015            &tags,
13016            started_at,
13017        )
13018        .await
13019        .unwrap();
13020        let initial = sample_initial_state();
13021        let beta = lookup(
13022            config.process_type,
13023            config.controller_type,
13024            ResponseLevel::Aggressive,
13025        )
13026        .beta;
13027        let mut engine = MrftEngine::new(
13028            config,
13029            initial.direction,
13030            beta,
13031            InitialReadings {
13032                pv_ini: initial.pv_ini,
13033                mv_ini: initial.mv_ini,
13034                mv_range_low: initial.mv_range_low,
13035                mv_range_high: initial.mv_range_high,
13036            },
13037            started_at,
13038            MrftCompat::default(),
13039        );
13040        let (mut ctrl_c, tx) = CtrlC::test_pair();
13041        tokio::spawn(async move {
13042            tokio::time::sleep(Duration::from_millis(50)).await;
13043            let _ = tx.send(1);
13044        });
13045
13046        let mut timing = timing_for_args(&args);
13047        let outcome = run_polling_loop(
13048            &pool,
13049            run.id,
13050            &args,
13051            &tags,
13052            &driver,
13053            &mut engine,
13054            time_anchor_at(started_at),
13055            &mut ctrl_c,
13056            &mut MutationGuard::default(),
13057            false,
13058            &mut timing,
13059            &mut None,
13060            config,
13061        )
13062        .await
13063        .unwrap();
13064
13065        assert!(matches!(
13066            outcome,
13067            PollOutcome::Aborted(AbortReason::UserInterrupt)
13068        ));
13069        assert!(
13070            TuneSampleRow::list_for_run(&pool, run.id)
13071                .await
13072                .unwrap()
13073                .is_empty()
13074        );
13075    }
13076
13077    #[tokio::test]
13078    async fn a_stalled_mv_write_during_a_tick_times_out_after_recording_the_sample() {
13079        let pool = seeded_pool().await;
13080        let template = honeywell_template();
13081        let tags = honeywell_tags();
13082        let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
13083        let mut args = fast_simulator_args();
13084        args.op_timeout_secs = 1;
13085        let config = build_loop_config(&args).unwrap();
13086        let started_at = Utc::now();
13087        let run = TuneRunRow::start(
13088            &pool,
13089            None,
13090            "stalled-mv-write-timeout",
13091            TuneDriver::Opcda,
13092            config,
13093            TemplateOrigin::Builtin,
13094            &template,
13095            &tags,
13096            started_at,
13097        )
13098        .await
13099        .unwrap();
13100        let initial = sample_initial_state();
13101        let mut engine = MrftEngine::new(
13102            config,
13103            initial.direction,
13104            lookup(
13105                config.process_type,
13106                config.controller_type,
13107                ResponseLevel::Aggressive,
13108            )
13109            .beta,
13110            InitialReadings {
13111                pv_ini: initial.pv_ini,
13112                mv_ini: initial.mv_ini,
13113                mv_range_low: initial.mv_range_low,
13114                mv_range_high: initial.mv_range_high,
13115            },
13116            started_at,
13117            MrftCompat::default(),
13118        );
13119
13120        let mut timing = timing_for_args(&args);
13121        let outcome = run_polling_loop(
13122            &pool,
13123            run.id,
13124            &args,
13125            &tags,
13126            &driver,
13127            &mut engine,
13128            time_anchor_at(started_at),
13129            &mut CtrlC::never(),
13130            &mut MutationGuard::default(),
13131            false,
13132            &mut timing,
13133            &mut None,
13134            config,
13135        )
13136        .await
13137        .unwrap();
13138
13139        assert!(matches!(
13140            outcome,
13141            PollOutcome::Aborted(AbortReason::OperationTimedOut { ref tag, op_timeout_secs })
13142                if tag == &tags.manipulated_variable && op_timeout_secs == 1
13143        ));
13144        assert_eq!(
13145            TuneSampleRow::list_for_run(&pool, run.id)
13146                .await
13147                .unwrap()
13148                .len(),
13149            1
13150        );
13151    }
13152
13153    // --- `attempt_restore` / `RestoreAttempt`: confirmed vs. incomplete, and both ways to ---
13154    // --- become incomplete (a second Ctrl+C, and `[tuning].restore_timeout_secs` elapsing) ---
13155
13156    #[tokio::test(start_paused = true)]
13157    async fn attempt_restore_confirms_a_normal_restore() {
13158        let template = honeywell_template();
13159        let tags = honeywell_tags();
13160        let driver = honeywell_driver_auto();
13161        let initial = read_initial_values(&driver, &tags, &template, false)
13162            .await
13163            .unwrap();
13164        let mut guard = MutationGuard::default();
13165        transition_to_manual(&driver, &tags, &template, &initial, &mut guard)
13166            .await
13167            .unwrap();
13168        let pool = SqlitePool::connect_lazy("sqlite::memory:").unwrap();
13169        let args = fast_simulator_args();
13170
13171        let outcome = attempt_restore_with_actuation(
13172            &pool,
13173            0,
13174            &args,
13175            &driver,
13176            &tags,
13177            &template,
13178            &initial,
13179            &guard,
13180            false,
13181            &mut CtrlC::never(),
13182            &mut None,
13183        )
13184        .await;
13185
13186        assert!(matches!(outcome, RestoreAttempt::Confirmed));
13187        assert_eq!(
13188            driver.value_of(&tags.manipulated_variable).as_deref(),
13189            Some("45")
13190        );
13191    }
13192
13193    #[tokio::test]
13194    async fn record_restore_status_best_effort_swallows_database_errors() {
13195        let pool = seeded_pool().await;
13196        record_restore_status_best_effort(&pool, i64::MAX, &RestoreAttempt::Confirmed).await;
13197    }
13198
13199    #[tokio::test(start_paused = true)]
13200    async fn attempt_restore_reports_incomplete_when_restore_timeout_secs_elapses() {
13201        let template = honeywell_template();
13202        let tags = honeywell_tags();
13203        // `restore`'s very first step writes the MV -- hanging it means `restore()` itself
13204        // can never resolve on its own, so only the timeout branch can win this race.
13205        let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
13206        let initial = sample_initial_state();
13207        let guard = MutationGuard::default();
13208        let pool = SqlitePool::connect_lazy("sqlite::memory:").unwrap();
13209        let mut args = fast_simulator_args();
13210        args.restore_timeout_secs = MV_ACTUATION_CONFIRMATION_SECS;
13211        args.op_timeout_secs = 30;
13212
13213        let outcome = attempt_restore_with_actuation(
13214            &pool,
13215            0,
13216            &args,
13217            &driver,
13218            &tags,
13219            &template,
13220            &initial,
13221            &guard,
13222            false,
13223            &mut CtrlC::never(),
13224            &mut None,
13225        )
13226        .await;
13227
13228        match outcome {
13229            RestoreAttempt::Incomplete { reason } => {
13230                assert!(reason.contains("[tuning].restore_timeout_secs"));
13231            }
13232            RestoreAttempt::Confirmed => panic!("expected RestoreAttempt::Incomplete"),
13233        }
13234    }
13235
13236    #[tokio::test]
13237    async fn attempt_restore_reports_incomplete_on_a_second_ctrl_c() {
13238        let template = honeywell_template();
13239        let tags = honeywell_tags();
13240        let driver = honeywell_driver_auto().hanging_write(&tags.manipulated_variable);
13241        let initial = sample_initial_state();
13242        let guard = MutationGuard::default();
13243        let (mut ctrl_c, tx) = CtrlC::test_pair();
13244        tx.send(1).unwrap();
13245        let pool = seeded_pool().await;
13246        let args = fast_simulator_args();
13247
13248        let outcome = attempt_restore_with_actuation(
13249            &pool,
13250            0,
13251            &args,
13252            &driver,
13253            &tags,
13254            &template,
13255            &initial,
13256            &guard,
13257            false,
13258            &mut ctrl_c,
13259            &mut None,
13260        )
13261        .await;
13262
13263        match outcome {
13264            RestoreAttempt::Incomplete { reason } => {
13265                assert!(reason.contains("second Ctrl+C"));
13266            }
13267            RestoreAttempt::Confirmed => panic!("expected RestoreAttempt::Incomplete"),
13268        }
13269    }
13270
13271    #[tokio::test]
13272    async fn restore_wrapper_handles_ctrl_c_after_mv_restore_completes() {
13273        let pool = seeded_pool().await;
13274        let template = honeywell_template();
13275        let tags = honeywell_tags();
13276        let driver = honeywell_driver_auto().hanging_write(tags.controller_mode.as_ref().unwrap());
13277        let initial = sample_initial_state();
13278        let guard = MutationGuard {
13279            mode_written: true,
13280            ..MutationGuard::default()
13281        };
13282        let (mut ctrl_c, tx) = CtrlC::test_pair();
13283        tokio::spawn(async move {
13284            tokio::time::sleep(Duration::from_millis(30)).await;
13285            let _ = tx.send(1);
13286        });
13287
13288        let outcome = attempt_restore_with_actuation(
13289            &pool,
13290            0,
13291            &fast_simulator_args(),
13292            &driver,
13293            &tags,
13294            &template,
13295            &initial,
13296            &guard,
13297            false,
13298            &mut ctrl_c,
13299            &mut None,
13300        )
13301        .await;
13302
13303        assert!(matches!(
13304            outcome,
13305            RestoreAttempt::Incomplete { ref reason } if reason.contains("second Ctrl+C")
13306        ));
13307        assert_eq!(
13308            driver.value_of(&tags.manipulated_variable).as_deref(),
13309            Some("45")
13310        );
13311    }
13312
13313    #[tokio::test(start_paused = true)]
13314    async fn restore_wrapper_handles_deadline_after_mv_restore_completes() {
13315        let pool = SqlitePool::connect_lazy("sqlite::memory:").unwrap();
13316        let template = honeywell_template();
13317        let tags = honeywell_tags();
13318        let driver = honeywell_driver_auto().hanging_write(tags.controller_mode.as_ref().unwrap());
13319        let initial = sample_initial_state();
13320        let guard = MutationGuard {
13321            mode_written: true,
13322            ..MutationGuard::default()
13323        };
13324        let mut args = fast_simulator_args();
13325        args.restore_timeout_secs = 1;
13326        args.op_timeout_secs = 30;
13327
13328        let outcome = attempt_restore_with_actuation(
13329            &pool,
13330            0,
13331            &args,
13332            &driver,
13333            &tags,
13334            &template,
13335            &initial,
13336            &guard,
13337            false,
13338            &mut CtrlC::never(),
13339            &mut None,
13340        )
13341        .await;
13342
13343        assert!(matches!(
13344            outcome,
13345            RestoreAttempt::Incomplete { ref reason }
13346                if reason.contains("[tuning].restore_timeout_secs")
13347        ));
13348        assert_eq!(
13349            driver.value_of(&tags.manipulated_variable).as_deref(),
13350            Some("45")
13351        );
13352    }
13353
13354    #[tokio::test]
13355    async fn accepted_mv_restore_gets_a_full_confirmation_window_before_remaining_restore_steps() {
13356        let pool = seeded_pool().await;
13357        let (run_id, _config, template, tags) =
13358            start_opc_test_run(&pool, "actuation-restore-deadline-extension").await;
13359        let driver = honeywell_driver_auto()
13360            .delaying_write(&tags.manipulated_variable, Duration::from_millis(2_300))
13361            .delaying_read(&tags.manipulated_variable, Duration::from_millis(900))
13362            .delaying_write(
13363                tags.controller_mode.as_ref().unwrap(),
13364                Duration::from_millis(500),
13365            );
13366        let initial = sample_initial_state();
13367        let guard = MutationGuard {
13368            mode_written: true,
13369            ..MutationGuard::default()
13370        };
13371        let mut args = fast_simulator_args();
13372        args.driver = DriverKindArg::Opcda;
13373        args.restore_timeout_secs = MV_ACTUATION_CONFIRMATION_SECS;
13374        args.op_timeout_secs = 30;
13375        let mut mv_actuations = Some(MvActuationTracker::for_run(&args, &initial).unwrap());
13376
13377        let outcome = attempt_restore_with_actuation(
13378            &pool,
13379            run_id,
13380            &args,
13381            &driver,
13382            &tags,
13383            &template,
13384            &initial,
13385            &guard,
13386            false,
13387            &mut CtrlC::never(),
13388            &mut mv_actuations,
13389        )
13390        .await;
13391
13392        assert!(matches!(outcome, RestoreAttempt::Confirmed));
13393        assert_eq!(
13394            driver.value_of(&tags.manipulated_variable).as_deref(),
13395            Some("45")
13396        );
13397        assert_eq!(
13398            driver
13399                .value_of(tags.controller_mode.as_ref().unwrap())
13400                .as_deref(),
13401            Some("1")
13402        );
13403        assert!(!driver.delayed_write_was_cancelled(&tags.manipulated_variable));
13404        assert!(!driver.delayed_read_was_cancelled(&tags.manipulated_variable));
13405    }
13406
13407    #[tokio::test]
13408    async fn restore_failure_wrapper_preserves_error_when_status_and_cleanup_writes_fail() {
13409        let pool = seeded_pool().await;
13410        pool.close().await;
13411        let original = anyhow::anyhow!("original polling failure");
13412        let error = restore_best_effort_then_propagate(
13413            &pool,
13414            42,
13415            &honeywell_driver_auto(),
13416            &honeywell_tags(),
13417            &honeywell_template(),
13418            &sample_initial_state(),
13419            &MutationGuard::default(),
13420            &fast_simulator_args(),
13421            false,
13422            &mut CtrlC::never(),
13423            &mut None,
13424            original,
13425        )
13426        .await;
13427
13428        assert_eq!(error.to_string(), "original polling failure");
13429    }
13430
13431    // --- `run_with_ctrl_c`: the real, ctrl-c-aware entry point, exercised end to end with ---
13432    // --- a simulated signal rather than only through the `CtrlC::never()`-backed `run` above
13433
13434    /// The one test exercising `run_with_ctrl_c` itself (every other test in this module goes
13435    /// through the `#[cfg(test)]`-only `run` wrapper, which always passes `CtrlC::never()` --
13436    /// see its doc comment) -- proving this is what `lib.rs::run_with_cli_and_ctrl_c` actually
13437    /// calls in production correctly reacts to a signalled `CtrlC` end to end: dispatch, the
13438    /// poll loop, the restore, and the final `TuneOutcome`/DB row.
13439    #[tokio::test]
13440    async fn run_with_ctrl_c_aborts_the_run_when_signalled_during_the_poll() {
13441        let pool = seeded_pool().await;
13442        let mut args = fast_simulator_args();
13443        // Impossible to legitimately finish within the ~50ms before the signal below fires,
13444        // matching the timeout test's own precedent for making a real completion race moot.
13445        args.cycles_count = Some(100_000);
13446        let (mut ctrl_c, tx) = CtrlC::test_pair();
13447        tokio::spawn(async move {
13448            tokio::time::sleep(Duration::from_millis(50)).await;
13449            let _ = tx.send(1);
13450        });
13451
13452        let outcome = run_with_ctrl_c(&pool, args, &test_config(), &mut ctrl_c)
13453            .await
13454            .unwrap();
13455        assert_eq!(outcome, TuneOutcome::Aborted);
13456
13457        let runs = TuneRunRow::list(
13458            &pool,
13459            &bhtune_db::models::TuneRunFilter::default(),
13460            bhtune_db::models::Pagination::first(10),
13461        )
13462        .await
13463        .unwrap();
13464        assert_eq!(runs.len(), 1);
13465        assert_eq!(runs[0].outcome, bhtune_db::models::TuneOutcome::Aborted);
13466    }
13467}