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::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    /// Loads a tracking arc from its serialization in parquet.
47    ///
48    /// Warning: no metadata is read from the parquet file, even that written to it by Nyx.
49    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        // Check the schema
60        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        // We can safely unwrap the columns since we've checked for their existance just before.
108        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            // Set the measurements in the tracking arc
210            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    /// Store this tracking arc to a parquet file.
299    pub fn to_parquet_simple<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, InputOutputError> {
300        self.to_parquet(path, ExportCfg::default())
301    }
302
303    /// Store this tracking arc to a parquet file, with optional metadata and a timestamp appended to the filename.
304    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        // Build the measurement iterator
327
328        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        // Build the schema
343        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        // Build the schema
369        let schema = Arc::new(Schema::new(hdrs));
370        let mut record: Vec<Arc<dyn Array>> = Vec::new();
371
372        // Build all of the records
373
374        // Epochs
375        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        // Device names
383        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        // Measurement data, column by column
390        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        // Rejected flag
403        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        // Serialize all of the devices and add that to the parquet file too.
427        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        // Return the path this was written to
465        Ok(path_buf)
466    }
467}