blob: efe86f40a58955c38a10526e73803a4c711e72db [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)
James Kuszmaul61a971f2020-01-01 15:06:18 -080031 if not self.reader.subscribe(channel.name, channel.type):
James Kuszmaul9031d1b2020-02-11 17:01:57 -080032 print("Warning: No such channel with name " + channel.name +
33 " and type " + channel.type)
34 continue
35 aliases.add(channel.alias)
James Kuszmaul61a971f2020-01-01 15:06:18 -080036
37 self.reader.process()
38
39 for channel in self.config.channel:
40 self.data[channel.alias] = []
James Kuszmaul9031d1b2020-02-11 17:01:57 -080041 if channel.alias not in aliases:
42 print("Unable to plot channel alias " + channel.alias)
43 continue
James Kuszmaul61a971f2020-01-01 15:06:18 -080044 for message in self.reader.get_data_for_channel(
45 channel.name, channel.type):
46 valid_json = message[2].replace('nan', '"nan"')
James Kuszmaul9031d1b2020-02-11 17:01:57 -080047 valid_json = valid_json.replace(' inf', ' "inf"')
48 valid_json = valid_json.replace('-inf', '"-inf"')
49 try:
50 parsed_json = json.loads(valid_json)
51 except json.decoder.JSONDecodeError as ex:
52 print("JSON Decode failed:")
53 print(valid_json)
54 raise ex
55 self.data[channel.alias].append(
56 (message[0], message[1], parsed_json))
James Kuszmaul7a7fe472020-01-12 22:07:10 -080057 self.calculate_signals()
58
James Kuszmaul9031d1b2020-02-11 17:01:57 -080059 def calculate_down_estimator_signals(self):
60 if 'DrivetrainStatus' in self.data:
61 # Calculates a rolling mean of the acceleration output from
62 # the down estimator.
63 entries = []
64 buffer_len = 100
65 last_100_accels = np.zeros((buffer_len, 3))
66 accels_next_row = 0
67 for entry in self.data['DrivetrainStatus']:
68 msg = entry[2]
69 new_msg = {}
70 if 'down_estimator' not in msg:
71 continue
72 down_estimator = msg['down_estimator']
73 new_msg['down_estimator'] = {}
74 accel_x = 'accel_x'
75 accel_y = 'accel_y'
76 accel_z = 'accel_z'
77 if (accel_x in down_estimator and accel_y in down_estimator
78 and accel_x in down_estimator):
79 last_100_accels[accels_next_row, :] = [
80 down_estimator[accel_x], down_estimator[accel_y],
81 down_estimator[accel_z]
82 ]
83
84 accels_next_row += 1
85 accels_next_row = accels_next_row % buffer_len
86 mean_accels = np.mean(last_100_accels, axis=0)
87 new_msg['down_estimator'][
88 'accel_x_rolling_mean'] = mean_accels[0]
89 new_msg['down_estimator'][
90 'accel_y_rolling_mean'] = mean_accels[1]
91 new_msg['down_estimator'][
92 'accel_z_rolling_mean'] = mean_accels[2]
93 entries.append((entry[0], entry[1], new_msg))
94 if 'CalcDrivetrainStatus' in self.data:
95 raise RuntimeError(
96 'CalcDrivetrainStatus is already a member of data.')
97 self.data['CalcDrivetrainStatus'] = entries
98
James Kuszmaul7a7fe472020-01-12 22:07:10 -080099 def calculate_imu_signals(self):
100 if 'IMU' in self.data:
101 entries = []
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800102 # Calculates a rolling mean of the raw output from the IMU.
103 buffer_len = 1000
104 last_1000_accels = np.zeros((buffer_len, 3))
105 accels_next_row = 0
106 last_1000_gyros = np.zeros((buffer_len, 3))
107 gyros_next_row = 0
James Kuszmaul7a7fe472020-01-12 22:07:10 -0800108 for entry in self.data['IMU']:
James Kuszmauld91e9b92020-10-25 16:27:11 -0700109 for msg in entry[2]['readings']:
110 accel_x = 'accelerometer_x'
111 accel_y = 'accelerometer_y'
112 accel_z = 'accelerometer_z'
113 gyro_x = 'gyro_x'
114 gyro_y = 'gyro_y'
115 gyro_z = 'gyro_z'
116 temp = 'temperature'
117 new_msg = {}
118 if temp in msg:
119 new_msg[temp] = msg[temp]
120 if accel_x in msg and accel_y in msg and accel_x in msg:
121 last_1000_accels[accels_next_row, :] = [
122 msg[accel_x], msg[accel_y], msg[accel_z]
123 ]
124 total_acceleration = np.linalg.norm(
125 last_1000_accels[accels_next_row, :])
126 new_msg['total_acceleration'] = total_acceleration
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800127
James Kuszmauld91e9b92020-10-25 16:27:11 -0700128 accels_next_row += 1
129 accels_next_row = accels_next_row % buffer_len
130 std_accels = np.std(last_1000_accels, axis=0)
131 new_msg['accel_x_rolling_std'] = std_accels[0]
132 new_msg['accel_y_rolling_std'] = std_accels[1]
133 new_msg['accel_z_rolling_std'] = std_accels[2]
134 mean_accels = np.mean(last_1000_accels, axis=0)
135 new_msg['accel_x_rolling_mean'] = mean_accels[0]
136 new_msg['accel_y_rolling_mean'] = mean_accels[1]
137 new_msg['accel_z_rolling_mean'] = mean_accels[2]
138 new_msg[accel_x] = msg[accel_x]
139 new_msg[accel_y] = msg[accel_y]
140 new_msg[accel_z] = msg[accel_z]
141 if gyro_x in msg and gyro_y in msg and gyro_z in msg:
142 last_1000_gyros[gyros_next_row, :] = [
143 msg[gyro_x], msg[gyro_y], msg[gyro_z]
144 ]
145 gyros_next_row += 1
146 gyros_next_row = gyros_next_row % buffer_len
147 std_gyros = np.std(last_1000_gyros, axis=0)
148 new_msg['gyro_x_rolling_std'] = std_gyros[0]
149 new_msg['gyro_y_rolling_std'] = std_gyros[1]
150 new_msg['gyro_z_rolling_std'] = std_gyros[2]
151 mean_gyros = np.mean(last_1000_gyros, axis=0)
152 new_msg['gyro_x_rolling_mean'] = mean_gyros[0]
153 new_msg['gyro_y_rolling_mean'] = mean_gyros[1]
154 new_msg['gyro_z_rolling_mean'] = mean_gyros[2]
155 new_msg[gyro_x] = msg[gyro_x]
156 new_msg[gyro_y] = msg[gyro_y]
157 new_msg[gyro_z] = msg[gyro_z]
158 timestamp = 'monotonic_timestamp_ns'
159 if timestamp in msg:
160 timestamp_sec = msg[timestamp] * 1e-9
161 new_msg['monotonic_timestamp_sec'] = timestamp_sec
162 entries.append((entry[0], entry[1], new_msg))
James Kuszmaul7a7fe472020-01-12 22:07:10 -0800163 if 'CalcIMU' in self.data:
164 raise RuntimeError('CalcIMU is already a member of data.')
165 self.data['CalcIMU'] = entries
166
167 def calculate_signals(self):
168 """Calculate any derived signals for plotting.
169
170 See calculate_imu_signals for an example, but the basic idea is that
171 for any data that is read in from the logfile, we may want to calculate
172 some derived signals--possibly as simple as doing unit conversions,
173 or more complicated version where we do some filtering or the such.
174 The way this will work is that the calculate_* functions will, if the
175 raw data is available, calculate the derived signals and place them into
176 fake "messages" with an alias of "Calc*". E.g., we currently calculate
177 an overall magnitude for the accelerometer readings, which is helpful
178 to understanding how some internal filters work."""
179 self.calculate_imu_signals()
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800180 self.calculate_down_estimator_signals()
James Kuszmaul61a971f2020-01-01 15:06:18 -0800181
James Kuszmaul14dad032020-01-19 17:56:59 -0800182 def extract_field(self, message: dict, field: str):
183 """Extracts a field with the given name from the message.
184
185 message will be a dictionary with field names as the keys and then
186 values, lists, or more dictionaries as the values. field is the full
187 path to the field to extract, with periods separating sub-messages."""
188 field_path = field.split('.')
189 value = message
190 for name in field_path:
191 # If the value wasn't populated in a given message, fill in
192 # NaN rather than crashing.
193 if name in value:
194 value = value[name]
195 else:
196 return None
197 # Catch NaNs and convert them to floats.
198 return float(value)
199
200 def plot_line(self, axes: matplotlib.axes.Axes, line: Line):
201 if not line.HasField('y_signal'):
202 raise ValueError("No y_channel specified for line.")
203 y_signal = line.y_signal
204 if not y_signal.channel in self.data:
205 raise ValueError("No channel alias " + y_signal.channel)
206 x_signal = line.x_signal if line.HasField('x_signal') else None
207 if x_signal is not None and not x_signal.channel in self.data:
208 raise ValueError("No channel alias " + x_signal.channel)
209 y_messages = self.data[y_signal.channel]
210 x_messages = self.data[
211 x_signal.channel] if x_signal is not None else None
212 if x_messages is not None and len(x_messages) != len(y_messages):
213 raise ValueError(
214 "X and Y signal lengths don't match. X channel is " +
215 x_signal.channel + " Y channel is " + y_signal.channel)
216 x_data = []
217 y_data = []
218 for ii in range(len(y_messages)):
219 y_entry = y_messages[ii]
220 if x_signal is None:
221 x_data.append(y_entry[0] * 1e-9)
222 else:
223 x_entry = x_messages[ii]
224 x_data.append(self.extract_field(x_entry[2], x_signal.field))
225 y_data.append(self.extract_field(y_entry[2], y_signal.field))
226 if x_data[-1] is None and y_data[-1] is not None:
227 raise ValueError(
228 "Only one of the x and y signals is present. X " +
229 x_signal.channel + "." + x_signal.field + " Y " +
230 y_signal.channel + "." + y_signal.field)
231 label_name = y_signal.channel + "." + y_signal.field
232 axes.plot(x_data, y_data, marker='o', label=label_name)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800233
234 def plot(self):
James Kuszmaul440ee252020-01-03 20:03:52 -0800235 shared_axis = None
James Kuszmaul61a971f2020-01-01 15:06:18 -0800236 for figure_config in self.config.figure:
237 fig = plt.figure()
238 num_subplots = len(figure_config.axes)
239 for ii in range(num_subplots):
James Kuszmaul61a971f2020-01-01 15:06:18 -0800240 axes_config = figure_config.axes[ii]
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800241 share_axis = axes_config.share_x_axis
242 axes = fig.add_subplot(
243 num_subplots,
244 1,
245 ii + 1,
246 sharex=shared_axis if share_axis else None)
247 if share_axis and shared_axis is None:
248 shared_axis = axes
James Kuszmaul14dad032020-01-19 17:56:59 -0800249 for line in axes_config.line:
250 self.plot_line(axes, line)
James Kuszmaul440ee252020-01-03 20:03:52 -0800251 # Make the legend transparent so we can see behind it.
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800252 legend = axes.legend(framealpha=0.5, loc='upper right')
253 axes.set_xlabel(axes_config.xlabel)
James Kuszmaul440ee252020-01-03 20:03:52 -0800254 axes.grid(True)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800255 if axes_config.HasField("ylabel"):
256 axes.set_ylabel(axes_config.ylabel)
257
258
259def main(argv):
260 parser = argparse.ArgumentParser(
261 description="Plot data from an aos logfile.")
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800262 parser.add_argument("--logfile",
263 type=str,
264 required=True,
265 help="Path to the logfile to parse.")
266 parser.add_argument("--config",
267 type=str,
268 required=True,
269 help="Name of the plot config to use.")
270 parser.add_argument("--config_dir",
271 type=str,
272 default="frc971/analysis/plot_configs",
273 help="Directory to look for plot configs in.")
James Kuszmaul61a971f2020-01-01 15:06:18 -0800274 args = parser.parse_args(argv[1:])
275
276 if not os.path.isdir(args.config_dir):
277 print(args.config_dir + " is not a directory.")
278 return 1
279 config_path = os.path.join(args.config_dir, args.config)
280 if not os.path.isfile(config_path):
281 print(config_path +
282 " does not exist or is not a file--available configs are")
283 for file_name in os.listdir(args.config_dir):
284 print(os.path.basename(file_name))
285 return 1
286
287 config = PlotConfig()
288 with open(config_path) as config_file:
289 text_format.Merge(config_file.read(), config)
290
291 if not os.path.isfile(args.logfile):
292 print(args.logfile + " is not a file.")
293 return 1
294
295 reader = LogReader(args.logfile)
296
297 plotter = Plotter(config, reader)
298 plotter.process_logfile()
299 plotter.plot()
300 plt.show()
301
302 return 0
303
304
305if __name__ == '__main__':
306 sys.exit(main(sys.argv))