test_parser.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import struct
  2. import warnings
  3. from pathlib import Path
  4. import pandas as pd
  5. import pytest
  6. from mlog_tool.constants import MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, MLOG_END_MSG
  7. from mlog_tool.models import BusFrame, LogHeader, ParsedLog, PlotTraceSpec
  8. from mlog_tool.parser import MLogParser
  9. from mlog_tool.plotting import LogPlotter
  10. from mlog_tool.utils import normalize_field_name
  11. def _pack_fixed_string(value: str, length: int) -> bytes:
  12. raw = value.encode("utf-8")[:length]
  13. return raw + (b"\x00" * (length - len(raw)))
  14. def _write_synthetic_log(path: Path, message_count: int = 2048) -> None:
  15. max_name_len = 16
  16. max_desc_len = 24
  17. max_model_info_len = 24
  18. payload = bytearray()
  19. payload.extend(struct.pack("<H I H H H", 1, 123456789, max_name_len, max_desc_len, max_model_info_len))
  20. payload.extend(_pack_fixed_string("synthetic", max_desc_len))
  21. payload.extend(_pack_fixed_string("unit-test", max_model_info_len))
  22. payload.extend(struct.pack("<B", 1))
  23. payload.extend(_pack_fixed_string("IMU0", max_name_len))
  24. payload.extend(struct.pack("<B B", 7, 3))
  25. payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
  26. payload.extend(struct.pack("<H H", 5, 1))
  27. payload.extend(_pack_fixed_string("ax", max_name_len))
  28. payload.extend(struct.pack("<H H", 6, 1))
  29. payload.extend(_pack_fixed_string("gyro", max_name_len))
  30. payload.extend(struct.pack("<H H", 6, 3))
  31. payload.extend(struct.pack("<B", 0))
  32. for index in range(message_count):
  33. payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 7]))
  34. payload.extend(struct.pack("<I", index * 10))
  35. payload.extend(struct.pack("<f", index * 0.5))
  36. payload.extend(struct.pack("<fff", index + 0.1, index + 0.2, index + 0.3))
  37. payload.extend(bytes([MLOG_END_MSG]))
  38. path.write_bytes(payload)
  39. def _write_wide_synthetic_log(path: Path, field_count: int = 128, message_count: int = 32) -> None:
  40. max_name_len = 16
  41. max_desc_len = 24
  42. max_model_info_len = 24
  43. payload = bytearray()
  44. payload.extend(struct.pack("<H I H H H", 1, 123456789, max_name_len, max_desc_len, max_model_info_len))
  45. payload.extend(_pack_fixed_string("wide", max_desc_len))
  46. payload.extend(_pack_fixed_string("unit-test", max_model_info_len))
  47. payload.extend(struct.pack("<B", 1))
  48. payload.extend(_pack_fixed_string("WIDE", max_name_len))
  49. payload.extend(struct.pack("<B B", 9, field_count + 1))
  50. payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
  51. payload.extend(struct.pack("<H H", 5, 1))
  52. for index in range(field_count):
  53. payload.extend(_pack_fixed_string(f"f{index}", max_name_len))
  54. payload.extend(struct.pack("<H H", 6, 1))
  55. payload.extend(struct.pack("<B", 0))
  56. for row_index in range(message_count):
  57. payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 9]))
  58. payload.extend(struct.pack("<I", row_index * 20))
  59. for field_index in range(field_count):
  60. payload.extend(struct.pack("<f", row_index + field_index / 10))
  61. payload.extend(bytes([MLOG_END_MSG]))
  62. path.write_bytes(payload)
  63. def _write_offset_bus_log(path: Path) -> None:
  64. max_name_len = 16
  65. max_desc_len = 24
  66. max_model_info_len = 24
  67. payload = bytearray()
  68. payload.extend(struct.pack("<H I H H H", 1, 123456789, max_name_len, max_desc_len, max_model_info_len))
  69. payload.extend(_pack_fixed_string("offset", max_desc_len))
  70. payload.extend(_pack_fixed_string("unit-test", max_model_info_len))
  71. payload.extend(struct.pack("<B", 2))
  72. payload.extend(_pack_fixed_string("IMU0", max_name_len))
  73. payload.extend(struct.pack("<B B", 7, 2))
  74. payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
  75. payload.extend(struct.pack("<H H", 5, 1))
  76. payload.extend(_pack_fixed_string("ax", max_name_len))
  77. payload.extend(struct.pack("<H H", 6, 1))
  78. payload.extend(_pack_fixed_string("GPS", max_name_len))
  79. payload.extend(struct.pack("<B B", 8, 2))
  80. payload.extend(_pack_fixed_string("timestamp_ms", max_name_len))
  81. payload.extend(struct.pack("<H H", 5, 1))
  82. payload.extend(_pack_fixed_string("ve", max_name_len))
  83. payload.extend(struct.pack("<H H", 6, 1))
  84. payload.extend(struct.pack("<B", 0))
  85. for timestamp_ms, value in [(1000, 1.0), (1010, 2.0)]:
  86. payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 7]))
  87. payload.extend(struct.pack("<I", timestamp_ms))
  88. payload.extend(struct.pack("<f", value))
  89. payload.extend(bytes([MLOG_END_MSG]))
  90. for timestamp_ms, value in [(1300, 3.0), (1310, 4.0)]:
  91. payload.extend(bytes([MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, 8]))
  92. payload.extend(struct.pack("<I", timestamp_ms))
  93. payload.extend(struct.pack("<f", value))
  94. payload.extend(bytes([MLOG_END_MSG]))
  95. path.write_bytes(payload)
  96. def test_parser_type_exists() -> None:
  97. parser = MLogParser()
  98. assert parser is not None
  99. def test_message_markers_match_matlab_definition() -> None:
  100. assert MLOG_BEGIN_MSG1 == 0x92
  101. assert MLOG_BEGIN_MSG2 == 0x05
  102. assert MLOG_END_MSG == 0x26
  103. def test_normalize_field_name() -> None:
  104. assert normalize_field_name("timestamp_ms") == "timestamp_ms"
  105. assert normalize_field_name("gyro[0]") == "gyro_0"
  106. assert normalize_field_name(" att roll ") == "att_roll"
  107. def test_parsed_log_parameter_lookup_returns_none_when_missing() -> None:
  108. header = LogHeader(
  109. version=1,
  110. timestamp=0,
  111. max_name_len=16,
  112. max_desc_len=16,
  113. max_model_info_len=16,
  114. description="demo",
  115. model_info="demo",
  116. )
  117. parsed_log = ParsedLog(source_path=Path("demo.bin"), header=header)
  118. assert parsed_log.get_parameter("INS", "acc_bias") is None
  119. def test_parser_can_parse_sample_log() -> None:
  120. parser = MLogParser()
  121. sample_log = Path(__file__).resolve().parents[2] / "mlog16.bin"
  122. if not sample_log.exists():
  123. pytest.skip("sample log mlog16.bin is not present in this workspace")
  124. parsed_log = parser.parse(sample_log)
  125. assert parsed_log.header.version >= 1
  126. assert len(parsed_log.header.buses) == 48
  127. assert len(parsed_log.header.parameter_groups) == 9
  128. assert len(parsed_log.buses) == 19
  129. assert "IMU0" in parsed_log.buses
  130. assert "GPS" in parsed_log.buses
  131. assert "time_s" in parsed_log.buses["IMU0"].frame.columns
  132. assert parsed_log.get_parameter_group("SYSTEM") is not None
  133. assert parsed_log.get_parameter("SYSTEM", "MAV_SYS_ID") == 2
  134. def test_plotter_can_compare_traces_across_subplots() -> None:
  135. header = LogHeader(
  136. version=1,
  137. timestamp=0,
  138. max_name_len=16,
  139. max_desc_len=16,
  140. max_model_info_len=16,
  141. description="demo",
  142. model_info="demo",
  143. )
  144. frame = pd.DataFrame(
  145. {
  146. "time_s": [0.0, 0.1, 0.2],
  147. "ax": [1.0, 2.0, 3.0],
  148. "ay": [4.0, 5.0, 6.0],
  149. }
  150. )
  151. parsed_log = ParsedLog(
  152. source_path=Path("demo.bin"),
  153. header=header,
  154. buses={
  155. "IMU0": BusFrame(name="IMU0", frame=frame, timestamp_field="time_s"),
  156. "GPS": BusFrame(name="GPS", frame=frame.rename(columns={"ax": "ve", "ay": "vn"}), timestamp_field="time_s"),
  157. },
  158. )
  159. figure = LogPlotter().build_comparison_figure(
  160. parsed_log,
  161. [
  162. [
  163. PlotTraceSpec(bus_name="IMU0", field_name="ax", subplot_index=0, label="IMU0.ax"),
  164. PlotTraceSpec(bus_name="GPS", field_name="ve", subplot_index=0, label="GPS.ve"),
  165. ],
  166. [PlotTraceSpec(bus_name="IMU0", field_name="ay", subplot_index=1, label="IMU0.ay")],
  167. ],
  168. subplot_count=2,
  169. )
  170. assert len(figure.axes) == 2
  171. assert figure.axes[0].get_title() == "Subplot 1"
  172. assert figure.axes[1].get_title() == "Subplot 2"
  173. assert figure.axes[0].get_shared_x_axes().joined(figure.axes[0], figure.axes[1])
  174. assert len(figure._mlog_cursor_payload) == 2
  175. assert len(figure._mlog_cursor_payload[0]["traces"]) == 2
  176. assert len(figure._mlog_cursor_payload[1]["traces"]) == 1
  177. def test_plotter_can_build_four_subplot_grid() -> None:
  178. header = LogHeader(
  179. version=1,
  180. timestamp=0,
  181. max_name_len=16,
  182. max_desc_len=16,
  183. max_model_info_len=16,
  184. description="demo",
  185. model_info="demo",
  186. )
  187. frame = pd.DataFrame(
  188. {
  189. "time_s": [0.0, 0.1, 0.2],
  190. "ax": [1.0, 2.0, 3.0],
  191. "ay": [4.0, 5.0, 6.0],
  192. "az": [7.0, 8.0, 9.0],
  193. "gx": [0.1, 0.2, 0.3],
  194. }
  195. )
  196. parsed_log = ParsedLog(
  197. source_path=Path("demo.bin"),
  198. header=header,
  199. buses={"IMU0": BusFrame(name="IMU0", frame=frame, timestamp_field="time_s")},
  200. )
  201. figure = LogPlotter().build_comparison_figure(
  202. parsed_log,
  203. [
  204. [PlotTraceSpec(bus_name="IMU0", field_name="ax", subplot_index=0, label="IMU0.ax")],
  205. [PlotTraceSpec(bus_name="IMU0", field_name="ay", subplot_index=1, label="IMU0.ay")],
  206. [PlotTraceSpec(bus_name="IMU0", field_name="az", subplot_index=2, label="IMU0.az")],
  207. [PlotTraceSpec(bus_name="IMU0", field_name="gx", subplot_index=3, label="IMU0.gx")],
  208. ],
  209. subplot_count=4,
  210. )
  211. assert len(figure.axes) == 4
  212. assert [axis.get_title() for axis in figure.axes] == [
  213. "Subplot 1",
  214. "Subplot 2",
  215. "Subplot 3",
  216. "Subplot 4",
  217. ]
  218. assert figure.axes[0].get_subplotspec().rowspan.start == 0
  219. assert figure.axes[1].get_subplotspec().rowspan.start == 0
  220. assert figure.axes[2].get_subplotspec().rowspan.start == 1
  221. assert figure.axes[3].get_subplotspec().rowspan.start == 1
  222. assert figure.axes[0].get_subplotspec().colspan.start == 0
  223. assert figure.axes[1].get_subplotspec().colspan.start == 1
  224. assert figure.axes[2].get_subplotspec().colspan.start == 0
  225. assert figure.axes[3].get_subplotspec().colspan.start == 1
  226. assert figure.axes[0].get_shared_x_axes().joined(figure.axes[0], figure.axes[3])
  227. assert len(figure._mlog_cursor_payload) == 4
  228. def test_parser_handles_many_messages_without_losing_column_shape(tmp_path: Path) -> None:
  229. parser = MLogParser()
  230. log_path = tmp_path / "synthetic_mlog.bin"
  231. _write_synthetic_log(log_path, message_count=4096)
  232. parsed_log = parser.parse(log_path)
  233. imu = parsed_log.get_bus("IMU0")
  234. assert imu is not None
  235. assert imu.timestamp_field == "time_s"
  236. assert list(imu.frame.columns[:6]) == ["timestamp_ms", "time_s", "ax", "gyro_0", "gyro_1", "gyro_2"]
  237. assert len(imu.frame) == 4096
  238. assert imu.frame.iloc[0]["time_s"] == 0.0
  239. assert imu.frame.iloc[-1]["timestamp_ms"] == 40950
  240. assert imu.frame.iloc[-1]["gyro_2"] == pytest.approx(4095.3)
  241. def test_parser_aligns_relative_time_to_global_first_timestamp(tmp_path: Path) -> None:
  242. parser = MLogParser()
  243. log_path = tmp_path / "offset_bus_mlog.bin"
  244. _write_offset_bus_log(log_path)
  245. parsed_log = parser.parse(log_path)
  246. imu = parsed_log.get_bus("IMU0")
  247. gps = parsed_log.get_bus("GPS")
  248. assert imu is not None
  249. assert gps is not None
  250. assert imu.timestamp_field == "time_s"
  251. assert gps.timestamp_field == "time_s"
  252. assert list(imu.frame["time_s"]) == pytest.approx([0.0, 0.01])
  253. assert list(gps.frame["time_s"]) == pytest.approx([0.3, 0.31])
  254. def test_parser_avoids_fragmentation_warning_when_adding_relative_time(tmp_path: Path) -> None:
  255. parser = MLogParser()
  256. log_path = tmp_path / "wide_synthetic_mlog.bin"
  257. _write_wide_synthetic_log(log_path, field_count=128, message_count=64)
  258. with warnings.catch_warnings():
  259. warnings.simplefilter("error", pd.errors.PerformanceWarning)
  260. parsed_log = parser.parse(log_path)
  261. wide_bus = parsed_log.get_bus("WIDE")
  262. assert wide_bus is not None
  263. assert wide_bus.timestamp_field == "time_s"
  264. assert list(wide_bus.frame.columns[:3]) == ["timestamp_ms", "time_s", "f0"]
  265. assert len(wide_bus.frame.columns) == 130
  266. assert wide_bus.frame.iloc[-1]["time_s"] == pytest.approx(1.26)