Skip to main content

nyx_space/od/msr/trackingdata/
io_parquet.rs

1/*
2    Nyx, blazing fast astrodynamics
3    Copyright (C) 2018-onwards Christopher Rabotin <christopher.rabotin@gmail.com>
4
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU Affero General Public License as published
7    by the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9
10    This program is distributed in the hope that it will be useful,
11    but WITHOUT ANY WARRANTY; without even the implied warranty of
12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13    GNU Affero General Public License for more details.
14
15    You should have received a copy of the GNU Affero General Public License
16    along with this program.  If not, see <https://www.gnu.org/licenses/>.
17*/
18use 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    /// Loads a tracking arc from its serialization in parquet.
48    ///
49    /// Warning: no metadata is read from the parquet file, even that written to it by Nyx.
50    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        // Check the schema
61        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        // We can safely unwrap the columns since we've checked for their existance just before.
109        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            // Set the measurements in the tracking arc
208            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    /// Store this tracking arc to a parquet file.
297    pub fn to_parquet_simple<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, InputOutputError> {
298        self.to_parquet(path, ExportCfg::default())
299    }
300
301    /// Store this tracking arc to a parquet file, with optional metadata and a timestamp appended to the filename.
302    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        // Build the measurement iterator
325
326        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        // Build the schema
341        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        // Build the schema
367        let schema = Arc::new(Schema::new(hdrs));
368        let mut record: Vec<Arc<dyn Array>> = Vec::new();
369
370        // Build all of the records
371
372        // Epochs
373        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        // Device names
381        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        // Measurement data, column by column
388        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        // Rejected flag
401        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        // Serialize all of the devices and add that to the parquet file too.
425        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        // Return the path this was written to
463        Ok(path_buf)
464    }
465}