blob: ecb23b35c6afe510d8cdcaec44e347516ab1f022 [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']:
109 accel_x = 'accelerometer_x'
110 accel_y = 'accelerometer_y'
111 accel_z = 'accelerometer_z'
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800112 gyro_x = 'gyro_x'
113 gyro_y = 'gyro_y'
114 gyro_z = 'gyro_z'
James Kuszmaul7a7fe472020-01-12 22:07:10 -0800115 msg = entry[2]
116 new_msg = {}
117 if accel_x in msg and accel_y in msg and accel_x in msg:
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800118 last_1000_accels[accels_next_row, :] = [
119 msg[accel_x], msg[accel_y], msg[accel_z]
120 ]
121 total_acceleration = np.linalg.norm(
122 last_1000_accels[accels_next_row, :])
James Kuszmaul7a7fe472020-01-12 22:07:10 -0800123 new_msg['total_acceleration'] = total_acceleration
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800124
125 accels_next_row += 1
126 accels_next_row = accels_next_row % buffer_len
127 std_accels = np.std(last_1000_accels, axis=0)
128 new_msg['accel_x_rolling_std'] = std_accels[0]
129 new_msg['accel_y_rolling_std'] = std_accels[1]
130 new_msg['accel_z_rolling_std'] = std_accels[2]
131 mean_accels = np.mean(last_1000_accels, axis=0)
132 new_msg['accel_x_rolling_mean'] = mean_accels[0]
133 new_msg['accel_y_rolling_mean'] = mean_accels[1]
134 new_msg['accel_z_rolling_mean'] = mean_accels[2]
135 if gyro_x in msg and gyro_y in msg and gyro_z in msg:
136 last_1000_gyros[gyros_next_row, :] = [
137 msg[gyro_x], msg[gyro_y], msg[gyro_z]
138 ]
139 gyros_next_row += 1
140 gyros_next_row = gyros_next_row % buffer_len
141 std_gyros = np.std(last_1000_gyros, axis=0)
142 new_msg['gyro_x_rolling_std'] = std_gyros[0]
143 new_msg['gyro_y_rolling_std'] = std_gyros[1]
144 new_msg['gyro_z_rolling_std'] = std_gyros[2]
145 mean_gyros = np.mean(last_1000_gyros, axis=0)
146 new_msg['gyro_x_rolling_mean'] = mean_gyros[0]
147 new_msg['gyro_y_rolling_mean'] = mean_gyros[1]
148 new_msg['gyro_z_rolling_mean'] = mean_gyros[2]
James Kuszmaul14dad032020-01-19 17:56:59 -0800149 timestamp = 'monotonic_timestamp_ns'
150 if timestamp in msg:
151 timestamp_sec = msg[timestamp] * 1e-9
152 new_msg['monotonic_timestamp_sec'] = timestamp_sec
James Kuszmaul7a7fe472020-01-12 22:07:10 -0800153 entries.append((entry[0], entry[1], new_msg))
154 if 'CalcIMU' in self.data:
155 raise RuntimeError('CalcIMU is already a member of data.')
156 self.data['CalcIMU'] = entries
157
158 def calculate_signals(self):
159 """Calculate any derived signals for plotting.
160
161 See calculate_imu_signals for an example, but the basic idea is that
162 for any data that is read in from the logfile, we may want to calculate
163 some derived signals--possibly as simple as doing unit conversions,
164 or more complicated version where we do some filtering or the such.
165 The way this will work is that the calculate_* functions will, if the
166 raw data is available, calculate the derived signals and place them into
167 fake "messages" with an alias of "Calc*". E.g., we currently calculate
168 an overall magnitude for the accelerometer readings, which is helpful
169 to understanding how some internal filters work."""
170 self.calculate_imu_signals()
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800171 self.calculate_down_estimator_signals()
James Kuszmaul61a971f2020-01-01 15:06:18 -0800172
James Kuszmaul14dad032020-01-19 17:56:59 -0800173 def extract_field(self, message: dict, field: str):
174 """Extracts a field with the given name from the message.
175
176 message will be a dictionary with field names as the keys and then
177 values, lists, or more dictionaries as the values. field is the full
178 path to the field to extract, with periods separating sub-messages."""
179 field_path = field.split('.')
180 value = message
181 for name in field_path:
182 # If the value wasn't populated in a given message, fill in
183 # NaN rather than crashing.
184 if name in value:
185 value = value[name]
186 else:
187 return None
188 # Catch NaNs and convert them to floats.
189 return float(value)
190
191 def plot_line(self, axes: matplotlib.axes.Axes, line: Line):
192 if not line.HasField('y_signal'):
193 raise ValueError("No y_channel specified for line.")
194 y_signal = line.y_signal
195 if not y_signal.channel in self.data:
196 raise ValueError("No channel alias " + y_signal.channel)
197 x_signal = line.x_signal if line.HasField('x_signal') else None
198 if x_signal is not None and not x_signal.channel in self.data:
199 raise ValueError("No channel alias " + x_signal.channel)
200 y_messages = self.data[y_signal.channel]
201 x_messages = self.data[
202 x_signal.channel] if x_signal is not None else None
203 if x_messages is not None and len(x_messages) != len(y_messages):
204 raise ValueError(
205 "X and Y signal lengths don't match. X channel is " +
206 x_signal.channel + " Y channel is " + y_signal.channel)
207 x_data = []
208 y_data = []
209 for ii in range(len(y_messages)):
210 y_entry = y_messages[ii]
211 if x_signal is None:
212 x_data.append(y_entry[0] * 1e-9)
213 else:
214 x_entry = x_messages[ii]
215 x_data.append(self.extract_field(x_entry[2], x_signal.field))
216 y_data.append(self.extract_field(y_entry[2], y_signal.field))
217 if x_data[-1] is None and y_data[-1] is not None:
218 raise ValueError(
219 "Only one of the x and y signals is present. X " +
220 x_signal.channel + "." + x_signal.field + " Y " +
221 y_signal.channel + "." + y_signal.field)
222 label_name = y_signal.channel + "." + y_signal.field
223 axes.plot(x_data, y_data, marker='o', label=label_name)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800224
225 def plot(self):
James Kuszmaul440ee252020-01-03 20:03:52 -0800226 shared_axis = None
James Kuszmaul61a971f2020-01-01 15:06:18 -0800227 for figure_config in self.config.figure:
228 fig = plt.figure()
229 num_subplots = len(figure_config.axes)
230 for ii in range(num_subplots):
James Kuszmaul61a971f2020-01-01 15:06:18 -0800231 axes_config = figure_config.axes[ii]
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800232 share_axis = axes_config.share_x_axis
233 axes = fig.add_subplot(
234 num_subplots,
235 1,
236 ii + 1,
237 sharex=shared_axis if share_axis else None)
238 if share_axis and shared_axis is None:
239 shared_axis = axes
James Kuszmaul14dad032020-01-19 17:56:59 -0800240 for line in axes_config.line:
241 self.plot_line(axes, line)
James Kuszmaul440ee252020-01-03 20:03:52 -0800242 # Make the legend transparent so we can see behind it.
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800243 legend = axes.legend(framealpha=0.5, loc='upper right')
244 axes.set_xlabel(axes_config.xlabel)
James Kuszmaul440ee252020-01-03 20:03:52 -0800245 axes.grid(True)
James Kuszmaul61a971f2020-01-01 15:06:18 -0800246 if axes_config.HasField("ylabel"):
247 axes.set_ylabel(axes_config.ylabel)
248
249
250def main(argv):
251 parser = argparse.ArgumentParser(
252 description="Plot data from an aos logfile.")
James Kuszmaul9031d1b2020-02-11 17:01:57 -0800253 parser.add_argument("--logfile",
254 type=str,
255 required=True,
256 help="Path to the logfile to parse.")
257 parser.add_argument("--config",
258 type=str,
259 required=True,
260 help="Name of the plot config to use.")
261 parser.add_argument("--config_dir",
262 type=str,
263 default="frc971/analysis/plot_configs",
264 help="Directory to look for plot configs in.")
James Kuszmaul61a971f2020-01-01 15:06:18 -0800265 args = parser.parse_args(argv[1:])
266
267 if not os.path.isdir(args.config_dir):
268 print(args.config_dir + " is not a directory.")
269 return 1
270 config_path = os.path.join(args.config_dir, args.config)
271 if not os.path.isfile(config_path):
272 print(config_path +
273 " does not exist or is not a file--available configs are")
274 for file_name in os.listdir(args.config_dir):
275 print(os.path.basename(file_name))
276 return 1
277
278 config = PlotConfig()
279 with open(config_path) as config_file:
280 text_format.Merge(config_file.read(), config)
281
282 if not os.path.isfile(args.logfile):
283 print(args.logfile + " is not a file.")
284 return 1
285
286 reader = LogReader(args.logfile)
287
288 plotter = Plotter(config, reader)
289 plotter.process_logfile()
290 plotter.plot()
291 plt.show()
292
293 return 0
294
295
296if __name__ == '__main__':
297 sys.exit(main(sys.argv))