nyx_space/od/msr/trackingdata/
io_parquet.rs1use crate::io::parquet_string::AbstractStringArray;
19use crate::io::watermark::pq_writer;
20use crate::io::{ArrowSnafu, InputOutputError, MissingDataSnafu, ParquetSnafu, StdIOSnafu};
21use crate::io::{EmptyDatasetSnafu, ExportCfg};
22use crate::od::ground_station::DopplerConfig;
23use crate::od::msr::{IntegrationRef, Measurement, MeasurementType};
24use arrow::array::{Array, BooleanBuilder, Float64Builder, StringBuilder};
25use arrow::datatypes::{DataType, Field, Schema};
26use arrow::record_batch::RecordBatch;
27use arrow::{
28 array::{BooleanArray, Float64Array, PrimitiveArray},
29 datatypes,
30 record_batch::RecordBatchReader,
31};
32use hifitime::{Epoch, TimeScale, Unit};
33use indexmap::IndexMap;
34use log::{info, warn};
35use parquet::arrow::ArrowWriter;
36use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
37use snafu::{ResultExt, ensure};
38use std::collections::HashMap;
39use std::fs::File;
40use std::path::{Path, PathBuf};
41use std::str::FromStr;
42use std::sync::Arc;
43
44use super::TrackingDataArc;
45
46impl TrackingDataArc {
47 pub fn from_parquet<P: AsRef<Path>>(path: P) -> Result<Self, InputOutputError> {
51 let file = File::open(&path).context(StdIOSnafu {
52 action: "opening file for tracking arc",
53 })?;
54 let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
55
56 let reader = builder.build().context(ParquetSnafu {
57 action: "reading tracking arc",
58 })?;
59
60 let mut has_epoch = false;
62 let mut has_tracking_dev = false;
63 let mut range_avail = false;
64 let mut doppler_avail = false;
65 let mut az_avail = false;
66 let mut el_avail = false;
67 let mut rejected_avail = false;
68 let mut integration_ref_avail = false;
69 let mut integration_time_avail = false;
70 for field in &reader.schema().fields {
71 match field.name().as_str() {
72 "Epoch (UTC)" => has_epoch = true,
73 "Tracking device" => has_tracking_dev = true,
74 "Range (km)" => range_avail = true,
75 "Doppler (km/s)" => doppler_avail = true,
76 "Azimuth (deg)" => az_avail = true,
77 "Elevation (deg)" => el_avail = true,
78 "Rejected" => rejected_avail = true,
79 "Integration reference" => integration_ref_avail = true,
80 "Integration interval (s)" => integration_time_avail = true,
81 _ => {}
82 }
83 }
84
85 ensure!(
86 has_epoch,
87 MissingDataSnafu {
88 which: "Epoch (UTC)"
89 }
90 );
91
92 ensure!(
93 has_tracking_dev,
94 MissingDataSnafu {
95 which: "Tracking device"
96 }
97 );
98
99 ensure!(
100 range_avail || doppler_avail || az_avail || el_avail,
101 MissingDataSnafu {
102 which: "`Range (km)` or `Doppler (km/s)` or `Azimuth (deg)` or `Elevation (deg)`"
103 }
104 );
105
106 let mut measurements = Vec::new();
107
108 for maybe_batch in reader {
110 let batch = maybe_batch.context(ArrowSnafu {
111 action: "reading batch of tracking data",
112 })?;
113
114 let tracking_device_col = batch.column_by_name("Tracking device").unwrap();
115 let tracking_device =
116 AbstractStringArray::try_from(tracking_device_col).context(ArrowSnafu {
117 action: "downcasting `Tracking device`",
118 })?;
119
120 let epochs_col = batch.column_by_name("Epoch (UTC)").unwrap();
121 let epochs = AbstractStringArray::try_from(epochs_col).context(ArrowSnafu {
122 action: "downcasting `Epoch (UTC)`",
123 })?;
124
125 let range_data: Option<&PrimitiveArray<datatypes::Float64Type>> = if range_avail {
126 Some(
127 batch
128 .column_by_name("Range (km)")
129 .unwrap()
130 .as_any()
131 .downcast_ref::<Float64Array>()
132 .unwrap(),
133 )
134 } else {
135 None
136 };
137
138 let doppler_data: Option<&PrimitiveArray<datatypes::Float64Type>> = if doppler_avail {
139 Some(
140 batch
141 .column_by_name("Doppler (km/s)")
142 .unwrap()
143 .as_any()
144 .downcast_ref::<Float64Array>()
145 .unwrap(),
146 )
147 } else {
148 None
149 };
150
151 let azimuth_data: Option<&PrimitiveArray<datatypes::Float64Type>> = if az_avail {
152 Some(
153 batch
154 .column_by_name("Azimuth (deg)")
155 .unwrap()
156 .as_any()
157 .downcast_ref::<Float64Array>()
158 .unwrap(),
159 )
160 } else {
161 None
162 };
163
164 let elevation_data: Option<&PrimitiveArray<datatypes::Float64Type>> = if el_avail {
165 Some(
166 batch
167 .column_by_name("Elevation (deg)")
168 .unwrap()
169 .as_any()
170 .downcast_ref::<Float64Array>()
171 .unwrap(),
172 )
173 } else {
174 None
175 };
176
177 let rejected_data: Option<&BooleanArray> = if rejected_avail {
178 Some(
179 batch
180 .column_by_name("Rejected")
181 .unwrap()
182 .as_any()
183 .downcast_ref::<BooleanArray>()
184 .unwrap(),
185 )
186 } else {
187 None
188 };
189
190 let integration_ref_data: Option<AbstractStringArray> = if integration_ref_avail {
191 batch
192 .column_by_name("Integration reference")
193 .and_then(|col| AbstractStringArray::try_from(col).ok())
194 } else {
195 None
196 };
197
198 let integration_time_data: Option<&PrimitiveArray<datatypes::Float64Type>> =
199 if integration_time_avail {
200 batch
201 .column_by_name("Integration interval (s)")
202 .and_then(|col| col.as_any().downcast_ref::<Float64Array>())
203 } else {
204 None
205 };
206
207 for i in 0..batch.num_rows() {
209 let epoch = Epoch::from_gregorian_str(epochs.value(i)).map_err(|e| {
210 InputOutputError::Inconsistency {
211 msg: format!("{e} when parsing epoch"),
212 }
213 })?;
214
215 let rejected = if let Some(rej_data) = rejected_data {
216 rej_data.value(i)
217 } else {
218 false
219 };
220
221 let integration_ref = integration_ref_data.as_ref().and_then(|data| {
222 if data.is_null(i) {
223 None
224 } else {
225 IntegrationRef::from_str(data.value(i)).ok()
226 }
227 });
228
229 let integration_time = integration_time_data.and_then(|data| {
230 if data.is_null(i) {
231 None
232 } else {
233 Some(Unit::Second * data.value(i))
234 }
235 });
236
237 let doppler_config = match (integration_time, integration_ref) {
238 (Some(time), Some(reference)) => Some(DopplerConfig {
239 integration_time: time,
240 integration_ref: reference,
241 }),
242 (Some(time), None) => Some(DopplerConfig {
243 integration_time: time,
244 integration_ref: IntegrationRef::Middle,
245 }),
246 (None, Some(reference)) => Some(DopplerConfig {
247 integration_time: DopplerConfig::default().integration_time,
248 integration_ref: reference,
249 }),
250 (None, None) => None,
251 };
252
253 let mut measurement = Measurement {
254 epoch,
255 tracker: tracking_device.value(i).to_string(),
256 data: IndexMap::new(),
257 rejected,
258 doppler_config,
259 };
260
261 if range_avail {
262 measurement
263 .data
264 .insert(MeasurementType::Range, range_data.unwrap().value(i));
265 }
266
267 if doppler_avail {
268 measurement
269 .data
270 .insert(MeasurementType::Doppler, doppler_data.unwrap().value(i));
271 }
272
273 if az_avail {
274 measurement
275 .data
276 .insert(MeasurementType::Azimuth, azimuth_data.unwrap().value(i));
277 }
278
279 if el_avail {
280 measurement
281 .data
282 .insert(MeasurementType::Elevation, elevation_data.unwrap().value(i));
283 }
284
285 measurements.push(measurement);
286 }
287 }
288
289 Ok(Self {
290 measurements,
291 moduli: None,
292 source: Some(path.as_ref().to_path_buf().display().to_string()),
293 force_reject: false,
294 })
295 }
296 pub fn to_parquet_simple<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, InputOutputError> {
298 self.to_parquet(path, ExportCfg::default())
299 }
300
301 pub fn to_parquet<P: AsRef<Path>>(
303 &self,
304 path: P,
305 cfg: ExportCfg,
306 ) -> Result<PathBuf, InputOutputError> {
307 ensure!(
308 !self.is_empty(),
309 EmptyDatasetSnafu {
310 action: "tracking data arc to parquet"
311 }
312 );
313
314 let path_buf = cfg.actual_path(path);
315
316 if cfg.step.is_some() {
317 warn!("The `step` parameter in the export is not supported for tracking arcs.");
318 }
319
320 if cfg.fields.is_some() {
321 warn!("The `fields` parameter in the export is not supported for tracking arcs.");
322 }
323
324 let measurements =
327 if cfg.start_epoch.is_some() || cfg.end_epoch.is_some() || cfg.step.is_some() {
328 let start = cfg
329 .start_epoch
330 .unwrap_or_else(|| self.start_epoch().unwrap());
331 let end = cfg.end_epoch.unwrap_or_else(|| self.end_epoch().unwrap());
332
333 info!("Exporting measurements from {start} to {end}.");
334
335 self.clone().filter_by_epoch(start..end).measurements
336 } else {
337 self.measurements.clone()
338 };
339
340 let mut hdrs = vec![
342 Field::new("Epoch (UTC)", DataType::Utf8, false),
343 Field::new("Tracking device", DataType::Utf8, false),
344 ];
345
346 let msr_types = self.unique_types();
347 let mut msr_fields = msr_types
348 .iter()
349 .map(|msr_type| msr_type.to_field())
350 .collect::<Vec<Field>>();
351
352 hdrs.append(&mut msr_fields);
353
354 hdrs.push(Field::new("Rejected", DataType::Boolean, false));
355
356 let has_doppler_config = measurements.iter().any(|m| m.doppler_config.is_some());
357 if has_doppler_config {
358 hdrs.push(Field::new("Integration reference", DataType::Utf8, true));
359 hdrs.push(Field::new(
360 "Integration interval (s)",
361 DataType::Float64,
362 true,
363 ));
364 }
365
366 let schema = Arc::new(Schema::new(hdrs));
368 let mut record: Vec<Arc<dyn Array>> = Vec::new();
369
370 let mut utc_epoch = StringBuilder::new();
374 for msr in &measurements {
375 let epoch = msr.epoch;
376 utc_epoch.append_value(epoch.to_time_scale(TimeScale::UTC).to_isoformat());
377 }
378 record.push(Arc::new(utc_epoch.finish()));
379
380 let mut device_names = StringBuilder::new();
382 for m in &measurements {
383 device_names.append_value(m.tracker.clone());
384 }
385 record.push(Arc::new(device_names.finish()));
386
387 for msr_type in msr_types {
389 let mut data_builder = Float64Builder::new();
390
391 for m in &measurements {
392 match m.data.get(&msr_type) {
393 Some(value) => data_builder.append_value(*value),
394 None => data_builder.append_null(),
395 };
396 }
397 record.push(Arc::new(data_builder.finish()));
398 }
399
400 let mut rejected_builder = BooleanBuilder::new();
402 for m in &measurements {
403 rejected_builder.append_value(m.rejected);
404 }
405 record.push(Arc::new(rejected_builder.finish()));
406
407 if has_doppler_config {
408 let mut integration_ref_builder = StringBuilder::new();
409 let mut integration_time_builder = Float64Builder::new();
410
411 for m in &measurements {
412 if let Some(cfg) = &m.doppler_config {
413 integration_ref_builder.append_value(format!("{:?}", cfg.integration_ref));
414 integration_time_builder.append_value(cfg.integration_time.to_seconds());
415 } else {
416 integration_ref_builder.append_null();
417 integration_time_builder.append_null();
418 }
419 }
420 record.push(Arc::new(integration_ref_builder.finish()));
421 record.push(Arc::new(integration_time_builder.finish()));
422 }
423
424 let mut metadata = HashMap::new();
426 metadata.insert("Purpose".to_string(), "Tracking Arc Data".to_string());
427 if let Some(add_meta) = cfg.metadata {
428 for (k, v) in add_meta {
429 metadata.insert(k, v);
430 }
431 }
432
433 if let Some(modulos) = &self.moduli {
434 for (msr_type, v) in modulos {
435 metadata.insert(format!("MODULUS:{msr_type:?}"), v.to_string());
436 }
437 }
438
439 let props = pq_writer(Some(metadata));
440
441 let file = File::create(&path_buf).context(StdIOSnafu {
442 action: "creating tracking data arc file",
443 })?;
444
445 let mut writer =
446 ArrowWriter::try_new(file, schema.clone(), props).context(ParquetSnafu {
447 action: "creating tracking data arc writer",
448 })?;
449
450 let batch = RecordBatch::try_new(schema, record).context(ArrowSnafu {
451 action: "creating tracking data arc batch record",
452 })?;
453 writer.write(&batch).context(ParquetSnafu {
454 action: "writing tracking data arc batch",
455 })?;
456 writer.close().context(ParquetSnafu {
457 action: "closing tracking data arc file",
458 })?;
459
460 info!("Serialized {self} to {}", path_buf.display());
461
462 Ok(path_buf)
464 }
465}