diff --git a/rocketserializer/_helpers.py b/rocketserializer/_helpers.py index 7dfbc40..9a9567d 100644 --- a/rocketserializer/_helpers.py +++ b/rocketserializer/_helpers.py @@ -88,14 +88,14 @@ def parse_ork_file(ork_path: Path): return bs, datapoints except UnicodeDecodeError as exc: error_msg = ( - "The .ork file is not in UTF-8." + "The .ork file is not in UTF-8. " + "Please open the .ork file in a text editor and save it as UTF-8." ) logger.error(error_msg) - raise UnicodeDecodeError(error_msg) from exc + raise ValueError(error_msg) from exc except Exception as e: logger.error("Error while parsing the file '%s': %s", ork_path.as_posix(), e) - raise e + raise def _dict_to_string(dictionary, indent=0): diff --git a/rocketserializer/cli.py b/rocketserializer/cli.py index 64f2efe..64c3195 100644 --- a/rocketserializer/cli.py +++ b/rocketserializer/cli.py @@ -71,7 +71,7 @@ def cli(): help="The path to the OpenRocket .jar file.", ) @click.option("--encoding", type=str, default="utf-8", required=False) -@click.option("--verbose", type=bool, default=False, required=False) +@click.option("--verbose", is_flag=True, default=False, help="Enable verbose logging") def ork2json(filepath, output=None, ork_jar=None, encoding="utf-8", verbose=False): """Generates a .json file from the .ork file. The .json file will be generated in the output folder using the information @@ -216,8 +216,8 @@ def ork2json(filepath, output=None, ork_jar=None, encoding="utf-8", verbose=Fals @click.option("--output", type=str, required=False) @click.option("--ork_jar", type=str, default=None, required=False) @click.option("--encoding", type=str, default="utf-8", required=False) -@click.option("--verbose", type=bool, default=False, required=False) -def ork2notebook(filepath, output, ork_jar=None, encoding="utf-8", verbose=False): +@click.option("--verbose", is_flag=True, default=False, help="Enable verbose logging") +def ork2notebook(filepath, output, ork_jar=None, encoding="utf-8", verbose=False): # pylint: disable=unused-argument """Generates a .ipynb file from the .ork file. Notes @@ -239,10 +239,9 @@ def ork2notebook(filepath, output, ork_jar=None, encoding="utf-8", verbose=False "--output", str(output), "--encoding", - str(encoding), - "--verbose", - str(verbose), ] + if verbose: + args.append("--verbose") if ork_jar: args.extend(["--ork_jar", str(ork_jar)]) diff --git a/rocketserializer/components/environment.py b/rocketserializer/components/environment.py index d5187f7..b02ea82 100644 --- a/rocketserializer/components/environment.py +++ b/rocketserializer/components/environment.py @@ -23,18 +23,18 @@ def search_environment(bs): """ settings = {} - latitude = float(bs.find("launchlatitude").text) - longitude = float(bs.find("launchlongitude").text) - elevation = float(bs.find("launchaltitude").text) - wind_average = float(bs.find("windaverage").text) - wind_turbulence = float(bs.find("windturbulence").text) - geodetic_method = bs.find("geodeticmethod").text + latitude = float(getattr(bs.find("launchlatitude"), "text", "0")) + longitude = float(getattr(bs.find("launchlongitude"), "text", "0")) + elevation = float(getattr(bs.find("launchaltitude"), "text", "0")) + wind_average = float(getattr(bs.find("windaverage"), "text", "0")) + wind_turbulence = float(getattr(bs.find("windturbulence"), "text", "0")) + geodetic_method = getattr(bs.find("geodeticmethod"), "text", "") logger.info( "Collected first environment settings: latitude, " + "longitude, elevation, wind_average, wind_turbulence, geodetic_method" ) try: - base_temperature = float(bs.find("basetemperature").text) + base_temperature = float(getattr(bs.find("basetemperature"), "text", "0")) logger.info( "The base temperature was found in the .ork file. It is %f °C.", base_temperature, @@ -46,7 +46,7 @@ def search_environment(bs): ) base_temperature = None try: - base_pressure = float(bs.find("basepressure").text) + base_pressure = float(getattr(bs.find("basepressure"), "text", "0")) logger.info( "The base pressure was found in the .ork file. It is %f Pa.", base_pressure, diff --git a/rocketserializer/components/fins.py b/rocketserializer/components/fins.py index 505f463..e9ad0b2 100644 --- a/rocketserializer/components/fins.py +++ b/rocketserializer/components/fins.py @@ -38,7 +38,7 @@ def search_trapezoidal_fins(bs, elements): "Starting collecting the settings for the trapezoidal fin set number '%d'", idx, ) - label = fin.find("name").text + label = getattr(fin.find("name"), "text", "") try: def get_element_by_name(name): @@ -57,32 +57,36 @@ def get_element_by_name(name): logger.error(message) raise KeyError(message) - n_fin = int(fin.find("fincount").text) + n_fin = int(getattr(fin.find("fincount"), "text", "0")) logger.info("Number of fins retrieved: %d", n_fin) - root_chord = float(fin.find("rootchord").text) + root_chord = float(getattr(fin.find("rootchord"), "text", "0")) logger.info("Root chord retrieved: %f", root_chord) - tip_chord = float(fin.find("tipchord").text) + tip_chord = float(getattr(fin.find("tipchord"), "text", "0")) logger.info("Tip chord retrieved: %f", tip_chord) - span = float(fin.find("height").text) + span = float(getattr(fin.find("height"), "text", "0")) logger.info("Span retrieved: %f", span) sweep_length = ( - float(fin.find("sweeplength").text) if fin.find("sweeplength") else None + float(getattr(fin.find("sweeplength"), "text", "0")) + if fin.find("sweeplength") + else None ) sweep_angle = ( - float(fin.find("sweepangle").text) if fin.find("sweepangle") else None + float(getattr(fin.find("sweepangle"), "text", "0")) + if fin.find("sweepangle") + else None ) logger.info( "Sweep length and angle retrieved: %s, %s", sweep_length, sweep_angle ) - cant_angle = float(fin.find("cant").text) + cant_angle = float(getattr(fin.find("cant"), "text", "0")) logger.info("Cant angle retrieved: %f", cant_angle) - section = fin.find("crosssection").text + section = getattr(fin.find("crosssection"), "text", "") logger.info("Crosssection format retrieved") fin_settings = { @@ -145,7 +149,7 @@ def search_elliptical_fins(bs, elements): "Starting collecting the settings for the elliptical fin set number '%d'", idx, ) - label = fin.find("name").text + label = getattr(fin.find("name"), "text", "") try: def get_element_by_name(name): @@ -164,19 +168,19 @@ def get_element_by_name(name): logger.error(message) raise KeyError(message) - n_fin = int(fin.find("fincount").text) + n_fin = int(getattr(fin.find("fincount"), "text", "0")) logger.info("Number of fins retrieved: %d", n_fin) - root_chord = float(fin.find("rootchord").text) + root_chord = float(getattr(fin.find("rootchord"), "text", "0")) logger.info("Root chord retrieved: %f", root_chord) - span = float(fin.find("height").text) + span = float(getattr(fin.find("height"), "text", "0")) logger.info("Span retrieved: %f", span) - cant_angle = float(fin.find("cant").text) + cant_angle = float(getattr(fin.find("cant"), "text", "0")) logger.info("Cant angle retrieved: %f", cant_angle) - section = fin.find("crosssection").text + section = getattr(fin.find("crosssection"), "text", "") logger.info("Crosssection format retrieved") fin_settings = { diff --git a/rocketserializer/components/flight.py b/rocketserializer/components/flight.py index ffb7ae6..09d7212 100644 --- a/rocketserializer/components/flight.py +++ b/rocketserializer/components/flight.py @@ -21,9 +21,9 @@ def search_launch_conditions(bs): """ settings = {} - launch_rod_length = float(bs.find("launchrodlength").text) - launch_rod_angle = float(bs.find("launchrodangle").text) - launch_rod_direction = float(bs.find("launchroddirection").text) + launch_rod_length = float(getattr(bs.find("launchrodlength"), "text", "0")) + launch_rod_angle = float(getattr(bs.find("launchrodangle"), "text", "0")) + launch_rod_direction = float(getattr(bs.find("launchroddirection"), "text", "0")) logger.info( "Collected launch conditions: launch rod length, launch rod angle, " "launch rod direction." diff --git a/rocketserializer/components/id.py b/rocketserializer/components/id.py index e8b093d..c282333 100644 --- a/rocketserializer/components/id.py +++ b/rocketserializer/components/id.py @@ -24,20 +24,31 @@ def search_id_info(bs, filepath): "filepath". """ settings = {} - settings["rocket_name"] = bs.find("rocket").find("name").text - logger.info("Collected the rocket name: '%s'", settings["rocket_name"]) - - try: - settings["comment"] = bs.find("rocket").find("comment").text.replace("\n", "") - logger.info("Collected the comment saved in the file: %s", settings["comment"]) - except AttributeError: - logger.warning("No auxiliary comment was found in the file.") + rocket_tag = bs.find("rocket") + if rocket_tag: + settings["rocket_name"] = getattr(rocket_tag.find("name"), "text", "") + logger.info("Collected the rocket name: '%s'", settings["rocket_name"]) + + comment_tag = rocket_tag.find("comment") + if comment_tag and comment_tag.text: + settings["comment"] = comment_tag.text.replace("\n", "") + logger.info( + "Collected the comment saved in the file: %s", settings["comment"] + ) + else: + logger.warning("No auxiliary comment was found in the file.") + settings["comment"] = None + + designer_tag = rocket_tag.find("designer") + if designer_tag and designer_tag.text: + settings["designer"] = designer_tag.text + logger.info("Collected the designer name: %s", settings["designer"]) + else: + logger.warning("No designer name was found in the file.") + settings["designer"] = None + else: + settings["rocket_name"] = "" settings["comment"] = None - try: - settings["designer"] = bs.find("rocket").find("designer").text - logger.info("Collected the designer name: %s", settings["designer"]) - except AttributeError: - logger.warning("No designer name was found in the file.") settings["designer"] = None # settings["ork_version"] = bs.attrs["creator"] settings["filepath"] = Path(filepath).as_posix() diff --git a/rocketserializer/components/motor.py b/rocketserializer/components/motor.py index 479df0c..7ad1c04 100644 --- a/rocketserializer/components/motor.py +++ b/rocketserializer/components/motor.py @@ -39,13 +39,18 @@ def search_motor(bs, datapoints, data_labels): settings = {} # retrieve motor geometry - motor_length = float(bs.find("motormount").find("length").text) - motor_radius = float(bs.find("motormount").find("diameter").text) / 2 + motormount = bs.find("motormount") + if motormount is not None: + motor_length = float(getattr(motormount.find("length"), "text", "") or "0") + diam = getattr(motormount.find("diameter"), "text", "") or "0" + motor_radius = float(diam) / 2 + else: + motor_length = 0.0 + motor_radius = 0.0 logger.info("Collected motor geometry: motor length and motor radius.") # get motor mass properties total_propellant_mass, motor_dry_mass, _ = __get_motor_mass(datapoints, data_labels) - motor_dry_mass = 0 # If NOTE: dry inertia is 0, this should ALWAYS be 0 too. center_of_dry_mass = 0 dry_inertia = (0, 0, 0) # impossible to retrieve from .ork file @@ -183,6 +188,10 @@ def __get_motor_mass(datapoints, data_labels): prop_mass_vector = motor_mass - motor_dry_mass prop_mass_vector = list(prop_mass_vector) logger.info("The motor dry mass is %.3f kg.", motor_dry_mass) + else: + raise ValueError( + "Neither 'Propellant mass' nor 'Motor mass' found in data_labels." + ) normalize = np.array(prop_mass_vector) normalize = normalize - normalize[np.argmin(normalize)] diff --git a/rocketserializer/components/nose_cone.py b/rocketserializer/components/nose_cone.py index 48c33c4..1d0d887 100644 --- a/rocketserializer/components/nose_cone.py +++ b/rocketserializer/components/nose_cone.py @@ -26,12 +26,13 @@ def search_nosecone(bs, elements=None, rocket_radius=None, just_radius=False): """ settings = {} nosecone = bs.find("nosecone") # TODO: allow for multiple nosecones - name = nosecone.find("name").text if nosecone else "nosecone" + name = getattr(nosecone.find("name"), "text", "") if nosecone else "nosecone" if not nosecone: nosecones = list( filter( - lambda x: x.find("name").text == "Nosecone", bs.find_all("transition") + lambda x: getattr(x.find("name"), "text", "") == "Nosecone", + bs.find_all("transition"), ) ) if len(nosecones) == 0: @@ -41,9 +42,9 @@ def search_nosecone(bs, elements=None, rocket_radius=None, just_radius=False): logger.info("Multiple nosecones found, using only the first one") nosecone = nosecones[0] # only the first nosecone is considered - length = float(nosecone.find("length").text) - kind = nosecone.find("shape").text - base_radius = nosecone.find("aftradius").text + length = float(getattr(nosecone.find("length"), "text", "0")) + kind = getattr(nosecone.find("shape"), "text", "") + base_radius = getattr(nosecone.find("aftradius"), "text", "") try: base_radius = float(base_radius) except ValueError: @@ -83,10 +84,11 @@ def get_position(name, length): if kind == "haack": logger.info("Nosecone is a haack nosecone, searching for the shape parameter") - shape_parameter = float(nosecone.find("shapeparameter").text) + shape_parameter = float(getattr(nosecone.find("shapeparameter"), "text", "0")) kind = "Von Karman" if shape_parameter == 0.0 else "lvhaack" - settings.update({"noseShapeParameter": shape_parameter}) logger.info("Shape parameter of the nosecone: %s", shape_parameter) + else: + shape_parameter = None settings = { "name": name, @@ -95,5 +97,8 @@ def get_position(name, length): "base_radius": base_radius, "position": get_position(name, length), } + if shape_parameter is not None: + settings["noseShapeParameter"] = shape_parameter + logger.info("Nosecone setting defined:\n %s", _dict_to_string(settings, indent=23)) return settings diff --git a/rocketserializer/components/parachute.py b/rocketserializer/components/parachute.py index 6aa4ca6..462218b 100644 --- a/rocketserializer/components/parachute.py +++ b/rocketserializer/components/parachute.py @@ -30,20 +30,24 @@ def search_parachutes(bs): for idx, chute in enumerate(chutes): logger.info("Starting to collect the settings of the parachute number %d", idx) - name = chute.find("name").text + name = getattr(chute.find("name"), "text", "") # parachute settings - cd = "auto" if "auto" in chute.find("cd").text else float(chute.find("cd").text) + cd = ( + "auto" + if "auto" in getattr(chute.find("cd"), "text", "") + else float(getattr(chute.find("cd"), "text", "0")) + ) cd = search_cd_chute_if_auto(chute) if cd == "auto" else cd - area = np.pi * float(chute.find("diameter").text) ** 2 / 4 + area = np.pi * float(getattr(chute.find("diameter"), "text", "0")) ** 2 / 4 cds = cd * area logger.info("Parachute '%s' has a drag coefficient of %f", name, cd) # deployment settings - deploy_event = chute.find("deployevent").text - deploy_delay = float(chute.find("deploydelay").text) + deploy_event = getattr(chute.find("deployevent"), "text", "") + deploy_delay = float(getattr(chute.find("deploydelay"), "text", "0")) deploy_altitude = ( - float(chute.find("deployaltitude").text) + float(getattr(chute.find("deployaltitude"), "text", "0")) if deploy_event == "altitude" else None ) @@ -70,17 +74,11 @@ def search_parachutes(bs): def search_cd_chute_if_auto(bs): - # if the parachute cd is st to "auto", then look for the cd in the next tag - # return float( - # next( - # filter(lambda x: x.text.replace(".", "").isnumeric(), bs.findAll("cd")) - # ).text - # ) - - # TODO: for the future, we need to check if the ork object has a drag coefficient - - # simply return 1.0 + # if the parachute cd is set to "auto", OpenRocket defaults to 0.75 (flat) + # or 1.5 (dome). Since we cannot easily deduce the type, 0.75 is the + # most common standard parachute CD in OR. logger.warning( - "cd auto: the cd is set to 1.0 for parachute %s", bs.find("name").text + "cd auto: the cd is set to 0.75 for parachute %s", + getattr(bs.find("name"), "text", "Unknown"), ) - return 1.0 + return 0.75 diff --git a/rocketserializer/components/rail_buttons.py b/rocketserializer/components/rail_buttons.py index 6e7da2d..d52168d 100644 --- a/rocketserializer/components/rail_buttons.py +++ b/rocketserializer/components/rail_buttons.py @@ -25,8 +25,8 @@ def search_rail_buttons(bs, elements: dict) -> dict: angular_position = 0.0 lugs = bs.find_all("launchlug") for lug in lugs: - if lug.find("name").text == name: - angular_position = float(lug.find("radialdirection").text) + if getattr(lug.find("name"), "text", "") == name: + angular_position = float(getattr(lug.find("radialdirection"), "text", "0")) break return { diff --git a/rocketserializer/components/rocket.py b/rocketserializer/components/rocket.py index a58c412..8e79ae8 100644 --- a/rocketserializer/components/rocket.py +++ b/rocketserializer/components/rocket.py @@ -55,8 +55,12 @@ def get_rocket_radius(bs): noses = bs.find_all("nosecone") transitions = bs.find_all("transition") - tubes_radius = [i.find("radius").text for i in tubes if i.find("radius")] - noses_radius = [i.find("aftradius").text for i in noses if i.find("aftradius")] + tubes_radius = [ + getattr(i.find("radius"), "text", "") for i in tubes if i.find("radius") + ] + noses_radius = [ + getattr(i.find("aftradius"), "text", "") for i in noses if i.find("aftradius") + ] # Also collect radii from transitions (foreradius and aftradius) transition_radius = [] diff --git a/rocketserializer/components/stored_results.py b/rocketserializer/components/stored_results.py index f9849b7..803d939 100644 --- a/rocketserializer/components/stored_results.py +++ b/rocketserializer/components/stored_results.py @@ -101,8 +101,7 @@ def __get_parameter(datapoints, data_labels, time_vector, label, position): parameter = np.array([time_vector, parameter]).T # sort by time parameter = parameter[parameter[:, 0].argsort()] - # clip the curve to remove negative values - parameter[parameter[:, 1] < 0, 1] = 0 + # do not clip negative values, as stability margin can be negative # Assuming parameter is a NumPy array and 'NaN' values are represented as np.nan # This will keep rows where the second column is not NaN parameter = parameter[~np.isnan(parameter[:, 1])] diff --git a/rocketserializer/components/transition.py b/rocketserializer/components/transition.py index 9823709..0308a71 100644 --- a/rocketserializer/components/transition.py +++ b/rocketserializer/components/transition.py @@ -54,7 +54,7 @@ def search_transitions(bs, elements, ork): for idx, transition in enumerate(transitions): logger.info("Starting to collect the settings of the transition number %d", idx) - label = transition.find("name").text + label = getattr(transition.find("name"), "text", "") logger.info("Collected the name of the transition number %d", idx) # Try to find matching Java transition by name or index @@ -68,21 +68,21 @@ def search_transitions(bs, elements, ork): if transition_ork is not None: top_radius = float(transition_ork.getForeRadius()) + bottom_radius = float(transition_ork.getAftRadius()) else: logger.warning( - "Could not find Java transition for '%s', using foreradius from XML.", + "Could not find Java transition for '%s', using radii from XML.", label, ) fore_tag = transition.find("foreradius") fore_text = fore_tag.text if fore_tag else "0" top_radius = 0.0 if "auto" in fore_text else float(fore_text) - bottom_radius = ( - transition.find("aftradius").text - if "auto" in transition.find("aftradius").text - else float(transition.find("aftradius").text) - ) - length = float(transition.find("length").text) + aft_tag = transition.find("aftradius") + aft_text = aft_tag.text if aft_tag else "0" + bottom_radius = 0.0 if "auto" in aft_text else float(aft_text) + + length = float(getattr(transition.find("length"), "text", "0")) logger.info("Collected the dimensions of the transition number %d", idx) def get_position(name, length): diff --git a/rocketserializer/nb_builder.py b/rocketserializer/nb_builder.py index 8f562eb..3012fbf 100644 --- a/rocketserializer/nb_builder.py +++ b/rocketserializer/nb_builder.py @@ -1,6 +1,7 @@ import json import logging import os +import subprocess import nbformat as nbf @@ -743,5 +744,5 @@ def save_notebook(self, nb: nbf.v4.new_notebook, destination: str) -> None: logger.info("[NOTEBOOK BUILDER] Notebook saved to '%s'", out_file) # apply black formatting after saving (requires black[jupyter]) - os.system(f"black {out_file}") + subprocess.run(["black", out_file], check=False) logger.info("[NOTEBOOK BUILDER] Black formatting applied to the final notebook") diff --git a/rocketserializer/ork_extractor.py b/rocketserializer/ork_extractor.py index 5570ea5..f5b4afd 100644 --- a/rocketserializer/ork_extractor.py +++ b/rocketserializer/ork_extractor.py @@ -45,6 +45,13 @@ def ork_extractor(bs, filepath, output_folder, ork): """ settings = {} + def _safe_search(func, default_ret, *args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Error extracting %s: %s", func.__name__, e, exc_info=True) + return default_ret + # Initialize some important vectors datapoints, data_labels, time_vector = __init_vectors(bs) logger.info("Initialized data vectors from the ORK file.") @@ -58,36 +65,54 @@ def ork_extractor(bs, filepath, output_folder, ork): id_info = search_id_info(bs, filepath) logger.info("Metadata parameters retrieved.") - environment = search_environment(bs) + environment = _safe_search(search_environment, {}, bs) logger.info("Environment parameters retrieved.") - rocket, motor_position = search_rocket( - bs, datapoints, data_labels, burnout_position + rocket_data = _safe_search( + search_rocket, + ({"center_of_mass_without_propellant": 0, "mass": 0, "radius": 0}, 0), + bs, + datapoints, + data_labels, + burnout_position, ) + rocket, motor_position = rocket_data motors["position"] = motor_position logger.info("Rocket parameters retrieved.") - flight = search_launch_conditions(bs) + flight = _safe_search(search_launch_conditions, {}, bs) logger.info("Flight conditions retrieved.") # process different elements of the rocket - center_of_dry_mass = rocket["center_of_mass_without_propellant"] - rocket_mass = rocket["mass"] - rocket_radius = rocket["radius"] - - elements = process_elements_position( - ork.getRocket(), {}, center_of_dry_mass, rocket_mass, top_position=0 + center_of_dry_mass = rocket.get("center_of_mass_without_propellant", 0) + rocket_mass = rocket.get("mass", 0) + rocket_radius = rocket.get("radius", 0) + + elements = _safe_search( + process_elements_position, + {}, + ork.getRocket(), + {}, + center_of_dry_mass, + rocket_mass, + top_position=0, ) logger.info("The elements are:\n%s", _dict_to_string(elements, indent=23)) - nosecones = search_nosecone(bs, elements, rocket_radius) - trapezoidal_fins = search_trapezoidal_fins(bs, elements) - elliptical_fins = search_elliptical_fins(bs, elements) - transitions = search_transitions(bs, elements, ork) - rail_buttons = search_rail_buttons(bs, elements) - parachutes = search_parachutes(bs) - stored_results = search_stored_results( - bs, datapoints, data_labels, time_vector, burnout_position + nosecones = _safe_search(search_nosecone, [], bs, elements, rocket_radius) + trapezoidal_fins = _safe_search(search_trapezoidal_fins, [], bs, elements) + elliptical_fins = _safe_search(search_elliptical_fins, [], bs, elements) + transitions = _safe_search(search_transitions, [], bs, elements, ork) + rail_buttons = _safe_search(search_rail_buttons, [], bs, elements) + parachutes = _safe_search(search_parachutes, [], bs) + stored_results = _safe_search( + search_stored_results, + {}, + bs, + datapoints, + data_labels, + time_vector, + burnout_position, ) # save everything to a dictionary @@ -152,12 +177,13 @@ def __init_vectors(bs): time_vector = [float(datapoint.text.split(",")[0]) for datapoint in datapoints] start_pos = 0 - final_pos = len(time_vector) - 1 + final_pos = len(time_vector) # Get the start position, the ignition time. for idx, position in enumerate(time_vector): if position == 0: start_pos = idx + break # Filter the datapoints to get only the ones after the ignition. datapoints = datapoints[start_pos:final_pos]