Skip to main content

bhtune_db/models/
tune_results.rs

1use bhtune_core::{
2    ResponseLevel,
3    tuning_math::{
4        CheckedTuningResult, PidParameters, TuningResult, TuningResultInvalidReason,
5        TuningResultStatus,
6    },
7};
8use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
9
10use crate::{
11    convert::{enum_to_text, option_enum_text, text_to_enum},
12    error::{DbError, DbResult},
13};
14
15// tune_results {{{1
16
17/// One row of `tune_results`: the calculated PID result for one [`ResponseLevel`] of one run.
18///
19/// Flattened rather than nesting [`TuningResult`]/[`PidParameters`] directly, since both of
20/// those types carry their own `response_level` field — nesting both would mean either two
21/// redundant copies that could disagree, or an awkward "just trust the outer one" rule. One
22/// flat set of columns matching the table exactly avoids that.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub struct TuneResultRow {
25    pub id: i64,
26    pub run_id: i64,
27    pub response_level: ResponseLevel,
28    pub kp: Option<f32>,
29    pub ti_minutes: Option<f32>,
30    pub td_minutes: Option<f32>,
31    pub proportional: Option<f32>,
32    pub integral: Option<f32>,
33    pub derivative: Option<f32>,
34    pub status: TuningResultStatus,
35    pub invalid_reason: Option<TuningResultInvalidReason>,
36}
37
38impl TuneResultRow {
39    /// Builds a (not-yet-inserted, `id = 0`) row from a matching [`TuningResult`]/
40    /// [`PidParameters`] pair, as produced together by
41    /// [`bhtune_core::tuning_math::calculate_tuning_result`]/
42    /// [`bhtune_core::tuning_math::calculate_pid_parameters`] for the same [`ResponseLevel`].
43    ///
44    /// # Panics
45    /// Panics if `tuning.response_level != pid.response_level`: pairing results for different
46    /// response levels together is always a caller bug, never a runtime data problem, the
47    /// same contract [`bhtune_core::tuning_math::measure_oscillation`] documents for its own
48    /// caller-contract panics.
49    pub fn from_calculated(run_id: i64, tuning: TuningResult, pid: PidParameters) -> TuneResultRow {
50        assert_eq!(
51            tuning.response_level, pid.response_level,
52            "TuningResult and PidParameters must be for the same ResponseLevel"
53        );
54        TuneResultRow {
55            id: 0,
56            run_id,
57            response_level: tuning.response_level,
58            kp: Some(tuning.kp),
59            ti_minutes: Some(tuning.ti_minutes),
60            td_minutes: Some(tuning.td_minutes),
61            proportional: Some(pid.proportional),
62            integral: Some(pid.integral),
63            derivative: Some(pid.derivative),
64            status: TuningResultStatus::Valid,
65            invalid_reason: None,
66        }
67    }
68
69    /// Builds a row from the checked calculation path. Invalid results retain their response
70    /// level and reason while leaving every numeric value absent, so they cannot be mistaken for
71    /// writable PID constants.
72    pub fn from_checked(run_id: i64, checked: CheckedTuningResult) -> TuneResultRow {
73        let values = checked.usable_values();
74        TuneResultRow {
75            id: 0,
76            run_id,
77            response_level: checked.response_level,
78            kp: values.map(|(tuning, _)| tuning.kp),
79            ti_minutes: values.map(|(tuning, _)| tuning.ti_minutes),
80            td_minutes: values.map(|(tuning, _)| tuning.td_minutes),
81            proportional: values.map(|(_, pid)| pid.proportional),
82            integral: values.map(|(_, pid)| pid.integral),
83            derivative: values.map(|(_, pid)| pid.derivative),
84            status: checked.status,
85            invalid_reason: checked.invalid_reason,
86        }
87    }
88
89    /// Inserts `row` (typically built via [`Self::from_calculated`]), returning the persisted
90    /// copy with its assigned `id`. `(run_id, response_level)` is unique (see the migration)
91    /// — a successfully completed run writes exactly the 3 [`ResponseLevel`] rows once, at
92    /// completion.
93    pub async fn insert(pool: &SqlitePool, row: &TuneResultRow) -> DbResult<TuneResultRow> {
94        let inserted = sqlx::query(
95            r"
96            INSERT INTO tune_results (
97                run_id, response_level, kp, ti_minutes, td_minutes,
98                proportional, integral, derivative, status, invalid_reason
99            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
100            RETURNING *
101            ",
102        )
103        .bind(row.run_id)
104        .bind(enum_to_text(&row.response_level)?)
105        .bind(row.kp)
106        .bind(row.ti_minutes)
107        .bind(row.td_minutes)
108        .bind(row.proportional)
109        .bind(row.integral)
110        .bind(row.derivative)
111        .bind(enum_to_text(&row.status)?)
112        .bind(option_enum_text(row.invalid_reason.as_ref())?)
113        .fetch_one(pool)
114        .await
115        .map_err(DbError::Query)?;
116
117        row_to_tune_result(inserted)
118    }
119
120    /// Lists every calculated result of `run_id`, ordered by [`ResponseLevel`] (which sorts
121    /// alphabetically as Aggressive, Moderate, Sluggish — the same order
122    /// [`bhtune_core::constants::ResponseLevel::ALL`] enumerates them in). 0 rows for a run
123    /// that never completed, up to 3 for one that did.
124    pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneResultRow>> {
125        let rows =
126            sqlx::query("SELECT * FROM tune_results WHERE run_id = ? ORDER BY response_level")
127                .bind(run_id)
128                .fetch_all(pool)
129                .await
130                .map_err(DbError::Query)?;
131        rows.into_iter().map(row_to_tune_result).collect()
132    }
133}
134
135fn row_to_tune_result(row: SqliteRow) -> DbResult<TuneResultRow> {
136    let response_level: String = row.try_get("response_level").map_err(DbError::Query)?;
137    Ok(TuneResultRow {
138        id: row.try_get("id").map_err(DbError::Query)?,
139        run_id: row.try_get("run_id").map_err(DbError::Query)?,
140        response_level: text_to_enum("response_level", &response_level)?,
141        kp: row.try_get("kp").map_err(DbError::Query)?,
142        ti_minutes: row.try_get("ti_minutes").map_err(DbError::Query)?,
143        td_minutes: row.try_get("td_minutes").map_err(DbError::Query)?,
144        proportional: row.try_get("proportional").map_err(DbError::Query)?,
145        integral: row.try_get("integral").map_err(DbError::Query)?,
146        derivative: row.try_get("derivative").map_err(DbError::Query)?,
147        status: {
148            let status: String = row.try_get("status").map_err(DbError::Query)?;
149            text_to_enum("status", &status)?
150        },
151        invalid_reason: {
152            let reason: Option<String> = row.try_get("invalid_reason").map_err(DbError::Query)?;
153            reason
154                .as_deref()
155                .map(|reason| text_to_enum("invalid_reason", reason))
156                .transpose()?
157        },
158    })
159}
160// }}}1
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn from_calculated_builds_matching_row() {
168        let tuning = TuningResult {
169            response_level: ResponseLevel::Moderate,
170            kp: 1.0,
171            ti_minutes: 2.0,
172            td_minutes: 0.0,
173        };
174        let pid = PidParameters {
175            response_level: ResponseLevel::Moderate,
176            proportional: 3.0,
177            integral: 4.0,
178            derivative: 0.0,
179        };
180        let row = TuneResultRow::from_calculated(42, tuning, pid);
181        assert_eq!(row.run_id, 42);
182        assert_eq!(row.response_level, ResponseLevel::Moderate);
183        assert_eq!(row.kp, Some(1.0));
184        assert_eq!(row.proportional, Some(3.0));
185    }
186
187    #[test]
188    #[should_panic(expected = "same ResponseLevel")]
189    fn from_calculated_panics_on_mismatched_response_level() {
190        let tuning = TuningResult {
191            response_level: ResponseLevel::Aggressive,
192            kp: 1.0,
193            ti_minutes: 2.0,
194            td_minutes: 0.0,
195        };
196        let pid = PidParameters {
197            response_level: ResponseLevel::Sluggish,
198            proportional: 3.0,
199            integral: 4.0,
200            derivative: 0.0,
201        };
202        TuneResultRow::from_calculated(1, tuning, pid);
203    }
204}