Skip to main content

bhtune_db/models/
tune_samples.rs

1use bhtune_core::{MrftState, Tick};
2use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
3
4use crate::{
5    convert::{enum_to_text, text_to_enum},
6    error::{DbError, DbResult},
7};
8
9#[cfg(doc)]
10use super::dcs_templates::TemplateOrigin;
11
12// tune_samples {{{1
13
14/// How much a [`TuneSampleRow`]'s `sample.pv` reading should be trusted, as recorded at the
15/// moment it was read (finding 5 of the live-plant safety review).
16///
17/// A `bhtune-db`-local mirror of
18/// [`bhtune_driver::Quality`](https://docs.rs/bhtune-driver/latest/bhtune_driver/types/enum.Quality.html),
19/// not a reuse of it directly:
20/// `bhtune-db` deliberately doesn't depend on `bhtune-driver` (a leaf I/O-adapter crate with
21/// a much heavier dependency tree -- `tokio`, `tonic`, `opcda-bridge` -- that has no business
22/// in the persistence crate just to name one three-variant enum), so the `bhtune` package, which
23/// already depends on both, is the one place that converts between them. This mirrors
24/// [`TemplateOrigin`]'s own precedent: a small, persistence-local enum rather than a second
25/// dependency edge.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
27#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
28#[serde(rename_all = "snake_case")]
29pub enum SampleQuality {
30    Good,
31    Uncertain,
32    Bad,
33}
34
35/// One row of `tune_samples`: a single tick's [`Tick`] input and resulting [`MrftState`].
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct TuneSampleRow {
38    pub id: i64,
39    pub run_id: i64,
40    /// 0-based sample sequence number within the run. Named `tick_index` rather than `tick`
41    /// to avoid colliding with the unrelated [`Tick`] type held in `sample`.
42    pub tick_index: i64,
43    pub sample: Tick,
44    pub state: MrftState,
45    /// The driver-reported quality of `sample.pv` at the moment it was read. See
46    /// [`SampleQuality`].
47    pub pv_quality: SampleQuality,
48}
49
50impl TuneSampleRow {
51    /// Records one tick of a run, taking the exact [`Tick`]/[`MrftState`] pair
52    /// [`bhtune_core::mrft::MrftEngine::step`] produced, plus the [`SampleQuality`] the
53    /// driver reported for `sample.pv` at read time. `(run_id, tick_index)` is unique (see
54    /// the migration), so re-recording the same tick twice is a caller bug, not a silent
55    /// overwrite.
56    pub async fn insert(
57        pool: &SqlitePool,
58        run_id: i64,
59        tick_index: i64,
60        sample: Tick,
61        state: MrftState,
62        pv_quality: SampleQuality,
63    ) -> DbResult<TuneSampleRow> {
64        let row = sqlx::query(
65            r"
66            INSERT INTO tune_samples (
67                run_id, tick, time, pv, pv_quality, hysteresis, mv_value_current,
68                mv_sign_next_step, counter_all_switches, cycles_completed, cycles_remaining
69            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
70            RETURNING *
71            ",
72        )
73        .bind(run_id)
74        .bind(tick_index)
75        .bind(sample.time)
76        .bind(sample.pv)
77        .bind(enum_to_text(&pv_quality)?)
78        .bind(state.hysteresis)
79        .bind(state.mv_value_current)
80        .bind(state.mv_sign_next_step)
81        .bind(state.counter_all_switches)
82        .bind(state.cycles_completed)
83        .bind(state.cycles_remaining)
84        .fetch_one(pool)
85        .await
86        .map_err(DbError::Query)?;
87
88        row_to_tune_sample(row)
89    }
90
91    /// Lists every sample of `run_id`, ordered by tick — the full per-tick trend shown on the
92    /// history page.
93    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneSampleRow>> {
94        let rows = sqlx::query("SELECT * FROM tune_samples WHERE run_id = ? ORDER BY tick")
95            .bind(run_id)
96            .fetch_all(pool)
97            .await
98            .map_err(DbError::Query)?;
99        rows.into_iter().map(row_to_tune_sample).collect()
100    }
101
102    /// Lists only the samples of `run_id` recorded *after* `after_tick`, ordered by tick --
103    /// what `bhtune-server`'s `GET /api/runs/{id}/stream` polls on
104    /// every iteration so it never re-sends a tick it has already pushed to the browser.
105    /// Pass `-1` to fetch every sample from the very first tick (`tune_samples.tick` is
106    /// `>= 0`, so nothing is ever excluded by that sentinel).
107    pub async fn list_for_run_since(
108        pool: &SqlitePool,
109        run_id: i64,
110        after_tick: i64,
111    ) -> DbResult<Vec<TuneSampleRow>> {
112        let rows =
113            sqlx::query("SELECT * FROM tune_samples WHERE run_id = ? AND tick > ? ORDER BY tick")
114                .bind(run_id)
115                .bind(after_tick)
116                .fetch_all(pool)
117                .await
118                .map_err(DbError::Query)?;
119        rows.into_iter().map(row_to_tune_sample).collect()
120    }
121}
122
123fn row_to_tune_sample(row: SqliteRow) -> DbResult<TuneSampleRow> {
124    Ok(TuneSampleRow {
125        id: row.try_get("id").map_err(DbError::Query)?,
126        run_id: row.try_get("run_id").map_err(DbError::Query)?,
127        tick_index: row.try_get("tick").map_err(DbError::Query)?,
128        sample: Tick {
129            time: row.try_get("time").map_err(DbError::Query)?,
130            pv: row.try_get("pv").map_err(DbError::Query)?,
131        },
132        state: MrftState {
133            hysteresis: row.try_get("hysteresis").map_err(DbError::Query)?,
134            mv_value_current: row.try_get("mv_value_current").map_err(DbError::Query)?,
135            mv_sign_next_step: row.try_get("mv_sign_next_step").map_err(DbError::Query)?,
136            counter_all_switches: row
137                .try_get("counter_all_switches")
138                .map_err(DbError::Query)?,
139            cycles_completed: row.try_get("cycles_completed").map_err(DbError::Query)?,
140            cycles_remaining: row.try_get("cycles_remaining").map_err(DbError::Query)?,
141        },
142        pv_quality: {
143            let pv_quality: String = row.try_get("pv_quality").map_err(DbError::Query)?;
144            text_to_enum("pv_quality", &pv_quality)?
145        },
146    })
147}
148// }}}1