diff --git a/modmesh/__init__.py b/modmesh/__init__.py index e1280690..f47d6c9b 100644 --- a/modmesh/__init__.py +++ b/modmesh/__init__.py @@ -41,7 +41,7 @@ from . import system # noqa: F401 from . import testing # noqa: F401 from . import toggle # noqa: F401 - +from . import track # noqa: F401 clinfo = core.ProcessInfo.instance.command_line diff --git a/modmesh/track/__init__.py b/modmesh/track/__init__.py index f7f6e340..ffec5330 100644 --- a/modmesh/track/__init__.py +++ b/modmesh/track/__init__.py @@ -34,6 +34,7 @@ __all__ = [ "dataset", + "dataframe", ] # vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: diff --git a/modmesh/track/dataframe.py b/modmesh/track/dataframe.py new file mode 100644 index 00000000..e8844012 --- /dev/null +++ b/modmesh/track/dataframe.py @@ -0,0 +1,194 @@ +# Copyright (c) 2024, Zong-han, Xie +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# - Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +import os +import contextlib +import numpy as np + +from modmesh import SimpleArrayUint64, SimpleArrayFloat64 + +all = ['DataFrame'] + + +class DataFrame(object): + + def __init__(self): + self._init_members() + + def _init_members(self): + self._columns = list() + self._index_data = None + self._index_name = None + self._data = list() + + def read_from_text_file( + self, + fname, + delimiter=',', + timestamp_in_file=True, + timestamp_column=None + ): + """ + Generate dataframe from a text file. + + :param fname: path to the text file. + :type fname: str | Iterable[str] | io.StringIO + :param delimiter: delimiter. + :type delimiter: str + :param timestamp_in_file: If the text file containing index column, + data in this column expected to be integer. + :type timestamp_in_file: bool + :prarm timestamp_column: Column which stores timestamp data. + :type timestamp_column: str + :return: None + """ + + if isinstance(fname, str): + if not os.path.exists(fname): + raise Exception("Text file '{}' does not exist".format(fname)) + fid = open(fname, 'rt') + fid_ctx = contextlib.closing(fid) + else: + fid = fname + fid_ctx = contextlib.nullcontext(fid) + + with fid_ctx: + fhd = iter(fid) + + idx_col_num = 0 if timestamp_in_file else None + + table_header = [ + x.strip() for x in next(fhd).strip().split(delimiter) + ] + nd_arr = np.genfromtxt(fhd, delimiter=delimiter) + + self._init_members() + + if timestamp_in_file: + if timestamp_column in table_header: + idx_col_num = table_header.index(timestamp_column) + self._index_data = SimpleArrayUint64( + array=nd_arr[:, idx_col_num].astype(np.uint64) + ) + self._index_name = table_header[idx_col_num] + else: + self._index_data = SimpleArrayUint64( + array=np.arange(nd_arr.shape[0]).astype(np.uint64) + ) + self._index_name = "Index" + + self._columns = table_header + if idx_col_num is not None: + self._columns.pop(idx_col_num) + + for i in range(nd_arr.shape[1]): + if i != idx_col_num: + self._data.append( + SimpleArrayFloat64(array=nd_arr[:, i].copy()) + ) + + def __getitem__(self, name): + if name not in self._columns: + raise Exception("Column '{}' does not exist".format(name)) + return self._data[self._columns.index(name)].ndarray + + @property + def columns(self): + return self._columns + + @property + def shape(self): + return (self._index_data.ndarray.shape[0], len(self._data)) + + @property + def index(self): + return self._index_data.ndarray + + def sort(self, columns=None, index_column=None, inplace=True): + """ + Sort the dataframe along the given index column + + :param columns: column names required in reordered DataFrame + :type columns: Option[List[str]] + :param index_column: column name treated as the index, if None is + given, sort along the index + :type index_column: Option[str] + :param inplace: flag indicates whether to sort inplace or out-of-place + :type inplace: bool + :return: sorted DataFrame (return self if inplace is set to + True) + """ + + if index_column is None and self._index_data is None: + raise ValueError("DataFrame: data frame has no index, " + "please provide index column") + + index_data = self._index_data if ( + index_column is None + ) else self._data[self._columns.index(index_column)] + indices = index_data.argsort() + + if inplace: + for i, col in enumerate(self._data): + self._data[i] = col.take_along_axis(indices) + + if self._index_data is not None: + self._index_data = self._index_data.take_along_axis(indices) + + return self + else: + if columns is None: + columns = [] + + ret = DataFrame() + ret._index_name = self._index_name + ret._index_data = self._index_data.take_along_axis(indices) + + for name in columns: + if name not in self._columns: + raise ValueError("Column '{}' does not exist".format(name)) + idx = self._columns.index(name) + new_col = self._data[idx].take_along_axis(indices) + ret._columns.append(name) + ret._data.append(new_col) + + return ret + + def sort_by_index(self, columns=None, inplace=True): + """ + Sort the dataframe along the index column + + :param columns: column names required in reordered DataFrame + :type columns: List[str] + :param inplace: flag indicates whether to sort inplace or out-of-place + :type inplace: bool + :return: sorted DataFrame (return self if inplace is set to + True) + """ + return self.sort(columns=columns, index_column=None, inplace=inplace) + +# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: diff --git a/modmesh/track/dataset.py b/modmesh/track/dataset.py index 93adea06..2ad2c13c 100644 --- a/modmesh/track/dataset.py +++ b/modmesh/track/dataset.py @@ -25,30 +25,146 @@ # EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """ -Download, extract and preprocess nasa flight dataset. +Download, extract, and load the NASA flight dataset. """ +import dataclasses import json import ssl +import pathlib import urllib.request import zipfile -from pathlib import Path +from . import dataframe + +__all__ = ["NasaDataset", "EventReference"] + + +@dataclasses.dataclass(frozen=True) +class _EventDataView: + """ + Lazy view of one row in a source-specific dataframe. + + :ivar dataframe: Source dataframe backing the row view. + :vartype dataframe: DataFrame + :ivar row: Row index in ``dataframe``. + :vartype row: int + """ + dataframe: 'dataframe.DataFrame' = dataclasses.field( + repr=False, + compare=False, + ) + row: int + + def __getitem__(self, column): + """ + Return the value of one column in the referenced row. + + :param column: Column name in the source dataframe. + :type column: str + :return: Scalar value stored at ``column`` and ``row``. + :rtype: object + """ + value = self.dataframe[column][self.row] + return value + + def to_dict(self): + """ + Materialize the referenced row as a dictionary. + + :return: Mapping from column name to row value. + :rtype: dict[str, object] + """ + return { + column: self[column] + for column in self.dataframe.columns + } + + def __repr__(self): + """ + Return a dictionary-like representation of the referenced row. + + :return: String form of the materialized row dictionary. + :rtype: str + """ + return repr(self.to_dict()) + + +@dataclasses.dataclass(frozen=True) +class EventReference: + """ + Reference one timestamped row in a source-specific dataset. + + :ivar dataset: Parent dataset that owns the referenced row. + :vartype dataset: NasaDataset + :ivar timestamp: Event timestamp in nanoseconds. + :vartype timestamp: int + :ivar source: Source dataset name. + :vartype source: str + :ivar row: Row index in the source dataframe. + :vartype row: int + """ + dataset: "NasaDataset" = dataclasses.field(repr=False, compare=False) + timestamp: int + source: str + row: int + + @property + def data(self): + """ + Return a lazy view of the original row data. + + :return: Lazy row view backed by the source dataframe. + :rtype: _EventDataView + """ + return _EventDataView(self.dataset.dataframes[self.source], self.row) class NasaDataset: """ - Helper for downloading, extracting NASA files. + Helper for downloading, extracting, and loading NASA files. + + :ivar url: NASA API endpoint returning a presigned download URL. + :vartype url: str + :ivar download_dir: Local directory used for downloaded + and extracted files. + :vartype download_dir: pathlib.Path + :ivar filename: Dataset zip filename. + :vartype filename: str + :ivar imu_csv: Path to the IMU CSV file. + :vartype imu_csv: pathlib.Path or str + :ivar lidar_csv: Path to the lidar CSV file. + :vartype lidar_csv: pathlib.Path or str + :ivar gt_csv: Path to the ground-truth CSV file. + :vartype gt_csv: pathlib.Path or str + :ivar dataframes: Loaded source dataframes keyed by source name. + :vartype dataframes: dict[str, DataFrame] + :ivar events: Timestamp-ordered event references. + :vartype events: list[EventReference] """ def __init__(self, url, filename): """ Initialize download/load configuration. + + :param url: NASA API endpoint returning the presigned download URL. + :type url: str + :param filename: Name of the downloaded zip archive. + :type filename: str + :return: None + :rtype: None """ self.url = url - self.download_dir = Path.cwd() / ".cache" / "download" + self.download_dir = pathlib.Path.cwd() / ".cache" / "download" self.filename = filename + self.csv_dir = self.download_dir / "Flight1_Catered_Dataset-20201013" + self.csv_dir /= "Data" + self.imu_csv = self.csv_dir / "dlc.csv" + self.lidar_csv = self.csv_dir / "commercial_lidar.csv" + self.gt_csv = self.csv_dir / "truth.csv" + self.events: list[EventReference] = [] + self.dataframes: dict[str, dataframe.DataFrame] = {} def download(self): """ @@ -57,7 +173,7 @@ def download(self): :return: ``None``. :rtype: None """ - file_path = Path(self.download_dir / self.filename) + file_path = pathlib.Path(self.download_dir / self.filename) if file_path.exists(): print(f"{file_path} exists,skip download.") return @@ -106,6 +222,81 @@ def _download_hook(self, block_num, block_size, total_size): bar = "#" * filled + "-" * (width - filled) print(f"\rDownloading [{bar}] {ratio:6.2%}", end="", flush=True) + def load(self): + """ + Load all source datasets and build the timestamp timeline. + + :return: None + :rtype: None + """ + self.dataframes["imu"] = self._load_dataframe(self.imu_csv) + self.dataframes["lidar"] = self._load_dataframe(self.lidar_csv) + self.dataframes["ground_truth"] = self._load_dataframe(self.gt_csv) + self._rebuild_timeline() + + def _load_dataframe(self, path): + """ + Load one CSV file into a time-series dataframe. + + :param path: Path to a source CSV file. + :type path: pathlib.Path or str + :return: Loaded dataframe for the source file. + :rtype: DataFrame + """ + tsdf = dataframe.DataFrame() + tsdf.read_from_text_file( + path, + delimiter=",", + timestamp_column="TIME_NANOSECONDS_TAI", + ) + return tsdf + + def _rebuild_timeline(self): + """ + Rebuild the timestamp timeline from loaded source dataframes. + + :return: None + :rtype: None + """ + timeline_map: dict[int, list[EventReference]] = {} + for source, df in self.dataframes.items(): + for row_index, timestamp in enumerate(df.index): + timestamp = int(timestamp) + timeline_map.setdefault(timestamp, []).append( + EventReference( + dataset=self, + timestamp=timestamp, + source=source, + row=row_index, + ) + ) + + self.events = [ + ref + for timestamp in sorted(timeline_map) + for ref in timeline_map[timestamp] + ] + + def __len__(self): + """ + Return the number of events in the timeline. + + :return: Number of loaded events. + :rtype: int + """ + return len(self.events) + + def __getitem__(self, idx): + """ + Return the event reference at ``idx``. + + :param idx: Event index. + :type idx: int + :return: Event reference at the specified index. + :rtype: EventReference + """ + return self.events[idx] + def main(): ssl._create_default_https_context = ssl._create_stdlib_context @@ -119,3 +310,5 @@ def main(): if __name__ == "__main__": main() + +# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: diff --git a/setup.py b/setup.py index f1734e57..59562751 100644 --- a/setup.py +++ b/setup.py @@ -71,6 +71,7 @@ def main(): 'modmesh.pilot.airfoil', 'modmesh.plot', 'modmesh.profiling', + 'modmesh.track', ], ext_modules=[CMakeExtension("_modmesh")], cmdclass={'build_ext': cmake_build_ext}, diff --git a/tests/test_timeseries_dataframe.py b/tests/test_timeseries_dataframe.py index 498fe7c6..f83a2b47 100644 --- a/tests/test_timeseries_dataframe.py +++ b/tests/test_timeseries_dataframe.py @@ -24,172 +24,13 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import os -import contextlib -from io import StringIO -import unittest +from io import StringIO import numpy as np +import unittest -from modmesh import SimpleArrayUint64, SimpleArrayFloat64 - - -class TimeSeriesDataFrame(object): - - def __init__(self): - self._init_members() - - def _init_members(self): - self._columns = list() - self._index_data = None - self._index_name = None - self._data = list() - - def read_from_text_file( - self, - fname, - delimiter=',', - timestamp_in_file=True, - timestamp_column=None - ): - """ - Generate dataframe from a text file. - - :param fname: path to the text file. - :type fname: str | Iterable[str] | io.StringIO - :param delimiter: delimiter. - :type delimiter: str - :param timestamp_in_file: If the text file containing index column, - data in this column expected to be integer. - :type timestamp_in_file: bool - :prarm timestamp_column: Column which stores timestamp data. - :type timestamp_column: str - :return: None - """ - - if isinstance(fname, str): - if not os.path.exists(fname): - raise Exception("Text file '{}' does not exist".format(fname)) - fid = open(fname, 'rt') - fid_ctx = contextlib.closing(fid) - else: - fid = fname - fid_ctx = contextlib.nullcontext(fid) - - with fid_ctx: - fhd = iter(fid) - - idx_col_num = 0 if timestamp_in_file else None - - table_header = [ - x.strip() for x in next(fhd).strip().split(delimiter) - ] - nd_arr = np.genfromtxt(fhd, delimiter=delimiter) - - self._init_members() - - if timestamp_in_file: - if timestamp_column in table_header: - idx_col_num = table_header.index(timestamp_column) - self._index_data = SimpleArrayUint64( - array=nd_arr[:, idx_col_num].astype(np.uint64) - ) - self._index_name = table_header[idx_col_num] - else: - self._index_data = SimpleArrayUint64( - array=np.arange(nd_arr.shape[0]).astype(np.uint64) - ) - self._index_name = "Index" - - self._columns = table_header - if idx_col_num is not None: - self._columns.pop(idx_col_num) - - for i in range(nd_arr.shape[1]): - if i != idx_col_num: - self._data.append( - SimpleArrayFloat64(array=nd_arr[:, i].copy()) - ) - - def __getitem__(self, name): - if name not in self._columns: - raise Exception("Column '{}' does not exist".format(name)) - return self._data[self._columns.index(name)].ndarray - - @property - def columns(self): - return self._columns - - @property - def shape(self): - return (self._index_data.ndarray.shape[0], len(self._data)) - - @property - def index(self): - return self._index_data.ndarray - - def sort(self, columns=None, index_column=None, inplace=True): - """ - Sort the dataframe along the given index column - - :param columns: column names required in reordered TimeSeriesDataFrame - :type columns: Option[List[str]] - :param index_column: column name treated as the index, if None is - given, sort along the index - :type index_column: Option[str] - :param inplace: flag indicates whether to sort inplace or out-of-place - :type inplace: bool - :return: sorted TimeSeriesDataFrame (return self if inplace is set to - True) - """ - - if index_column is None and self._index_data is None: - raise ValueError("TimeSeriesDataFrame: data frame has no index, " - "please provide index column") - - index_data = self._index_data if ( - index_column is None - ) else self._data[self._columns.index(index_column)] - indices = index_data.argsort() - - if inplace: - for i, col in enumerate(self._data): - self._data[i] = col.take_along_axis(indices) - - if self._index_data is not None: - self._index_data = self._index_data.take_along_axis(indices) - - return self - else: - if columns is None: - columns = [] - - ret = TimeSeriesDataFrame() - ret._index_name = self._index_name - ret._index_data = self._index_data.take_along_axis(indices) - - for name in columns: - if name not in self._columns: - raise ValueError("Column '{}' does not exist".format(name)) - idx = self._columns.index(name) - new_col = self._data[idx].take_along_axis(indices) - ret._columns.append(name) - ret._data.append(new_col) - - return ret - - def sort_by_index(self, columns=None, inplace=True): - """ - Sort the dataframe along the index column - :param columns: column names required in reordered TimeSeriesDataFrame - :type columns: List[str] - :param inplace: flag indicates whether to sort inplace or out-of-place - :type inplace: bool - :return: sorted TimeSeriesDataFrame (return self if inplace is set to - True) - """ - return self.sort(columns=columns, index_column=None, inplace=inplace) +from modmesh.track import dataframe class TimeSeriesDataFrameTC(unittest.TestCase): @@ -235,7 +76,7 @@ class TimeSeriesDataFrameTC(unittest.TestCase): """ def test_read_from_text_file_basic(self): - tsdf = TimeSeriesDataFrame() + tsdf = dataframe.DataFrame() tsdf.read_from_text_file(StringIO(self.dlc_data)) self.assertEqual(tsdf._columns, self.col_sol) @@ -268,17 +109,17 @@ def test_read_from_text_file_basic(self): self.assertEqual(tsdf._index_name, 'Index') def test_dataframe_attribute_columns(self): - tsdf = TimeSeriesDataFrame() + tsdf = dataframe.DataFrame() tsdf.read_from_text_file(StringIO(self.dlc_data)) self.assertEqual(tsdf.columns, self.col_sol) def test_dataframe_attribute_shape(self): - tsdf = TimeSeriesDataFrame() + tsdf = dataframe.DataFrame() tsdf.read_from_text_file(StringIO(self.dlc_data)) self.assertEqual(tsdf.shape, (10, 3)) def test_dataframe_attribute_index(self): - tsdf = TimeSeriesDataFrame() + tsdf = dataframe.DataFrame() tsdf.read_from_text_file(StringIO(self.dlc_data)) nd_arr = np.genfromtxt(StringIO(self.dlc_data), delimiter=',')[1:] @@ -288,7 +129,7 @@ def test_dataframe_attribute_index(self): ) def test_dataframe_get_column(self): - tsdf = TimeSeriesDataFrame() + tsdf = dataframe.DataFrame() tsdf.read_from_text_file(StringIO(self.dlc_data)) col_data = tsdf['DELTA_VEL[1]'] @@ -298,7 +139,7 @@ def test_dataframe_get_column(self): self.assertEqual(list(col_data), list(nd_arr[:, 1])) def test_dataframe_sort(self): - tsdf = TimeSeriesDataFrame() + tsdf = dataframe.DataFrame() tsdf.read_from_text_file(StringIO(self.unsorted_dlc_data)) # Test out-of-place sort diff --git a/tests/test_track.py b/tests/test_track.py new file mode 100644 index 00000000..2b3fb926 --- /dev/null +++ b/tests/test_track.py @@ -0,0 +1,196 @@ +# Copyright (c) 2024, Zong-han, Xie +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# - Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# - Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +import pathlib +import tempfile +import unittest + +from modmesh.track import dataset + + +class NasaDatasetTC(unittest.TestCase): + + def _write_csv(self, directory, name, content): + path = pathlib.Path(directory) / name + path.write_text(content, encoding="utf-8") + return str(path) + + def test_load_flight_dataset(self): + with tempfile.TemporaryDirectory() as tmpdir: + imu_data = ( + "TIME_NANOSECONDS_TAI ,DATA_DELTA_VEL[1] ," + "DATA_DELTA_VEL[2] ,DATA_DELTA_VEL[3] ," + "DATA_DELTA_ANGLE[1] ,DATA_DELTA_ANGLE[2] ," + "DATA_DELTA_ANGLE[3]\n" + "30,3.0,3.1,3.2,30.0,30.1,30.2\n" + "10,1.0,1.1,1.2,10.0,10.1,10.2\n" + ) + imu_csv = self._write_csv( + tmpdir, + "dlc.csv", + imu_data, + ) + lidar_data = ( + "TIME_NANOSECONDS_TAI ,OMPS_Range_M[1] ," + "OMPS_Range_M[2] ,OMPS_Range_M[3] ,OMPS_Range_M[4] ," + "OMPS_DopplerSpeed_MpS[1] ,OMPS_DopplerSpeed_MpS[2] ," + "OMPS_DopplerSpeed_MpS[3] ,OMPS_DopplerSpeed_MpS[4]\n" + "40,4.0,4.1,4.2,4.3,40.0,40.1,40.2,40.3\n" + "20,2.0,2.1,2.2,2.3,20.0,20.1,20.2,20.3\n" + ) + lidar_csv = self._write_csv( + tmpdir, + "commercial_lidar.csv", + lidar_data, + ) + gt_data = ( + "TIME_NANOSECONDS_TAI ,truth_pos_CON_ECEF_ECEF_M[1] ," + "truth_pos_CON_ECEF_ECEF_M[2] ," + "truth_pos_CON_ECEF_ECEF_M[3] ," + "truth_vel_CON_ECEF_ECEF_MpS[1] ," + "truth_vel_CON_ECEF_ECEF_MpS[2] ," + "truth_vel_CON_ECEF_ECEF_MpS[3] ," + "truth_quat_CON2ECEF[1] ,truth_quat_CON2ECEF[2] ," + "truth_quat_CON2ECEF[3] ,truth_quat_CON2ECEF[4]\n" + "25,2.5,2.6,2.7,25.0,25.1,25.2,0.25,0.26,0.27,0.28\n" + "5,0.5,0.6,0.7,5.0,5.1,5.2,0.05,0.06,0.07,0.08\n" + ) + gt_csv = self._write_csv( + tmpdir, + "truth.csv", + gt_data, + ) + + dst = dataset.NasaDataset(url="", filename="") + dst.imu_csv = imu_csv + dst.lidar_csv = lidar_csv + dst.gt_csv = gt_csv + + dst.load() + + self.assertEqual(len(dst.events), 6) + + expected_events = [ + ( + 5, + "ground_truth", + { + "truth_pos_CON_ECEF_ECEF_M[1]": 0.5, + "truth_pos_CON_ECEF_ECEF_M[2]": 0.6, + "truth_pos_CON_ECEF_ECEF_M[3]": 0.7, + "truth_vel_CON_ECEF_ECEF_MpS[1]": 5.0, + "truth_vel_CON_ECEF_ECEF_MpS[2]": 5.1, + "truth_vel_CON_ECEF_ECEF_MpS[3]": 5.2, + "truth_quat_CON2ECEF[1]": 0.05, + "truth_quat_CON2ECEF[2]": 0.06, + "truth_quat_CON2ECEF[3]": 0.07, + "truth_quat_CON2ECEF[4]": 0.08, + }, + ), + ( + 10, + "imu", + { + "DATA_DELTA_VEL[1]": 1.0, + "DATA_DELTA_VEL[2]": 1.1, + "DATA_DELTA_VEL[3]": 1.2, + "DATA_DELTA_ANGLE[1]": 10.0, + "DATA_DELTA_ANGLE[2]": 10.1, + "DATA_DELTA_ANGLE[3]": 10.2, + }, + ), + ( + 20, + "lidar", + { + "OMPS_Range_M[1]": 2.0, + "OMPS_Range_M[2]": 2.1, + "OMPS_Range_M[3]": 2.2, + "OMPS_Range_M[4]": 2.3, + "OMPS_DopplerSpeed_MpS[1]": 20.0, + "OMPS_DopplerSpeed_MpS[2]": 20.1, + "OMPS_DopplerSpeed_MpS[3]": 20.2, + "OMPS_DopplerSpeed_MpS[4]": 20.3, + }, + ), + ( + 25, + "ground_truth", + { + "truth_pos_CON_ECEF_ECEF_M[1]": 2.5, + "truth_pos_CON_ECEF_ECEF_M[2]": 2.6, + "truth_pos_CON_ECEF_ECEF_M[3]": 2.7, + "truth_vel_CON_ECEF_ECEF_MpS[1]": 25.0, + "truth_vel_CON_ECEF_ECEF_MpS[2]": 25.1, + "truth_vel_CON_ECEF_ECEF_MpS[3]": 25.2, + "truth_quat_CON2ECEF[1]": 0.25, + "truth_quat_CON2ECEF[2]": 0.26, + "truth_quat_CON2ECEF[3]": 0.27, + "truth_quat_CON2ECEF[4]": 0.28, + }, + ), + ( + 30, + "imu", + { + "DATA_DELTA_VEL[1]": 3.0, + "DATA_DELTA_VEL[2]": 3.1, + "DATA_DELTA_VEL[3]": 3.2, + "DATA_DELTA_ANGLE[1]": 30.0, + "DATA_DELTA_ANGLE[2]": 30.1, + "DATA_DELTA_ANGLE[3]": 30.2, + }, + ), + ( + 40, + "lidar", + { + "OMPS_Range_M[1]": 4.0, + "OMPS_Range_M[2]": 4.1, + "OMPS_Range_M[3]": 4.2, + "OMPS_Range_M[4]": 4.3, + "OMPS_DopplerSpeed_MpS[1]": 40.0, + "OMPS_DopplerSpeed_MpS[2]": 40.1, + "OMPS_DopplerSpeed_MpS[3]": 40.2, + "OMPS_DopplerSpeed_MpS[4]": 40.3, + }, + ), + ] + + for event, (timestamp, source, data) in zip( + dst.events, + expected_events, + ): + self.assertEqual(event.timestamp, timestamp) + self.assertEqual(event.source, source) + self.assertIsInstance(event.row, int) + self.assertEqual(event.data.to_dict(), data) + for key, value in data.items(): + self.assertEqual(event.data[key], value) + + +# vim: set ff=unix fenc=utf8 et sw=4 ts=4 sts=4: