diff --git a/ff_sim/ff_sim/controller_metrics.py b/ff_sim/ff_sim/controller_metrics.py index 1b4677b..1c1ad2c 100755 --- a/ff_sim/ff_sim/controller_metrics.py +++ b/ff_sim/ff_sim/controller_metrics.py @@ -90,7 +90,7 @@ def process_new_binary_thrust_cmd(self, msg: ThrusterCommand) -> None: self.prev_thruster_sum = np.sum(thrusters) self.steps += 1 - if not self.rolled_up: # Ensure valid duty cycles at the beginning + if not self.rolled_up: # Ensure valid duty cycles at the beginning self.time_hist.append(dt) for i in range(8): self.thrust_hist[i].append(thrusters[i]) @@ -99,7 +99,7 @@ def process_new_binary_thrust_cmd(self, msg: ThrusterCommand) -> None: ) if self.steps >= self.duty_cycle_window: self.rolled_up = True - else: # Once queue is filled up, can just treat this as a list that's constantly being updawted + else: # Once queue is filled up, can just treat this as a list that's constantly being updawted self.time_hist.pop(0) self.time_hist.append(dt) for i in range(8): diff --git a/ff_sim/ff_sim/simulator_node.py b/ff_sim/ff_sim/simulator_node.py index 56b4cfb..4e906bf 100755 --- a/ff_sim/ff_sim/simulator_node.py +++ b/ff_sim/ff_sim/simulator_node.py @@ -403,11 +403,11 @@ def compute_dynamics_dt(self, x_k, u_k, dt, discretization="Euler"): print("[FreeFlyerSimulator::compute_dynamics_dt]: Unknown Discretization Scheme.") # Wrap theta to [-pi, pi] - + # x_next[2] = ((x_next[2] % (2 * np.pi)) + 2 * np.pi) % (2 * np.pi) # if x_next[2] > np.pi: # x_next[2] -= 2 * np.pi - x_next[2] = (x_next[2] + np.pi) % (2*np.pi) - np.pi + x_next[2] = (x_next[2] + np.pi) % (2 * np.pi) - np.pi return x_next diff --git a/testing_files/injectGoalPose.py b/testing_files/injectGoalPose.py index 62db08a..d1036b3 100755 --- a/testing_files/injectGoalPose.py +++ b/testing_files/injectGoalPose.py @@ -32,8 +32,14 @@ import math import sys -goal_positions = [[0.5,0.5,-math.pi/2], [0.5,0.5,math.pi/2], [1.0,1.0,math.pi/2], [2.0,1.5,math.pi/2]] -goal_velos = [[0.0,0.0,0.0], [0.0,0.0,0.0], [0.0,0.0,0.0], [0.0,0.0,0.0]] +goal_positions = [ + [0.5, 0.5, -math.pi / 2], + [0.5, 0.5, math.pi / 2], + [1.0, 1.0, math.pi / 2], + [2.0, 1.5, math.pi / 2], +] +goal_velos = [[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]] + class GoalPublisherNode(Node): def __init__(self, index): @@ -54,10 +60,11 @@ def publish_goal(self, pos, velo): goal_pose.state.pose.theta = pos[2] goal_pose.state.twist.vx = velo[0] goal_pose.state.twist.vy = velo[1] - goal_pose.state.twist.wz = velo[2] + goal_pose.state.twist.wz = velo[2] self.pub_goal.publish(goal_pose) + def main(): index = int(sys.argv[1]) rclpy.init() @@ -66,6 +73,6 @@ def main(): rclpy.spin(publisher) rclpy.shutdown() + if __name__ == "__main__": main() - \ No newline at end of file diff --git a/testing_files/unpackRosBag.py b/testing_files/unpackRosBag.py index bf514ea..62e6a48 100644 --- a/testing_files/unpackRosBag.py +++ b/testing_files/unpackRosBag.py @@ -37,47 +37,73 @@ add_types = {} prefix = "./ff_msgs/msg/" -names = ["FreeFlyerStateStamped", "ControllerMetrics", "FreeFlyerState", "ThrusterCommand", "Pose2D", "Twist2D"] +names = [ + "FreeFlyerStateStamped", + "ControllerMetrics", + "FreeFlyerState", + "ThrusterCommand", + "Pose2D", + "Twist2D", +] msgfiles = [prefix + name + ".msg" for name in names] msgnames = ["ff_msgs/msg/" + name for name in names] for i, pathstr in enumerate(msgfiles): msgpath = Path(pathstr) - msgdef = msgpath.read_text(encoding='utf-8') + msgdef = msgpath.read_text(encoding="utf-8") add_types.update(get_types_from_msg(msgdef, msgnames[i])) typestore.register(add_types) -START_POINT = [0.5, 0.5, -math.pi/2] -folders = ['./testing_files/experiment_data/exp_pd_turnaround/', - './testing_files/experiment_data/exp_opt_turnaround/', - './testing_files/experiment_data/exp_pd_short/', - './testing_files/experiment_data/exp_opt_short/', - './testing_files/experiment_data/exp_pd_long/', - './testing_files/experiment_data/exp_opt_long/'] +START_POINT = [0.5, 0.5, -math.pi / 2] +folders = [ + "./testing_files/experiment_data/exp_pd_turnaround/", + "./testing_files/experiment_data/exp_opt_turnaround/", + "./testing_files/experiment_data/exp_pd_short/", + "./testing_files/experiment_data/exp_opt_short/", + "./testing_files/experiment_data/exp_pd_long/", + "./testing_files/experiment_data/exp_opt_long/", +] ROSBAG_NAMES = [] for i, folder in enumerate(folders): ROSBAG_NAMES.append([]) filelist = os.listdir(folder) filelist.sort() for filename in filelist: - ROSBAG_NAMES[i].append(folder+filename) - -TITLES = ['PD Baseline Controller Turnaround', 'Optimization Controller Turnaround', - 'PD Baseline Controller Short-Distance', 'Optimization Controller Short-Distance', - 'PD Baseline Controller Long-Distance', 'Optimization Controller Long-Distance'] + ROSBAG_NAMES[i].append(folder + filename) + +TITLES = [ + "PD Baseline Controller Turnaround", + "Optimization Controller Turnaround", + "PD Baseline Controller Short-Distance", + "Optimization Controller Short-Distance", + "PD Baseline Controller Long-Distance", + "Optimization Controller Long-Distance", +] EXPERIMENT = [0, 0, 1, 1, 2, 2] -COLORS = ['cornflowerblue', 'orangered', 'mediumseagreen'] -YLIMS_BOUNDS = [[(0.45,0.55), (0.45,0.55), (-np.pi-0.2,np.pi+0.2), (-10, 120)], - [(0.4,1.1), (0.4,1.1), (-np.pi-0.2,np.pi+0.2), (-10, 140)], - [(0.4,2.3), (0.4,1.65), (-np.pi-0.2,np.pi+0.2), (-10, 250)]] +COLORS = ["cornflowerblue", "orangered", "mediumseagreen"] +YLIMS_BOUNDS = [ + [(0.45, 0.55), (0.45, 0.55), (-np.pi - 0.2, np.pi + 0.2), (-10, 120)], + [(0.4, 1.1), (0.4, 1.1), (-np.pi - 0.2, np.pi + 0.2), (-10, 140)], + [(0.4, 2.3), (0.4, 1.65), (-np.pi - 0.2, np.pi + 0.2), (-10, 250)], +] POS_THRESHOLD = 0.05 THETA_THRESHOLD = 0.174 NUM_STATS = 9 -STATS = ["Pos Rise Time", "Pos Settling Time", "Pos Max Overshoot", "Pos RMS Settled Err", - "Theta Rise Time", "Theta Settling Time", "Theta Max Overshoot", "Theta RMS Settled Err", "Settling Gas Used"] +STATS = [ + "Pos Rise Time", + "Pos Settling Time", + "Pos Max Overshoot", + "Pos RMS Settled Err", + "Theta Rise Time", + "Theta Settling Time", + "Theta Max Overshoot", + "Theta RMS Settled Err", + "Settling Gas Used", +] + def quat2euler(x, y, z, w): """ @@ -90,33 +116,35 @@ def quat2euler(x, y, z, w): t0 = +2.0 * (w * x + y * z) t1 = +1.0 - 2.0 * (x * x + y * y) roll_x = math.atan2(t0, t1) - + t2 = +2.0 * (w * y - z * x) t2 = +1.0 if t2 > +1.0 else t2 t2 = -1.0 if t2 < -1.0 else t2 pitch_y = math.asin(t2) - + t3 = +2.0 * (w * z + x * y) t4 = +1.0 - 2.0 * (y * y + z * z) yaw_z = math.atan2(t3, t4) - - return roll_x, pitch_y, yaw_z # in radians + + return roll_x, pitch_y, yaw_z # in radians + def define_experiment(exp_number): FIRST_GOAL, RETURN_GOAL = None, None if exp_number == 0: - FIRST_GOAL = [0.5, 0.5, math.pi/2] + FIRST_GOAL = [0.5, 0.5, math.pi / 2] RETURN_GOAL = START_POINT elif exp_number == 1: - FIRST_GOAL = [1, 1, math.pi/2] + FIRST_GOAL = [1, 1, math.pi / 2] RETURN_GOAL = START_POINT elif exp_number == 2: - FIRST_GOAL = [2.0, 1.5, math.pi/2] + FIRST_GOAL = [2.0, 1.5, math.pi / 2] RETURN_GOAL = START_POINT else: print("ERR: Invalid Experiment mode!") return FIRST_GOAL, RETURN_GOAL + def unpack_rosbag(rosbag_name, exp_number): FIRST_GOAL, RETURN_GOAL = define_experiment(exp_number) @@ -144,57 +172,92 @@ def unpack_rosbag(rosbag_name, exp_number): # create reader instance and open for reading with Reader(rosbag_name) as reader: for connection, timestamp, rawdata in reader.messages(): - if (experiment_start): + if experiment_start: try: - if connection.topic == '/vrpn_mocap/robot/pose': + if connection.topic == "/vrpn_mocap/robot/pose": msg = typestore.deserialize_cdr(rawdata, connection.msgtype) pos_time.append(msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0) xlist.append(msg.pose.position.x) ylist.append(msg.pose.position.y) - th = quat2euler(msg.pose.orientation.x, msg.pose.orientation.y, msg.pose.orientation.z, msg.pose.orientation.w) - thlist.append(th[2]) # Yaw - elif connection.topic == '/robot/ctrl/state': + th = quat2euler( + msg.pose.orientation.x, + msg.pose.orientation.y, + msg.pose.orientation.z, + msg.pose.orientation.w, + ) + thlist.append(th[2]) # Yaw + elif connection.topic == "/robot/ctrl/state": msg = typestore.deserialize_cdr(rawdata, connection.msgtype) - goal_time.append(msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0) + goal_time.append( + msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0 + ) goalxlist.append(msg.state.pose.x) goalylist.append(msg.state.pose.y) - goalthlist.append(msg.state.pose.theta) # Yaw - elif connection.topic == '/robot/metrics/controller': + goalthlist.append(msg.state.pose.theta) # Yaw + elif connection.topic == "/robot/metrics/controller": msg = typestore.deserialize_cdr(rawdata, connection.msgtype) if not track_thruster_start: thruster_0 = msg.total_gas_time track_thruster_start = True - metrics_time.append(msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0) + metrics_time.append( + msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0 + ) totalgaslist.append(msg.total_gas_time - thruster_0) - elif connection.topic == '/robot/commands/binary_thrust': + elif connection.topic == "/robot/commands/binary_thrust": msg = typestore.deserialize_cdr(rawdata, connection.msgtype) - thruster_time.append(msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0) + thruster_time.append( + msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 - t0 + ) thruster_outputs.append(msg.switches) - + except KeyError: print("Err: Not ready to process these messages yet!") else: - if connection.topic == '/robot/ctrl/state': + if connection.topic == "/robot/ctrl/state": msg = typestore.deserialize_cdr(rawdata, connection.msgtype) pos = [msg.state.pose.x, msg.state.pose.y, msg.state.pose.theta] - if (pos == FIRST_GOAL): + if pos == FIRST_GOAL: t0 = msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9 experiment_start = True goal_time.append(0) goalxlist.append(pos[0]) goalylist.append(pos[1]) - goalthlist.append(pos[2]) # Yaw + goalthlist.append(pos[2]) # Yaw + + return ( + pos_time, + xlist, + ylist, + thlist, + goal_time, + goalxlist, + goalylist, + goalthlist, + metrics_time, + totalgaslist, + thruster_time, + thruster_outputs, + ) - return (pos_time, xlist, ylist, thlist, - goal_time, goalxlist, goalylist, goalthlist, - metrics_time, totalgaslist, - thruster_time, thruster_outputs) def plot_experiment_results(unpacked_vals, color_ind, exp_number, fig=None, axs=None): - pos_time, xlist, ylist, thlist, goal_time, goalxlist, goalylist, goalthlist, metrics_time, totalgaslist, thruster_time, thruster_outputs = unpacked_vals - - if (fig is None or axs is None): - fig, axs = plt.subplots(4, 1, constrained_layout=True, sharex = True) + ( + pos_time, + xlist, + ylist, + thlist, + goal_time, + goalxlist, + goalylist, + goalthlist, + metrics_time, + totalgaslist, + thruster_time, + thruster_outputs, + ) = unpacked_vals + + if fig is None or axs is None: + fig, axs = plt.subplots(4, 1, constrained_layout=True, sharex=True) fig.set_figheight(9) fig.set_figwidth(7) # Artificially add extra goal command to data to make goal step function look right @@ -202,37 +265,51 @@ def plot_experiment_results(unpacked_vals, color_ind, exp_number, fig=None, axs= goalxlist.append(goalxlist[-1]) goalylist.append(goalylist[-1]) goalthlist.append(goalthlist[-1]) - + axs[0].plot(pos_time, xlist, COLORS[color_ind]) - axs[0].plot(goal_time, goalxlist, COLORS[color_ind], linestyle='--', drawstyle='steps-post') + axs[0].plot(goal_time, goalxlist, COLORS[color_ind], linestyle="--", drawstyle="steps-post") axs[0].set_ylabel("X-Position [m]") - axs[0].grid(True, which='major', axis='y') + axs[0].grid(True, which="major", axis="y") axs[0].set_ylim(YLIMS_BOUNDS[exp_number][0]) axs[1].plot(pos_time, ylist, COLORS[color_ind]) - axs[1].plot(goal_time, goalylist, COLORS[color_ind], linestyle='--', drawstyle='steps-post') + axs[1].plot(goal_time, goalylist, COLORS[color_ind], linestyle="--", drawstyle="steps-post") axs[1].set_ylabel("Y-Position [m]") - axs[1].grid(True, which='major', axis='y') + axs[1].grid(True, which="major", axis="y") axs[1].set_ylim(YLIMS_BOUNDS[exp_number][1]) axs[2].plot(pos_time, thlist, COLORS[color_ind]) - axs[2].plot(goal_time, goalthlist, COLORS[color_ind], linestyle='--', drawstyle='steps-post') + axs[2].plot(goal_time, goalthlist, COLORS[color_ind], linestyle="--", drawstyle="steps-post") axs[2].set_ylabel("Orientation [rad]") - axs[2].grid(True, which='major', axis='y') + axs[2].grid(True, which="major", axis="y") axs[2].set_ylim(YLIMS_BOUNDS[exp_number][2]) axs[3].plot(metrics_time, totalgaslist, COLORS[color_ind]) axs[3].set_ylabel("Total Time Thrusters On [s]") - axs[3].grid(True, which='major', axis='y') + axs[3].grid(True, which="major", axis="y") axs[3].set_xlabel("Time [s]") axs[3].set_ylim(YLIMS_BOUNDS[exp_number][3]) return fig, axs + def extract_performance_metrics(unpacked_vals, exp_number): FIRST_GOAL, RETURN_GOAL = define_experiment(exp_number) - pos_time, xlist, ylist, thlist, goal_time, goalxlist, goalylist, goalthlist, metrics_time, totalgaslist, thruster_time, thruster_outputs = unpacked_vals + ( + pos_time, + xlist, + ylist, + thlist, + goal_time, + goalxlist, + goalylist, + goalthlist, + metrics_time, + totalgaslist, + thruster_time, + thruster_outputs, + ) = unpacked_vals xchange = goalxlist.index(RETURN_GOAL[0]) ychange = goalylist.index(RETURN_GOAL[1]) thchange = goalthlist.index(RETURN_GOAL[2]) @@ -241,22 +318,37 @@ def extract_performance_metrics(unpacked_vals, exp_number): # Divide data into two phases pos_index = np.where(np.array(pos_time) > goal_change_time)[0][0] metrics_index = np.where(np.array(metrics_time) > goal_change_time)[0][0] - - # Process first phase (START->FIRST_GOAL) - first_slice = (pos_time[:pos_index], xlist[:pos_index], ylist[:pos_index], thlist[:pos_index], - metrics_time[:metrics_index], totalgaslist[:metrics_index]) + + # Process first phase (START->FIRST_GOAL) + first_slice = ( + pos_time[:pos_index], + xlist[:pos_index], + ylist[:pos_index], + thlist[:pos_index], + metrics_time[:metrics_index], + totalgaslist[:metrics_index], + ) calcmetrics_1 = experiment_analysis(RETURN_GOAL, FIRST_GOAL, first_slice, exp_number) - # Process second phase (FIRST_GOAL->START) - second_slice = (pos_time[pos_index:], xlist[pos_index:], ylist[pos_index:], thlist[pos_index:], - metrics_time[metrics_index:], totalgaslist[metrics_index:]) + # Process second phase (FIRST_GOAL->START) + second_slice = ( + pos_time[pos_index:], + xlist[pos_index:], + ylist[pos_index:], + thlist[pos_index:], + metrics_time[metrics_index:], + totalgaslist[metrics_index:], + ) calcmetrics_2 = experiment_analysis(FIRST_GOAL, RETURN_GOAL, second_slice, exp_number) - + return calcmetrics_1, calcmetrics_2 + def experiment_analysis(start, goal, sliced_data, exp_number): pos_time, xlist, ylist, thlist, metrics_time, totalgaslist = sliced_data - position_matrix = np.hstack((np.array(xlist).reshape((-1,1)), np.array(ylist).reshape((-1,1)))) + position_matrix = np.hstack( + (np.array(xlist).reshape((-1, 1)), np.array(ylist).reshape((-1, 1))) + ) pos_err = np.linalg.norm(position_matrix - np.array(goal[:2]), axis=1) th_err = np.abs(np.array(thlist) - goal[2]) @@ -266,7 +358,7 @@ def experiment_analysis(start, goal, sliced_data, exp_number): metrics_time = [t - metrics_t0 for t in metrics_time] # Process position metrics - if (exp_number == 0): # No settling/rising when just holding position + if exp_number == 0: # No settling/rising when just holding position posRiseInd = 0 posRiseTime = pos_time[posRiseInd] posSettledInd = 0 @@ -277,25 +369,26 @@ def experiment_analysis(start, goal, sliced_data, exp_number): if np.any(pos_err > POS_THRESHOLD): posSettledInd = np.where(pos_err > POS_THRESHOLD)[0][-1] - posSettlingTime = pos_time[posSettledInd] - if (posSettlingTime == pos_time[-1]): - print("Position Never settles!", position_matrix[-1,:], pos_err[-1]) + posSettlingTime = pos_time[posSettledInd] + if posSettlingTime == pos_time[-1]: + print("Position Never settles!", position_matrix[-1, :], pos_err[-1]) posSettlingTime = None else: print("Position Never diverges!") posSettledInd = 0 posSettlingTime = pos_time[posSettledInd] - if (posSettlingTime is None): + if posSettlingTime is None: posRMSSettledErr = None print("No valid settled error to look at") else: - posRMSSettledErr = np.linalg.norm(pos_err[posSettledInd:]) / np.sqrt(pos_err.shape[0] - posSettledInd) + posRMSSettledErr = np.linalg.norm(pos_err[posSettledInd:]) / np.sqrt( + pos_err.shape[0] - posSettledInd + ) # print(pos_err[:5], np.where(pos_err < POS_THRESHOLD)[0][0]) - + posMaxOvershoot = np.max(pos_err[posRiseInd:]) - # Process orientation metrics thRiseInd = np.where(th_err < THETA_THRESHOLD)[0][0] @@ -303,31 +396,33 @@ def experiment_analysis(start, goal, sliced_data, exp_number): thMaxOvershoot = np.max(th_err[thRiseInd:]) if np.any(th_err > THETA_THRESHOLD): thSettledInd = np.where(th_err > THETA_THRESHOLD)[0][-1] - thSettlingTime = pos_time[thSettledInd] - if (thSettlingTime == pos_time[-1]): + thSettlingTime = pos_time[thSettledInd] + if thSettlingTime == pos_time[-1]: print("Theta Never settles!", th_err[-1]) thSettlingTime = None else: print("Theta Never diverges!") thSettlingTime = 0 - if (thSettlingTime is None): + if thSettlingTime is None: thRMSSettledErr = None print("No valid settled error to look at") else: - thRMSSettledErr = np.linalg.norm(th_err[thSettledInd:]) / np.sqrt(th_err.shape[0] - thSettledInd) - + thRMSSettledErr = np.linalg.norm(th_err[thSettledInd:]) / np.sqrt( + th_err.shape[0] - thSettledInd + ) # Process gas usage metrics settling_time_candidates = [t for t in [thSettlingTime, posSettlingTime] if t is not None] settling_time = max(settling_time_candidates) if len(settling_time_candidates) != 0 else -1 - if (settling_time != -1): - settling_gas = totalgaslist[np.where(np.array(metrics_time) > settling_time)[0][0]] - totalgaslist[0] + if settling_time != -1: + settling_gas = ( + totalgaslist[np.where(np.array(metrics_time) > settling_time)[0][0]] - totalgaslist[0] + ) else: print("No valid settling time to use!") settling_gas = None - print("Position Rise Time:", posRiseTime) print("Position Settling Time:", posSettlingTime, "out of", pos_time[-1]) print("Position Settled Error:", posRMSSettledErr) @@ -338,7 +433,17 @@ def experiment_analysis(start, goal, sliced_data, exp_number): print("Theta Max Overshoot:", thMaxOvershoot) print("Settling Gas Usage:", settling_gas) - return (posRiseTime, posSettlingTime, posMaxOvershoot, posRMSSettledErr, thRiseTime, thSettlingTime, thMaxOvershoot, thRMSSettledErr, settling_gas) + return ( + posRiseTime, + posSettlingTime, + posMaxOvershoot, + posRMSSettledErr, + thRiseTime, + thSettlingTime, + thMaxOvershoot, + thRMSSettledErr, + settling_gas, + ) def main(): @@ -356,9 +461,9 @@ def main(): unpacked = unpack_rosbag(ROSBAG_NAME, EXPERIMENT[i]) metrics_1, metrics_2 = extract_performance_metrics(unpacked, EXPERIMENT[i]) for k in range(NUM_STATS): - if (metrics_1[k] is not None): + if metrics_1[k] is not None: stats[k].append(metrics_1[k]) - if (metrics_2[k] is not None): + if metrics_2[k] is not None: stats[k].append(metrics_2[k]) fig, axs = plot_experiment_results(unpacked, j, EXPERIMENT[i], fig, axs) # axs[0].vlines(posRiseTime, 0.4, 0.6) if posRiseTime is not None else print("hi") @@ -366,7 +471,16 @@ def main(): # axs[2].vlines(thRiseTime, -2,2) if thRiseTime is not None else print("hi") # axs[2].vlines(thSettlingTime, -2,2) if thSettlingTime is not None else print("hi") fig.suptitle(TITLES[i]) - fig.legend(["Trial 1 Actual", "Trial 1 Setpoint", "Trial 2 Actual", "Trial 2 Setpoint","Trial 3 Actual", "Trial 3 Setpoint"]) + fig.legend( + [ + "Trial 1 Actual", + "Trial 1 Setpoint", + "Trial 2 Actual", + "Trial 2 Setpoint", + "Trial 3 Actual", + "Trial 3 Setpoint", + ] + ) for k in range(NUM_STATS): average_stats[i][k] = np.around(np.mean(stats[k]), 5) std_stats[i][k] = np.around(np.std(stats[k]), 5) @@ -382,5 +496,6 @@ def main(): print("Max:", max_stats[i]) plt.show() + if __name__ == "__main__": main()