Skip to main content

bhtune_runtime/tune/
writeback.rs

1#![allow(rustdoc::broken_intra_doc_links)]
2
3use bhtune_core::{
4    ControllerPidValues, ControllerType, DcsTemplate, LoopConfig, LoopTags, PidParameters,
5    ResponseLevel, TuningResultStatus, controller_pid_values,
6};
7use bhtune_db::SqlitePool;
8use bhtune_db::models::{
9    LiveOwnershipRow, NewTuneWrite, RollbackState, TuneResultRow, TuneRunRow, TuneWriteRow,
10    WriteKind, WriteReadback,
11};
12use bhtune_driver::Driver;
13use chrono::Utc;
14
15use crate::live_ownership::{AuditedDriver, LiveOperationGuard};
16
17use super::outcome::WriteBackOutcome;
18use super::quality::{read_f32, write_value};
19
20/// Reads the existing Proportional/Integral/Derivative values before any write is attempted.
21/// Reading all three is a hard stop on the first failure and guarantees that a later rollback
22/// always has a known-good value to restore. `pub(crate)`: also reused by
23/// `commands::history::revert`, which needs the identical pre-read step before writing a
24/// past run's recorded values back.
25pub(crate) async fn read_previous_pid_values(
26    driver: &dyn Driver,
27    p_tag: &str,
28    i_tag: &str,
29    d_tag: &str,
30    allow_uncertain: bool,
31) -> anyhow::Result<WriteReadback> {
32    let proportional = read_f32(driver, p_tag, allow_uncertain)
33        .await
34        .map_err(|e| anyhow::anyhow!("pre-read of Proportional tag '{p_tag}' failed: {e}"))?;
35    let integral = read_f32(driver, i_tag, allow_uncertain)
36        .await
37        .map_err(|e| anyhow::anyhow!("pre-read of Integral tag '{i_tag}' failed: {e}"))?;
38    let derivative = read_f32(driver, d_tag, allow_uncertain)
39        .await
40        .map_err(|e| anyhow::anyhow!("pre-read of Derivative tag '{d_tag}' failed: {e}"))?;
41    Ok(WriteReadback {
42        proportional,
43        integral,
44        derivative,
45    })
46}
47
48async fn persist_pid_restore_intent(
49    pool: &SqlitePool,
50    ownership: &LiveOperationGuard,
51    run_id: i64,
52    kind: WriteKind,
53    response_level: ResponseLevel,
54    previous: WriteReadback,
55    target: WriteReadback,
56) -> anyhow::Result<()> {
57    let owner = LiveOwnershipRow::get(pool, ownership.owner().id)
58        .await?
59        .ok_or_else(|| anyhow::anyhow!("live owner {} disappeared", ownership.owner().id))?;
60    let mut intent = match owner.restore_intent_json {
61        Some(json) => serde_json::from_str::<serde_json::Value>(&json)?,
62        None => serde_json::json!({}),
63    };
64    let Some(root) = intent.as_object_mut() else {
65        anyhow::bail!("persisted live restore intent is not a JSON object");
66    };
67    root.insert(
68        "pid_restore".to_string(),
69        serde_json::json!({
70            "run_id": run_id,
71            "write_kind": kind,
72            "response_level": response_level,
73            "previous": {
74                "proportional": previous.proportional,
75                "integral": previous.integral,
76                "derivative": previous.derivative,
77            },
78            "target": {
79                "proportional": target.proportional,
80                "integral": target.integral,
81                "derivative": target.derivative,
82            },
83        }),
84    );
85    ownership
86        .persist_restore_intent(&serde_json::to_string(&intent)?)
87        .await
88}
89
90/// Whether a PID write-back's confirmation readback is close enough to `requested` to count
91/// as confirmed. Combined absolute (1e-3) and relative (1%) tolerance rather than exact
92/// equality, since a DCS's own internal unit conversion/precision means the readback of a
93/// just-written float is not guaranteed to be bit-identical -- and a purely relative
94/// tolerance breaks down for a requested value at or near zero (e.g. `D = 0` for a PI
95/// controller).
96pub(super) fn pid_value_within_tolerance(requested: f32, actual: f32) -> bool {
97    let tolerance = (1e-3_f32).max(0.01 * requested.abs());
98    (actual - requested).abs() <= tolerance
99}
100/// Writes `value` to `tag` and reads it back to confirm the DCS accepted it within
101/// [`pid_value_within_tolerance`], reusing [`write_value`] (so a transport error and a
102/// rejected write both surface the same way) and [`read_f32`] (so a poor-quality or
103/// non-numeric readback is never mistaken for confirmation). `label` is only used to prefix
104/// the error message so a caller writing several constants in sequence can tell which one
105/// failed. `pub(crate)`: also reused by `commands::history::revert` for the identical
106/// write-and-verify step against a run's recorded previous values.
107pub(crate) async fn write_and_verify_pid_value(
108    driver: &dyn Driver,
109    label: &str,
110    tag: &str,
111    value: f32,
112    allow_uncertain: bool,
113) -> Result<f32, String> {
114    write_value(driver, tag, value)
115        .await
116        .map_err(|e| format!("{label} write to '{tag}' failed: {e}"))?;
117    let readback = read_f32(driver, tag, allow_uncertain)
118        .await
119        .map_err(|e| format!("{label} readback from '{tag}' failed: {e}"))?;
120    if pid_value_within_tolerance(value, readback) {
121        Ok(readback)
122    } else {
123        Err(format!(
124            "{label} readback {readback} from '{tag}' is outside tolerance of requested {value}"
125        ))
126    }
127}
128/// Best-effort rollback of whichever PID constants were confirmed written before a later one
129/// failed -- mirroring `restore()`'s rule to attempt every step independently rather than
130/// short-circuit on the first failure. `targets` contains `(label, tag, written_value,
131/// previous_value)` tuples, in any order. Returns `Ok(())` only if every rollback write
132/// succeeded; otherwise `Err` describing every one that did not.
133pub(super) async fn rollback_pid_writes(
134    driver: &dyn Driver,
135    targets: &[(&str, &str, f32, f32)],
136    ownership: &LiveOperationGuard,
137) -> Result<(), String> {
138    let mut failures = Vec::new();
139    for (label, tag, value_written, previous_value) in targets {
140        let Some(previous_json) = serde_json::Number::from_f64(f64::from(*value_written))
141            .map(|number| number.to_string())
142        else {
143            failures.push(format!(
144                "{label} rollback audit intent for '{tag}' has a non-finite write value"
145            ));
146            continue;
147        };
148        ownership
149            .queue_previous_write_value(tag, previous_json)
150            .await;
151        if let Err(e) = write_value(driver, tag, *previous_value).await {
152            failures.push(format!("{label} rollback write to '{tag}' failed: {e}"));
153        }
154    }
155    if failures.is_empty() {
156        Ok(())
157    } else {
158        Err(failures.join("; "))
159    }
160}
161/// The result of [`write_pid_values`] -- deliberately simpler than [`WriteBackOutcome`]
162/// below, which layers CLI-only concerns (a `Skipped` variant covering unconfigured tags, no
163/// recorded results, or an interactive skip) on top of this. `write_pid_values` only ever
164/// runs once a specific, available target has already been chosen, so there is nothing left
165/// to "skip" by the time it's called. Named distinctly from `bhtune_driver::WriteOutcome`
166/// (that one describes a single raw tag write's own outcome; this one describes the full
167/// pre-read/write/verify/rollback/audit sequence across all three PID constants).
168#[derive(Debug, Clone, PartialEq)]
169pub enum PidWriteOutcome {
170    /// Every constant was written and confirmed within tolerance.
171    Written,
172    /// The pre-read, a write, or a readback failed. `detail` is a human-readable summary
173    /// suitable for surfacing directly to a CLI user or an HTTP error body -- including
174    /// whether/how rollback resolved, for a [`WriteKind::Write`] that failed partway
175    /// through. A [`TuneWriteRow`] audit row was still inserted recording the same story in
176    /// full detail; this is only ever a summary of it.
177    Failed { detail: String },
178}
179
180#[derive(Debug, Clone, PartialEq)]
181pub enum WriteBackSelection {
182    Selected(ResponseLevel),
183    Skipped(String),
184    Failed(String),
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum WriteBackSkipReason {
189    NoPidTags,
190    NoResults,
191}
192
193/// Presentation of one raw result's controller target, including precision failures that
194/// do not change the recorded calculation status.
195#[derive(Debug, Clone, PartialEq, serde::Serialize)]
196pub struct PidWritePreview {
197    pub response_level: ResponseLevel,
198    pub values: Option<ControllerPidValues>,
199    pub unavailable_reason: Option<String>,
200}
201
202/// Optional adapter hook for response-level selection and progress reporting.
203///
204/// The runtime performs all result validation, writes, readback confirmation, rollback, and
205/// audit persistence. An adapter may provide an interactive selector; non-interactive
206/// callers can omit the handler or select a response level on the request.
207pub trait WriteBackHandler: Send {
208    /// Receives persisted result previews after restoration attempts, even without PID tags.
209    fn pid_results_ready(&mut self, _previews: &[PidWritePreview]) {}
210
211    fn select_response_level(&mut self, previews: &[PidWritePreview]) -> WriteBackSelection;
212
213    fn write_back_selected(&mut self, _values: &ControllerPidValues, _requested: bool) {}
214
215    fn write_back_skipped(&mut self, _reason: WriteBackSkipReason) {}
216
217    fn write_back_failed(&mut self, _detail: &str) {}
218
219    fn write_back_finished(&mut self, _response_level: ResponseLevel, _outcome: &PidWriteOutcome) {}
220}
221/// Pre-reads the existing Proportional/Integral/Derivative values, writes and verifies
222/// `target` (Proportional then Integral then Derivative, stopping at the first failure),
223/// rolls back to the pre-read values on partial failure (only for `kind =
224/// `[`WriteKind::Write`]` -- [`WriteKind::Revert`] never does, so a revert can't chase its
225/// own failure with a nested rollback; see [`WriteKind`]'s own doc comment), and records
226/// exactly one [`TuneWriteRow`] audit row for the attempt, success or not.
227///
228/// The one implementation of "pre-read, write, verify, roll back, audit" in the whole
229/// workspace, shared by three callers: `maybe_write_back`'s in-run write-back,
230/// [`crate::history::revert_run`], and `bhtune-server`'s post-hoc `POST /api/runs/{id}/write`/
231/// `.../revert` -- `pub` (not `pub(crate)`) specifically so that
232/// third, different-crate caller can reach it. `bhtune-server` calls only this function, not
233/// the lower-level `read_previous_pid_values`/`write_and_verify_pid_value` helpers this
234/// builds on -- those stay `pub(crate)`, since adapters need only the complete audited
235/// sequence.
236///
237/// `target` is the caller-selected P/I/D values to write: freshly calculated parameters for a
238/// [`WriteKind::Write`], or a past write's recorded `previous` values for a
239/// [`WriteKind::Revert`]. Never propagates a driver/database error via `?` for an
240/// operational failure -- a pre-read failure, a rejected write, a failed confirmation
241/// readback, or a failed rollback all still produce their audit row and return
242/// [`PidWriteOutcome::Failed`]; the `Err` case is reserved for the one thing that really is
243/// exceptional here, [`TuneWriteRow::insert`] itself failing.
244#[allow(clippy::too_many_arguments)]
245pub async fn write_pid_values(
246    pool: &SqlitePool,
247    run_id: i64,
248    driver: &dyn Driver,
249    p_tag: &str,
250    i_tag: &str,
251    d_tag: &str,
252    response_level: ResponseLevel,
253    target: WriteReadback,
254    kind: WriteKind,
255    allow_uncertain: bool,
256) -> anyhow::Result<PidWriteOutcome> {
257    let run = TuneRunRow::get(pool, run_id)
258        .await?
259        .ok_or_else(|| anyhow::anyhow!("no run with id {run_id}"))?;
260    let operation_kind = match kind {
261        WriteKind::Write => bhtune_db::models::LiveOperationKind::PidWrite,
262        WriteKind::Revert => bhtune_db::models::LiveOperationKind::PidRevert,
263    };
264    let restore_intent = serde_json::to_string(&serde_json::json!({
265        "version": 1,
266        "kind": "pid_restore",
267        "run_id": run_id,
268        "write_kind": kind,
269        "state": "awaiting_prewrite_readings",
270    }))?;
271    let ownership =
272        LiveOperationGuard::acquire_for_recorded_run(pool, &run, operation_kind, restore_intent)
273            .await?;
274    let audited = AuditedDriver::new(driver, pool, &ownership, Some(run_id));
275    let result = write_pid_values_inner(
276        pool,
277        run_id,
278        &audited,
279        p_tag,
280        i_tag,
281        d_tag,
282        response_level,
283        target,
284        kind,
285        allow_uncertain,
286        &ownership,
287    )
288    .await;
289    let release_result = ownership.release().await;
290    match (result, release_result) {
291        (Ok(outcome), Ok(())) => Ok(outcome),
292        (Err(error), Ok(())) => Err(error),
293        (Ok(_), Err(error)) => Err(error),
294        (Err(error), Err(release_error)) => Err(anyhow::anyhow!(
295            "{error}; additionally failed to persist live ownership release: {release_error}"
296        )),
297    }
298}
299
300/// Performs an already-owned PID write sequence. `driver` must be the `AuditedDriver` for
301/// the same guard so each write intent is persisted before I/O.
302#[allow(clippy::too_many_arguments)]
303pub async fn write_pid_values_with_owner(
304    pool: &SqlitePool,
305    run_id: i64,
306    driver: &AuditedDriver<'_>,
307    p_tag: &str,
308    i_tag: &str,
309    d_tag: &str,
310    response_level: ResponseLevel,
311    target: WriteReadback,
312    kind: WriteKind,
313    allow_uncertain: bool,
314) -> anyhow::Result<PidWriteOutcome> {
315    if driver.run_id() != Some(run_id) || driver.owner().owner().run_id != Some(run_id) {
316        anyhow::bail!("live ownership does not match PID write run {run_id}");
317    }
318    let valid_operation =
319        operation_kind_authorizes_pid_write(kind, driver.owner().owner().operation_kind);
320    if !valid_operation {
321        anyhow::bail!("live ownership kind does not authorize the requested PID operation");
322    }
323    write_pid_values_inner(
324        pool,
325        run_id,
326        driver,
327        p_tag,
328        i_tag,
329        d_tag,
330        response_level,
331        target,
332        kind,
333        allow_uncertain,
334        driver.owner(),
335    )
336    .await
337}
338
339fn operation_kind_authorizes_pid_write(
340    kind: WriteKind,
341    operation_kind: bhtune_db::models::LiveOperationKind,
342) -> bool {
343    if kind == WriteKind::Write {
344        operation_kind == bhtune_db::models::LiveOperationKind::Tune
345            || operation_kind == bhtune_db::models::LiveOperationKind::PidWrite
346    } else {
347        operation_kind == bhtune_db::models::LiveOperationKind::PidRevert
348    }
349}
350
351#[allow(clippy::too_many_arguments)]
352async fn write_pid_values_inner(
353    pool: &SqlitePool,
354    run_id: i64,
355    driver: &dyn Driver,
356    p_tag: &str,
357    i_tag: &str,
358    d_tag: &str,
359    response_level: ResponseLevel,
360    target: WriteReadback,
361    kind: WriteKind,
362    allow_uncertain: bool,
363    ownership: &LiveOperationGuard,
364) -> anyhow::Result<PidWriteOutcome> {
365    ownership.ensure_healthy()?;
366    if ownership.owner().run_id != Some(run_id) {
367        anyhow::bail!("live ownership does not match PID write run {run_id}");
368    }
369    let written_at = Utc::now();
370    let mut new_write = NewTuneWrite::new(response_level, written_at);
371    new_write.kind = kind;
372    new_write.allow_uncertain_quality = allow_uncertain;
373
374    let previous =
375        match read_previous_pid_values(driver, p_tag, i_tag, d_tag, allow_uncertain).await {
376            Ok(previous) => previous,
377            Err(e) => {
378                let error_message = e.to_string();
379                new_write.error_message = Some(error_message.clone());
380                TuneWriteRow::insert(pool, run_id, new_write).await?;
381                tracing::error!(
382                    run_id,
383                    ?response_level,
384                    ?kind,
385                    %error_message,
386                    "PID pre-read failed"
387                );
388                return Ok(PidWriteOutcome::Failed {
389                    detail: format!("pre-read failed: {error_message}"),
390                });
391            }
392        };
393    new_write.previous = Some(previous);
394    persist_pid_restore_intent(
395        pool,
396        ownership,
397        run_id,
398        kind,
399        response_level,
400        previous,
401        target,
402    )
403    .await?;
404
405    // Write and verify Proportional, then Integral, then Derivative, stopping at the first
406    // failure. `rollback_targets` accumulates only the constants confirmed written so far,
407    // so a failure partway through knows exactly what needs rolling back (when rollback
408    // applies at all -- see `kind` below).
409    let steps: [(&str, &str, f32, f32); 3] = [
410        (
411            "Proportional",
412            p_tag,
413            target.proportional,
414            previous.proportional,
415        ),
416        ("Integral", i_tag, target.integral, previous.integral),
417        ("Derivative", d_tag, target.derivative, previous.derivative),
418    ];
419    let mut written_vals: [Option<f32>; 3] = [None; 3];
420    let mut readback_vals: [Option<f32>; 3] = [None; 3];
421    let mut rollback_targets: Vec<(&str, &str, f32, f32)> = Vec::new();
422    let mut failure: Option<String> = None;
423
424    for (i, (label, tag, value, previous_value)) in steps.into_iter().enumerate() {
425        written_vals[i] = Some(value);
426        let previous_json = serde_json::to_string(&previous_value)?;
427        ownership
428            .queue_previous_write_value(tag, previous_json)
429            .await;
430        match write_and_verify_pid_value(driver, label, tag, value, allow_uncertain).await {
431            Ok(readback) => {
432                readback_vals[i] = Some(readback);
433                rollback_targets.push((label, tag, value, previous_value));
434            }
435            Err(e) => {
436                failure = Some(e);
437                break;
438            }
439        }
440    }
441
442    new_write.proportional_written = written_vals[0];
443    new_write.integral_written = written_vals[1];
444    new_write.derivative_written = written_vals[2];
445    new_write.proportional_readback = readback_vals[0];
446    new_write.integral_readback = readback_vals[1];
447    new_write.derivative_readback = readback_vals[2];
448
449    let Some(error_message) = failure else {
450        new_write.success = true;
451        TuneWriteRow::insert(pool, run_id, new_write).await?;
452        tracing::info!(run_id, ?response_level, ?kind, "PID write succeeded");
453        return Ok(PidWriteOutcome::Written);
454    };
455
456    new_write.success = false;
457    new_write.error_message = Some(error_message.clone());
458
459    // `WriteKind::Revert` never chases its own failure with a nested rollback (see that
460    // variant's doc comment); neither does a `Write` that failed before confirming even one
461    // constant, since there is nothing yet to roll back.
462    if kind != WriteKind::Write || rollback_targets.is_empty() {
463        TuneWriteRow::insert(pool, run_id, new_write).await?;
464        tracing::error!(run_id, ?response_level, ?kind, %error_message, "PID write failed");
465        return Ok(PidWriteOutcome::Failed {
466            detail: error_message,
467        });
468    }
469
470    match rollback_pid_writes(driver, &rollback_targets, ownership).await {
471        Ok(()) => {
472            new_write.rollback_state = Some(RollbackState::Succeeded);
473            TuneWriteRow::insert(pool, run_id, new_write).await?;
474            tracing::error!(run_id, ?response_level, %error_message, "PID write failed partway through; rollback succeeded");
475            Ok(PidWriteOutcome::Failed {
476                detail: format!("{error_message} (rolled back)"),
477            })
478        }
479        Err(rollback_error) => {
480            new_write.rollback_state = Some(RollbackState::Failed);
481            new_write.rollback_error = Some(rollback_error.clone());
482            TuneWriteRow::insert(pool, run_id, new_write).await?;
483            tracing::error!(
484                run_id,
485                ?response_level,
486                %error_message,
487                %rollback_error,
488                "PID write failed partway through; rollback also failed"
489            );
490            Ok(PidWriteOutcome::Failed {
491                detail: format!(
492                    "{error_message}; rollback also failed: {rollback_error} -- the loop may \
493                     hold a mismatched set of PID constants, see \
494                     `bhtune history revert {run_id}`"
495                ),
496            })
497        }
498    }
499}
500/// Validates a persisted result's raw, full-precision PID parameters.
501///
502/// This is the single validity gate shared by the CLI and HTTP write paths. A result must be
503/// explicitly valid and contain finite values for all three constants; malformed historical
504/// rows are rejected rather than being allowed to reach a live driver.
505pub fn pid_parameters_for_result(result: &TuneResultRow) -> anyhow::Result<PidParameters> {
506    if result.status != TuningResultStatus::Valid {
507        let reason = result
508            .invalid_reason
509            .map(|reason| reason.to_string())
510            .unwrap_or_else(|| "no invalid reason was recorded".to_string());
511        anyhow::bail!(
512            "{:?} calculated result is invalid: {reason}",
513            result.response_level
514        );
515    }
516    if result.invalid_reason.is_some() {
517        anyhow::bail!(
518            "{:?} calculated result has an invalid reason despite being marked valid",
519            result.response_level
520        );
521    }
522
523    let proportional = result.proportional.ok_or_else(|| {
524        anyhow::anyhow!(
525            "{:?} calculated result is missing its proportional value",
526            result.response_level
527        )
528    })?;
529    let integral = result.integral.ok_or_else(|| {
530        anyhow::anyhow!(
531            "{:?} calculated result is missing its integral value",
532            result.response_level
533        )
534    })?;
535    let derivative = result.derivative.ok_or_else(|| {
536        anyhow::anyhow!(
537            "{:?} calculated result is missing its derivative value",
538            result.response_level
539        )
540    })?;
541    if !proportional.is_finite() || !integral.is_finite() || !derivative.is_finite() {
542        anyhow::bail!(
543            "{:?} calculated result contains a non-finite PID value",
544            result.response_level
545        );
546    }
547
548    Ok(PidParameters {
549        response_level: result.response_level,
550        proportional,
551        integral,
552        derivative,
553    })
554}
555
556/// The shared new-write validity/precision gate. Restore and revert paths deliberately
557/// do not call this: their recorded targets must retain their original precision.
558pub fn controller_pid_for_result(
559    result: &TuneResultRow,
560    controller_type: ControllerType,
561    template: &DcsTemplate,
562) -> anyhow::Result<ControllerPidValues> {
563    let pid = pid_parameters_for_result(result)?;
564    controller_pid_values(pid, controller_type, template).map_err(|error| {
565        anyhow::anyhow!(
566            "{:?} controller PID target is unavailable: {error}",
567            result.response_level
568        )
569    })
570}
571
572pub fn pid_write_preview(
573    result: &TuneResultRow,
574    controller_type: ControllerType,
575    template: &DcsTemplate,
576) -> PidWritePreview {
577    let (values, unavailable_reason) =
578        match controller_pid_for_result(result, controller_type, template) {
579            Ok(values) => (Some(values), None),
580            Err(error) => (None, Some(error.to_string())),
581        };
582    PidWritePreview {
583        response_level: result.response_level,
584        values,
585        unavailable_reason,
586    }
587}
588/// Writes the requested PID result, or asks an optional adapter handler to select one.
589/// Without a handler, an unrequested write is skipped; the runtime never reads stdin or
590/// writes to stdout.
591#[allow(clippy::too_many_arguments)]
592#[cfg(test)]
593pub(super) async fn maybe_write_back(
594    pool: &SqlitePool,
595    run_id: i64,
596    tags: &LoopTags,
597    template: &DcsTemplate,
598    driver: &dyn Driver,
599    config: LoopConfig,
600    write_pid: Option<ResponseLevel>,
601    allow_uncertain: bool,
602    handler: Option<&mut dyn WriteBackHandler>,
603) -> anyhow::Result<(WriteBackOutcome, Option<String>)> {
604    maybe_write_back_with_owner(
605        pool,
606        run_id,
607        tags,
608        template,
609        driver,
610        config,
611        write_pid,
612        allow_uncertain,
613        handler,
614        None,
615    )
616    .await
617}
618
619#[allow(clippy::too_many_arguments)]
620pub(super) async fn maybe_write_back_with_owner(
621    pool: &SqlitePool,
622    run_id: i64,
623    tags: &LoopTags,
624    template: &DcsTemplate,
625    driver: &dyn Driver,
626    config: LoopConfig,
627    write_pid: Option<ResponseLevel>,
628    allow_uncertain: bool,
629    mut handler: Option<&mut dyn WriteBackHandler>,
630    ownership: Option<&LiveOperationGuard>,
631) -> anyhow::Result<(WriteBackOutcome, Option<String>)> {
632    let (Some(p_tag), Some(i_tag), Some(d_tag)) = (
633        &tags.proportional_constant,
634        &tags.integral_constant,
635        &tags.derivative_constant,
636    ) else {
637        if let Some(handler) = handler.as_deref_mut() {
638            handler.write_back_skipped(WriteBackSkipReason::NoPidTags);
639        }
640        return Ok((
641            WriteBackOutcome::Skipped,
642            Some("no PID constant tags configured for this run's driver/template".to_string()),
643        ));
644    };
645
646    let results = TuneResultRow::list_for_run(pool, run_id).await?;
647    if results.is_empty() {
648        if let Some(handler) = handler.as_deref_mut() {
649            handler.write_back_skipped(WriteBackSkipReason::NoResults);
650        }
651        return Ok((
652            WriteBackOutcome::Skipped,
653            Some("no calculated results were recorded for this run".to_string()),
654        ));
655    }
656
657    let selection = match write_pid {
658        Some(level) => WriteBackSelection::Selected(level),
659        None => match handler.as_deref_mut() {
660            Some(handler) => handler.select_response_level(
661                &results
662                    .iter()
663                    .map(|result| pid_write_preview(result, config.controller_type, template))
664                    .collect::<Vec<_>>(),
665            ),
666            None => WriteBackSelection::Skipped(
667                "no response level was selected by the caller".to_string(),
668            ),
669        },
670    };
671    let response_level = match selection {
672        WriteBackSelection::Selected(level) => level,
673        WriteBackSelection::Skipped(detail) => {
674            return Ok((WriteBackOutcome::Skipped, Some(detail)));
675        }
676        WriteBackSelection::Failed(detail) => {
677            if let Some(handler) = handler.as_deref_mut() {
678                handler.write_back_failed(&detail);
679            }
680            return Ok((WriteBackOutcome::Failed, Some(detail)));
681        }
682    };
683
684    let Some(selected) = results
685        .iter()
686        .find(|result| result.response_level == response_level)
687    else {
688        let detail = format!("no calculated result recorded for response level {response_level:?}");
689        if let Some(handler) = handler.as_deref_mut() {
690            handler.write_back_failed(&detail);
691        }
692        return Ok((WriteBackOutcome::Failed, Some(detail)));
693    };
694    let written = match controller_pid_for_result(selected, config.controller_type, template) {
695        Ok(written) => written,
696        Err(error) => {
697            let detail = error.to_string();
698            if let Some(handler) = handler.as_deref_mut() {
699                handler.write_back_failed(&detail);
700            }
701            return Ok((WriteBackOutcome::Failed, Some(detail)));
702        }
703    };
704    let response_level = written.response_level;
705    if let Some(handler) = handler.as_deref_mut() {
706        handler.write_back_selected(&written, write_pid.is_some());
707    }
708    let target = WriteReadback {
709        proportional: written.proportional.value,
710        integral: written.integral.value,
711        derivative: written.derivative.value,
712    };
713
714    let outcome = match ownership {
715        Some(ownership) => {
716            write_pid_values_inner(
717                pool,
718                run_id,
719                driver,
720                p_tag,
721                i_tag,
722                d_tag,
723                response_level,
724                target,
725                WriteKind::Write,
726                allow_uncertain,
727                ownership,
728            )
729            .await?
730        }
731        None => anyhow::bail!(
732            "PID write-back requires a live ownership guard before controller mutation"
733        ),
734    };
735
736    if let Some(handler) = handler {
737        handler.write_back_finished(response_level, &outcome);
738    }
739
740    let report = match outcome {
741        PidWriteOutcome::Written => (WriteBackOutcome::Written { response_level }, None),
742        PidWriteOutcome::Failed { detail } => (WriteBackOutcome::Failed, Some(detail)),
743    };
744    Ok(report)
745}
746
747#[cfg(test)]
748mod tests;