From 322109a87fa87a2fd7e8feabc14dd740b1f22e7d Mon Sep 17 00:00:00 2001 From: Nicholas Shiland Date: Mon, 19 Aug 2024 10:25:58 -0400 Subject: [PATCH] feat (hardware-testing): add JIRA functionality to testing scripts (#16013) # Overview This pull request adds a few new features to existing hardware testing scripts and a small bug fix. ## Test Plan and Hands on Testing I ran every new script on robots while initiating errors and trying to break them. All changes to the jira_tool script work with every current references of the script. ## Changelog First, the speed_accel_profile script can now post results and raw data to Jira and print a summary of the test outside of Jira. Next, in the "gripper_and_zmount_move" you can now change the distance, log results, and post results to a Jira ticket. Both of these script changes required a few small changes and additions to the jira_tool script. Also, both of these scripts can be used without Jira integration and outside of ABR. Lastly, a bug in the abr_robot_error script that prevented LPC data from being collected was fixed. ## Review requests Checking message statements and if some functions should be defined in different scripts. ## Risk assessment Not much, this only affects ABR and hardware testing scripts. --- .../abr_testing/automation/jira_tool.py | 35 ++- .../data_collection/abr_robot_error.py | 37 +-- .../scripts/gripper_and_zmount_move.py | 239 ++++++++++++++++-- .../scripts/speed_accel_profile.py | 106 +++++++- 4 files changed, 360 insertions(+), 57 deletions(-) diff --git a/abr-testing/abr_testing/automation/jira_tool.py b/abr-testing/abr_testing/automation/jira_tool.py index 9b0ee6bb428..6f81503ec42 100644 --- a/abr-testing/abr_testing/automation/jira_tool.py +++ b/abr-testing/abr_testing/automation/jira_tool.py @@ -174,14 +174,16 @@ def post_attachment_to_ticket(self, issue_id: str, attachment_path: str) -> None "file": (attachment_path, open(attachment_path, "rb"), "application-type") } JSON_headers = {"Accept": "application/json", "X-Atlassian-Token": "no-check"} + attachment_url = f"{self.url}/rest/api/3/issue/{issue_id}/attachments" try: - response = requests.post( - f"{self.url}/rest/api/3/issue/{issue_id}/attachments", + response = requests.request( + "POST", + attachment_url, headers=JSON_headers, auth=self.auth, files=file, ) - print(f"File: {attachment_path} posted to ticket {issue_id}") + print(f"File: {attachment_path} posted to ticket {issue_id}.") except json.JSONDecodeError: error_message = str(response.content) print(f"JSON decoding error occurred. Response content: {error_message}.") @@ -257,9 +259,9 @@ def get_project_components(self, project_id: str) -> List[Dict[str, str]]: components_list = response.json() return components_list - def comment(self, content_list: List[Dict[str, Any]], issue_url: str) -> None: + def comment(self, content_list: List[Dict[str, Any]], issue_key: str) -> None: """Leave comment on JIRA Ticket.""" - comment_url = issue_url + "/comment" + comment_url = f"{self.url}/rest/api/3/issue/{issue_key}/comment" payload = json.dumps( { "body": { @@ -273,6 +275,29 @@ def comment(self, content_list: List[Dict[str, Any]], issue_url: str) -> None: "POST", comment_url, data=payload, headers=self.headers, auth=self.auth ) + def format_jira_comment(self, comment_info: Any) -> List[Dict[str, Any]]: + """Formats a string input to work with the "comment" function.""" + content_list: List = [] + line_1 = { + "type": "paragraph", + "content": [{"type": "text", "text": comment_info}], + } + content_list.insert(0, line_1) + return content_list + + def get_ticket(self) -> str: + """Gets and confirms jira ticket number.""" + while True: + issue_key = input("Ticket Key: ") + url = f"{self.url}/rest/api/3/issue/{issue_key}" + headers = {"Accept": "application/json"} + response = requests.request("GET", url, headers=headers, auth=self.auth) + if str(response) == "": + break + else: + print("Please input a valid JIRA Key") + return issue_key + if __name__ == "__main__": """Create ticket for specified robot.""" diff --git a/abr-testing/abr_testing/data_collection/abr_robot_error.py b/abr-testing/abr_testing/data_collection/abr_robot_error.py index 9614e3e53e1..56a6bb5f29c 100644 --- a/abr-testing/abr_testing/data_collection/abr_robot_error.py +++ b/abr-testing/abr_testing/data_collection/abr_robot_error.py @@ -200,7 +200,7 @@ def read_each_log(folder_path: str, issue_url: str) -> None: "content": [{"type": "text", "text": message}], } content_list.insert(0, line_1) - ticket.comment(content_list, issue_url) + ticket.comment(content_list, issue_key) no_word_found_message = ( f"Key words '{not_found_words} were not found in {file_name}." ) @@ -209,7 +209,7 @@ def read_each_log(folder_path: str, issue_url: str) -> None: "content": [{"type": "text", "text": no_word_found_message}], } content_list.append(no_word_found_dict) - ticket.comment(content_list, issue_url) + ticket.comment(content_list, issue_key) def match_error_to_component( @@ -383,21 +383,26 @@ def get_run_error_info_from_robot( errored_labware_dict["Slot"] = labware["location"].get("slotName", "") errored_labware_dict["Labware Type"] = labware.get("definitionUri", "") offset_id = labware.get("offsetId", "") - for lpc in lpc_dict: - if lpc.get("id", "") == offset_id: - errored_labware_dict["X"] = lpc["vector"].get("x", "") - errored_labware_dict["Y"] = lpc["vector"].get("y", "") - errored_labware_dict["Z"] = lpc["vector"].get("z", "") - errored_labware_dict["Module"] = lpc["location"].get( - "moduleModel", "" - ) - errored_labware_dict["Adapter"] = lpc["location"].get( - "definitionUri", "" - ) + if offset_id == "": + labware_slot = errored_labware_dict["Slot"] + lpc_message = f"The current LPC coords found at {labware_slot} are (0, 0, 0). \ +Please confirm with the ABR-LPC sheet and re-LPC." + else: + for lpc in lpc_dict: + if lpc.get("id", "") == offset_id: + errored_labware_dict["X"] = lpc["vector"].get("x", "") + errored_labware_dict["Y"] = lpc["vector"].get("y", "") + errored_labware_dict["Z"] = lpc["vector"].get("z", "") + errored_labware_dict["Module"] = lpc["location"].get( + "moduleModel", "" + ) + errored_labware_dict["Adapter"] = lpc["location"].get( + "definitionUri", "" + ) - lpc_message = compare_lpc_to_historical_data( - errored_labware_dict, parent, storage_directory - ) + lpc_message = compare_lpc_to_historical_data( + errored_labware_dict, parent, storage_directory + ) description["protocol_step"] = protocol_step description["right_mount"] = results.get("right", "No attachment") diff --git a/hardware-testing/hardware_testing/scripts/gripper_and_zmount_move.py b/hardware-testing/hardware_testing/scripts/gripper_and_zmount_move.py index b174a22b808..da2dcad8769 100644 --- a/hardware-testing/hardware_testing/scripts/gripper_and_zmount_move.py +++ b/hardware-testing/hardware_testing/scripts/gripper_and_zmount_move.py @@ -1,10 +1,17 @@ -"""Demo OT3 Gantry Functionality.""" -# Author: Carlos Ferandez float: + """Gets and confirms z axis travel.""" + limit = 150 + if mount_name != "gripper": + limit = 210 + while True: + try: + travel = float( + input( + f"How far would you like the z axis to travel? \ +The range is between 1 and {str(limit)}: " + ) + ) + if 0 < int(travel) <= limit: + break + else: + print(f"Please choose a value between 1 and {str(limit)}.") + except ValueError: + print("Please enter a number.") + return travel + + +def get_summary(file_path: str, count: int, errored: bool, error_message: str) -> str: + """Creates a summary of the results of the z axis test.""" + with open(file_path, newline="") as csvfile: + csvobj = csv.reader(csvfile, delimiter=",") + + full_list = list(csvobj) + row_of_interest = full_list[count] + cropped_cycle = str(row_of_interest).split("'")[1] + cropped_time = str(row_of_interest).split("'")[3] + cropped_time = cropped_time[1:] + + if errored is True: + comment_message = f"This test failed due to \ +{error_message} on {cropped_cycle} and {cropped_time}." + else: + comment_message = ( + f"This test successfully completed at {cropped_cycle} and {cropped_time}." + ) + return comment_message + + +def get_robot_ip() -> str: + """Gets and confirms robot IP address.""" + while True: + ip = input("Robot IP: ") + # From health: robot name + try: + response = requests.get( + f"http://{ip}:31950/health", headers={"opentrons-version": "3"} + ) + # confirm connection of IP + if str(response) == "": + break + else: + print("Please input a valid IP address") + except BaseException: + print("Please input a valid IP address") + return ip + + +def get_robot_info(ip: str, mount_name: str) -> tuple[str, str]: + """Grabs robot name and instrument serial.""" + response = requests.get( + f"http://{ip}:31950/health", headers={"opentrons-version": "3"} + ) + health_data = response.json() + robot_name = health_data.get("name", "") + # from pipettes/instruments we get pipette/gripper serial + if mount_name == "gripper": + response = requests.get( + f"http://{ip}:31950/instruments", headers={"opentrons-version": "3"} + ) + instruments = response.json() + for item in instruments["data"]: + if item["mount"] == "extension": + instrument_serial = item["serialNumber"] + + else: + response = requests.get( + f"http://{ip}:31950/pipettes", headers={"opentrons-version": "3"} + ) + pipette_data = response.json() + instrument_serial = pipette_data[mount_name].get("id", "") + if str(instrument_serial) == "None": + raise Exception("Please specify a valid mount.") + return robot_name, instrument_serial + + async def _main( - mount: OT3Mount, simulate: bool, time_min: int, z_axis: Axis, distance: int + mount: OT3Mount, mount_name: str, simulate: bool, time_min: int, z_axis: Axis ) -> None: + + domain_url = "https://opentrons.atlassian.net" + + # make directory for tests. check if directory exists, make if doesn't. + BASE_DIRECTORY = "/userfs/data/testing_data/z_axis_test/" + if not os.path.exists(BASE_DIRECTORY): + os.makedirs(BASE_DIRECTORY) + + travel = get_travel(mount_name) + + # Ask, get, and test Jira ticket link + connect_jira = False + while True: + y_or_no = input("Do you want to attach the results to a JIRA Ticket? Y/N: ") + if y_or_no == "Y" or y_or_no == "y": + connect_jira = True + # grab testing teams jira api info from a local file - MAKE INTO FUNCTION + storage_directory = "/var/lib/jupyter/notebooks" + jira_info = os.path.join(storage_directory, "jira_credentials.json") + # create an dict copying the contents of the testing team jira info + try: + jira_keys = json.load(open(jira_info)) + # grab token and email from the dict + tot_info = jira_keys["information"] + api_token = tot_info["api_token"] + email = tot_info["email"] + except FileNotFoundError: + raise Exception( + f"Please add json file with the testing team \ +jira credentials to: {storage_directory}." + ) + ticket = jira_tool.JiraTicket(domain_url, api_token, email) + issue_key = ticket.get_ticket() + break + elif y_or_no == "N" or y_or_no == "n": + connect_jira = False + break + else: + print("Please Choose a Valid Option") + + # get and confirm robot IP address then grab robot name and instrument serial + ip = get_robot_ip() + robot_info = get_robot_info(ip, mount_name) + robot_name = robot_info[0] + instrument_serial = robot_info[1] + print(instrument_serial) + print(robot_name) + + # Create csv file and add initial line + current_datetime = datetime.datetime.now() + time_start = current_datetime.strftime("%m-%d-%y, at %H-%M-%S") + + init_data = [ + [ + f"Robot: {robot_name}", + f" Mount: {mount_name}", + f" Distance: {travel}", + f" Instrument Serial: {instrument_serial}", + ], + ] + + file_path = f"{BASE_DIRECTORY}/{robot_name} test on {time_start}" + + with open(file_path, mode="w", newline="") as creating_new_csv_file: + writer = csv.writer(creating_new_csv_file) + writer.writerows(init_data) + + # hw api setup hw_api = await build_async_ot3_hardware_api( is_simulating=simulate, use_defaults=True ) @@ -26,34 +191,54 @@ async def _main( timeout_start = time.time() timeout = time_min * 60 count = 0 - x_offset = 80 - y_offset = 44 + errored = False + # finding home and starting to move + error_message = "" try: await hw_api.home() await asyncio.sleep(1) - await hw_api.set_lights(rails=True) - home_position = await hw_api.current_position_ot3(mount) - try: - await hw_api.grip(force_newtons=None, stay_engaged=True) - except errors.exceptions.GripperNotPresentError: - print("Gripper not attached.") - print(f"home: {home_position}") - x_home = home_position[Axis.X] - x_offset - y_home = home_position[Axis.Y] - y_offset - z_home = home_position[z_axis] + await hw_api.move_rel(mount, Point(0, 0, -1)) while time.time() < timeout_start + timeout: # while True: - print(f"time: {time.time()-timeout_start}") - await hw_api.move_to(mount, Point(x_home, y_home, z_home)) - await hw_api.move_to(mount, Point(x_home, y_home, z_home - int(distance))) + await hw_api.move_rel(mount, Point(0, 0, (-1 * int(travel)))) + await hw_api.move_rel(mount, Point(0, 0, int(travel))) + # grab and print time and move count count += 1 print(f"cycle: {count}") + runtime = time.time() - timeout_start + print(f"time: {runtime}") + # write count and runtime to csv sheet + run_data = [ + [f"Cycle: {count}", f" Time: {runtime}"], + ] + with open(file_path, "a", newline="") as csvfile: + writer = csv.writer(csvfile) + writer.writerows(run_data) await hw_api.home() - except KeyboardInterrupt: - await hw_api.disengage_axes([Axis.X, Axis.Y, Axis.Z, Axis.G]) + except StallOrCollisionDetectedError: + errored = True + error_message = "Stall or Collision" + except PythonException: + errored = True + error_message = "KeyboardInterupt" + except BaseException as e: + errored = True + errorn = type(e).__name__ + print(f"THIS ERROR WAS: {errorn}") + error_message = str(errorn) finally: + # Grab info and comment on JIRA await hw_api.disengage_axes([Axis.X, Axis.Y, Axis.Z, Axis.G]) await hw_api.clean_up() + comment_message = get_summary(file_path, count, errored, error_message) + print(comment_message) + if connect_jira is True: + # use REST to comment on JIRA ticket + comment = ticket.format_jira_comment(comment_message) + ticket.comment(comment, issue_key) + + # post csv file created to jira ticket + ticket.post_attachment_to_ticket(issue_key, file_path) def main() -> None: @@ -65,21 +250,19 @@ def main() -> None: "--mount", type=str, choices=["left", "right", "gripper"], default="left" ) args = parser.parse_args() - print(args.mount) if args.mount == "left": mount = OT3Mount.LEFT + mount_name = "left" z_axis = Axis.Z_L - distance = 115 elif args.mount == "gripper": mount = OT3Mount.GRIPPER + mount_name = "gripper" z_axis = Axis.Z_G - distance = 190 else: mount = OT3Mount.RIGHT + mount_name = "right" z_axis = Axis.Z_R - distance = 115 - print(f"Mount Testing: {mount}") - asyncio.run(_main(mount, args.simulate, args.time_min, z_axis, distance)) + asyncio.run(_main(mount, mount_name, args.simulate, args.time_min, z_axis)) if __name__ == "__main__": diff --git a/hardware-testing/hardware_testing/scripts/speed_accel_profile.py b/hardware-testing/hardware_testing/scripts/speed_accel_profile.py index c00a68038cd..52fed84c9f2 100644 --- a/hardware-testing/hardware_testing/scripts/speed_accel_profile.py +++ b/hardware-testing/hardware_testing/scripts/speed_accel_profile.py @@ -2,21 +2,27 @@ import argparse import asyncio import csv +import json import numpy as np import time import os from typing import Tuple, Dict - +from abr_testing.automation import jira_tool # type: ignore[import] from opentrons.hardware_control.ot3api import OT3API from opentrons_shared_data.errors.exceptions import PositionUnknownError - -from hardware_testing.opentrons_api.types import GantryLoad, OT3Mount, Axis, Point +from hardware_testing.opentrons_api.types import ( + GantryLoad, + OT3Mount, + Axis, + Point, +) from hardware_testing.opentrons_api.helpers_ot3 import ( build_async_ot3_hardware_api, GantryLoadSettings, set_gantry_load_per_axis_settings_ot3, ) + import logging logging.basicConfig(level=logging.INFO) @@ -313,6 +319,36 @@ async def _main(is_simulating: bool) -> None: ) print("HOMING") await api.home([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) + + while True: + y_or_no = input("Do you want to attach the results to a JIRA Ticket? Y/N: ") + if y_or_no == "Y" or y_or_no == "y": + connect_jira = True + # grab testing teams jira api info from a local file + storage_directory = "/var/lib/jupyter/notebooks" + jira_info = os.path.join(storage_directory, "jira_credentials.json") + # create an dict copying the contents of the testing team jira info + try: + jira_keys = json.load(open(jira_info)) + # grab token and email from the dict + tot_info = jira_keys["information"] + api_token = tot_info["api_token"] + email = tot_info["email"] + except FileNotFoundError: + raise Exception( + f"Please add json file with the testing \ +team jira credentials to: {storage_directory}." + ) + domain_url = "https://opentrons.atlassian.net" + ticket = jira_tool.JiraTicket(domain_url, api_token, email) + issue_key = ticket.get_ticket() + break + elif y_or_no == "N" or y_or_no == "n": + connect_jira = False + break + else: + print("Please Choose a Valid Option") + try: # #run the test while recording raw results table_results = {} @@ -320,8 +356,8 @@ async def _main(is_simulating: bool) -> None: # check if directory exists, make if doesn't if not os.path.exists(BASE_DIRECTORY): os.makedirs(BASE_DIRECTORY) - - with open(BASE_DIRECTORY + SAVE_NAME + AXIS + ".csv", mode="w") as csv_file: + raw_path = BASE_DIRECTORY + SAVE_NAME + AXIS + ".csv" + with open(raw_path, mode="w") as csv_file: fieldnames = ["axis", "current", "speed", "acceleration", "error", "cycles"] writer = csv.DictWriter(csv_file, fieldnames=fieldnames) writer.writeheader() @@ -395,10 +431,9 @@ async def _main(is_simulating: bool) -> None: # create tableized output csv for neat presentation # print(table_results) test_axis_list = list(AXIS) + table_path = BASE_DIRECTORY + SAVE_NAME + test_axis + "_table.csv" for test_axis in test_axis_list: - with open( - BASE_DIRECTORY + SAVE_NAME + test_axis + "_table.csv", mode="w" - ) as csv_file: + with open(table_path, mode="w") as csv_file: fieldnames = ["Current", "Speed"] + [ *parameter_range(LOAD, test_axis, "ACCEL") ] @@ -424,6 +459,61 @@ async def _main(is_simulating: bool) -> None: except KeyboardInterrupt: await api.disengage_axes([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) finally: + """grab avg, max, and min values and associated values""" + row_count = 0 + with open(raw_path, newline="") as csvfile: + row_count = sum(1 for row in csvfile) + # initialize these variables as they are all locked behind if statements + tot_error = 0.0 + max_error = 0.0 + min_error = 100.0 + # open the csv file containing raw data + with open(raw_path, newline="") as csvfile: + csvobj = csv.reader(csvfile, delimiter=",") + full_list = list(csvobj) + row_count = row_count - 1 + # read through raw csv file and collect total error, max and min values + for count in range(row_count): + row_of_interest = str(full_list[count + 1]) + error = float(row_of_interest.split("'")[9]) + tot_error = tot_error + error + if max_error <= error: + max_error = error + max_error_info = str(row_of_interest) + if min_error >= error: + min_error = error + min_error_info = str(row_of_interest) + # find average error info and round all errors + avg_error = tot_error / row_count + avg_error = round(avg_error, 5) + avg_error_message = f"Average error was {avg_error}." + max_error = round(max_error, 5) + min_error = round(min_error, 5) + # grab maximum, minimum info and fomrat into a message + max_current = max_error_info.split("'")[3] + max_speed = max_error_info.split("'")[5] + max_accel = max_error_info.split("'")[7] + max_error_message = f"Maximum error was {max_error} and occured at {max_current} current, \ +{max_speed} speed, and {max_accel} acceleration." + min_current = min_error_info.split("'")[3] + min_speed = min_error_info.split("'")[5] + min_accel = min_error_info.split("'")[7] + min_error_message = f"Minimum error was {min_error} and occured at {min_current} current, \ +{min_speed} speed, and {min_accel} acceleration." + comment_message = ( + f"{max_error_message}\n{min_error_message}\n{avg_error_message}" + ) + print(comment_message) + # if jira connection is requested, add jira comment and attach files + if connect_jira is True: + # comment to Jira + ticket_message = [] + ticket_message = ticket.format_jira_comment(comment_message) + ticket.comment(ticket_message, issue_key) + + # post csv files created to jira ticket + ticket.post_attachment_to_ticket(issue_key, raw_path) + ticket.post_attachment_to_ticket(issue_key, table_path) # await api.disengage_axes([Axis.X, Axis.Y, Axis.Z_L, Axis.Z_R]) await api.clean_up()