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