Skip to main content

bhtune_db/models/
tune_mv_actuations.rs

1use chrono::{DateTime, Utc};
2use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
3
4use crate::{
5    convert::{enum_to_text, option_enum_text, text_to_enum},
6    error::{DbError, DbResult},
7};
8
9use super::tune_samples::SampleQuality;
10
11#[cfg(doc)]
12use super::tune_samples::TuneSampleRow;
13
14// tune_mv_actuations {{{1
15
16/// The physical purpose of one accepted manipulated-variable command.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
19#[serde(rename_all = "snake_case")]
20pub enum MvActuationKind {
21    /// An MRFT relay step, including the engine's final snapback to the initial MV.
22    Relay,
23    /// The authoritative post-run restore write to the original MV.
24    Restore,
25}
26
27/// Lifecycle state of one accepted manipulated-variable command.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
29#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
30#[serde(rename_all = "snake_case")]
31pub enum MvActuationStatus {
32    /// The write was accepted, but the live MV has not yet produced terminal evidence.
33    Pending,
34    /// A live readback matched `target_mv` within the recorded `tolerance`.
35    Confirmed,
36    /// A finite, acceptable-quality live readback missed the target/tolerance requirement.
37    Failed,
38    /// The run ended before the command could be conclusively checked (for example, a
39    /// cancellation, driver error, or operation timeout).
40    Unverified,
41    /// A later authoritative command deliberately replaced responsibility for confirming
42    /// this command, such as the restore write taking over from the engine's final snapback.
43    Superseded,
44}
45
46impl MvActuationStatus {
47    fn ensure_terminal(self) -> DbResult<()> {
48        if self == MvActuationStatus::Pending {
49            Err(DbError::InvalidMvActuationFinalStatus)
50        } else {
51            Ok(())
52        }
53    }
54}
55
56/// Input for [`TuneMvActuationRow::insert_pending`].
57///
58/// `commanded_at` is the time the driver accepted the write, while
59/// `confirmation_due_at` is the exact deadline selected by the actuation policy for this
60/// command. Persisting both that deadline and `tolerance` makes historical evidence
61/// interpretable even if the policy changes in a later release.
62#[derive(Debug, Clone, PartialEq)]
63pub struct NewTuneMvActuation {
64    pub sequence: i64,
65    pub kind: MvActuationKind,
66    pub commanded_at: DateTime<Utc>,
67    pub target_mv: f32,
68    /// The immediately preceding commanded value used to derive the relay-step-aware
69    /// tolerance. `None` for the first command in a run and valid for either command kind.
70    pub previous_commanded_mv: Option<f32>,
71    pub tolerance: f32,
72    pub confirmation_due_at: DateTime<Utc>,
73}
74
75/// One row of `tune_mv_actuations`: durable evidence for an accepted OPC DA MV write.
76///
77/// These rows are separate from [`TuneSampleRow`], whose MV remains the engine's commanded
78/// series for trend/export compatibility. `readback_mv` and `readback_quality` are the most
79/// recent physical observation made by the verifier; `attempt_count` counts every persisted
80/// observation, including attempts where no numeric value or trustworthy quality could be
81/// obtained.
82#[derive(Debug, Clone, PartialEq, serde::Serialize)]
83pub struct TuneMvActuationRow {
84    pub id: i64,
85    pub run_id: i64,
86    /// Monotonic command order within one run. Unique together with `run_id`.
87    pub sequence: i64,
88    pub kind: MvActuationKind,
89    /// UTC projection of the instant at which the driver accepted the MV write.
90    pub commanded_at: DateTime<Utc>,
91    pub target_mv: f32,
92    pub previous_commanded_mv: Option<f32>,
93    /// Exact absolute target/readback tolerance applied to this command.
94    pub tolerance: f32,
95    /// Exact time at which the command must have terminal evidence under the active policy.
96    pub confirmation_due_at: DateTime<Utc>,
97    pub last_checked_at: Option<DateTime<Utc>>,
98    pub readback_mv: Option<f32>,
99    pub readback_quality: Option<SampleQuality>,
100    pub attempt_count: i64,
101    pub status: MvActuationStatus,
102    /// Operator-facing explanation, primarily for failed/unverified/superseded rows.
103    pub detail: Option<String>,
104}
105
106impl TuneMvActuationRow {
107    /// Inserts one accepted command in [`MvActuationStatus::Pending`] state. Observation
108    /// fields are empty and `attempt_count` is zero until [`Self::record_observation`] or
109    /// [`Self::record_final_observation`] is called.
110    pub async fn insert_pending(
111        pool: &SqlitePool,
112        run_id: i64,
113        new: NewTuneMvActuation,
114    ) -> DbResult<TuneMvActuationRow> {
115        let row = sqlx::query(
116            r"
117            INSERT INTO tune_mv_actuations (
118                run_id, sequence, kind, commanded_at, target_mv, previous_commanded_mv,
119                tolerance, confirmation_due_at
120            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
121            RETURNING *
122            ",
123        )
124        .bind(run_id)
125        .bind(new.sequence)
126        .bind(enum_to_text(&new.kind)?)
127        .bind(new.commanded_at)
128        .bind(new.target_mv)
129        .bind(new.previous_commanded_mv)
130        .bind(new.tolerance)
131        .bind(new.confirmation_due_at)
132        .fetch_one(pool)
133        .await
134        .map_err(DbError::Query)?;
135
136        row_to_tune_mv_actuation(row)
137    }
138
139    /// Records the latest verification attempt while leaving the command pending. Both
140    /// observation values are optional so a caller can still persist that an attempted
141    /// check produced no usable numeric value and/or no quality classification.
142    pub async fn record_observation(
143        pool: &SqlitePool,
144        id: i64,
145        checked_at: DateTime<Utc>,
146        readback_mv: Option<f32>,
147        readback_quality: Option<SampleQuality>,
148    ) -> DbResult<TuneMvActuationRow> {
149        let row = sqlx::query(
150            r"
151            UPDATE tune_mv_actuations
152            SET last_checked_at = ?, readback_mv = ?, readback_quality = ?,
153                attempt_count = attempt_count + 1
154            WHERE id = ? AND status = 'pending'
155            RETURNING *
156            ",
157        )
158        .bind(checked_at)
159        .bind(readback_mv)
160        .bind(option_enum_text(readback_quality.as_ref())?)
161        .bind(id)
162        .fetch_one(pool)
163        .await
164        .map_err(DbError::Query)?;
165
166        row_to_tune_mv_actuation(row)
167    }
168
169    /// Atomically records the final observation and terminal status. This is preferable to
170    /// separate [`Self::record_observation`] and [`Self::finalize`] calls when one read
171    /// conclusively confirms or rejects the target, because readers can never observe that
172    /// final evidence while the row still misleadingly says `pending`.
173    pub async fn record_final_observation(
174        pool: &SqlitePool,
175        id: i64,
176        checked_at: DateTime<Utc>,
177        readback_mv: Option<f32>,
178        readback_quality: Option<SampleQuality>,
179        status: MvActuationStatus,
180        detail: Option<&str>,
181    ) -> DbResult<TuneMvActuationRow> {
182        status.ensure_terminal()?;
183        let row = sqlx::query(
184            r"
185            UPDATE tune_mv_actuations
186            SET last_checked_at = ?, readback_mv = ?, readback_quality = ?,
187                attempt_count = attempt_count + 1, status = ?, detail = ?
188            WHERE id = ? AND status = 'pending'
189            RETURNING *
190            ",
191        )
192        .bind(checked_at)
193        .bind(readback_mv)
194        .bind(option_enum_text(readback_quality.as_ref())?)
195        .bind(enum_to_text(&status)?)
196        .bind(detail)
197        .bind(id)
198        .fetch_one(pool)
199        .await
200        .map_err(DbError::Query)?;
201
202        row_to_tune_mv_actuation(row)
203    }
204
205    /// Finalizes a pending command without inventing an observation. Used for terminal paths
206    /// such as interruption (`Unverified`) or deliberate handoff (`Superseded`).
207    pub async fn finalize(
208        pool: &SqlitePool,
209        id: i64,
210        status: MvActuationStatus,
211        detail: Option<&str>,
212    ) -> DbResult<TuneMvActuationRow> {
213        status.ensure_terminal()?;
214        let row = sqlx::query(
215            r"
216            UPDATE tune_mv_actuations
217            SET status = ?, detail = ?
218            WHERE id = ? AND status = 'pending'
219            RETURNING *
220            ",
221        )
222        .bind(enum_to_text(&status)?)
223        .bind(detail)
224        .bind(id)
225        .fetch_one(pool)
226        .await
227        .map_err(DbError::Query)?;
228
229        row_to_tune_mv_actuation(row)
230    }
231
232    /// Finalizes every still-pending command belonging to `run_id`, returning the number of
233    /// rows changed. Already-terminal rows and rows for other runs are never modified. This
234    /// is the terminal-path backstop that prevents a completed/failed/aborted run from
235    /// retaining misleading `pending` audit records.
236    pub async fn finalize_pending_for_run(
237        pool: &SqlitePool,
238        run_id: i64,
239        status: MvActuationStatus,
240        detail: Option<&str>,
241    ) -> DbResult<u64> {
242        status.ensure_terminal()?;
243        let result = sqlx::query(
244            r"
245            UPDATE tune_mv_actuations
246            SET status = ?, detail = ?
247            WHERE run_id = ? AND status = 'pending'
248            ",
249        )
250        .bind(enum_to_text(&status)?)
251        .bind(detail)
252        .bind(run_id)
253        .execute(pool)
254        .await
255        .map_err(DbError::Query)?;
256        Ok(result.rows_affected())
257    }
258
259    /// Lists every accepted MV command for `run_id` in command-sequence order.
260    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneMvActuationRow>> {
261        let rows =
262            sqlx::query("SELECT * FROM tune_mv_actuations WHERE run_id = ? ORDER BY sequence, id")
263                .bind(run_id)
264                .fetch_all(pool)
265                .await
266                .map_err(DbError::Query)?;
267        rows.into_iter().map(row_to_tune_mv_actuation).collect()
268    }
269}
270
271fn row_to_tune_mv_actuation(row: SqliteRow) -> DbResult<TuneMvActuationRow> {
272    let kind: String = row.try_get("kind").map_err(DbError::Query)?;
273    let readback_quality: Option<String> =
274        row.try_get("readback_quality").map_err(DbError::Query)?;
275    let status: String = row.try_get("status").map_err(DbError::Query)?;
276    Ok(TuneMvActuationRow {
277        id: row.try_get("id").map_err(DbError::Query)?,
278        run_id: row.try_get("run_id").map_err(DbError::Query)?,
279        sequence: row.try_get("sequence").map_err(DbError::Query)?,
280        kind: text_to_enum("kind", &kind)?,
281        commanded_at: row.try_get("commanded_at").map_err(DbError::Query)?,
282        target_mv: row.try_get("target_mv").map_err(DbError::Query)?,
283        previous_commanded_mv: row
284            .try_get("previous_commanded_mv")
285            .map_err(DbError::Query)?,
286        tolerance: row.try_get("tolerance").map_err(DbError::Query)?,
287        confirmation_due_at: row.try_get("confirmation_due_at").map_err(DbError::Query)?,
288        last_checked_at: row.try_get("last_checked_at").map_err(DbError::Query)?,
289        readback_mv: row.try_get("readback_mv").map_err(DbError::Query)?,
290        readback_quality: readback_quality
291            .map(|quality| text_to_enum("readback_quality", &quality))
292            .transpose()?,
293        attempt_count: row.try_get("attempt_count").map_err(DbError::Query)?,
294        status: text_to_enum("status", &status)?,
295        detail: row.try_get("detail").map_err(DbError::Query)?,
296    })
297}
298// }}}1