1use crate::io::ExportCfg;
20use crate::io::watermark::prj_name_ver;
21use crate::io::{InputOutputError, StdIOSnafu};
22use crate::od::ground_station::DopplerConfig;
23use crate::od::msr::{IntegrationRef, Measurement, MeasurementType};
24use anise::constants::SPEED_OF_LIGHT_KM_S;
25use hifitime::efmt::{Format, Formatter};
26use hifitime::{Duration, Epoch, TimeScale, Unit};
27use indexmap::{IndexMap, IndexSet};
28use log::{error, info, warn};
29use snafu::ResultExt;
30use std::collections::HashMap;
31use std::fs::File;
32use std::io::Write;
33use std::io::{BufRead, BufReader, BufWriter};
34use std::path::{Path, PathBuf};
35use std::str::FromStr;
36
37use super::TrackingDataArc;
38
39impl TrackingDataArc {
40 pub fn from_tdm<P: AsRef<Path>>(
88 path: P,
89 aliases: Option<HashMap<String, String>>,
90 ) -> Result<Self, InputOutputError> {
91 let file = File::open(&path).context(StdIOSnafu {
92 action: "opening CCSDS TDM file for tracking arc",
93 })?;
94
95 let source = path.as_ref().to_path_buf().display().to_string();
96 info!("parsing CCSDS TDM {source}");
97
98 let mut measurements = Vec::new();
99 let mut metadata = HashMap::new();
100
101 let reader = BufReader::new(file);
102
103 let mut in_data_section = false;
104 let mut current_tracker = String::new();
105 let mut time_system = TimeScale::UTC;
106 let mut has_freq_data = false;
107 let mut msr_divider = 1.0;
108 let mut integration_ref = None;
109 let mut integration_time = None;
110
111 for line in reader.lines() {
112 let line = line.context(StdIOSnafu {
113 action: "reading CCSDS TDM file",
114 })?;
115 let line = line.trim();
116
117 if line == "DATA_START" {
118 in_data_section = true;
119 continue;
120 } else if line == "DATA_STOP" {
121 in_data_section = false;
122 }
123
124 if !in_data_section {
125 if line.starts_with("PARTICIPANT_1") {
126 current_tracker = line.split('=').nth(1).unwrap_or("").trim().to_string();
127 if let Some(aliases) = &aliases
129 && let Some(alias) = aliases.get(¤t_tracker)
130 {
131 current_tracker = alias.clone();
132 }
133 } else if line.starts_with("TIME_SYSTEM") {
134 let ts = line.split('=').nth(1).unwrap_or("UTC").trim();
135 if let Ok(ts) = TimeScale::from_str(ts) {
137 time_system = ts;
138 } else {
139 return Err(InputOutputError::UnsupportedData {
140 which: format!("time scale `{ts}` not supported"),
141 });
142 }
143 } else if line.starts_with("PATH") {
144 match line.split(",").count() {
145 2 => msr_divider = 1.0,
146 3 => msr_divider = 2.0,
147 cnt => {
148 return Err(InputOutputError::UnsupportedData {
149 which: format!(
150 "found {cnt} paths in TDM, only 1 or 2 are supported"
151 ),
152 });
153 }
154 }
155 } else if line.starts_with("INTEGRATION_REF") {
156 let value = line.split('=').nth(1).unwrap_or("");
157 integration_ref = Some(IntegrationRef::from_str(value)?);
158 } else if line.starts_with("INTEGRATION_TIME")
159 || line.starts_with("INTEGRATION_INTERVAL")
160 {
161 let value = line.split('=').nth(1).unwrap_or("").trim();
162 let dur = if let Ok(val) = value.parse::<f64>() {
163 Unit::Second * val
164 } else if let Ok(dur) = Duration::from_str(value) {
165 dur
166 } else {
167 return Err(InputOutputError::UnsupportedData {
168 which: format!("invalid integration time `{value}`"),
169 });
170 };
171 integration_time = Some(dur);
172 }
173
174 let mut splt = line.split('=');
175 if let Some(keyword) = splt.nth(0) {
176 if let Some(value) = splt.nth(0) {
178 metadata.insert(keyword.trim().to_string(), value.trim().to_string());
179 }
180 }
181
182 continue;
183 }
184
185 if let Some((mtype, epoch, value)) = parse_measurement_line(line, time_system)? {
186 let effective_divider = if mtype.may_be_two_way() {
188 msr_divider
189 } else {
190 if [
191 MeasurementType::ReceiveFrequency,
192 MeasurementType::TransmitFrequency,
193 MeasurementType::TransmitFrequencyRate,
194 ]
195 .contains(&mtype)
196 {
197 has_freq_data = true;
198 }
199 1.0
200 };
201
202 let mut scaled_value = value;
203 if mtype == MeasurementType::Range {
204 if let Some(range_units) = metadata.get("RANGE_UNITS") {
205 scaled_value =
206 convert_range_units(value, range_units.as_str(), effective_divider)?;
207 } else {
208 return Err(InputOutputError::MissingData {
209 which: "RANGE_UNITS not specified in metadata for RANGE measurement"
210 .to_string(),
211 });
212 }
213 } else {
214 scaled_value /= effective_divider;
215 }
216
217 let is_concurrent = measurements.last().is_some_and(|last: &Measurement| {
220 last.epoch == epoch && last.tracker == current_tracker
221 });
222
223 let doppler_config = match (integration_time, integration_ref) {
224 (Some(time), Some(reference)) => Some(DopplerConfig {
225 integration_time: time,
226 integration_ref: reference,
227 }),
228 (Some(time), None) => Some(DopplerConfig {
229 integration_time: time,
230 integration_ref: IntegrationRef::default(),
231 }),
232 (None, Some(reference)) => Some(DopplerConfig {
233 integration_time: DopplerConfig::default().integration_time,
234 integration_ref: reference,
235 }),
236 (None, None) => None,
237 };
238
239 if is_concurrent {
240 let last = measurements.last_mut().unwrap();
241 last.data.insert(mtype, scaled_value);
242 if last.doppler_config.is_none() {
243 last.doppler_config = doppler_config;
244 }
245 } else {
246 let mut data = IndexMap::new();
248 data.insert(mtype, scaled_value);
249
250 measurements.push(Measurement {
251 tracker: current_tracker.clone(),
252 epoch,
253 data,
254 rejected: false,
255 doppler_config,
256 });
257 }
258 }
259 }
260
261 let mut turnaround_ratio = None;
262 let drop_freq_data;
263 if has_freq_data {
264 if let Some(ta_num_str) = metadata.get("TURNAROUND_NUMERATOR") {
266 if let Some(ta_denom_str) = metadata.get("TURNAROUND_DENOMINATOR") {
267 if let Ok(ta_num) = ta_num_str.parse::<i32>() {
268 if let Ok(ta_denom) = ta_denom_str.parse::<i32>() {
269 turnaround_ratio = Some(f64::from(ta_num) / f64::from(ta_denom));
271 info!("turn-around ratio is {ta_num}/{ta_denom}");
272 drop_freq_data = false;
273 } else {
274 error!(
275 "turn-around denominator `{ta_denom_str}` is not a valid integer"
276 );
277 drop_freq_data = true;
278 }
279 } else {
280 error!("turn-around numerator `{ta_num_str}` is not a valid integer");
281 drop_freq_data = true;
282 }
283 } else {
284 error!(
285 "required turn-around denominator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
286 );
287 drop_freq_data = true;
288 }
289 } else {
290 error!(
291 "required turn-around numerator missing from metadata -- dropping ALL RECEIVE/TRANSMIT data"
292 );
293 drop_freq_data = true;
294 }
295 } else {
296 drop_freq_data = true;
297 }
298
299 let corrections_applied = if let Some(corr_flag) = metadata.get("CORRECTIONS_APPLIED") {
300 match corr_flag.trim().to_lowercase().as_str() {
301 "no" => false,
302 "yes" => true,
303 _ => {
304 warn!("invalid CORRECTIONS_APPLIED `{corr_flag}`");
305 false
306 }
307 }
308 } else {
309 false
310 };
311
312 let mut freq_types = IndexSet::new();
315 freq_types.insert(MeasurementType::ReceiveFrequency);
316 freq_types.insert(MeasurementType::TransmitFrequency);
317 freq_types.insert(MeasurementType::TransmitFrequencyRate);
318
319 let mut latest_transmit_freq = None;
320 let mut latest_transmit_epoch = None;
321 let mut latest_transmit_rate = 0.0;
322
323 let mut all_applied_corrections = IndexSet::new();
324
325 for measurement in &mut measurements {
326 let epoch = measurement.epoch;
327 if !corrections_applied {
329 for msr_type in [
330 MeasurementType::Range,
331 MeasurementType::Doppler,
332 MeasurementType::Azimuth,
333 MeasurementType::Elevation,
334 MeasurementType::ReceiveFrequency,
335 MeasurementType::TransmitFrequency,
336 MeasurementType::TransmitFrequencyRate,
337 ] {
338 let kws = match msr_type {
339 MeasurementType::Doppler => vec![
340 "CORRECTION_DOPPLER".to_string(),
341 "CORRECTION_DOPPLER_INTEGRATED".to_string(),
342 "CORRECTION_DOPPLER_INSTANTANEOUS".to_string(),
343 ],
344 _ => vec![format!("CORRECTION_{}", msr_type.ccsds_tdm_name())],
345 };
346
347 for kw in kws {
348 if let Some(correction_str) = metadata.get(&kw) {
349 if let Ok(correction) = correction_str.parse::<f64>() {
350 let scaled_correction = match msr_type {
351 MeasurementType::Range => {
352 if let Some(range_units) = metadata.get("RANGE_UNITS") {
353 match convert_range_units(
354 correction,
355 range_units,
356 msr_divider,
357 ) {
358 Ok(sc) => sc,
359 Err(e) => {
360 warn!(
361 "failed to convert CORRECTION_RANGE: {e}"
362 );
363 continue;
364 }
365 }
366 } else {
367 warn!(
368 "RANGE_UNITS missing when converting CORRECTION_RANGE"
369 );
370 correction / msr_divider
371 }
372 }
373 MeasurementType::Doppler => correction / msr_divider,
374 _ => correction,
375 };
376
377 measurement.correct(msr_type, scaled_correction);
378 all_applied_corrections.insert(msr_type);
379 } else {
380 warn!("invalid correction value for {kw}");
381 }
382 }
383 }
384 }
385 }
386
387 if drop_freq_data {
388 for freq in &freq_types {
389 measurement.data.swap_remove(freq);
390 }
391 continue;
392 }
393
394 if let Some(rate) = measurement
396 .data
397 .get(&MeasurementType::TransmitFrequencyRate)
398 {
399 if let (Some(last_f), Some(last_e)) = (latest_transmit_freq, latest_transmit_epoch)
400 {
401 let dt: Duration = epoch - last_e;
402 latest_transmit_freq = Some(last_f + latest_transmit_rate * dt.to_seconds());
403 }
404 latest_transmit_epoch = Some(epoch);
405 latest_transmit_rate = *rate;
406 }
407
408 if let Some(freq) = measurement.data.get(&MeasurementType::TransmitFrequency) {
409 latest_transmit_freq = Some(*freq);
410 latest_transmit_epoch = Some(epoch);
411 }
412
413 if !measurement
414 .data
415 .contains_key(&MeasurementType::ReceiveFrequency)
416 {
417 for freq in &freq_types {
420 measurement.data.swap_remove(freq);
421 }
422 continue;
423 }
424
425 if latest_transmit_freq.is_none() {
427 warn!(
428 "receive frequency found at {epoch} but no transmit frequency was ever set, ignoring"
429 );
430 for freq in &freq_types {
431 measurement.data.swap_remove(freq);
432 }
433 continue;
434 }
435
436 let dt: Duration = epoch - latest_transmit_epoch.unwrap();
437 let transmit_freq_hz =
438 latest_transmit_freq.unwrap() + latest_transmit_rate * dt.to_seconds();
439
440 let receive_freq_hz = *measurement
441 .data
442 .get(&MeasurementType::ReceiveFrequency)
443 .unwrap();
444
445 let doppler_shift_hz = transmit_freq_hz * turnaround_ratio.unwrap() - receive_freq_hz;
447 let rho_dot_km_s = (doppler_shift_hz * SPEED_OF_LIGHT_KM_S)
449 / (2.0 * transmit_freq_hz * turnaround_ratio.unwrap());
450
451 for freq in &freq_types {
453 measurement.data.swap_remove(freq);
454 }
455 measurement
456 .data
457 .insert(MeasurementType::Doppler, rho_dot_km_s);
458 }
459
460 if !all_applied_corrections.is_empty() {
461 info!("applied corrections for {all_applied_corrections:?}");
462 }
463
464 let moduli = if let Some(range_modulus) = metadata.get("RANGE_MODULUS") {
465 if let Ok(value) = range_modulus.parse::<f64>() {
466 if value > 0.0 {
467 let mut modulos = IndexMap::new();
468 modulos.insert(MeasurementType::Range, value);
469 Some(modulos)
471 } else {
472 None
474 }
475 } else {
476 warn!("could not parse RANGE_MODULUS of `{range_modulus}` as a double");
477 None
478 }
479 } else {
480 None
481 };
482
483 measurements.retain(|m| !m.data.is_empty());
485
486 let mut trk = Self {
487 measurements,
488 source: Some(source),
489 moduli,
490 force_reject: false,
491 };
492
493 trk.sort();
495
496 if trk.unique_types().is_empty() {
497 Err(InputOutputError::EmptyDataset {
498 action: "CCSDS TDM file",
499 })
500 } else {
501 Ok(trk)
502 }
503 }
504
505 pub fn to_tdm_file<P: AsRef<Path>>(
507 mut self,
508 spacecraft_name: String,
509 aliases: Option<HashMap<String, String>>,
510 path: P,
511 cfg: ExportCfg,
512 ) -> Result<PathBuf, InputOutputError> {
513 if self.is_empty() {
514 return Err(InputOutputError::MissingData {
515 which: " - empty tracking data cannot be exported to TDM".to_string(),
516 });
517 }
518
519 if let Some(start_epoch) = cfg.start_epoch {
521 if let Some(end_epoch) = cfg.end_epoch {
522 self = self.filter_by_epoch(start_epoch..end_epoch);
523 } else {
524 self = self.filter_by_epoch(start_epoch..);
525 }
526 } else if let Some(end_epoch) = cfg.end_epoch {
527 self = self.filter_by_epoch(..end_epoch);
528 }
529
530 let tick = Epoch::now().unwrap();
531 info!("Exporting tracking data to CCSDS TDM file...");
532
533 let path_buf = cfg.actual_path(path);
535
536 let metadata = cfg.metadata.unwrap_or_default();
537
538 let file = File::create(&path_buf).context(StdIOSnafu {
539 action: "creating CCSDS TDM file for tracking arc",
540 })?;
541 let mut writer = BufWriter::new(file);
542
543 let err_hdlr = |source| InputOutputError::StdIOError {
544 source,
545 action: "writing data to TDM file",
546 };
547
548 let iso8601_no_ts = Format::from_str("%Y-%m-%dT%H:%M:%S.%f").unwrap();
550
551 writeln!(writer, "CCSDS_TDM_VERS = 2.0").map_err(err_hdlr)?;
553 writeln!(
554 writer,
555 "\nCOMMENT Build by {} -- https://nyxspace.com",
556 prj_name_ver()
557 )
558 .map_err(err_hdlr)?;
559 writeln!(
560 writer,
561 "COMMENT Nyx Space provided under the AGPL v3 open source license -- https://nyxspace.com/pricing\n"
562 )
563 .map_err(err_hdlr)?;
564 writeln!(
565 writer,
566 "CREATION_DATE = {}",
567 Formatter::new(Epoch::now().unwrap(), iso8601_no_ts)
568 )
569 .map_err(err_hdlr)?;
570 writeln!(
571 writer,
572 "ORIGINATOR = {}\n",
573 metadata
574 .get("originator")
575 .unwrap_or(&"Nyx Space".to_string())
576 )
577 .map_err(err_hdlr)?;
578
579 let trackers = self.unique_aliases();
582
583 for tracker in trackers {
584 let tracker_data = self.clone().filter_by_tracker(tracker.clone());
585
586 let types = tracker_data.unique_types();
587
588 let two_way_types = types
589 .iter()
590 .filter(|msr_type| msr_type.may_be_two_way())
591 .copied()
592 .collect::<Vec<_>>();
593
594 let one_way_types = types
595 .iter()
596 .filter(|msr_type| !msr_type.may_be_two_way())
597 .copied()
598 .collect::<Vec<_>>();
599
600 for (tno, types) in [two_way_types, one_way_types].iter().enumerate() {
602 writeln!(writer, "META_START").map_err(err_hdlr)?;
603 writeln!(writer, "\tTIME_SYSTEM = UTC").map_err(err_hdlr)?;
604 writeln!(
605 writer,
606 "\tSTART_TIME = {}",
607 Formatter::new(tracker_data.start_epoch().unwrap(), iso8601_no_ts)
608 )
609 .map_err(err_hdlr)?;
610 writeln!(
611 writer,
612 "\tSTOP_TIME = {}",
613 Formatter::new(tracker_data.end_epoch().unwrap(), iso8601_no_ts)
614 )
615 .map_err(err_hdlr)?;
616
617 let multiplier = if tno == 0 {
618 writeln!(writer, "\tPATH = 1,2,1").map_err(err_hdlr)?;
619 2.0
620 } else {
621 writeln!(writer, "\tPATH = 1,2").map_err(err_hdlr)?;
622 1.0
623 };
624
625 writeln!(
626 writer,
627 "\tPARTICIPANT_1 = {}",
628 if let Some(aliases) = &aliases {
629 if let Some(alias) = aliases.get(&tracker) {
630 alias
631 } else {
632 &tracker
633 }
634 } else {
635 &tracker
636 }
637 )
638 .map_err(err_hdlr)?;
639
640 writeln!(writer, "\tPARTICIPANT_2 = {spacecraft_name}").map_err(err_hdlr)?;
641
642 writeln!(writer, "\tMODE = SEQUENTIAL").map_err(err_hdlr)?;
643
644 for (k, v) in &metadata {
646 let k_upper = k.to_uppercase();
647 if k != "originator"
648 && (!types.contains(&MeasurementType::Doppler)
649 || (k_upper != "INTEGRATION_INTERVAL" && k_upper != "INTEGRATION_REF"))
650 {
651 writeln!(writer, "\t{k} = {v}").map_err(err_hdlr)?;
652 }
653 }
654
655 if types.contains(&MeasurementType::Doppler)
656 && let Some(doppler_cfg) = tracker_data
657 .measurements
658 .iter()
659 .find_map(|m| m.doppler_config)
660 {
661 writeln!(
662 writer,
663 "\tINTEGRATION_INTERVAL = {:.6}",
664 doppler_cfg.integration_time.to_seconds()
665 )
666 .map_err(err_hdlr)?;
667 let ref_str = match doppler_cfg.integration_ref {
668 IntegrationRef::Start => "START",
669 IntegrationRef::Middle => "MIDDLE",
670 IntegrationRef::End => "END",
671 };
672 writeln!(writer, "\tINTEGRATION_REF = {ref_str}").map_err(err_hdlr)?;
673 }
674
675 if types.contains(&MeasurementType::Range) {
676 writeln!(writer, "\tRANGE_UNITS = km").map_err(err_hdlr)?;
677
678 if let Some(moduli) = &self.moduli
679 && let Some(range_modulus) = moduli.get(&MeasurementType::Range)
680 {
681 writeln!(writer, "\tRANGE_MODULUS = {range_modulus:E}")
682 .map_err(err_hdlr)?;
683 }
684 }
685
686 if types.contains(&MeasurementType::Azimuth)
687 || types.contains(&MeasurementType::Elevation)
688 {
689 writeln!(writer, "\tANGLE_TYPE = AZEL").map_err(err_hdlr)?;
690 }
691
692 writeln!(writer, "META_STOP\n").map_err(err_hdlr)?;
693
694 writeln!(writer, "DATA_START").map_err(err_hdlr)?;
696
697 for m in &tracker_data.measurements {
699 for (mtype, value) in &m.data {
700 if !types.contains(mtype) {
701 continue;
702 }
703
704 writeln!(
705 writer,
706 "\t{:<20} = {:<23}\t{:.12}",
707 mtype.ccsds_tdm_name(),
708 Formatter::new(m.epoch, iso8601_no_ts),
709 value * multiplier
710 )
711 .map_err(err_hdlr)?;
712 }
713 }
714
715 writeln!(writer, "DATA_STOP\n").map_err(err_hdlr)?;
716 }
717 }
718
719 #[allow(clippy::writeln_empty_string)]
720 writeln!(writer, "").map_err(err_hdlr)?;
721
722 let tock_time = Epoch::now().unwrap() - tick;
724 info!("CCSDS TDM written to {} in {tock_time}", path_buf.display());
725 Ok(path_buf)
726 }
727}
728
729fn convert_range_units(
730 value: f64,
731 range_units: &str,
732 divider: f64,
733) -> Result<f64, InputOutputError> {
734 match range_units {
735 "km" => Ok(value / divider),
736 "RU" => Err(InputOutputError::UnsupportedData {
737 which: "RANGE_UNITS `RU` requires mission-specific conversion and is not currently supported".to_string(),
738 }),
739 "s" => Ok((value * SPEED_OF_LIGHT_KM_S) / divider),
740 "m" => {
741 warn!(
742 "RANGE_UNITS in TDM file is `m`, which is not CCSDS compliant. Proceeding with conversion to km."
743 );
744 Ok((value / 1000.0) / divider)
745 }
746 "ms" => {
747 warn!(
748 "RANGE_UNITS in TDM file is `ms`, which is not CCSDS compliant. Proceeding with conversion to km."
749 );
750 Ok((value * 1e-3 * SPEED_OF_LIGHT_KM_S) / divider)
751 }
752 "us" => {
753 warn!(
754 "RANGE_UNITS in TDM file is `us`, which is not CCSDS compliant. Proceeding with conversion to km."
755 );
756 Ok((value * 1e-6 * SPEED_OF_LIGHT_KM_S) / divider)
757 }
758 "ns" | "NANOSEC" => {
759 warn!(
760 "RANGE_UNITS in TDM file is `ns`, which is not CCSDS compliant. Proceeding with conversion to km."
761 );
762 Ok((value * 1e-9 * SPEED_OF_LIGHT_KM_S) / divider)
763 }
764 _ => Err(InputOutputError::UnsupportedData {
765 which: format!("unsupported RANGE_UNITS `{range_units}`"),
766 }),
767 }
768}
769
770fn parse_measurement_line(
771 line: &str,
772 time_system: TimeScale,
773) -> Result<Option<(MeasurementType, Epoch, f64)>, InputOutputError> {
774 let parts: Vec<&str> = line.split('=').collect();
775 if parts.len() != 2 {
776 return Ok(None);
777 }
778
779 let (mtype_str, data) = (parts[0].trim(), parts[1].trim());
780 let mtype = match mtype_str {
781 "RANGE" => MeasurementType::Range,
782 "DOPPLER_INSTANTANEOUS" | "DOPPLER_INTEGRATED" => MeasurementType::Doppler,
783 "ANGLE_1" => MeasurementType::Azimuth,
784 "ANGLE_2" => MeasurementType::Elevation,
785 "RECEIVE_FREQ" | "RECEIVE_FREQ_1" | "RECEIVE_FREQ_2" | "RECEIVE_FREQ_3"
786 | "RECEIVE_FREQ_4" | "RECEIVE_FREQ_5" => MeasurementType::ReceiveFrequency,
787 "TRANSMIT_FREQ" | "TRANSMIT_FREQ_1" | "TRANSMIT_FREQ_2" | "TRANSMIT_FREQ_3"
788 | "TRANSMIT_FREQ_4" | "TRANSMIT_FREQ_5" => MeasurementType::TransmitFrequency,
789 "TRANSMIT_FREQ_RATE"
790 | "TRANSMIT_FREQ_RATE_1"
791 | "TRANSMIT_FREQ_RATE_2"
792 | "TRANSMIT_FREQ_RATE_3"
793 | "TRANSMIT_FREQ_RATE_4"
794 | "TRANSMIT_FREQ_RATE_5" => MeasurementType::TransmitFrequencyRate,
795 _ => {
796 return Err(InputOutputError::UnsupportedData {
797 which: mtype_str.to_string(),
798 });
799 }
800 };
801
802 let data_parts: Vec<&str> = data.split_whitespace().collect();
803 if data_parts.len() != 2 {
804 return Ok(None);
805 }
806
807 let epoch =
808 Epoch::from_gregorian_str(&format!("{} {time_system}", data_parts[0])).map_err(|e| {
809 InputOutputError::Inconsistency {
810 msg: format!("{e} when parsing epoch"),
811 }
812 })?;
813
814 let value = data_parts[1]
815 .parse::<f64>()
816 .map_err(|e| InputOutputError::UnsupportedData {
817 which: format!("`{}` is not a float: {e}", data_parts[1]),
818 })?;
819
820 Ok(Some((mtype, epoch, value)))
821}