blob: 1eba71606f5f3af9d0017347f197692ecbd5d651 [file] [log] [blame]
James Kuszmaul61a971f2020-01-01 15:06:18 -08001#!/usr/bin/python3
2# Sample usage:
3# bazel run -c opt //frc971/analysis:plot -- --logfile /tmp/log.fbs --config gyro.pb
4import argparse
5import json
6import os.path
7from pathlib import Path
8import sys
9
10from frc971.analysis.py_log_reader import LogReader
James Kuszmaul14dad032020-01-19 17:56:59 -080011from frc971.analysis.plot_config_pb2 import PlotConfig, Signal, Line
James Kuszmaul61a971f2020-01-01 15:06:18 -080012from google.protobuf import text_format
13
James Kuszmaul7a7fe472020-01-12 22:07:10 -080014import numpy as np
James Kuszmaul61a971f2020-01-01 15:06:18 -080015import matplotlib
16from matplotlib import pyplot as plt
17
18
19class Plotter:
20 def __init__(self, plot_config: PlotConfig, reader: LogReader):
21 self.config = plot_config
22 self.reader = reader
23 # Data streams, indexed by alias.
24 self.data = {}
25
26 def process_logfile(self):
27 aliases = set()
28 for channel in self.config.channel:
29 if channel.alias in aliases:
30 raise ValueError("Duplicate alias " + channel.alias)
31 aliases.add(channel.alias)
32 if not self.reader.subscribe(channel.name, channel.type):
33 raise ValueError("No such channel with name " + channel.name +
34 " and type " + channel.type)
35
36 self.reader.process()
37
38 for channel in self.config.channel:
39 self.data[channel.alias] = []
40 for message in self.reader.get_data_for_channel(
41 channel.name, channel.type):
42 valid_json = message[2].replace('nan', '"nan"')
43 parsed_json = json.loads(valid_json)
44 self.data[channel.alias].append((message[0], message[1],
45 parsed_json))
James Kuszmaul7a7fe472020-01-12 22:07:10 -080046 self.calculate_signals()
47
48 def calculate_imu_signals(self):
49 if 'IMU' in self.data:
50 entries = []
51 for entry in self.data['IMU']:
52 accel_x = 'accelerometer_x'
53 accel_y = 'accelerometer_y'
54 accel_z = 'accelerometer_z'
55 msg = entry[2]
56 new_msg = {}
57 if accel_x in msg and accel_y in msg and accel_x in msg:
58 total_acceleration = np.sqrt(
59 msg[accel_x]**2 + msg[accel_y]**2 + msg[accel_z]**2)
60 new_msg['total_acceleration'] = total_acceleration
James Kuszmaul14dad032020-01-19 17:56:59 -080061 timestamp = 'monotonic_timestamp_ns'
62 if timestamp in msg:
63 timestamp_sec = msg[timestamp] * 1e-9
64 new_msg['monotonic_timestamp_sec'] = timestamp_sec
James Kuszmaul7a7fe472020-01-12 22:07:10 -080065 entries.append((entry[0], entry[1], new_msg))
66 if 'CalcIMU' in self.data:
67 raise RuntimeError('CalcIMU is already a member of data.')
68 self.data['CalcIMU'] = entries
69
70 def calculate_signals(self):
71 """Calculate any derived signals for plotting.
72
73 See calculate_imu_signals for an example, but the basic idea is that
74 for any data that is read in from the logfile, we may want to calculate
75 some derived signals--possibly as simple as doing unit conversions,
76 or more complicated version where we do some filtering or the such.
77 The way this will work is that the calculate_* functions will, if the
78 raw data is available, calculate the derived signals and place them into
79 fake "messages" with an alias of "Calc*". E.g., we currently calculate
80 an overall magnitude for the accelerometer readings, which is helpful
81 to understanding how some internal filters work."""
82 self.calculate_imu_signals()
James Kuszmaul61a971f2020-01-01 15:06:18 -080083
James Kuszmaul14dad032020-01-19 17:56:59 -080084 def extract_field(self, message: dict, field: str):
85 """Extracts a field with the given name from the message.
86
87 message will be a dictionary with field names as the keys and then
88 values, lists, or more dictionaries as the values. field is the full
89 path to the field to extract, with periods separating sub-messages."""
90 field_path = field.split('.')
91 value = message
92 for name in field_path:
93 # If the value wasn't populated in a given message, fill in
94 # NaN rather than crashing.
95 if name in value:
96 value = value[name]
97 else:
98 return None
99 # Catch NaNs and convert them to floats.
100 return float(value)
101
102 def plot_line(self, axes: matplotlib.axes.Axes, line: Line):
103 if not line.HasField('y_signal'):
104 raise ValueError("No y_channel specified for line.")
105 y_signal = line.y_signal
106 if not y_signal.channel in self.data:
107 raise ValueError("No channel alias " + y_signal.channel)
108 x_signal = line.x_signal if line.HasField('x_signal') else None
109 if x_signal is not None and not x_signal.channel in self.data:
110 raise ValueError("No channel alias " + x_signal.channel)
111 y_messages = self.data[y_signal.channel]
112 x_messages = self.data[
113 x_signal.channel] if x_signal is not None else None
114 if x_messages is not None and len(x_messages) != len(y_messages):
115 raise ValueError(
116 "X and Y signal lengths don't match. X channel is " +
117 x_signal.channel + " Y channel is " + y_signal.channel)
118 x_data = []
119 y_data = []
120 for ii in range(len(y_messages)):
121 y_entry = y_messages[ii]
122 if x_signal is None:
123 x_data.append(y_entry[0] * 1e-9)
124 else:
125 x_entry = x_messages[ii]
126 x_data.append(self.extract_field(x_entry[2], x_signal.field))
127 y_data.append(self.extract_field(y_entry[2], y_signal.field))
128 if x_data[-1] is None and y_data[-1] is not None:
129 raise ValueError(
130 "Only one of the x and y signals is present. X " +
131 x_signal.channel + "." + x_signal.field + " Y " +
132 y_signal.channel + "." + y_signal.field)
133 label_name = y_signal.channel + "." + y_signal.field
134 axes.plot(x_data, y_data, marker='o', label=label_name)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800135
136 def plot(self):
James Kuszmaul440ee252020-01-03 20:03:52 -0800137 shared_axis = None
James Kuszmaul61a971f2020-01-01 15:06:18 -0800138 for figure_config in self.config.figure:
139 fig = plt.figure()
140 num_subplots = len(figure_config.axes)
141 for ii in range(num_subplots):
James Kuszmaul7a7fe472020-01-12 22:07:10 -0800142 axes = fig.add_subplot(
143 num_subplots, 1, ii + 1, sharex=shared_axis)
James Kuszmaul440ee252020-01-03 20:03:52 -0800144 shared_axis = shared_axis or axes
James Kuszmaul61a971f2020-01-01 15:06:18 -0800145 axes_config = figure_config.axes[ii]
James Kuszmaul14dad032020-01-19 17:56:59 -0800146 for line in axes_config.line:
147 self.plot_line(axes, line)
James Kuszmaul440ee252020-01-03 20:03:52 -0800148 # Make the legend transparent so we can see behind it.
149 legend = axes.legend(framealpha=0.5)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800150 axes.set_xlabel("Monotonic Time (sec)")
James Kuszmaul440ee252020-01-03 20:03:52 -0800151 axes.grid(True)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800152 if axes_config.HasField("ylabel"):
153 axes.set_ylabel(axes_config.ylabel)
154
155
156def main(argv):
157 parser = argparse.ArgumentParser(
158 description="Plot data from an aos logfile.")
159 parser.add_argument(
160 "--logfile",
161 type=str,
162 required=True,
163 help="Path to the logfile to parse.")
164 parser.add_argument(
165 "--config",
166 type=str,
167 required=True,
168 help="Name of the plot config to use.")
169 parser.add_argument(
170 "--config_dir",
171 type=str,
172 default="frc971/analysis/plot_configs",
173 help="Directory to look for plot configs in.")
174 args = parser.parse_args(argv[1:])
175
176 if not os.path.isdir(args.config_dir):
177 print(args.config_dir + " is not a directory.")
178 return 1
179 config_path = os.path.join(args.config_dir, args.config)
180 if not os.path.isfile(config_path):
181 print(config_path +
182 " does not exist or is not a file--available configs are")
183 for file_name in os.listdir(args.config_dir):
184 print(os.path.basename(file_name))
185 return 1
186
187 config = PlotConfig()
188 with open(config_path) as config_file:
189 text_format.Merge(config_file.read(), config)
190
191 if not os.path.isfile(args.logfile):
192 print(args.logfile + " is not a file.")
193 return 1
194
195 reader = LogReader(args.logfile)
196
197 plotter = Plotter(config, reader)
198 plotter.process_logfile()
199 plotter.plot()
200 plt.show()
201
202 return 0
203
204
205if __name__ == '__main__':
206 sys.exit(main(sys.argv))