Skip to main content

bhtune_db/models/
tune_writes.rs

1use bhtune_core::ResponseLevel;
2use chrono::{DateTime, Utc};
3use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
4
5use crate::{
6    convert::{enum_to_text, option_enum_text, text_to_enum},
7    error::{DbError, DbResult},
8};
9
10#[cfg(doc)]
11use super::{tune_results::TuneResultRow, tune_runs::TuneRunRow};
12
13// tune_writes {{{1
14
15/// One row of `tune_writes`: an audit record of PID constants actually written back to the
16/// DCS for one [`ResponseLevel`] of one run, distinct from what was merely *calculated*
17/// ([`TuneResultRow`]). Flattened for the same reason as `TuneResultRow`.
18///
19/// `*_written`/`*_readback` are independently nullable (not all-or-nothing like `previous`)
20/// because the operation writes and verifies P, then I, then D in sequence, stopping at the
21/// first failure. A partial attempt leaves constants after the failure point at `None` rather
22/// than 0, distinguishing "never attempted" from "attempted and confirmed zero".
23#[derive(Debug, Clone, PartialEq)]
24pub struct TuneWriteRow {
25    pub id: i64,
26    pub run_id: i64,
27    pub response_level: ResponseLevel,
28    pub written_at: DateTime<Utc>,
29    /// Whether this row is a normal write-back or `bhtune history revert` undoing one. See
30    /// [`WriteKind`].
31    pub kind: WriteKind,
32    /// Whether this operation allowed `Quality::Uncertain` readings. This is
33    /// the explicit per-operation policy supplied at insertion time; it is
34    /// independent of [`TuneRunRow::allow_uncertain_quality`].
35    pub allow_uncertain_quality: bool,
36    /// The P/I/D values read from the driver *before* any write was attempted. `None` only
37    /// when the pre-read itself failed -- a hard stop before any write, so nothing else on
38    /// this row was ever attempted either (`success = false`, every other field below `None`).
39    pub previous: Option<WriteReadback>,
40    pub proportional_written: Option<f32>,
41    pub integral_written: Option<f32>,
42    pub derivative_written: Option<f32>,
43    /// Read back immediately after writing to confirm the DCS accepted the value within
44    /// tolerance. `None` whenever the corresponding `*_written` field is `None`, or when the
45    /// write was sent but the readback attempt itself failed.
46    pub proportional_readback: Option<f32>,
47    pub integral_readback: Option<f32>,
48    pub derivative_readback: Option<f32>,
49    pub success: bool,
50    pub error_message: Option<String>,
51    /// Set only when `success = false` and at least one constant had already been written
52    /// before the failure, so a best-effort rollback to `previous` was attempted. `None`
53    /// means rollback did not apply -- either every constant wrote successfully (`success =
54    /// true`) or the pre-read failed before any write was attempted. Always `None` for a
55    /// `kind = Revert` row.
56    pub rollback_state: Option<RollbackState>,
57    pub rollback_error: Option<String>,
58}
59
60/// A triple of proportional/integral/derivative values, read from the driver before any
61/// write is attempted ([`TuneWriteRow::previous`]). Not a `bhtune-core` type like
62/// `bhtune_core::tuning_math::OpcWriteValues`: this is a raw observation, not a
63/// calculated/intended value.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct WriteReadback {
66    pub proportional: f32,
67    pub integral: f32,
68    pub derivative: f32,
69}
70
71/// Whether a best-effort rollback of a partially-completed PID write was attempted and, if
72/// so, whether it succeeded. See [`TuneWriteRow::rollback_state`] for when this is `None`.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
74#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
75#[serde(rename_all = "snake_case")]
76pub enum RollbackState {
77    /// Every constant that had been written was successfully written back to its `previous`
78    /// value.
79    Succeeded,
80    /// At least one constant could not be written back to its `previous` value -- the loop
81    /// may still hold a mismatched, partially-updated set of PID constants. See
82    /// [`TuneWriteRow::rollback_error`] and `bhtune history revert` for recovering by hand.
83    Failed,
84}
85
86/// Distinguishes a normal write-back from `bhtune history revert` undoing an earlier one.
87/// Both share [`TuneWriteRow`]'s exact shape -- pre-read, write-and-verify each constant,
88/// audit the outcome -- so they live in the same table rather than a second near-duplicate
89/// one; `kind` is the one column that tells them apart.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
91#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
92#[serde(rename_all = "snake_case")]
93pub enum WriteKind {
94    /// A write-back of freshly calculated PID parameters (`maybe_write_back`).
95    Write,
96    /// `bhtune history revert` writing an earlier `Write` row's `previous` values back,
97    /// undoing it. Never itself has a `rollback_state` -- a revert does not chain into a
98    /// further rollback.
99    Revert,
100}
101
102/// Everything needed to record one write-back attempt, successful or not. Built up by the
103/// caller through the sequential pre-read / write-and-verify / rollback steps, then persisted
104/// in a single [`TuneWriteRow::insert`] call so partial writes and rollback outcomes are
105/// represented alongside complete success or pre-read failure.
106#[derive(Debug, Clone, PartialEq)]
107pub struct NewTuneWrite {
108    pub response_level: ResponseLevel,
109    pub written_at: DateTime<Utc>,
110    pub kind: WriteKind,
111    pub allow_uncertain_quality: bool,
112    pub previous: Option<WriteReadback>,
113    pub proportional_written: Option<f32>,
114    pub integral_written: Option<f32>,
115    pub derivative_written: Option<f32>,
116    pub proportional_readback: Option<f32>,
117    pub integral_readback: Option<f32>,
118    pub derivative_readback: Option<f32>,
119    pub success: bool,
120    pub error_message: Option<String>,
121    pub rollback_state: Option<RollbackState>,
122    pub rollback_error: Option<String>,
123}
124
125impl NewTuneWrite {
126    /// Starts a record with every previous/written/readback/rollback field unset and
127    /// `kind = WriteKind::Write`. New production write/revert operations must overwrite
128    /// `allow_uncertain_quality` with the policy captured when that operation began; the
129    /// permissive default keeps direct repository/test construction backward-compatible.
130    pub fn new(response_level: ResponseLevel, written_at: DateTime<Utc>) -> Self {
131        NewTuneWrite {
132            response_level,
133            written_at,
134            kind: WriteKind::Write,
135            allow_uncertain_quality: true,
136            previous: None,
137            proportional_written: None,
138            integral_written: None,
139            derivative_written: None,
140            proportional_readback: None,
141            integral_readback: None,
142            derivative_readback: None,
143            success: false,
144            error_message: None,
145            rollback_state: None,
146            rollback_error: None,
147        }
148    }
149}
150
151impl TuneWriteRow {
152    /// Records one write-back attempt exactly as `new` describes it -- see [`NewTuneWrite`].
153    pub async fn insert(
154        pool: &SqlitePool,
155        run_id: i64,
156        new: NewTuneWrite,
157    ) -> DbResult<TuneWriteRow> {
158        let row = sqlx::query(
159            r"
160            INSERT INTO tune_writes (
161                run_id, response_level, written_at, kind,
162                allow_uncertain_quality,
163                proportional_previous, integral_previous, derivative_previous,
164                proportional_written, integral_written, derivative_written,
165                proportional_readback, integral_readback, derivative_readback,
166                success, error_message, rollback_state, rollback_error
167            ) VALUES (
168                ?, ?, ?, ?,
169                ?,
170                ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
171            )
172            RETURNING *
173            ",
174        )
175        .bind(run_id)
176        .bind(enum_to_text(&new.response_level)?)
177        .bind(new.written_at)
178        .bind(enum_to_text(&new.kind)?)
179        .bind(new.allow_uncertain_quality)
180        .bind(new.previous.map(|p| p.proportional))
181        .bind(new.previous.map(|p| p.integral))
182        .bind(new.previous.map(|p| p.derivative))
183        .bind(new.proportional_written)
184        .bind(new.integral_written)
185        .bind(new.derivative_written)
186        .bind(new.proportional_readback)
187        .bind(new.integral_readback)
188        .bind(new.derivative_readback)
189        .bind(new.success)
190        .bind(new.error_message)
191        .bind(option_enum_text(new.rollback_state.as_ref())?)
192        .bind(new.rollback_error)
193        .fetch_one(pool)
194        .await
195        .map_err(DbError::Query)?;
196
197        row_to_tune_write(row)
198    }
199
200    /// Lists every write-back attempt for `run_id`, oldest first — the full "who changed this
201    /// loop and when" audit trail.
202    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneWriteRow>> {
203        let rows = sqlx::query("SELECT * FROM tune_writes WHERE run_id = ? ORDER BY written_at")
204            .bind(run_id)
205            .fetch_all(pool)
206            .await
207            .map_err(DbError::Query)?;
208        rows.into_iter().map(row_to_tune_write).collect()
209    }
210}
211
212fn row_to_tune_write(row: SqliteRow) -> DbResult<TuneWriteRow> {
213    let response_level: String = row.try_get("response_level").map_err(DbError::Query)?;
214    let kind: String = row.try_get("kind").map_err(DbError::Query)?;
215    let proportional_previous: Option<f32> = row
216        .try_get("proportional_previous")
217        .map_err(DbError::Query)?;
218    let integral_previous: Option<f32> =
219        row.try_get("integral_previous").map_err(DbError::Query)?;
220    let derivative_previous: Option<f32> =
221        row.try_get("derivative_previous").map_err(DbError::Query)?;
222    let previous = match (
223        proportional_previous,
224        integral_previous,
225        derivative_previous,
226    ) {
227        (Some(proportional), Some(integral), Some(derivative)) => Some(WriteReadback {
228            proportional,
229            integral,
230            derivative,
231        }),
232        _ => None,
233    };
234    let rollback_state: Option<String> = row.try_get("rollback_state").map_err(DbError::Query)?;
235    Ok(TuneWriteRow {
236        id: row.try_get("id").map_err(DbError::Query)?,
237        run_id: row.try_get("run_id").map_err(DbError::Query)?,
238        response_level: text_to_enum("response_level", &response_level)?,
239        written_at: row.try_get("written_at").map_err(DbError::Query)?,
240        kind: text_to_enum("kind", &kind)?,
241        allow_uncertain_quality: row
242            .try_get("allow_uncertain_quality")
243            .map_err(DbError::Query)?,
244        previous,
245        proportional_written: row
246            .try_get("proportional_written")
247            .map_err(DbError::Query)?,
248        integral_written: row.try_get("integral_written").map_err(DbError::Query)?,
249        derivative_written: row.try_get("derivative_written").map_err(DbError::Query)?,
250        proportional_readback: row
251            .try_get("proportional_readback")
252            .map_err(DbError::Query)?,
253        integral_readback: row.try_get("integral_readback").map_err(DbError::Query)?,
254        derivative_readback: row.try_get("derivative_readback").map_err(DbError::Query)?,
255        success: row.try_get("success").map_err(DbError::Query)?,
256        error_message: row.try_get("error_message").map_err(DbError::Query)?,
257        rollback_state: rollback_state
258            .map(|s| text_to_enum("rollback_state", &s))
259            .transpose()?,
260        rollback_error: row.try_get("rollback_error").map_err(DbError::Query)?,
261    })
262}
263// }}}1