1use bhtune_core::{
30 ControllerDirection, ControllerType, DcsTemplate, LoopConfig, LoopTags, MrftState, ProcessType,
31 ResponseLevel, Tick,
32 tuning_math::{
33 CheckedTuningResult, PidParameters, TuningResult, TuningResultInvalidReason,
34 TuningResultStatus,
35 },
36};
37use chrono::{DateTime, Utc};
38use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool, sqlite::SqliteRow};
39
40use crate::{
41 convert::{enum_to_text, text_to_enum},
42 error::{DbError, DbResult},
43};
44
45pub const DEMO_RESTART_INTERRUPTED_REASON: &str = "demo run was interrupted by a server restart";
50
51#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DemoSessionRow {
55 pub id: i64,
56 pub token_hash: String,
57 pub created_at: DateTime<Utc>,
58 pub last_seen_at: DateTime<Utc>,
59 pub expires_at: DateTime<Utc>,
60 pub revoked_at: Option<DateTime<Utc>>,
61}
62
63impl DemoSessionRow {
64 pub async fn create(
67 pool: &SqlitePool,
68 token_hash: &str,
69 now: DateTime<Utc>,
70 expires_at: DateTime<Utc>,
71 ) -> DbResult<Self> {
72 Self::get_or_create(pool, token_hash, now, expires_at).await
73 }
74
75 pub async fn get_or_create(
82 pool: &SqlitePool,
83 token_hash: &str,
84 now: DateTime<Utc>,
85 expires_at: DateTime<Utc>,
86 ) -> DbResult<Self> {
87 let inserted = sqlx::query(
88 "INSERT INTO demo_sessions (token_hash, created_at, last_seen_at, expires_at) \
89 VALUES (?, ?, ?, ?) \
90 ON CONFLICT(token_hash) DO NOTHING \
91 RETURNING *",
92 )
93 .bind(token_hash)
94 .bind(now)
95 .bind(now)
96 .bind(expires_at)
97 .fetch_optional(pool)
98 .await
99 .map_err(DbError::Query)?;
100
101 match inserted {
102 Some(row) => row_to_demo_session(row),
103 None => Self::get_by_token_hash(pool, token_hash, now)
104 .await?
105 .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound)),
106 }
107 }
108
109 pub async fn get_by_token_hash(
113 pool: &SqlitePool,
114 token_hash: &str,
115 now: DateTime<Utc>,
116 ) -> DbResult<Option<Self>> {
117 let row = sqlx::query(
118 "UPDATE demo_sessions \
119 SET last_seen_at = MAX(last_seen_at, ?) \
120 WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > ? \
121 RETURNING *",
122 )
123 .bind(now)
124 .bind(token_hash)
125 .bind(now)
126 .fetch_optional(pool)
127 .await
128 .map_err(DbError::Query)?;
129 row.map(row_to_demo_session).transpose()
130 }
131
132 pub async fn touch_by_token_hash(
135 pool: &SqlitePool,
136 token_hash: &str,
137 now: DateTime<Utc>,
138 ) -> DbResult<Option<Self>> {
139 Self::get_by_token_hash(pool, token_hash, now).await
140 }
141
142 pub async fn revoke(pool: &SqlitePool, id: i64, now: DateTime<Utc>) -> DbResult<bool> {
143 let result = sqlx::query(
144 "UPDATE demo_sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL",
145 )
146 .bind(now)
147 .bind(id)
148 .execute(pool)
149 .await
150 .map_err(DbError::Query)?;
151 Ok(result.rows_affected() != 0)
152 }
153
154 pub async fn cleanup_expired(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
160 let result = sqlx::query(
161 "DELETE FROM demo_sessions \
162 WHERE (expires_at <= ? OR revoked_at IS NOT NULL) \
163 AND NOT EXISTS ( \
164 SELECT 1 FROM tune_runs \
165 WHERE tune_runs.demo_session_id = demo_sessions.id \
166 AND tune_runs.outcome = 'running' \
167 )",
168 )
169 .bind(now)
170 .execute(pool)
171 .await
172 .map_err(DbError::Query)?;
173 Ok(result.rows_affected())
174 }
175
176 pub async fn recover_running_demo_runs(pool: &SqlitePool, now: DateTime<Utc>) -> DbResult<u64> {
179 let result = sqlx::query(
180 "UPDATE tune_runs SET outcome = 'failed', completed_at = ?, \
181 failure_reason = ? \
182 WHERE demo_session_id IS NOT NULL AND outcome = 'running'",
183 )
184 .bind(now)
185 .bind(DEMO_RESTART_INTERRUPTED_REASON)
186 .execute(pool)
187 .await
188 .map_err(DbError::Query)?;
189 Ok(result.rows_affected())
190 }
191}
192
193fn row_to_demo_session(row: SqliteRow) -> DbResult<DemoSessionRow> {
194 Ok(DemoSessionRow {
195 id: row.try_get("id").map_err(DbError::Query)?,
196 token_hash: row.try_get("token_hash").map_err(DbError::Query)?,
197 created_at: row.try_get("created_at").map_err(DbError::Query)?,
198 last_seen_at: row.try_get("last_seen_at").map_err(DbError::Query)?,
199 expires_at: row.try_get("expires_at").map_err(DbError::Query)?,
200 revoked_at: row.try_get("revoked_at").map_err(DbError::Query)?,
201 })
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
221#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
222#[serde(rename_all = "snake_case")]
223pub enum TemplateOrigin {
224 Builtin,
227 Catalog,
232 User,
235}
236
237#[derive(Debug, Clone, PartialEq)]
240pub struct DcsTemplateRow {
241 pub id: i64,
242 pub origin: TemplateOrigin,
243 pub template: DcsTemplate,
244 pub created_at: DateTime<Utc>,
245 pub updated_at: DateTime<Utc>,
246}
247
248impl DcsTemplateRow {
249 pub async fn insert(
254 pool: &SqlitePool,
255 template: &DcsTemplate,
256 origin: TemplateOrigin,
257 now: DateTime<Utc>,
258 ) -> DbResult<DcsTemplateRow> {
259 let versions_json = serde_json::to_string(&template.versions)
260 .expect("Vec<String> serialization is infallible");
261 let row = sqlx::query(
262 r#"
263 INSERT INTO dcs_templates (
264 name, origin, revert_mode, proportional_type, integral_type,
265 integral_unit, derivative_type, derivative_unit,
266 process_variable_suffix, manipulated_variable_suffix, setpoint_variable_suffix,
267 controller_direction_suffix, controller_mode_suffix, mode_attribute_suffix,
268 upper_pv_range_suffix, lower_pv_range_suffix, upper_mv_range_suffix,
269 lower_mv_range_suffix, proportional_constant_suffix, integral_constant_suffix,
270 derivative_constant_suffix, mode_manual_value, mode_auto_value,
271 mode_attribute_program_value, controller_action_direct_value,
272 versions_json, description, source,
273 created_at, updated_at
274 )
275 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
276 RETURNING *
277 "#,
278 )
279 .bind(&template.name)
280 .bind(enum_to_text(&origin))
281 .bind(template.revert_mode)
282 .bind(enum_to_text(&template.proportional_type))
283 .bind(enum_to_text(&template.integral_type))
284 .bind(enum_to_text(&template.integral_unit))
285 .bind(enum_to_text(&template.derivative_type))
286 .bind(enum_to_text(&template.derivative_unit))
287 .bind(&template.process_variable_suffix)
288 .bind(&template.manipulated_variable_suffix)
289 .bind(&template.setpoint_variable_suffix)
290 .bind(&template.controller_direction_suffix)
291 .bind(&template.controller_mode_suffix)
292 .bind(&template.mode_attribute_suffix)
293 .bind(&template.upper_pv_range_suffix)
294 .bind(&template.lower_pv_range_suffix)
295 .bind(&template.upper_mv_range_suffix)
296 .bind(&template.lower_mv_range_suffix)
297 .bind(&template.proportional_constant_suffix)
298 .bind(&template.integral_constant_suffix)
299 .bind(&template.derivative_constant_suffix)
300 .bind(&template.mode_manual_value)
301 .bind(&template.mode_auto_value)
302 .bind(&template.mode_attribute_program_value)
303 .bind(&template.controller_action_direct_value)
304 .bind(versions_json)
305 .bind(&template.description)
306 .bind(&template.source)
307 .bind(now)
308 .bind(now)
309 .fetch_one(pool)
310 .await
311 .map_err(DbError::Query)?;
312
313 row_to_dcs_template(row)
314 }
315
316 pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<DcsTemplateRow>> {
318 let row = sqlx::query("SELECT * FROM dcs_templates WHERE id = ?")
319 .bind(id)
320 .fetch_optional(pool)
321 .await
322 .map_err(DbError::Query)?;
323 row.map(row_to_dcs_template).transpose()
324 }
325
326 pub async fn get_by_name(pool: &SqlitePool, name: &str) -> DbResult<Option<DcsTemplateRow>> {
330 let row = sqlx::query("SELECT * FROM dcs_templates WHERE name = ?")
331 .bind(name)
332 .fetch_optional(pool)
333 .await
334 .map_err(DbError::Query)?;
335 row.map(row_to_dcs_template).transpose()
336 }
337
338 pub async fn list(pool: &SqlitePool) -> DbResult<Vec<DcsTemplateRow>> {
340 let rows = sqlx::query("SELECT * FROM dcs_templates ORDER BY name")
341 .fetch_all(pool)
342 .await
343 .map_err(DbError::Query)?;
344 rows.into_iter().map(row_to_dcs_template).collect()
345 }
346
347 pub async fn update(
352 pool: &SqlitePool,
353 id: i64,
354 template: &DcsTemplate,
355 now: DateTime<Utc>,
356 ) -> DbResult<DcsTemplateRow> {
357 let versions_json = serde_json::to_string(&template.versions)
358 .expect("Vec<String> serialization is infallible");
359 let row = sqlx::query(
360 r#"
361 UPDATE dcs_templates SET
362 revert_mode = ?, proportional_type = ?, integral_type = ?,
363 integral_unit = ?, derivative_type = ?, derivative_unit = ?,
364 process_variable_suffix = ?, manipulated_variable_suffix = ?,
365 setpoint_variable_suffix = ?, controller_direction_suffix = ?,
366 controller_mode_suffix = ?, mode_attribute_suffix = ?,
367 upper_pv_range_suffix = ?, lower_pv_range_suffix = ?,
368 upper_mv_range_suffix = ?, lower_mv_range_suffix = ?,
369 proportional_constant_suffix = ?, integral_constant_suffix = ?,
370 derivative_constant_suffix = ?, mode_manual_value = ?, mode_auto_value = ?,
371 mode_attribute_program_value = ?, controller_action_direct_value = ?,
372 versions_json = ?, description = ?, source = ?,
373 updated_at = ?
374 WHERE id = ?
375 RETURNING *
376 "#,
377 )
378 .bind(template.revert_mode)
379 .bind(enum_to_text(&template.proportional_type))
380 .bind(enum_to_text(&template.integral_type))
381 .bind(enum_to_text(&template.integral_unit))
382 .bind(enum_to_text(&template.derivative_type))
383 .bind(enum_to_text(&template.derivative_unit))
384 .bind(&template.process_variable_suffix)
385 .bind(&template.manipulated_variable_suffix)
386 .bind(&template.setpoint_variable_suffix)
387 .bind(&template.controller_direction_suffix)
388 .bind(&template.controller_mode_suffix)
389 .bind(&template.mode_attribute_suffix)
390 .bind(&template.upper_pv_range_suffix)
391 .bind(&template.lower_pv_range_suffix)
392 .bind(&template.upper_mv_range_suffix)
393 .bind(&template.lower_mv_range_suffix)
394 .bind(&template.proportional_constant_suffix)
395 .bind(&template.integral_constant_suffix)
396 .bind(&template.derivative_constant_suffix)
397 .bind(&template.mode_manual_value)
398 .bind(&template.mode_auto_value)
399 .bind(&template.mode_attribute_program_value)
400 .bind(&template.controller_action_direct_value)
401 .bind(versions_json)
402 .bind(&template.description)
403 .bind(&template.source)
404 .bind(now)
405 .bind(id)
406 .fetch_one(pool)
407 .await
408 .map_err(DbError::Query)?;
409
410 row_to_dcs_template(row)
411 }
412
413 pub async fn delete(pool: &SqlitePool, id: i64) -> DbResult<bool> {
435 let result = sqlx::query("DELETE FROM dcs_templates WHERE id = ?")
436 .bind(id)
437 .execute(pool)
438 .await
439 .map_err(|e| {
440 if e.as_database_error().is_some() {
441 DbError::TemplateInUse { id }
442 } else {
443 DbError::Query(e)
444 }
445 })?;
446 Ok(result.rows_affected() > 0)
447 }
448}
449
450fn row_to_dcs_template(row: SqliteRow) -> DbResult<DcsTemplateRow> {
451 let get_enum = |column: &'static str| -> DbResult<String> {
452 row.try_get::<String, _>(column).map_err(DbError::Query)
453 };
454
455 let template = DcsTemplate {
456 name: row.try_get("name").map_err(DbError::Query)?,
457 revert_mode: row.try_get("revert_mode").map_err(DbError::Query)?,
458 proportional_type: text_to_enum("proportional_type", &get_enum("proportional_type")?)?,
459 integral_type: text_to_enum("integral_type", &get_enum("integral_type")?)?,
460 integral_unit: text_to_enum("integral_unit", &get_enum("integral_unit")?)?,
461 derivative_type: text_to_enum("derivative_type", &get_enum("derivative_type")?)?,
462 derivative_unit: text_to_enum("derivative_unit", &get_enum("derivative_unit")?)?,
463 process_variable_suffix: row
464 .try_get("process_variable_suffix")
465 .map_err(DbError::Query)?,
466 manipulated_variable_suffix: row
467 .try_get("manipulated_variable_suffix")
468 .map_err(DbError::Query)?,
469 setpoint_variable_suffix: row
470 .try_get("setpoint_variable_suffix")
471 .map_err(DbError::Query)?,
472 controller_direction_suffix: row
473 .try_get("controller_direction_suffix")
474 .map_err(DbError::Query)?,
475 controller_mode_suffix: row
476 .try_get("controller_mode_suffix")
477 .map_err(DbError::Query)?,
478 mode_attribute_suffix: row
479 .try_get("mode_attribute_suffix")
480 .map_err(DbError::Query)?,
481 upper_pv_range_suffix: row
482 .try_get("upper_pv_range_suffix")
483 .map_err(DbError::Query)?,
484 lower_pv_range_suffix: row
485 .try_get("lower_pv_range_suffix")
486 .map_err(DbError::Query)?,
487 upper_mv_range_suffix: row
488 .try_get("upper_mv_range_suffix")
489 .map_err(DbError::Query)?,
490 lower_mv_range_suffix: row
491 .try_get("lower_mv_range_suffix")
492 .map_err(DbError::Query)?,
493 proportional_constant_suffix: row
494 .try_get("proportional_constant_suffix")
495 .map_err(DbError::Query)?,
496 integral_constant_suffix: row
497 .try_get("integral_constant_suffix")
498 .map_err(DbError::Query)?,
499 derivative_constant_suffix: row
500 .try_get("derivative_constant_suffix")
501 .map_err(DbError::Query)?,
502 mode_manual_value: row.try_get("mode_manual_value").map_err(DbError::Query)?,
503 mode_auto_value: row.try_get("mode_auto_value").map_err(DbError::Query)?,
504 mode_attribute_program_value: row
505 .try_get("mode_attribute_program_value")
506 .map_err(DbError::Query)?,
507 controller_action_direct_value: row
508 .try_get("controller_action_direct_value")
509 .map_err(DbError::Query)?,
510 versions: {
511 let versions_json: String = row.try_get("versions_json").map_err(DbError::Query)?;
512 serde_json::from_str(&versions_json).map_err(|source| DbError::InvalidJsonShape {
513 column: "versions_json",
514 source,
515 })?
516 },
517 description: row.try_get("description").map_err(DbError::Query)?,
518 source: row.try_get("source").map_err(DbError::Query)?,
519 };
520
521 Ok(DcsTemplateRow {
522 id: row.try_get("id").map_err(DbError::Query)?,
523 origin: text_to_enum("origin", &get_enum("origin")?)?,
524 template,
525 created_at: row.try_get("created_at").map_err(DbError::Query)?,
526 updated_at: row.try_get("updated_at").map_err(DbError::Query)?,
527 })
528}
529#[derive(Debug, Clone, PartialEq)]
535pub struct LoopRow {
536 pub id: i64,
537 pub name: String,
538 pub dcs_template_id: i64,
539 pub tags: LoopTags,
540 pub config: LoopConfig,
541 pub created_at: DateTime<Utc>,
542 pub updated_at: DateTime<Utc>,
543}
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
552#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
553#[serde(rename_all = "snake_case")]
554pub enum TuneDriver {
555 Opcda,
556 Simulator,
557 Replay,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
562#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
563#[serde(rename_all = "snake_case")]
564pub enum TuneOutcome {
565 Running,
566 Completed,
567 Failed,
568 Aborted,
569}
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
573#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
574#[serde(rename_all = "snake_case")]
575pub enum TimingBasis {
576 SimulatedFixedStep,
579 LiveMonotonic,
582}
583
584#[derive(
587 Debug, Clone, Copy, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize,
588)]
589#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
590#[serde(rename_all = "snake_case")]
591pub enum SamplingAdequacy {
592 Adequate,
594 Marginal,
596 #[default]
598 NotAssessed,
599}
600
601#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
603#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
604pub struct TimingSummary {
605 pub count: u64,
606 pub mean_ms: Option<f64>,
607 pub max_ms: Option<f64>,
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
618#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
619pub struct PollLatencyMetrics {
620 pub pv_read: TimingSummary,
621 pub mv_write: TimingSummary,
622 pub mv_verification: TimingSummary,
623 pub sample_persist: TimingSummary,
624 pub tick_work: TimingSummary,
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
633#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
634pub struct TimingMetrics {
635 pub basis: TimingBasis,
636 pub requested_interval_ms: u64,
637 pub sample_gap_count: u64,
638 pub mean_sample_gap_ms: Option<f64>,
639 pub max_sample_gap_ms: Option<f64>,
640 pub missed_poll_opportunity_count: u64,
643 pub measured_oscillation_period_ms: Option<f64>,
644 pub approximate_samples_per_period: Option<f64>,
645 #[serde(default)]
648 pub sampling_adequacy: SamplingAdequacy,
649 #[serde(default)]
651 pub poll_latency: Option<PollLatencyMetrics>,
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
660#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
661pub struct EffectiveTuning {
662 pub mrft_delay_secs: u32,
663 pub poll_interval_ms: u64,
664 pub timeout_secs: u64,
665 pub op_timeout_secs: u64,
666 pub restore_timeout_secs: u64,
667}
668
669#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
675#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
676#[serde(rename_all = "snake_case")]
677pub enum RestoreStatus {
678 Confirmed,
680 Incomplete,
685}
686
687#[derive(Debug, Clone, PartialEq)]
695pub struct TuneRunInitialReadings {
696 pub pv_ini: f32,
697 pub mv_ini: f32,
698 pub mv_range_low: f32,
699 pub mv_range_high: f32,
700 pub pv_range_high: f32,
701 pub pv_range_low: f32,
702 pub controller_direction: ControllerDirection,
703 pub mode_raw: Option<String>,
708 pub mode_attribute_raw: Option<String>,
711 pub setpoint_ini: Option<f32>,
717}
718
719#[derive(Debug, Clone, PartialEq)]
721pub struct TuneRunRow {
722 pub id: i64,
723 pub loop_id: Option<i64>,
724 pub demo_session_id: Option<i64>,
725 pub loop_name: String,
726 pub driver: TuneDriver,
727 pub opc_server: Option<String>,
732 pub bridge_host: Option<String>,
735 pub started_at: DateTime<Utc>,
736 pub completed_at: Option<DateTime<Utc>>,
737 pub outcome: TuneOutcome,
738 pub failure_reason: Option<String>,
739 pub config: LoopConfig,
742 pub template_origin: TemplateOrigin,
744 pub template: DcsTemplate,
752 pub tags: LoopTags,
755 pub request_json: String,
763 pub notes: Option<String>,
765 pub initial_readings: Option<TuneRunInitialReadings>,
766 pub allow_uncertain_quality: bool,
773 pub timing_metrics: Option<TimingMetrics>,
776 pub effective_tuning: Option<EffectiveTuning>,
780 pub restore_status: Option<RestoreStatus>,
784 pub restore_detail: Option<String>,
788 pub created_at: DateTime<Utc>,
789}
790
791#[derive(Debug, Clone, Default, PartialEq)]
795pub struct TuneRunFilter {
796 pub loop_id: Option<i64>,
797 pub demo_session_id: Option<i64>,
798 pub process_type: Option<ProcessType>,
799 pub controller_type: Option<ControllerType>,
800 pub outcome: Option<TuneOutcome>,
801 pub driver: Option<TuneDriver>,
802 pub opc_server: Option<String>,
805 pub bridge_host: Option<String>,
808 pub started_after: Option<DateTime<Utc>>,
810 pub started_before: Option<DateTime<Utc>>,
812 pub template_name: Option<String>,
813 pub template_origin: Option<TemplateOrigin>,
814}
815
816impl TuneRunFilter {
817 pub fn with_demo_session_id(mut self, demo_session_id: i64) -> TuneRunFilter {
818 self.demo_session_id = Some(demo_session_id);
819 self
820 }
821 pub fn with_loop_id(mut self, loop_id: i64) -> TuneRunFilter {
822 self.loop_id = Some(loop_id);
823 self
824 }
825
826 pub fn with_process_type(mut self, process_type: ProcessType) -> TuneRunFilter {
827 self.process_type = Some(process_type);
828 self
829 }
830
831 pub fn with_controller_type(mut self, controller_type: ControllerType) -> TuneRunFilter {
832 self.controller_type = Some(controller_type);
833 self
834 }
835
836 pub fn with_outcome(mut self, outcome: TuneOutcome) -> TuneRunFilter {
837 self.outcome = Some(outcome);
838 self
839 }
840
841 pub fn with_driver(mut self, driver: TuneDriver) -> TuneRunFilter {
842 self.driver = Some(driver);
843 self
844 }
845
846 pub fn with_opc_server(mut self, opc_server: impl Into<String>) -> TuneRunFilter {
847 self.opc_server = Some(opc_server.into());
848 self
849 }
850
851 pub fn with_bridge_host(mut self, bridge_host: impl Into<String>) -> TuneRunFilter {
852 self.bridge_host = Some(bridge_host.into());
853 self
854 }
855
856 pub fn with_started_after(mut self, started_after: DateTime<Utc>) -> TuneRunFilter {
857 self.started_after = Some(started_after);
858 self
859 }
860
861 pub fn with_started_before(mut self, started_before: DateTime<Utc>) -> TuneRunFilter {
862 self.started_before = Some(started_before);
863 self
864 }
865
866 pub fn with_template_name(mut self, template_name: impl Into<String>) -> TuneRunFilter {
867 self.template_name = Some(template_name.into());
868 self
869 }
870
871 pub fn with_template_origin(mut self, template_origin: TemplateOrigin) -> TuneRunFilter {
872 self.template_origin = Some(template_origin);
873 self
874 }
875}
876
877#[derive(Debug, Clone, Copy, PartialEq)]
880pub struct Pagination {
881 pub limit: i64,
882 pub offset: i64,
883}
884
885impl Pagination {
886 pub fn new(limit: i64, offset: i64) -> Pagination {
887 Pagination { limit, offset }
888 }
889
890 pub fn first(limit: i64) -> Pagination {
892 Pagination { limit, offset: 0 }
893 }
894}
895
896impl Default for Pagination {
897 fn default() -> Pagination {
899 Pagination {
900 limit: 50,
901 offset: 0,
902 }
903 }
904}
905
906impl TuneRunRow {
907 #[allow(clippy::too_many_arguments)]
922 pub async fn start(
923 pool: &SqlitePool,
924 loop_id: Option<i64>,
925 loop_name: &str,
926 driver: TuneDriver,
927 config: LoopConfig,
928 template_origin: TemplateOrigin,
929 template: &DcsTemplate,
930 tags: &LoopTags,
931 now: DateTime<Utc>,
932 ) -> DbResult<TuneRunRow> {
933 Self::start_with_demo_session(
934 pool,
935 None,
936 loop_id,
937 loop_name,
938 driver,
939 config,
940 template_origin,
941 template,
942 tags,
943 now,
944 )
945 .await
946 }
947
948 #[allow(clippy::too_many_arguments)]
949 pub async fn start_with_demo_session(
950 pool: &SqlitePool,
951 demo_session_id: Option<i64>,
952 loop_id: Option<i64>,
953 loop_name: &str,
954 driver: TuneDriver,
955 config: LoopConfig,
956 template_origin: TemplateOrigin,
957 template: &DcsTemplate,
958 tags: &LoopTags,
959 now: DateTime<Utc>,
960 ) -> DbResult<TuneRunRow> {
961 let template_snapshot_json =
962 serde_json::to_string(template).expect("DcsTemplate serialization is infallible");
963 let tags_json = serde_json::to_string(tags).expect("LoopTags serialization is infallible");
964
965 let row = sqlx::query(
966 r#"
967 INSERT INTO tune_runs (
968 loop_id, demo_session_id, loop_name, driver, started_at, outcome,
969 process_type, controller_type, relay_amp_percent, num_cycles_skip,
970 num_cycles_count, noise_protection_secs, mrft_delay_secs,
971 template_name, template_origin, template_snapshot_json, tags_json,
972 created_at
973 )
974 SELECT ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
975 WHERE ? IS NULL OR EXISTS (
976 SELECT 1
977 FROM demo_sessions
978 WHERE id = ? AND revoked_at IS NULL AND expires_at > ?
979 )
980 RETURNING *
981 "#,
982 )
983 .bind(loop_id)
984 .bind(demo_session_id)
985 .bind(loop_name)
986 .bind(enum_to_text(&driver))
987 .bind(now)
988 .bind(enum_to_text(&config.process_type))
989 .bind(enum_to_text(&config.controller_type))
990 .bind(config.relay_amp_percent)
991 .bind(config.num_cycles_skip)
992 .bind(config.num_cycles_count)
993 .bind(config.noise_protection_secs)
994 .bind(config.mrft_delay_secs)
995 .bind(&template.name)
996 .bind(enum_to_text(&template_origin))
997 .bind(template_snapshot_json)
998 .bind(tags_json)
999 .bind(now)
1000 .bind(demo_session_id)
1001 .bind(demo_session_id)
1002 .bind(now)
1003 .fetch_optional(pool)
1004 .await
1005 .map_err(DbError::Query)?
1006 .ok_or_else(|| DbError::Query(sqlx::Error::RowNotFound))?;
1007
1008 row_to_tune_run(row)
1009 }
1010
1011 #[allow(clippy::too_many_arguments)]
1014 pub async fn start_owned(
1015 pool: &SqlitePool,
1016 demo_session_id: i64,
1017 loop_name: &str,
1018 config: LoopConfig,
1019 template_origin: TemplateOrigin,
1020 template: &DcsTemplate,
1021 tags: &LoopTags,
1022 now: DateTime<Utc>,
1023 ) -> DbResult<TuneRunRow> {
1024 Self::start_with_demo_session(
1025 pool,
1026 Some(demo_session_id),
1027 None,
1028 loop_name,
1029 TuneDriver::Simulator,
1030 config,
1031 template_origin,
1032 template,
1033 tags,
1034 now,
1035 )
1036 .await
1037 }
1038
1039 pub async fn get_for_demo_session(
1040 pool: &SqlitePool,
1041 run_id: i64,
1042 demo_session_id: i64,
1043 ) -> DbResult<Option<TuneRunRow>> {
1044 let row = sqlx::query("SELECT * FROM tune_runs WHERE id = ? AND demo_session_id = ?")
1045 .bind(run_id)
1046 .bind(demo_session_id)
1047 .fetch_optional(pool)
1048 .await
1049 .map_err(DbError::Query)?;
1050 row.map(row_to_tune_run).transpose()
1051 }
1052
1053 pub async fn list_for_demo_session(
1054 pool: &SqlitePool,
1055 demo_session_id: i64,
1056 pagination: Pagination,
1057 ) -> DbResult<Vec<TuneRunRow>> {
1058 let rows = sqlx::query(
1059 "SELECT * FROM tune_runs WHERE demo_session_id = ? \
1060 ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
1061 )
1062 .bind(demo_session_id)
1063 .bind(pagination.limit)
1064 .bind(pagination.offset)
1065 .fetch_all(pool)
1066 .await
1067 .map_err(DbError::Query)?;
1068 rows.into_iter().map(row_to_tune_run).collect()
1069 }
1070
1071 pub async fn newest_for_demo_session(
1072 pool: &SqlitePool,
1073 demo_session_id: i64,
1074 ) -> DbResult<Option<TuneRunRow>> {
1075 let row = sqlx::query(
1076 "SELECT * FROM tune_runs WHERE demo_session_id = ? \
1077 ORDER BY started_at DESC, id DESC LIMIT 1",
1078 )
1079 .bind(demo_session_id)
1080 .fetch_optional(pool)
1081 .await
1082 .map_err(DbError::Query)?;
1083 row.map(row_to_tune_run).transpose()
1084 }
1085
1086 pub async fn count_for_demo_session(pool: &SqlitePool, demo_session_id: i64) -> DbResult<i64> {
1087 sqlx::query_scalar("SELECT COUNT(*) FROM tune_runs WHERE demo_session_id = ?")
1088 .bind(demo_session_id)
1089 .fetch_one(pool)
1090 .await
1091 .map_err(DbError::Query)
1092 }
1093
1094 pub async fn count_demo_owned(pool: &SqlitePool) -> DbResult<i64> {
1097 sqlx::query_scalar("SELECT COUNT(*) FROM tune_runs WHERE demo_session_id IS NOT NULL")
1098 .fetch_one(pool)
1099 .await
1100 .map_err(DbError::Query)
1101 }
1102
1103 pub async fn prune_terminal_for_demo_session(
1107 pool: &SqlitePool,
1108 demo_session_id: i64,
1109 retain: u32,
1110 ) -> DbResult<u64> {
1111 let result = sqlx::query(
1112 "DELETE FROM tune_runs \
1113 WHERE id IN ( \
1114 SELECT id FROM tune_runs \
1115 WHERE demo_session_id = ? AND outcome <> 'running' \
1116 ORDER BY started_at DESC, id DESC \
1117 LIMIT -1 OFFSET ? \
1118 )",
1119 )
1120 .bind(demo_session_id)
1121 .bind(i64::from(retain))
1122 .execute(pool)
1123 .await
1124 .map_err(DbError::Query)?;
1125 Ok(result.rows_affected())
1126 }
1127
1128 pub async fn prune_terminal_demo_owned(pool: &SqlitePool, retain: u32) -> DbResult<u64> {
1131 let result = sqlx::query(
1132 "WITH ranked AS ( \
1133 SELECT id, ROW_NUMBER() OVER ( \
1134 PARTITION BY demo_session_id ORDER BY started_at DESC, id DESC \
1135 ) AS retention_rank \
1136 FROM tune_runs \
1137 WHERE demo_session_id IS NOT NULL AND outcome <> 'running' \
1138 ) \
1139 DELETE FROM tune_runs \
1140 WHERE id IN (SELECT id FROM ranked WHERE retention_rank > ?)",
1141 )
1142 .bind(i64::from(retain))
1143 .execute(pool)
1144 .await
1145 .map_err(DbError::Query)?;
1146 Ok(result.rows_affected())
1147 }
1148
1149 pub async fn count_rows_for_demo_session(
1150 pool: &SqlitePool,
1151 demo_session_id: i64,
1152 ) -> DbResult<i64> {
1153 sqlx::query_scalar(
1154 "SELECT
1155 (SELECT COUNT(*) FROM tune_runs WHERE demo_session_id = ?)
1156 + (SELECT COUNT(*) FROM tune_samples WHERE run_id IN
1157 (SELECT id FROM tune_runs WHERE demo_session_id = ?))
1158 + (SELECT COUNT(*) FROM tune_results WHERE run_id IN
1159 (SELECT id FROM tune_runs WHERE demo_session_id = ?))
1160 + (SELECT COUNT(*) FROM tune_writes WHERE run_id IN
1161 (SELECT id FROM tune_runs WHERE demo_session_id = ?))
1162 + (SELECT COUNT(*) FROM tune_mv_actuations WHERE run_id IN
1163 (SELECT id FROM tune_runs WHERE demo_session_id = ?))",
1164 )
1165 .bind(demo_session_id)
1166 .bind(demo_session_id)
1167 .bind(demo_session_id)
1168 .bind(demo_session_id)
1169 .bind(demo_session_id)
1170 .fetch_one(pool)
1171 .await
1172 .map_err(DbError::Query)
1173 }
1174
1175 pub async fn record_connection(
1192 pool: &SqlitePool,
1193 run_id: i64,
1194 opc_server: Option<&str>,
1195 bridge_host: Option<&str>,
1196 request_json: &str,
1197 ) -> DbResult<TuneRunRow> {
1198 let row = sqlx::query(
1199 r#"
1200 UPDATE tune_runs SET opc_server = ?, bridge_host = ?, request_json = ?
1201 WHERE id = ?
1202 RETURNING *
1203 "#,
1204 )
1205 .bind(opc_server)
1206 .bind(bridge_host)
1207 .bind(request_json)
1208 .bind(run_id)
1209 .fetch_one(pool)
1210 .await
1211 .map_err(DbError::Query)?;
1212
1213 row_to_tune_run(row)
1214 }
1215
1216 pub async fn record_effective_tuning(
1221 pool: &SqlitePool,
1222 run_id: i64,
1223 effective_tuning: EffectiveTuning,
1224 ) -> DbResult<TuneRunRow> {
1225 let effective_tuning_json = serde_json::to_string(&effective_tuning)
1226 .expect("EffectiveTuning serialization is infallible");
1227 let row = sqlx::query(
1228 r#"
1229 UPDATE tune_runs SET effective_tuning_json = ?
1230 WHERE id = ?
1231 RETURNING *
1232 "#,
1233 )
1234 .bind(effective_tuning_json)
1235 .bind(run_id)
1236 .fetch_one(pool)
1237 .await
1238 .map_err(DbError::Query)?;
1239
1240 row_to_tune_run(row)
1241 }
1242
1243 pub async fn update_notes(
1248 pool: &SqlitePool,
1249 run_id: i64,
1250 notes: Option<&str>,
1251 ) -> DbResult<TuneRunRow> {
1252 let row = sqlx::query(
1253 r#"
1254 UPDATE tune_runs SET notes = ?
1255 WHERE id = ?
1256 RETURNING *
1257 "#,
1258 )
1259 .bind(notes)
1260 .bind(run_id)
1261 .fetch_one(pool)
1262 .await
1263 .map_err(DbError::Query)?;
1264
1265 row_to_tune_run(row)
1266 }
1267
1268 pub async fn record_initial_readings(
1277 pool: &SqlitePool,
1278 run_id: i64,
1279 readings: TuneRunInitialReadings,
1280 ) -> DbResult<TuneRunRow> {
1281 let row = sqlx::query(
1282 r#"
1283 UPDATE tune_runs SET
1284 pv_ini = ?, mv_ini = ?, mv_range_low = ?, mv_range_high = ?,
1285 pv_range_high = ?, pv_range_low = ?, controller_direction = ?,
1286 mode_raw = ?, mode_attribute_raw = ?, setpoint_ini = ?
1287 WHERE id = ?
1288 RETURNING *
1289 "#,
1290 )
1291 .bind(readings.pv_ini)
1292 .bind(readings.mv_ini)
1293 .bind(readings.mv_range_low)
1294 .bind(readings.mv_range_high)
1295 .bind(readings.pv_range_high)
1296 .bind(readings.pv_range_low)
1297 .bind(enum_to_text(&readings.controller_direction))
1298 .bind(readings.mode_raw)
1299 .bind(readings.mode_attribute_raw)
1300 .bind(readings.setpoint_ini)
1301 .bind(run_id)
1302 .fetch_one(pool)
1303 .await
1304 .map_err(DbError::Query)?;
1305
1306 row_to_tune_run(row)
1307 }
1308
1309 pub async fn record_allow_uncertain_quality(
1320 pool: &SqlitePool,
1321 run_id: i64,
1322 allow_uncertain_quality: bool,
1323 ) -> DbResult<TuneRunRow> {
1324 let row = sqlx::query(
1325 r#"
1326 UPDATE tune_runs SET allow_uncertain_quality = ?
1327 WHERE id = ?
1328 RETURNING *
1329 "#,
1330 )
1331 .bind(allow_uncertain_quality)
1332 .bind(run_id)
1333 .fetch_one(pool)
1334 .await
1335 .map_err(DbError::Query)?;
1336
1337 row_to_tune_run(row)
1338 }
1339
1340 pub async fn record_timing_metrics(
1347 pool: &SqlitePool,
1348 run_id: i64,
1349 metrics: TimingMetrics,
1350 ) -> DbResult<TuneRunRow> {
1351 let metrics_json =
1352 serde_json::to_string(&metrics).expect("TimingMetrics serialization is infallible");
1353 let row = sqlx::query(
1354 r#"
1355 UPDATE tune_runs SET timing_metrics_json = ?
1356 WHERE id = ?
1357 RETURNING *
1358 "#,
1359 )
1360 .bind(metrics_json)
1361 .bind(run_id)
1362 .fetch_one(pool)
1363 .await
1364 .map_err(DbError::Query)?;
1365
1366 row_to_tune_run(row)
1367 }
1368
1369 pub async fn record_restore_status(
1380 pool: &SqlitePool,
1381 run_id: i64,
1382 status: RestoreStatus,
1383 detail: Option<&str>,
1384 ) -> DbResult<TuneRunRow> {
1385 let row = sqlx::query(
1386 r#"
1387 UPDATE tune_runs SET restore_status = ?, restore_detail = ?
1388 WHERE id = ?
1389 RETURNING *
1390 "#,
1391 )
1392 .bind(enum_to_text(&status))
1393 .bind(detail)
1394 .bind(run_id)
1395 .fetch_one(pool)
1396 .await
1397 .map_err(DbError::Query)?;
1398
1399 row_to_tune_run(row)
1400 }
1401
1402 pub async fn complete(
1405 pool: &SqlitePool,
1406 run_id: i64,
1407 completed_at: DateTime<Utc>,
1408 ) -> DbResult<TuneRunRow> {
1409 let row = sqlx::query(
1410 "UPDATE tune_runs SET outcome = 'completed', completed_at = ? WHERE id = ? RETURNING *",
1411 )
1412 .bind(completed_at)
1413 .bind(run_id)
1414 .fetch_one(pool)
1415 .await
1416 .map_err(DbError::Query)?;
1417
1418 row_to_tune_run(row)
1419 }
1420
1421 pub async fn complete_with_timing_metrics(
1425 pool: &SqlitePool,
1426 run_id: i64,
1427 completed_at: DateTime<Utc>,
1428 timing_metrics: Option<TimingMetrics>,
1429 ) -> DbResult<TuneRunRow> {
1430 Self::set_terminal_outcome_with_timing_metrics(
1431 pool,
1432 run_id,
1433 completed_at,
1434 TuneOutcome::Completed,
1435 timing_metrics,
1436 None,
1437 )
1438 .await
1439 }
1440
1441 pub async fn fail(
1445 pool: &SqlitePool,
1446 run_id: i64,
1447 completed_at: DateTime<Utc>,
1448 failure_reason: &str,
1449 ) -> DbResult<TuneRunRow> {
1450 let row = sqlx::query(
1451 r#"
1452 UPDATE tune_runs SET outcome = 'failed', completed_at = ?, failure_reason = ?
1453 WHERE id = ?
1454 RETURNING *
1455 "#,
1456 )
1457 .bind(completed_at)
1458 .bind(failure_reason)
1459 .bind(run_id)
1460 .fetch_one(pool)
1461 .await
1462 .map_err(DbError::Query)?;
1463
1464 row_to_tune_run(row)
1465 }
1466
1467 pub async fn abort(
1470 pool: &SqlitePool,
1471 run_id: i64,
1472 completed_at: DateTime<Utc>,
1473 ) -> DbResult<TuneRunRow> {
1474 let row = sqlx::query(
1475 "UPDATE tune_runs SET outcome = 'aborted', completed_at = ? WHERE id = ? RETURNING *",
1476 )
1477 .bind(completed_at)
1478 .bind(run_id)
1479 .fetch_one(pool)
1480 .await
1481 .map_err(DbError::Query)?;
1482
1483 row_to_tune_run(row)
1484 }
1485
1486 pub async fn abort_with_timing_metrics(
1489 pool: &SqlitePool,
1490 run_id: i64,
1491 completed_at: DateTime<Utc>,
1492 timing_metrics: Option<TimingMetrics>,
1493 ) -> DbResult<TuneRunRow> {
1494 Self::set_terminal_outcome_with_timing_metrics(
1495 pool,
1496 run_id,
1497 completed_at,
1498 TuneOutcome::Aborted,
1499 timing_metrics,
1500 None,
1501 )
1502 .await
1503 }
1504
1505 pub async fn abort_with_timing_metrics_and_reason(
1511 pool: &SqlitePool,
1512 run_id: i64,
1513 completed_at: DateTime<Utc>,
1514 timing_metrics: Option<TimingMetrics>,
1515 reason: &str,
1516 ) -> DbResult<TuneRunRow> {
1517 Self::set_terminal_outcome_with_timing_metrics(
1518 pool,
1519 run_id,
1520 completed_at,
1521 TuneOutcome::Aborted,
1522 timing_metrics,
1523 Some(reason),
1524 )
1525 .await
1526 }
1527
1528 async fn set_terminal_outcome_with_timing_metrics(
1529 pool: &SqlitePool,
1530 run_id: i64,
1531 completed_at: DateTime<Utc>,
1532 outcome: TuneOutcome,
1533 timing_metrics: Option<TimingMetrics>,
1534 failure_reason: Option<&str>,
1535 ) -> DbResult<TuneRunRow> {
1536 let timing_metrics_json = timing_metrics.map(|metrics| {
1537 serde_json::to_string(&metrics).expect("TimingMetrics serialization is infallible")
1538 });
1539 let row = sqlx::query(
1540 r#"
1541 UPDATE tune_runs
1542 SET outcome = ?, completed_at = ?, timing_metrics_json = ?, failure_reason = ?
1543 WHERE id = ?
1544 RETURNING *
1545 "#,
1546 )
1547 .bind(enum_to_text(&outcome))
1548 .bind(completed_at)
1549 .bind(timing_metrics_json)
1550 .bind(failure_reason)
1551 .bind(run_id)
1552 .fetch_one(pool)
1553 .await
1554 .map_err(DbError::Query)?;
1555
1556 row_to_tune_run(row)
1557 }
1558
1559 pub async fn get(pool: &SqlitePool, id: i64) -> DbResult<Option<TuneRunRow>> {
1561 let row = sqlx::query("SELECT * FROM tune_runs WHERE id = ?")
1562 .bind(id)
1563 .fetch_optional(pool)
1564 .await
1565 .map_err(DbError::Query)?;
1566 row.map(row_to_tune_run).transpose()
1567 }
1568
1569 pub async fn list(
1572 pool: &SqlitePool,
1573 filter: &TuneRunFilter,
1574 pagination: Pagination,
1575 ) -> DbResult<Vec<TuneRunRow>> {
1576 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT * FROM tune_runs");
1577 push_filter(&mut builder, filter);
1578 builder.push(" ORDER BY started_at DESC LIMIT ");
1579 builder.push_bind(pagination.limit);
1580 builder.push(" OFFSET ");
1581 builder.push_bind(pagination.offset);
1582
1583 let rows = builder
1584 .build()
1585 .fetch_all(pool)
1586 .await
1587 .map_err(DbError::Query)?;
1588 rows.into_iter().map(row_to_tune_run).collect()
1589 }
1590
1591 pub async fn count(pool: &SqlitePool, filter: &TuneRunFilter) -> DbResult<i64> {
1594 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("SELECT COUNT(*) FROM tune_runs");
1595 push_filter(&mut builder, filter);
1596 builder
1597 .build_query_scalar::<i64>()
1598 .fetch_one(pool)
1599 .await
1600 .map_err(DbError::Query)
1601 }
1602
1603 pub async fn delete_matching(pool: &SqlitePool, filter: &TuneRunFilter) -> DbResult<u64> {
1618 let mut builder: QueryBuilder<Sqlite> = QueryBuilder::new("DELETE FROM tune_runs");
1619 push_filter(&mut builder, filter);
1620 let result = builder
1621 .build()
1622 .execute(pool)
1623 .await
1624 .map_err(DbError::Query)?;
1625 Ok(result.rows_affected())
1626 }
1627
1628 pub async fn delete(pool: &SqlitePool, id: i64) -> DbResult<bool> {
1636 let result = sqlx::query("DELETE FROM tune_runs WHERE id = ?")
1637 .bind(id)
1638 .execute(pool)
1639 .await
1640 .map_err(DbError::Query)?;
1641 Ok(result.rows_affected() > 0)
1642 }
1643
1644 pub async fn delete_for_demo_session(
1645 pool: &SqlitePool,
1646 id: i64,
1647 demo_session_id: i64,
1648 ) -> DbResult<bool> {
1649 let result = sqlx::query("DELETE FROM tune_runs WHERE id = ? AND demo_session_id = ?")
1650 .bind(id)
1651 .bind(demo_session_id)
1652 .execute(pool)
1653 .await
1654 .map_err(DbError::Query)?;
1655 Ok(result.rows_affected() > 0)
1656 }
1657}
1658
1659fn push_filter(builder: &mut QueryBuilder<Sqlite>, filter: &TuneRunFilter) {
1663 builder.push(" WHERE 1=1");
1666
1667 if let Some(loop_id) = filter.loop_id {
1668 builder.push(" AND loop_id = ").push_bind(loop_id);
1669 }
1670 if let Some(demo_session_id) = filter.demo_session_id {
1671 builder
1672 .push(" AND demo_session_id = ")
1673 .push_bind(demo_session_id);
1674 }
1675 if let Some(process_type) = filter.process_type {
1676 builder
1677 .push(" AND process_type = ")
1678 .push_bind(enum_to_text(&process_type));
1679 }
1680 if let Some(controller_type) = filter.controller_type {
1681 builder
1682 .push(" AND controller_type = ")
1683 .push_bind(enum_to_text(&controller_type));
1684 }
1685 if let Some(outcome) = filter.outcome {
1686 builder
1687 .push(" AND outcome = ")
1688 .push_bind(enum_to_text(&outcome));
1689 }
1690 if let Some(driver) = filter.driver {
1691 builder
1692 .push(" AND driver = ")
1693 .push_bind(enum_to_text(&driver));
1694 }
1695 if let Some(opc_server) = &filter.opc_server {
1696 builder
1697 .push(" AND opc_server = ")
1698 .push_bind(opc_server.clone());
1699 }
1700 if let Some(bridge_host) = &filter.bridge_host {
1701 builder
1702 .push(" AND bridge_host = ")
1703 .push_bind(bridge_host.clone());
1704 }
1705 if let Some(started_after) = filter.started_after {
1706 builder.push(" AND started_at >= ").push_bind(started_after);
1707 }
1708 if let Some(started_before) = filter.started_before {
1709 builder
1710 .push(" AND started_at <= ")
1711 .push_bind(started_before);
1712 }
1713 if let Some(template_name) = &filter.template_name {
1714 builder
1715 .push(" AND template_name = ")
1716 .push_bind(template_name.clone());
1717 }
1718 if let Some(template_origin) = filter.template_origin {
1719 builder
1720 .push(" AND template_origin = ")
1721 .push_bind(enum_to_text(&template_origin));
1722 }
1723}
1724
1725fn row_to_tune_run(row: SqliteRow) -> DbResult<TuneRunRow> {
1726 let pv_ini: Option<f32> = row.try_get("pv_ini").map_err(DbError::Query)?;
1727 let initial_readings = match pv_ini {
1728 Some(pv_ini) => {
1729 let controller_direction: String = row
1730 .try_get("controller_direction")
1731 .map_err(DbError::Query)?;
1732 Some(TuneRunInitialReadings {
1733 pv_ini,
1734 mv_ini: row.try_get("mv_ini").map_err(DbError::Query)?,
1735 mv_range_low: row.try_get("mv_range_low").map_err(DbError::Query)?,
1736 mv_range_high: row.try_get("mv_range_high").map_err(DbError::Query)?,
1737 pv_range_high: row.try_get("pv_range_high").map_err(DbError::Query)?,
1738 pv_range_low: row.try_get("pv_range_low").map_err(DbError::Query)?,
1739 controller_direction: text_to_enum("controller_direction", &controller_direction)?,
1740 mode_raw: row.try_get("mode_raw").map_err(DbError::Query)?,
1741 mode_attribute_raw: row.try_get("mode_attribute_raw").map_err(DbError::Query)?,
1742 setpoint_ini: row.try_get("setpoint_ini").map_err(DbError::Query)?,
1743 })
1744 }
1745 None => None,
1746 };
1747
1748 let process_type: String = row.try_get("process_type").map_err(DbError::Query)?;
1749 let controller_type: String = row.try_get("controller_type").map_err(DbError::Query)?;
1750 let config = LoopConfig {
1751 process_type: text_to_enum("process_type", &process_type)?,
1752 controller_type: text_to_enum("controller_type", &controller_type)?,
1753 relay_amp_percent: row.try_get("relay_amp_percent").map_err(DbError::Query)?,
1754 num_cycles_skip: row.try_get("num_cycles_skip").map_err(DbError::Query)?,
1755 num_cycles_count: row.try_get("num_cycles_count").map_err(DbError::Query)?,
1756 noise_protection_secs: row
1757 .try_get("noise_protection_secs")
1758 .map_err(DbError::Query)?,
1759 mrft_delay_secs: row.try_get("mrft_delay_secs").map_err(DbError::Query)?,
1760 };
1761
1762 let driver: String = row.try_get("driver").map_err(DbError::Query)?;
1763 let outcome: String = row.try_get("outcome").map_err(DbError::Query)?;
1764
1765 let restore_status_text: Option<String> =
1766 row.try_get("restore_status").map_err(DbError::Query)?;
1767 let restore_status = restore_status_text
1768 .map(|text| text_to_enum("restore_status", &text))
1769 .transpose()?;
1770
1771 let template_origin: String = row.try_get("template_origin").map_err(DbError::Query)?;
1772 let template_snapshot_json: String = row
1773 .try_get("template_snapshot_json")
1774 .map_err(DbError::Query)?;
1775 let tags_json: String = row.try_get("tags_json").map_err(DbError::Query)?;
1776 let request_json: String = row.try_get("request_json").map_err(DbError::Query)?;
1777 let timing_metrics_json: Option<String> =
1778 row.try_get("timing_metrics_json").map_err(DbError::Query)?;
1779 let effective_tuning_json: Option<String> = row
1780 .try_get("effective_tuning_json")
1781 .map_err(DbError::Query)?;
1782 let template: DcsTemplate =
1783 serde_json::from_str(&template_snapshot_json).map_err(|source| {
1784 DbError::InvalidJsonShape {
1785 column: "template_snapshot_json",
1786 source,
1787 }
1788 })?;
1789 let tags: LoopTags =
1790 serde_json::from_str(&tags_json).map_err(|source| DbError::InvalidJsonShape {
1791 column: "tags_json",
1792 source,
1793 })?;
1794 let timing_metrics = timing_metrics_json
1795 .map(|json| {
1796 serde_json::from_str(&json).map_err(|source| DbError::InvalidJsonShape {
1797 column: "timing_metrics_json",
1798 source,
1799 })
1800 })
1801 .transpose()?;
1802 let effective_tuning = effective_tuning_json
1803 .map(|json| {
1804 serde_json::from_str(&json).map_err(|source| DbError::InvalidJsonShape {
1805 column: "effective_tuning_json",
1806 source,
1807 })
1808 })
1809 .transpose()?;
1810
1811 Ok(TuneRunRow {
1812 id: row.try_get("id").map_err(DbError::Query)?,
1813 loop_id: row.try_get("loop_id").map_err(DbError::Query)?,
1814 demo_session_id: row.try_get("demo_session_id").map_err(DbError::Query)?,
1815 loop_name: row.try_get("loop_name").map_err(DbError::Query)?,
1816 driver: text_to_enum("driver", &driver)?,
1817 opc_server: row.try_get("opc_server").map_err(DbError::Query)?,
1818 bridge_host: row.try_get("bridge_host").map_err(DbError::Query)?,
1819 started_at: row.try_get("started_at").map_err(DbError::Query)?,
1820 completed_at: row.try_get("completed_at").map_err(DbError::Query)?,
1821 outcome: text_to_enum("outcome", &outcome)?,
1822 failure_reason: row.try_get("failure_reason").map_err(DbError::Query)?,
1823 config,
1824 template_origin: text_to_enum("template_origin", &template_origin)?,
1825 template,
1826 tags,
1827 request_json,
1828 notes: row.try_get("notes").map_err(DbError::Query)?,
1829 initial_readings,
1830 allow_uncertain_quality: row
1831 .try_get("allow_uncertain_quality")
1832 .map_err(DbError::Query)?,
1833 timing_metrics,
1834 effective_tuning,
1835 restore_status,
1836 restore_detail: row.try_get("restore_detail").map_err(DbError::Query)?,
1837 created_at: row.try_get("created_at").map_err(DbError::Query)?,
1838 })
1839}
1840#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1855#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
1856#[serde(rename_all = "snake_case")]
1857pub enum SampleQuality {
1858 Good,
1859 Uncertain,
1860 Bad,
1861}
1862
1863#[derive(Debug, Clone, Copy, PartialEq)]
1865pub struct TuneSampleRow {
1866 pub id: i64,
1867 pub run_id: i64,
1868 pub tick_index: i64,
1871 pub sample: Tick,
1872 pub state: MrftState,
1873 pub pv_quality: SampleQuality,
1876}
1877
1878impl TuneSampleRow {
1879 pub async fn insert(
1885 pool: &SqlitePool,
1886 run_id: i64,
1887 tick_index: i64,
1888 sample: Tick,
1889 state: MrftState,
1890 pv_quality: SampleQuality,
1891 ) -> DbResult<TuneSampleRow> {
1892 let row = sqlx::query(
1893 r#"
1894 INSERT INTO tune_samples (
1895 run_id, tick, time, pv, pv_quality, hysteresis, mv_value_current,
1896 mv_sign_next_step, counter_all_switches, cycles_completed, cycles_remaining
1897 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1898 RETURNING *
1899 "#,
1900 )
1901 .bind(run_id)
1902 .bind(tick_index)
1903 .bind(sample.time)
1904 .bind(sample.pv)
1905 .bind(enum_to_text(&pv_quality))
1906 .bind(state.hysteresis)
1907 .bind(state.mv_value_current)
1908 .bind(state.mv_sign_next_step)
1909 .bind(state.counter_all_switches)
1910 .bind(state.cycles_completed)
1911 .bind(state.cycles_remaining)
1912 .fetch_one(pool)
1913 .await
1914 .map_err(DbError::Query)?;
1915
1916 row_to_tune_sample(row)
1917 }
1918
1919 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneSampleRow>> {
1922 let rows = sqlx::query("SELECT * FROM tune_samples WHERE run_id = ? ORDER BY tick")
1923 .bind(run_id)
1924 .fetch_all(pool)
1925 .await
1926 .map_err(DbError::Query)?;
1927 rows.into_iter().map(row_to_tune_sample).collect()
1928 }
1929
1930 pub async fn list_for_run_since(
1936 pool: &SqlitePool,
1937 run_id: i64,
1938 after_tick: i64,
1939 ) -> DbResult<Vec<TuneSampleRow>> {
1940 let rows =
1941 sqlx::query("SELECT * FROM tune_samples WHERE run_id = ? AND tick > ? ORDER BY tick")
1942 .bind(run_id)
1943 .bind(after_tick)
1944 .fetch_all(pool)
1945 .await
1946 .map_err(DbError::Query)?;
1947 rows.into_iter().map(row_to_tune_sample).collect()
1948 }
1949}
1950
1951fn row_to_tune_sample(row: SqliteRow) -> DbResult<TuneSampleRow> {
1952 Ok(TuneSampleRow {
1953 id: row.try_get("id").map_err(DbError::Query)?,
1954 run_id: row.try_get("run_id").map_err(DbError::Query)?,
1955 tick_index: row.try_get("tick").map_err(DbError::Query)?,
1956 sample: Tick {
1957 time: row.try_get("time").map_err(DbError::Query)?,
1958 pv: row.try_get("pv").map_err(DbError::Query)?,
1959 },
1960 state: MrftState {
1961 hysteresis: row.try_get("hysteresis").map_err(DbError::Query)?,
1962 mv_value_current: row.try_get("mv_value_current").map_err(DbError::Query)?,
1963 mv_sign_next_step: row.try_get("mv_sign_next_step").map_err(DbError::Query)?,
1964 counter_all_switches: row
1965 .try_get("counter_all_switches")
1966 .map_err(DbError::Query)?,
1967 cycles_completed: row.try_get("cycles_completed").map_err(DbError::Query)?,
1968 cycles_remaining: row.try_get("cycles_remaining").map_err(DbError::Query)?,
1969 },
1970 pv_quality: {
1971 let pv_quality: String = row.try_get("pv_quality").map_err(DbError::Query)?;
1972 text_to_enum("pv_quality", &pv_quality)?
1973 },
1974 })
1975}
1976#[derive(Debug, Clone, Copy, PartialEq)]
1987pub struct TuneResultRow {
1988 pub id: i64,
1989 pub run_id: i64,
1990 pub response_level: ResponseLevel,
1991 pub kp: Option<f32>,
1992 pub ti_minutes: Option<f32>,
1993 pub td_minutes: Option<f32>,
1994 pub proportional: Option<f32>,
1995 pub integral: Option<f32>,
1996 pub derivative: Option<f32>,
1997 pub status: TuningResultStatus,
1998 pub invalid_reason: Option<TuningResultInvalidReason>,
1999}
2000
2001impl TuneResultRow {
2002 pub fn from_calculated(run_id: i64, tuning: TuningResult, pid: PidParameters) -> TuneResultRow {
2013 assert_eq!(
2014 tuning.response_level, pid.response_level,
2015 "TuningResult and PidParameters must be for the same ResponseLevel"
2016 );
2017 TuneResultRow {
2018 id: 0,
2019 run_id,
2020 response_level: tuning.response_level,
2021 kp: Some(tuning.kp),
2022 ti_minutes: Some(tuning.ti_minutes),
2023 td_minutes: Some(tuning.td_minutes),
2024 proportional: Some(pid.proportional),
2025 integral: Some(pid.integral),
2026 derivative: Some(pid.derivative),
2027 status: TuningResultStatus::Valid,
2028 invalid_reason: None,
2029 }
2030 }
2031
2032 pub fn from_checked(run_id: i64, checked: CheckedTuningResult) -> TuneResultRow {
2036 let values = checked.usable_values();
2037 TuneResultRow {
2038 id: 0,
2039 run_id,
2040 response_level: checked.response_level,
2041 kp: values.map(|(tuning, _)| tuning.kp),
2042 ti_minutes: values.map(|(tuning, _)| tuning.ti_minutes),
2043 td_minutes: values.map(|(tuning, _)| tuning.td_minutes),
2044 proportional: values.map(|(_, pid)| pid.proportional),
2045 integral: values.map(|(_, pid)| pid.integral),
2046 derivative: values.map(|(_, pid)| pid.derivative),
2047 status: checked.status,
2048 invalid_reason: checked.invalid_reason,
2049 }
2050 }
2051
2052 pub async fn insert(pool: &SqlitePool, row: &TuneResultRow) -> DbResult<TuneResultRow> {
2057 let inserted = sqlx::query(
2058 r#"
2059 INSERT INTO tune_results (
2060 run_id, response_level, kp, ti_minutes, td_minutes,
2061 proportional, integral, derivative, status, invalid_reason
2062 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2063 RETURNING *
2064 "#,
2065 )
2066 .bind(row.run_id)
2067 .bind(enum_to_text(&row.response_level))
2068 .bind(row.kp)
2069 .bind(row.ti_minutes)
2070 .bind(row.td_minutes)
2071 .bind(row.proportional)
2072 .bind(row.integral)
2073 .bind(row.derivative)
2074 .bind(enum_to_text(&row.status))
2075 .bind(row.invalid_reason.map(|reason| enum_to_text(&reason)))
2076 .fetch_one(pool)
2077 .await
2078 .map_err(DbError::Query)?;
2079
2080 row_to_tune_result(inserted)
2081 }
2082
2083 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneResultRow>> {
2088 let rows =
2089 sqlx::query("SELECT * FROM tune_results WHERE run_id = ? ORDER BY response_level")
2090 .bind(run_id)
2091 .fetch_all(pool)
2092 .await
2093 .map_err(DbError::Query)?;
2094 rows.into_iter().map(row_to_tune_result).collect()
2095 }
2096}
2097
2098fn row_to_tune_result(row: SqliteRow) -> DbResult<TuneResultRow> {
2099 let response_level: String = row.try_get("response_level").map_err(DbError::Query)?;
2100 Ok(TuneResultRow {
2101 id: row.try_get("id").map_err(DbError::Query)?,
2102 run_id: row.try_get("run_id").map_err(DbError::Query)?,
2103 response_level: text_to_enum("response_level", &response_level)?,
2104 kp: row.try_get("kp").map_err(DbError::Query)?,
2105 ti_minutes: row.try_get("ti_minutes").map_err(DbError::Query)?,
2106 td_minutes: row.try_get("td_minutes").map_err(DbError::Query)?,
2107 proportional: row.try_get("proportional").map_err(DbError::Query)?,
2108 integral: row.try_get("integral").map_err(DbError::Query)?,
2109 derivative: row.try_get("derivative").map_err(DbError::Query)?,
2110 status: {
2111 let status: String = row.try_get("status").map_err(DbError::Query)?;
2112 text_to_enum("status", &status)?
2113 },
2114 invalid_reason: {
2115 let reason: Option<String> = row.try_get("invalid_reason").map_err(DbError::Query)?;
2116 reason
2117 .as_deref()
2118 .map(|reason| text_to_enum("invalid_reason", reason))
2119 .transpose()?
2120 },
2121 })
2122}
2123#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2129#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2130#[serde(rename_all = "snake_case")]
2131pub enum MvActuationKind {
2132 Relay,
2134 Restore,
2136}
2137
2138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2140#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2141#[serde(rename_all = "snake_case")]
2142pub enum MvActuationStatus {
2143 Pending,
2145 Confirmed,
2147 Failed,
2149 Unverified,
2152 Superseded,
2155}
2156
2157impl MvActuationStatus {
2158 fn ensure_terminal(self) -> DbResult<()> {
2159 if self == MvActuationStatus::Pending {
2160 Err(DbError::InvalidMvActuationFinalStatus)
2161 } else {
2162 Ok(())
2163 }
2164 }
2165}
2166
2167#[derive(Debug, Clone, PartialEq)]
2174pub struct NewTuneMvActuation {
2175 pub sequence: i64,
2176 pub kind: MvActuationKind,
2177 pub commanded_at: DateTime<Utc>,
2178 pub target_mv: f32,
2179 pub previous_commanded_mv: Option<f32>,
2182 pub tolerance: f32,
2183 pub confirmation_due_at: DateTime<Utc>,
2184}
2185
2186#[derive(Debug, Clone, PartialEq)]
2194pub struct TuneMvActuationRow {
2195 pub id: i64,
2196 pub run_id: i64,
2197 pub sequence: i64,
2199 pub kind: MvActuationKind,
2200 pub commanded_at: DateTime<Utc>,
2202 pub target_mv: f32,
2203 pub previous_commanded_mv: Option<f32>,
2204 pub tolerance: f32,
2206 pub confirmation_due_at: DateTime<Utc>,
2208 pub last_checked_at: Option<DateTime<Utc>>,
2209 pub readback_mv: Option<f32>,
2210 pub readback_quality: Option<SampleQuality>,
2211 pub attempt_count: i64,
2212 pub status: MvActuationStatus,
2213 pub detail: Option<String>,
2215}
2216
2217impl TuneMvActuationRow {
2218 pub async fn insert_pending(
2222 pool: &SqlitePool,
2223 run_id: i64,
2224 new: NewTuneMvActuation,
2225 ) -> DbResult<TuneMvActuationRow> {
2226 let row = sqlx::query(
2227 r#"
2228 INSERT INTO tune_mv_actuations (
2229 run_id, sequence, kind, commanded_at, target_mv, previous_commanded_mv,
2230 tolerance, confirmation_due_at
2231 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2232 RETURNING *
2233 "#,
2234 )
2235 .bind(run_id)
2236 .bind(new.sequence)
2237 .bind(enum_to_text(&new.kind))
2238 .bind(new.commanded_at)
2239 .bind(new.target_mv)
2240 .bind(new.previous_commanded_mv)
2241 .bind(new.tolerance)
2242 .bind(new.confirmation_due_at)
2243 .fetch_one(pool)
2244 .await
2245 .map_err(DbError::Query)?;
2246
2247 row_to_tune_mv_actuation(row)
2248 }
2249
2250 pub async fn record_observation(
2254 pool: &SqlitePool,
2255 id: i64,
2256 checked_at: DateTime<Utc>,
2257 readback_mv: Option<f32>,
2258 readback_quality: Option<SampleQuality>,
2259 ) -> DbResult<TuneMvActuationRow> {
2260 let row = sqlx::query(
2261 r#"
2262 UPDATE tune_mv_actuations
2263 SET last_checked_at = ?, readback_mv = ?, readback_quality = ?,
2264 attempt_count = attempt_count + 1
2265 WHERE id = ? AND status = 'pending'
2266 RETURNING *
2267 "#,
2268 )
2269 .bind(checked_at)
2270 .bind(readback_mv)
2271 .bind(readback_quality.map(|quality| enum_to_text(&quality)))
2272 .bind(id)
2273 .fetch_one(pool)
2274 .await
2275 .map_err(DbError::Query)?;
2276
2277 row_to_tune_mv_actuation(row)
2278 }
2279
2280 pub async fn record_final_observation(
2285 pool: &SqlitePool,
2286 id: i64,
2287 checked_at: DateTime<Utc>,
2288 readback_mv: Option<f32>,
2289 readback_quality: Option<SampleQuality>,
2290 status: MvActuationStatus,
2291 detail: Option<&str>,
2292 ) -> DbResult<TuneMvActuationRow> {
2293 status.ensure_terminal()?;
2294 let row = sqlx::query(
2295 r#"
2296 UPDATE tune_mv_actuations
2297 SET last_checked_at = ?, readback_mv = ?, readback_quality = ?,
2298 attempt_count = attempt_count + 1, status = ?, detail = ?
2299 WHERE id = ? AND status = 'pending'
2300 RETURNING *
2301 "#,
2302 )
2303 .bind(checked_at)
2304 .bind(readback_mv)
2305 .bind(readback_quality.map(|quality| enum_to_text(&quality)))
2306 .bind(enum_to_text(&status))
2307 .bind(detail)
2308 .bind(id)
2309 .fetch_one(pool)
2310 .await
2311 .map_err(DbError::Query)?;
2312
2313 row_to_tune_mv_actuation(row)
2314 }
2315
2316 pub async fn finalize(
2319 pool: &SqlitePool,
2320 id: i64,
2321 status: MvActuationStatus,
2322 detail: Option<&str>,
2323 ) -> DbResult<TuneMvActuationRow> {
2324 status.ensure_terminal()?;
2325 let row = sqlx::query(
2326 r#"
2327 UPDATE tune_mv_actuations
2328 SET status = ?, detail = ?
2329 WHERE id = ? AND status = 'pending'
2330 RETURNING *
2331 "#,
2332 )
2333 .bind(enum_to_text(&status))
2334 .bind(detail)
2335 .bind(id)
2336 .fetch_one(pool)
2337 .await
2338 .map_err(DbError::Query)?;
2339
2340 row_to_tune_mv_actuation(row)
2341 }
2342
2343 pub async fn finalize_pending_for_run(
2348 pool: &SqlitePool,
2349 run_id: i64,
2350 status: MvActuationStatus,
2351 detail: Option<&str>,
2352 ) -> DbResult<u64> {
2353 status.ensure_terminal()?;
2354 let result = sqlx::query(
2355 r#"
2356 UPDATE tune_mv_actuations
2357 SET status = ?, detail = ?
2358 WHERE run_id = ? AND status = 'pending'
2359 "#,
2360 )
2361 .bind(enum_to_text(&status))
2362 .bind(detail)
2363 .bind(run_id)
2364 .execute(pool)
2365 .await
2366 .map_err(DbError::Query)?;
2367 Ok(result.rows_affected())
2368 }
2369
2370 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneMvActuationRow>> {
2372 let rows =
2373 sqlx::query("SELECT * FROM tune_mv_actuations WHERE run_id = ? ORDER BY sequence, id")
2374 .bind(run_id)
2375 .fetch_all(pool)
2376 .await
2377 .map_err(DbError::Query)?;
2378 rows.into_iter().map(row_to_tune_mv_actuation).collect()
2379 }
2380}
2381
2382fn row_to_tune_mv_actuation(row: SqliteRow) -> DbResult<TuneMvActuationRow> {
2383 let kind: String = row.try_get("kind").map_err(DbError::Query)?;
2384 let readback_quality: Option<String> =
2385 row.try_get("readback_quality").map_err(DbError::Query)?;
2386 let status: String = row.try_get("status").map_err(DbError::Query)?;
2387 Ok(TuneMvActuationRow {
2388 id: row.try_get("id").map_err(DbError::Query)?,
2389 run_id: row.try_get("run_id").map_err(DbError::Query)?,
2390 sequence: row.try_get("sequence").map_err(DbError::Query)?,
2391 kind: text_to_enum("kind", &kind)?,
2392 commanded_at: row.try_get("commanded_at").map_err(DbError::Query)?,
2393 target_mv: row.try_get("target_mv").map_err(DbError::Query)?,
2394 previous_commanded_mv: row
2395 .try_get("previous_commanded_mv")
2396 .map_err(DbError::Query)?,
2397 tolerance: row.try_get("tolerance").map_err(DbError::Query)?,
2398 confirmation_due_at: row.try_get("confirmation_due_at").map_err(DbError::Query)?,
2399 last_checked_at: row.try_get("last_checked_at").map_err(DbError::Query)?,
2400 readback_mv: row.try_get("readback_mv").map_err(DbError::Query)?,
2401 readback_quality: readback_quality
2402 .map(|quality| text_to_enum("readback_quality", &quality))
2403 .transpose()?,
2404 attempt_count: row.try_get("attempt_count").map_err(DbError::Query)?,
2405 status: text_to_enum("status", &status)?,
2406 detail: row.try_get("detail").map_err(DbError::Query)?,
2407 })
2408}
2409#[derive(Debug, Clone, PartialEq)]
2423pub struct TuneWriteRow {
2424 pub id: i64,
2425 pub run_id: i64,
2426 pub response_level: ResponseLevel,
2427 pub written_at: DateTime<Utc>,
2428 pub kind: WriteKind,
2431 pub allow_uncertain_quality: bool,
2435 pub previous: Option<WriteReadback>,
2439 pub proportional_written: Option<f32>,
2440 pub integral_written: Option<f32>,
2441 pub derivative_written: Option<f32>,
2442 pub proportional_readback: Option<f32>,
2446 pub integral_readback: Option<f32>,
2447 pub derivative_readback: Option<f32>,
2448 pub success: bool,
2449 pub error_message: Option<String>,
2450 pub rollback_state: Option<RollbackState>,
2456 pub rollback_error: Option<String>,
2457}
2458
2459#[derive(Debug, Clone, Copy, PartialEq)]
2464pub struct WriteReadback {
2465 pub proportional: f32,
2466 pub integral: f32,
2467 pub derivative: f32,
2468}
2469
2470#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2473#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2474#[serde(rename_all = "snake_case")]
2475pub enum RollbackState {
2476 Succeeded,
2479 Failed,
2483}
2484
2485#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
2490#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
2491#[serde(rename_all = "snake_case")]
2492pub enum WriteKind {
2493 Write,
2495 Revert,
2499}
2500
2501#[derive(Debug, Clone, PartialEq)]
2508pub struct NewTuneWrite {
2509 pub response_level: ResponseLevel,
2510 pub written_at: DateTime<Utc>,
2511 pub kind: WriteKind,
2512 pub allow_uncertain_quality: bool,
2513 pub previous: Option<WriteReadback>,
2514 pub proportional_written: Option<f32>,
2515 pub integral_written: Option<f32>,
2516 pub derivative_written: Option<f32>,
2517 pub proportional_readback: Option<f32>,
2518 pub integral_readback: Option<f32>,
2519 pub derivative_readback: Option<f32>,
2520 pub success: bool,
2521 pub error_message: Option<String>,
2522 pub rollback_state: Option<RollbackState>,
2523 pub rollback_error: Option<String>,
2524}
2525
2526impl NewTuneWrite {
2527 pub fn new(response_level: ResponseLevel, written_at: DateTime<Utc>) -> Self {
2532 NewTuneWrite {
2533 response_level,
2534 written_at,
2535 kind: WriteKind::Write,
2536 allow_uncertain_quality: true,
2537 previous: None,
2538 proportional_written: None,
2539 integral_written: None,
2540 derivative_written: None,
2541 proportional_readback: None,
2542 integral_readback: None,
2543 derivative_readback: None,
2544 success: false,
2545 error_message: None,
2546 rollback_state: None,
2547 rollback_error: None,
2548 }
2549 }
2550}
2551
2552impl TuneWriteRow {
2553 pub async fn insert(
2555 pool: &SqlitePool,
2556 run_id: i64,
2557 new: NewTuneWrite,
2558 ) -> DbResult<TuneWriteRow> {
2559 let row = sqlx::query(
2560 r#"
2561 INSERT INTO tune_writes (
2562 run_id, response_level, written_at, kind,
2563 allow_uncertain_quality,
2564 proportional_previous, integral_previous, derivative_previous,
2565 proportional_written, integral_written, derivative_written,
2566 proportional_readback, integral_readback, derivative_readback,
2567 success, error_message, rollback_state, rollback_error
2568 ) VALUES (
2569 ?, ?, ?, ?,
2570 ?,
2571 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
2572 )
2573 RETURNING *
2574 "#,
2575 )
2576 .bind(run_id)
2577 .bind(enum_to_text(&new.response_level))
2578 .bind(new.written_at)
2579 .bind(enum_to_text(&new.kind))
2580 .bind(new.allow_uncertain_quality)
2581 .bind(new.previous.map(|p| p.proportional))
2582 .bind(new.previous.map(|p| p.integral))
2583 .bind(new.previous.map(|p| p.derivative))
2584 .bind(new.proportional_written)
2585 .bind(new.integral_written)
2586 .bind(new.derivative_written)
2587 .bind(new.proportional_readback)
2588 .bind(new.integral_readback)
2589 .bind(new.derivative_readback)
2590 .bind(new.success)
2591 .bind(new.error_message)
2592 .bind(new.rollback_state.map(|s| enum_to_text(&s)))
2593 .bind(new.rollback_error)
2594 .fetch_one(pool)
2595 .await
2596 .map_err(DbError::Query)?;
2597
2598 row_to_tune_write(row)
2599 }
2600
2601 pub async fn list_for_run(pool: &SqlitePool, run_id: i64) -> DbResult<Vec<TuneWriteRow>> {
2604 let rows = sqlx::query("SELECT * FROM tune_writes WHERE run_id = ? ORDER BY written_at")
2605 .bind(run_id)
2606 .fetch_all(pool)
2607 .await
2608 .map_err(DbError::Query)?;
2609 rows.into_iter().map(row_to_tune_write).collect()
2610 }
2611}
2612
2613fn row_to_tune_write(row: SqliteRow) -> DbResult<TuneWriteRow> {
2614 let response_level: String = row.try_get("response_level").map_err(DbError::Query)?;
2615 let kind: String = row.try_get("kind").map_err(DbError::Query)?;
2616 let proportional_previous: Option<f32> = row
2617 .try_get("proportional_previous")
2618 .map_err(DbError::Query)?;
2619 let integral_previous: Option<f32> =
2620 row.try_get("integral_previous").map_err(DbError::Query)?;
2621 let derivative_previous: Option<f32> =
2622 row.try_get("derivative_previous").map_err(DbError::Query)?;
2623 let previous = match (
2624 proportional_previous,
2625 integral_previous,
2626 derivative_previous,
2627 ) {
2628 (Some(proportional), Some(integral), Some(derivative)) => Some(WriteReadback {
2629 proportional,
2630 integral,
2631 derivative,
2632 }),
2633 _ => None,
2634 };
2635 let rollback_state: Option<String> = row.try_get("rollback_state").map_err(DbError::Query)?;
2636 Ok(TuneWriteRow {
2637 id: row.try_get("id").map_err(DbError::Query)?,
2638 run_id: row.try_get("run_id").map_err(DbError::Query)?,
2639 response_level: text_to_enum("response_level", &response_level)?,
2640 written_at: row.try_get("written_at").map_err(DbError::Query)?,
2641 kind: text_to_enum("kind", &kind)?,
2642 allow_uncertain_quality: row
2643 .try_get("allow_uncertain_quality")
2644 .map_err(DbError::Query)?,
2645 previous,
2646 proportional_written: row
2647 .try_get("proportional_written")
2648 .map_err(DbError::Query)?,
2649 integral_written: row.try_get("integral_written").map_err(DbError::Query)?,
2650 derivative_written: row.try_get("derivative_written").map_err(DbError::Query)?,
2651 proportional_readback: row
2652 .try_get("proportional_readback")
2653 .map_err(DbError::Query)?,
2654 integral_readback: row.try_get("integral_readback").map_err(DbError::Query)?,
2655 derivative_readback: row.try_get("derivative_readback").map_err(DbError::Query)?,
2656 success: row.try_get("success").map_err(DbError::Query)?,
2657 error_message: row.try_get("error_message").map_err(DbError::Query)?,
2658 rollback_state: rollback_state
2659 .map(|s| text_to_enum("rollback_state", &s))
2660 .transpose()?,
2661 rollback_error: row.try_get("rollback_error").map_err(DbError::Query)?,
2662 })
2663}
2664#[derive(Debug, Clone, PartialEq)]
2670pub struct SettingRow {
2671 pub key: String,
2672 pub value: serde_json::Value,
2673 pub updated_at: DateTime<Utc>,
2674}
2675
2676impl SettingRow {
2677 pub async fn get(pool: &SqlitePool, key: &str) -> DbResult<Option<Self>> {
2683 let row = sqlx::query("SELECT key, value, updated_at FROM settings WHERE key = ?")
2684 .bind(key)
2685 .fetch_optional(pool)
2686 .await
2687 .map_err(DbError::Query)?;
2688
2689 row.map(setting_from_row).transpose()
2690 }
2691
2692 pub async fn upsert(
2697 pool: &SqlitePool,
2698 key: &str,
2699 value: &serde_json::Value,
2700 updated_at: DateTime<Utc>,
2701 ) -> DbResult<Self> {
2702 let value_json = value.to_string();
2703 let row = sqlx::query(
2704 "INSERT INTO settings (key, value, updated_at)
2705 VALUES (?, ?, ?)
2706 ON CONFLICT(key) DO UPDATE SET
2707 value = excluded.value,
2708 updated_at = excluded.updated_at
2709 RETURNING key, value, updated_at",
2710 )
2711 .bind(key)
2712 .bind(value_json)
2713 .bind(updated_at)
2714 .fetch_one(pool)
2715 .await
2716 .map_err(DbError::Query)?;
2717
2718 setting_from_row(row)
2719 }
2720}
2721
2722fn setting_from_row(row: SqliteRow) -> DbResult<SettingRow> {
2723 let value_json: String = row.try_get("value").map_err(DbError::Query)?;
2724 let value = serde_json::from_str(&value_json).map_err(|source| DbError::InvalidJsonShape {
2725 column: "settings.value",
2726 source,
2727 })?;
2728
2729 Ok(SettingRow {
2730 key: row.try_get("key").map_err(DbError::Query)?,
2731 value,
2732 updated_at: row.try_get("updated_at").map_err(DbError::Query)?,
2733 })
2734}
2735#[cfg(test)]
2738mod tests {
2739 use super::*;
2740 use crate::convert::{enum_to_text, text_to_enum};
2741
2742 async fn sample_run() -> (crate::SqlitePool, i64) {
2743 let pool = crate::connect_in_memory().await.unwrap();
2744 let template = bhtune_core::built_in_templates().remove(0);
2745 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.FIC101.PV", &template);
2746 let config = bhtune_core::LoopConfig {
2747 process_type: bhtune_core::ProcessType::Flow,
2748 controller_type: bhtune_core::ControllerType::Pi,
2749 relay_amp_percent: 5.0,
2750 num_cycles_skip: 1,
2751 num_cycles_count: 2,
2752 noise_protection_secs: 3,
2753 mrft_delay_secs: 0,
2754 };
2755 let run = TuneRunRow::start(
2756 &pool,
2757 None,
2758 "Unit1.FIC101.PV",
2759 TuneDriver::Simulator,
2760 config,
2761 TemplateOrigin::Builtin,
2762 &template,
2763 &tags,
2764 chrono::Utc::now(),
2765 )
2766 .await
2767 .unwrap();
2768 (pool, run.id)
2769 }
2770
2771 #[test]
2772 fn tune_driver_round_trips_and_matches_check_constraint() {
2773 let cases = [
2774 (TuneDriver::Opcda, "opcda"),
2775 (TuneDriver::Simulator, "simulator"),
2776 (TuneDriver::Replay, "replay"),
2777 ];
2778 for (variant, text) in cases {
2779 assert_eq!(enum_to_text(&variant), text);
2780 assert_eq!(text_to_enum::<TuneDriver>("driver", text).unwrap(), variant);
2781 }
2782 }
2783
2784 #[test]
2785 fn tune_outcome_round_trips_and_matches_check_constraint() {
2786 let cases = [
2787 (TuneOutcome::Running, "running"),
2788 (TuneOutcome::Completed, "completed"),
2789 (TuneOutcome::Failed, "failed"),
2790 (TuneOutcome::Aborted, "aborted"),
2791 ];
2792 for (variant, text) in cases {
2793 assert_eq!(enum_to_text(&variant), text);
2794 assert_eq!(
2795 text_to_enum::<TuneOutcome>("outcome", text).unwrap(),
2796 variant
2797 );
2798 }
2799 }
2800
2801 #[test]
2802 fn from_calculated_builds_matching_row() {
2803 let tuning = TuningResult {
2804 response_level: ResponseLevel::Moderate,
2805 kp: 1.0,
2806 ti_minutes: 2.0,
2807 td_minutes: 0.0,
2808 };
2809 let pid = PidParameters {
2810 response_level: ResponseLevel::Moderate,
2811 proportional: 3.0,
2812 integral: 4.0,
2813 derivative: 0.0,
2814 };
2815 let row = TuneResultRow::from_calculated(42, tuning, pid);
2816 assert_eq!(row.run_id, 42);
2817 assert_eq!(row.response_level, ResponseLevel::Moderate);
2818 assert_eq!(row.kp, Some(1.0));
2819 assert_eq!(row.proportional, Some(3.0));
2820 }
2821
2822 #[test]
2823 #[should_panic(expected = "same ResponseLevel")]
2824 fn from_calculated_panics_on_mismatched_response_level() {
2825 let tuning = TuningResult {
2826 response_level: ResponseLevel::Aggressive,
2827 kp: 1.0,
2828 ti_minutes: 2.0,
2829 td_minutes: 0.0,
2830 };
2831 let pid = PidParameters {
2832 response_level: ResponseLevel::Sluggish,
2833 proportional: 3.0,
2834 integral: 4.0,
2835 derivative: 0.0,
2836 };
2837 TuneResultRow::from_calculated(1, tuning, pid);
2838 }
2839
2840 #[tokio::test]
2841 async fn record_restore_status_round_trips_both_status_shapes() {
2842 let (pool, run_id) = sample_run().await;
2843 let confirmed =
2844 TuneRunRow::record_restore_status(&pool, run_id, RestoreStatus::Confirmed, None)
2845 .await
2846 .unwrap();
2847 assert_eq!(confirmed.restore_status, Some(RestoreStatus::Confirmed));
2848 assert_eq!(confirmed.restore_detail, None);
2849
2850 let incomplete = TuneRunRow::record_restore_status(
2851 &pool,
2852 run_id,
2853 RestoreStatus::Incomplete,
2854 Some("MV restore failed"),
2855 )
2856 .await
2857 .unwrap();
2858 assert_eq!(incomplete.restore_status, Some(RestoreStatus::Incomplete));
2859 assert_eq!(
2860 incomplete.restore_detail.as_deref(),
2861 Some("MV restore failed")
2862 );
2863 }
2864
2865 #[tokio::test]
2866 async fn tune_run_delete_reports_both_existing_and_missing_ids() {
2867 let (pool, run_id) = sample_run().await;
2868 assert!(TuneRunRow::delete(&pool, run_id).await.unwrap());
2869 assert!(!TuneRunRow::delete(&pool, run_id).await.unwrap());
2870 }
2871
2872 #[tokio::test]
2873 async fn malformed_tune_run_json_is_reported_with_the_respective_column() {
2874 for (column, expected) in [
2875 ("template_snapshot_json", "template_snapshot_json"),
2876 ("tags_json", "tags_json"),
2877 ("timing_metrics_json", "timing_metrics_json"),
2878 ("effective_tuning_json", "effective_tuning_json"),
2879 ] {
2880 let (pool, run_id) = sample_run().await;
2881 let query = match column {
2882 "template_snapshot_json" => {
2883 sqlx::query("UPDATE tune_runs SET template_snapshot_json = ? WHERE id = ?")
2884 }
2885 "tags_json" => sqlx::query("UPDATE tune_runs SET tags_json = ? WHERE id = ?"),
2886 "timing_metrics_json" => {
2887 sqlx::query("UPDATE tune_runs SET timing_metrics_json = ? WHERE id = ?")
2888 }
2889 "effective_tuning_json" => {
2890 sqlx::query("UPDATE tune_runs SET effective_tuning_json = ? WHERE id = ?")
2891 }
2892 _ => unreachable!(),
2893 };
2894 query
2895 .bind("\"wrong-shape\"")
2896 .bind(run_id)
2897 .execute(&pool)
2898 .await
2899 .unwrap();
2900 let err = TuneRunRow::get(&pool, run_id).await.unwrap_err();
2901 assert!(matches!(
2902 err,
2903 DbError::InvalidJsonShape { column: actual, .. } if actual == expected
2904 ));
2905 }
2906 }
2907
2908 #[tokio::test]
2909 async fn malformed_setting_json_is_reported_as_invalid_shape() {
2910 let pool = crate::connect_in_memory().await.unwrap();
2911 sqlx::query("CREATE TABLE malformed_settings (key TEXT, value TEXT, updated_at TEXT)")
2912 .execute(&pool)
2913 .await
2914 .unwrap();
2915 sqlx::query("INSERT INTO malformed_settings (key, value, updated_at) VALUES (?, ?, ?)")
2916 .bind("draft")
2917 .bind("{not-json")
2918 .bind(chrono::Utc::now())
2919 .execute(&pool)
2920 .await
2921 .unwrap();
2922
2923 let row = sqlx::query("SELECT key, value, updated_at FROM malformed_settings")
2924 .fetch_one(&pool)
2925 .await
2926 .unwrap();
2927 let err = setting_from_row(row).unwrap_err();
2928 assert!(matches!(
2929 err,
2930 DbError::InvalidJsonShape {
2931 column: "settings.value",
2932 ..
2933 }
2934 ));
2935 }
2936}