1use std::io::Write;
8
9use bhtune_db::SqlitePool;
10use bhtune_db::models::TuneSampleRow;
11use serde::Serialize;
12
13use crate::args::{ExportArgs, ExportFormat};
14
15#[derive(Serialize)]
16pub struct SampleRecord {
17 tick: i64,
18 time: chrono::DateTime<chrono::Utc>,
19 pv: f32,
20 pv_quality: bhtune_db::models::SampleQuality,
21 hysteresis: f32,
22 mv_value_current: f32,
23 mv_sign_next_step: i8,
24 counter_all_switches: u32,
25 cycles_completed: i32,
26 cycles_remaining: i32,
27}
28
29impl From<&TuneSampleRow> for SampleRecord {
30 fn from(row: &TuneSampleRow) -> Self {
31 SampleRecord {
32 tick: row.tick_index,
33 time: row.sample.time,
34 pv: row.sample.pv,
35 pv_quality: row.pv_quality,
36 hysteresis: row.state.hysteresis,
37 mv_value_current: row.state.mv_value_current,
38 mv_sign_next_step: row.state.mv_sign_next_step,
39 counter_all_switches: row.state.counter_all_switches,
40 cycles_completed: row.state.cycles_completed,
41 cycles_remaining: row.state.cycles_remaining,
42 }
43 }
44}
45
46pub fn samples_to_bytes(
51 samples: &[TuneSampleRow],
52 format: ExportFormat,
53) -> anyhow::Result<Vec<u8>> {
54 let records: Vec<SampleRecord> = samples.iter().map(SampleRecord::from).collect();
55 match format {
56 ExportFormat::Csv => {
57 let mut writer = csv::Writer::from_writer(Vec::new());
58 for record in &records {
59 writer.serialize(record)?;
60 }
61 Ok(writer.into_inner()?)
62 }
63 ExportFormat::Json => Ok(serde_json::to_vec_pretty(&records)?),
64 }
65}
66
67pub async fn run(pool: &SqlitePool, args: ExportArgs) -> anyhow::Result<()> {
68 let samples = TuneSampleRow::list_for_run(pool, args.run_id).await?;
69 if samples.is_empty() {
70 anyhow::bail!(
71 "run {} has no recorded samples (unknown run id, or it never started)",
72 args.run_id
73 );
74 }
75 let record_count = samples.len();
76 let bytes = samples_to_bytes(&samples, args.format)?;
77
78 match &args.output {
79 Some(path) => {
80 std::fs::write(path, &bytes)
81 .map_err(|e| anyhow::anyhow!("failed to write '{}': {e}", path.display()))?;
82 println!(
83 "Exported {} sample(s) from run {} to '{}'.",
84 record_count,
85 args.run_id,
86 path.display()
87 );
88 }
89 None => {
90 std::io::stdout().write_all(&bytes)?;
91 }
92 }
93 Ok(())
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use bhtune_core::mrft::{MrftState, Tick};
100
101 async fn pool_with_one_sample() -> (SqlitePool, i64) {
102 let pool = bhtune_db::connect_in_memory().await.unwrap();
103 let now = chrono::Utc::now();
104 let template = bhtune_core::built_in_templates().remove(0);
105 let tags = bhtune_core::LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
106 let run = bhtune_db::models::TuneRunRow::start(
107 &pool,
108 None,
109 "Unit1.LIC101.PV",
110 bhtune_db::models::TuneDriver::Simulator,
111 bhtune_core::LoopConfig {
112 process_type: bhtune_core::ProcessType::Flow,
113 controller_type: bhtune_core::ControllerType::Pi,
114 relay_amp_percent: 10.0,
115 num_cycles_skip: 1,
116 num_cycles_count: 2,
117 noise_protection_secs: 3,
118 mrft_delay_secs: 0,
119 },
120 bhtune_db::models::TemplateOrigin::Builtin,
121 &template,
122 &tags,
123 now,
124 )
125 .await
126 .unwrap();
127
128 TuneSampleRow::insert(
129 &pool,
130 run.id,
131 0,
132 Tick {
133 time: now,
134 pv: 50.0,
135 },
136 MrftState {
137 hysteresis: 0.0,
138 mv_value_current: 50.0,
139 mv_sign_next_step: 1,
140 counter_all_switches: 0,
141 cycles_completed: 0,
142 cycles_remaining: 2,
143 },
144 bhtune_db::models::SampleQuality::Good,
145 )
146 .await
147 .unwrap();
148
149 (pool, run.id)
150 }
151
152 #[tokio::test]
156 async fn samples_to_bytes_csv_matches_the_cli_export_shape() {
157 let (pool, run_id) = pool_with_one_sample().await;
158 let samples = TuneSampleRow::list_for_run(&pool, run_id).await.unwrap();
159 let bytes = samples_to_bytes(&samples, ExportFormat::Csv).unwrap();
160 let text = String::from_utf8(bytes).unwrap();
161 let mut lines = text.lines();
162 assert_eq!(
163 lines.next().unwrap(),
164 "tick,time,pv,pv_quality,hysteresis,mv_value_current,mv_sign_next_step,counter_all_switches,cycles_completed,cycles_remaining"
165 );
166 assert!(lines.next().unwrap().starts_with("0,"));
167 }
168
169 #[tokio::test]
170 async fn samples_to_bytes_json_matches_the_cli_export_shape() {
171 let (pool, run_id) = pool_with_one_sample().await;
172 let samples = TuneSampleRow::list_for_run(&pool, run_id).await.unwrap();
173 let bytes = samples_to_bytes(&samples, ExportFormat::Json).unwrap();
174 let parsed: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
175 assert_eq!(parsed[0]["pv"], 50.0);
176 assert_eq!(parsed[0]["tick"], 0);
177 }
178
179 #[tokio::test]
180 async fn exports_csv_to_stdout_when_no_output_path_given() {
181 let (pool, run_id) = pool_with_one_sample().await;
182 run(
183 &pool,
184 ExportArgs {
185 run_id,
186 format: ExportFormat::Csv,
187 output: None,
188 },
189 )
190 .await
191 .unwrap();
192 }
193
194 #[tokio::test]
195 async fn exports_csv_to_a_file_with_correct_columns() {
196 let (pool, run_id) = pool_with_one_sample().await;
197 let dir = tempfile::tempdir().unwrap();
198 let path = dir.path().join("run.csv");
199 run(
200 &pool,
201 ExportArgs {
202 run_id,
203 format: ExportFormat::Csv,
204 output: Some(path.clone()),
205 },
206 )
207 .await
208 .unwrap();
209
210 let contents = std::fs::read_to_string(&path).unwrap();
211 let mut lines = contents.lines();
212 let header = lines.next().unwrap();
213 assert_eq!(
214 header,
215 "tick,time,pv,pv_quality,hysteresis,mv_value_current,mv_sign_next_step,counter_all_switches,cycles_completed,cycles_remaining"
216 );
217 let data = lines.next().unwrap();
218 let fields: Vec<&str> = data.split(',').collect();
219 assert_eq!(fields[0], "0"); assert_eq!(fields[2], "50.0"); assert_eq!(fields[3], "good"); assert_eq!(fields[5], "50.0"); }
224
225 #[tokio::test]
226 async fn exports_json_to_a_file() {
227 let (pool, run_id) = pool_with_one_sample().await;
228 let dir = tempfile::tempdir().unwrap();
229 let path = dir.path().join("run.json");
230 run(
231 &pool,
232 ExportArgs {
233 run_id,
234 format: ExportFormat::Json,
235 output: Some(path.clone()),
236 },
237 )
238 .await
239 .unwrap();
240
241 let contents = std::fs::read_to_string(&path).unwrap();
242 let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
243 assert_eq!(parsed[0]["pv"], 50.0);
244 }
245
246 #[tokio::test]
247 async fn errors_for_a_run_with_no_samples() {
248 let pool = bhtune_db::connect_in_memory().await.unwrap();
249 let err = run(
250 &pool,
251 ExportArgs {
252 run_id: 999,
253 format: ExportFormat::Csv,
254 output: None,
255 },
256 )
257 .await
258 .unwrap_err();
259 assert!(err.to_string().contains("999"));
260 }
261
262 #[tokio::test]
263 async fn reports_the_destination_when_writing_an_export_fails() {
264 let (pool, run_id) = pool_with_one_sample().await;
265 let err = run(
266 &pool,
267 ExportArgs {
268 run_id,
269 format: ExportFormat::Csv,
270 output: Some(".".into()),
271 },
272 )
273 .await
274 .unwrap_err();
275
276 assert!(err.to_string().contains("failed to write '.'"));
277 }
278}