diff --git a/rocketpy/_encoders.py b/rocketpy/_encoders.py index 189b7a5b5..d3d7005ca 100644 --- a/rocketpy/_encoders.py +++ b/rocketpy/_encoders.py @@ -1,8 +1,11 @@ """Defines a custom JSON encoder for RocketPy objects.""" +import base64 import json from datetime import datetime +from importlib import import_module +import dill import numpy as np @@ -10,6 +13,10 @@ class RocketPyEncoder(json.JSONEncoder): """Custom JSON encoder for RocketPy objects. It defines how to encode different types of objects to a JSON supported format.""" + def __init__(self, *args, **kwargs): + self.include_outputs = kwargs.pop("include_outputs", True) + super().__init__(*args, **kwargs) + def default(self, o): if isinstance( o, @@ -33,17 +40,152 @@ def default(self, o): elif isinstance(o, np.ndarray): return o.tolist() elif isinstance(o, datetime): - return o.isoformat() + return [o.year, o.month, o.day, o.hour] elif hasattr(o, "__iter__") and not isinstance(o, str): return list(o) elif hasattr(o, "to_dict"): - return o.to_dict() + encoding = o.to_dict(self.include_outputs) + encoding = remove_circular_references(encoding) + + encoding["signature"] = get_class_signature(o) + + return encoding + elif hasattr(o, "__dict__"): - exception_set = {"prints", "plots"} - return { - key: value - for key, value in o.__dict__.items() - if key not in exception_set - } + encoding = remove_circular_references(o.__dict__) + + if "rocketpy" in o.__class__.__module__: + encoding["signature"] = get_class_signature(o) + + return encoding else: return super().default(o) + + +class RocketPyDecoder(json.JSONDecoder): + """Custom JSON decoder for RocketPy objects. It defines how to decode + different types of objects from a JSON supported format.""" + + def __init__(self, *args, **kwargs): + super().__init__(object_hook=self.object_hook, *args, **kwargs) + + def object_hook(self, obj): + if "signature" in obj: + signature = obj.pop("signature") + + try: + class_ = get_class_from_signature(signature) + + if hasattr(class_, "from_dict"): + return class_.from_dict(obj) + else: + # Filter keyword arguments + kwargs = { + key: value + for key, value in obj.items() + if key in class_.__init__.__code__.co_varnames + } + + return class_(**kwargs) + except (ImportError, AttributeError): + return obj + else: + return obj + + +def get_class_signature(obj): + """Returns the signature of a class in the form of a string. + The signature is an importable string that can be used to import + the class by its module. + + Parameters + ---------- + obj : object + Object to get the signature from. + + Returns + ------- + str + Signature of the class. + """ + class_ = obj.__class__ + name = getattr(class_, '__qualname__', class_.__name__) + + return {"module": class_.__module__, "name": name} + + +def get_class_from_signature(signature): + """Returns the class by importing its signature. + + Parameters + ---------- + signature : str + Signature of the class. + + Returns + ------- + type + Class defined by the signature. + """ + module = import_module(signature["module"]) + inner_class = None + + for class_ in signature["name"].split("."): + inner_class = getattr(module, class_) + + return inner_class + + +def remove_circular_references(obj_dict): + """Removes circular references from a dictionary. + + Parameters + ---------- + obj_dict : dict + Dictionary to remove circular references from. + + Returns + ------- + dict + Dictionary without circular references. + """ + obj_dict.pop("prints", None) + obj_dict.pop("plots", None) + + return obj_dict + + +def to_hex_encode(obj, encoder=base64.b85encode): + """Converts an object to hex representation using dill. + + Parameters + ---------- + obj : object + Object to be converted to hex. + encoder : callable, optional + Function to encode the bytes. Default is base64.b85encode. + + Returns + ------- + bytes + Object converted to bytes. + """ + return encoder(dill.dumps(obj)).hex() + + +def from_hex_decode(obj_bytes, decoder=base64.b85decode): + """Converts an object from hex representation using dill. + + Parameters + ---------- + obj_bytes : str + Hex string to be converted to object. + decoder : callable, optional + Function to decode the bytes. Default is base64.b85decode. + + Returns + ------- + object + Object converted from bytes. + """ + return dill.loads(decoder(bytes.fromhex(obj_bytes))) diff --git a/rocketpy/environment/environment.py b/rocketpy/environment/environment.py index 55edc14fa..795d42a93 100644 --- a/rocketpy/environment/environment.py +++ b/rocketpy/environment/environment.py @@ -2743,6 +2743,96 @@ def decimal_degrees_to_arc_seconds(angle): arc_seconds = (remainder * 60 - arc_minutes) * 60 return degrees, arc_minutes, arc_seconds + def to_dict(self, include_outputs=True): + env_dict = { + "gravity": self.gravity, + "date": self.date, + "latitude": self.latitude, + "longitude": self.longitude, + "elevation": self.elevation, + "datum": self.datum, + "timezone": self.timezone, + "_max_expected_height": self.max_expected_height, + "atmospheric_model_type": self.atmospheric_model_type, + "pressure": self.pressure, + "barometric_height": self.barometric_height, + "temperature": self.temperature, + "wind_velocity_x": self.wind_velocity_x, + "wind_velocity_y": self.wind_velocity_y, + "wind_heading": self.wind_heading, + "wind_direction": self.wind_direction, + "wind_speed": self.wind_speed, + } + + if include_outputs: + env_dict["density"] = self.density + env_dict["speed_of_sound"] = self.speed_of_sound + env_dict["dynamic_viscosity"] = self.dynamic_viscosity + + return env_dict + + @classmethod + def from_dict(cls, data): # pylint: disable=too-many-statements + env = cls( + gravity=data["gravity"], + date=data["date"], + latitude=data["latitude"], + longitude=data["longitude"], + elevation=data["elevation"], + datum=data["datum"], + timezone=data["timezone"], + max_expected_height=data["_max_expected_height"], + ) + atmospheric_model = data["atmospheric_model_type"] + + if atmospheric_model == "standard_atmosphere": + env.set_atmospheric_model("standard_atmosphere") + elif atmospheric_model == "custom_atmosphere": + env.set_atmospheric_model( + type="custom_atmosphere", + pressure=data["pressure"], + temperature=data["temperature"], + wind_u=data["wind_velocity_x"], + wind_v=data["wind_velocity_y"], + ) + else: + env.__set_pressure_function(data["pressure"]) + env.__set_barometric_height_function(data["barometric_height"]) + env.__set_temperature_function(data["temperature"]) + env.__set_wind_velocity_x_function(data["wind_velocity_x"]) + env.__set_wind_velocity_y_function(data["wind_velocity_y"]) + env.__set_wind_heading_function(data["wind_heading"]) + env.__set_wind_direction_function(data["wind_direction"]) + env.__set_wind_speed_function(data["wind_speed"]) + env.elevation = data["elevation"] + env.max_expected_height = data["_max_expected_height"] + + if atmospheric_model in ["windy", "forecast", "reanalysis", "ensemble"]: + env.atmospheric_model_init_date = data["atmospheric_model_init_date"] + env.atmospheric_model_end_date = data["atmospheric_model_end_date"] + env.atmospheric_model_interval = data["atmospheric_model_interval"] + env.atmospheric_model_init_lat = data["atmospheric_model_init_lat"] + env.atmospheric_model_end_lat = data["atmospheric_model_end_lat"] + env.atmospheric_model_init_lon = data["atmospheric_model_init_lon"] + env.atmospheric_model_end_lon = data["atmospheric_model_end_lon"] + + if atmospheric_model == "ensemble": + env.level_ensemble = data["level_ensemble"] + env.height_ensemble = data["height_ensemble"] + env.temperature_ensemble = data["temperature_ensemble"] + env.wind_u_ensemble = data["wind_u_ensemble"] + env.wind_v_ensemble = data["wind_v_ensemble"] + env.wind_heading_ensemble = data["wind_heading_ensemble"] + env.wind_direction_ensemble = data["wind_direction_ensemble"] + env.wind_speed_ensemble = data["wind_speed_ensemble"] + env.num_ensemble_members = data["num_ensemble_members"] + + env.calculate_density_profile() + env.calculate_speed_of_sound_profile() + env.calculate_dynamic_viscosity() + + return env + if __name__ == "__main__": import doctest diff --git a/rocketpy/environment/environment_analysis.py b/rocketpy/environment/environment_analysis.py index 6b917d88a..35d2ac7f2 100644 --- a/rocketpy/environment/environment_analysis.py +++ b/rocketpy/environment/environment_analysis.py @@ -423,7 +423,10 @@ def __check_coordinates_inside_grid( or lat_index > len(lat_array) - 1 ): raise ValueError( - f"Latitude and longitude pair {(self.latitude, self.longitude)} is outside the grid available in the given file, which is defined by {(lat_array[0], lon_array[0])} and {(lat_array[-1], lon_array[-1])}." + f"Latitude and longitude pair {(self.latitude, self.longitude)} " + "is outside the grid available in the given file, which " + f"is defined by {(lat_array[0], lon_array[0])} and " + f"{(lat_array[-1], lon_array[-1])}." ) def __localize_input_dates(self): diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 52dd8b440..2ad3c385e 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -5,9 +5,7 @@ carefully as it may impact all the rest of the project. """ -import base64 import warnings -import zlib from bisect import bisect_left from collections.abc import Iterable from copy import deepcopy @@ -15,7 +13,6 @@ from inspect import signature from pathlib import Path -import dill import matplotlib.pyplot as plt import numpy as np from scipy import integrate, linalg, optimize @@ -25,6 +22,8 @@ RBFInterpolator, ) +from rocketpy._encoders import from_hex_decode, to_hex_encode + # Numpy 1.x compatibility, # TODO: remove these lines when all dependencies support numpy>=2.0.0 if np.lib.NumpyVersion(np.__version__) >= "2.0.0b1": @@ -712,9 +711,9 @@ def set_discrete( if func.__dom_dim__ == 1: xs = np.linspace(lower, upper, samples) ys = func.get_value(xs.tolist()) if one_by_one else func.get_value(xs) - func.set_source(np.concatenate(([xs], [ys])).transpose()) - func.set_interpolation(interpolation) - func.set_extrapolation(extrapolation) + func.__interpolation__ = interpolation + func.__extrapolation__ = extrapolation + func.set_source(np.column_stack((xs, ys))) elif func.__dom_dim__ == 2: lower = 2 * [lower] if isinstance(lower, NUMERICAL_TYPES) else lower upper = 2 * [upper] if isinstance(upper, NUMERICAL_TYPES) else upper @@ -3390,7 +3389,7 @@ def __validate_extrapolation(self, extrapolation): extrapolation = "natural" return extrapolation - def to_dict(self): + def to_dict(self, _): """Serializes the Function instance to a dictionary. Returns @@ -3401,7 +3400,7 @@ def to_dict(self): source = self.source if callable(source): - source = zlib.compress(base64.b85encode(dill.dumps(source))).hex() + source = to_hex_encode(source) return { "source": source, @@ -3423,9 +3422,7 @@ def from_dict(cls, func_dict): """ source = func_dict["source"] if func_dict["interpolation"] is None and func_dict["extrapolation"] is None: - source = dill.loads( - base64.b85decode(zlib.decompress(bytes.fromhex(source))) - ) + source = from_hex_decode(source) return cls( source=source, diff --git a/rocketpy/mathutils/vector_matrix.py b/rocketpy/mathutils/vector_matrix.py index 0da44935d..08aa03cac 100644 --- a/rocketpy/mathutils/vector_matrix.py +++ b/rocketpy/mathutils/vector_matrix.py @@ -403,6 +403,10 @@ def zeros(): """Returns the zero vector.""" return Vector([0, 0, 0]) + def to_dict(self, _): + """Returns the vector as a JSON compatible element.""" + return list(self.components) + @staticmethod def i(): """Returns the i vector, [1, 0, 0].""" @@ -418,9 +422,10 @@ def k(): """Returns the k vector, [0, 0, 1].""" return Vector([0, 0, 1]) - def to_dict(self): - """Returns the vector as a JSON compatible element.""" - return list(self.components) + @classmethod + def from_dict(cls, data): + """Creates a Vector instance from a dictionary.""" + return cls(data) class Matrix: @@ -1002,7 +1007,7 @@ def __repr__(self): + f" [{self.zx}, {self.zy}, {self.zz}])" ) - def to_dict(self): + def to_dict(self, _): """Returns the matrix as a JSON compatible element.""" return [list(row) for row in self.components] @@ -1092,6 +1097,11 @@ def transformation_euler_angles(roll, pitch, roll2): """ return Matrix.transformation(euler313_to_quaternions(roll, pitch, roll2)) + @classmethod + def from_dict(cls, data): + """Creates a Matrix instance from a dictionary.""" + return cls(data) + if __name__ == "__main__": import doctest diff --git a/rocketpy/motors/fluid.py b/rocketpy/motors/fluid.py index 4be124ec3..3d6efca48 100644 --- a/rocketpy/motors/fluid.py +++ b/rocketpy/motors/fluid.py @@ -60,3 +60,10 @@ def __str__(self): """ return f"Fluid: {self.name}" + + def to_dict(self, _): + return {"name": self.name, "density": self.density} + + @classmethod + def from_dict(cls, data): + return cls(data["name"], data["density"]) diff --git a/rocketpy/motors/hybrid_motor.py b/rocketpy/motors/hybrid_motor.py index 2916486d0..0fef8d06c 100644 --- a/rocketpy/motors/hybrid_motor.py +++ b/rocketpy/motors/hybrid_motor.py @@ -614,3 +614,68 @@ def all_info(self): """Prints out all data and graphs available about the Motor.""" self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update( + { + "grain_number": self.grain_number, + "grain_density": self.grain_density, + "grain_outer_radius": self.grain_outer_radius, + "grain_initial_inner_radius": self.grain_initial_inner_radius, + "grain_initial_height": self.grain_initial_height, + "grain_separation": self.grain_separation, + "grains_center_of_mass_position": self.grains_center_of_mass_position, + "throat_radius": self.throat_radius, + "positioned_tanks": [ + {"tank": tank["tank"], "position": tank["position"]} + for tank in self.positioned_tanks + ], + } + ) + + if include_outputs: + data.update( + { + "grain_inner_radius": self.solid.grain_inner_radius, + "grain_height": self.solid.grain_height, + "burn_area": self.solid.burn_area, + "burn_rate": self.solid.burn_rate, + } + ) + + return data + + @classmethod + def from_dict(cls, data): + motor = cls( + thrust_source=data["thrust_source"], + burn_time=data["burn_time"], + nozzle_radius=data["nozzle_radius"], + dry_mass=data["dry_mass"], + center_of_dry_mass_position=data["center_of_dry_mass_position"], + dry_inertia=( + data["dry_I_11"], + data["dry_I_22"], + data["dry_I_33"], + data["dry_I_12"], + data["dry_I_13"], + data["dry_I_23"], + ), + interpolation_method=data["interpolate"], + coordinate_system_orientation=data["coordinate_system_orientation"], + grain_number=data["grain_number"], + grain_density=data["grain_density"], + grain_outer_radius=data["grain_outer_radius"], + grain_initial_inner_radius=data["grain_initial_inner_radius"], + grain_initial_height=data["grain_initial_height"], + grain_separation=data["grain_separation"], + grains_center_of_mass_position=data["grains_center_of_mass_position"], + nozzle_position=data["nozzle_position"], + throat_radius=data["throat_radius"], + ) + + for tank in data["positioned_tanks"]: + motor.add_tank(tank["tank"], tank["position"]) + + return motor diff --git a/rocketpy/motors/liquid_motor.py b/rocketpy/motors/liquid_motor.py index cde0e9d03..d90a6e26d 100644 --- a/rocketpy/motors/liquid_motor.py +++ b/rocketpy/motors/liquid_motor.py @@ -481,3 +481,41 @@ def all_info(self): """ self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update( + { + "positioned_tanks": [ + {"tank": tank["tank"], "position": tank["position"]} + for tank in self.positioned_tanks + ], + } + ) + return data + + @classmethod + def from_dict(cls, data): + motor = cls( + thrust_source=data["thrust_source"], + burn_time=data["burn_time"], + nozzle_radius=data["nozzle_radius"], + dry_mass=data["dry_mass"], + center_of_dry_mass_position=data["center_of_dry_mass_position"], + dry_inertia=( + data["dry_I_11"], + data["dry_I_22"], + data["dry_I_33"], + data["dry_I_12"], + data["dry_I_13"], + data["dry_I_23"], + ), + nozzle_position=data["nozzle_position"], + interpolation_method=data["interpolate"], + coordinate_system_orientation=data["coordinate_system_orientation"], + ) + + for tank in data["positioned_tanks"]: + motor.add_tank(tank["tank"], tank["position"]) + + return motor diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index e0e6dfc9a..5e68b2e10 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -1083,6 +1083,60 @@ def get_attr_value(obj, attr_name, multiplier=1): # Write last line file.write(f"{self.thrust.source[-1, 0]:.4f} {0:.3f}\n") + def to_dict(self, include_outputs=True): + thrust_source = self.thrust_source + + if isinstance(thrust_source, str): + thrust_source = self.thrust.source + elif callable(thrust_source) and not isinstance(thrust_source, Function): + thrust_source = Function(thrust_source) + + data = { + "thrust_source": self.thrust, + "dry_I_11": self.dry_I_11, + "dry_I_22": self.dry_I_22, + "dry_I_33": self.dry_I_33, + "dry_I_12": self.dry_I_12, + "dry_I_13": self.dry_I_13, + "dry_I_23": self.dry_I_23, + "nozzle_radius": self.nozzle_radius, + "center_of_dry_mass_position": self.center_of_dry_mass_position, + "dry_mass": self.dry_mass, + "nozzle_position": self.nozzle_position, + "burn_time": self.burn_time, + "interpolate": self.interpolate, + "coordinate_system_orientation": self.coordinate_system_orientation, + } + + if include_outputs: + data.update( + { + "total_mass": self.total_mass, + "propellant_mass": self.propellant_mass, + "mass_flow_rate": self.mass_flow_rate, + "center_of_mass": self.center_of_mass, + "center_of_propellant_mass": self.center_of_propellant_mass, + "total_impulse": self.total_impulse, + "exhaust_velocity": self.exhaust_velocity, + "propellant_initial_mass": self.propellant_initial_mass, + "structural_mass_ratio": self.structural_mass_ratio, + "I_11": self.I_11, + "I_22": self.I_22, + "I_33": self.I_33, + "I_12": self.I_12, + "I_13": self.I_13, + "I_23": self.I_23, + "propellant_I_11": self.propellant_I_11, + "propellant_I_22": self.propellant_I_22, + "propellant_I_33": self.propellant_I_33, + "propellant_I_12": self.propellant_I_12, + "propellant_I_13": self.propellant_I_13, + "propellant_I_23": self.propellant_I_23, + } + ) + + return data + def info(self): """Prints out a summary of the data and graphs available about the Motor. @@ -1501,6 +1555,42 @@ def all_info(self): self.prints.all() self.plots.all() + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update( + { + "chamber_radius": self.chamber_radius, + "chamber_height": self.chamber_height, + "chamber_position": self.chamber_position, + "propellant_initial_mass": self.propellant_initial_mass, + } + ) + return data + + @classmethod + def from_dict(cls, data): + return cls( + thrust_source=data["thrust_source"], + burn_time=data["burn_time"], + chamber_radius=data["chamber_radius"], + chamber_height=data["chamber_height"], + chamber_position=data["chamber_position"], + propellant_initial_mass=data["propellant_initial_mass"], + nozzle_radius=data["nozzle_radius"], + dry_mass=data["dry_mass"], + center_of_dry_mass_position=data["center_of_dry_mass_position"], + dry_inertia=( + data["dry_I_11"], + data["dry_I_22"], + data["dry_I_33"], + data["dry_I_12"], + data["dry_I_13"], + data["dry_I_23"], + ), + nozzle_position=data["nozzle_position"], + interpolation_method=data["interpolate"], + ) + class EmptyMotor: """Class that represents an empty motor with no mass and no thrust.""" diff --git a/rocketpy/motors/solid_motor.py b/rocketpy/motors/solid_motor.py index f6f09967e..32dd0d3f7 100644 --- a/rocketpy/motors/solid_motor.py +++ b/rocketpy/motors/solid_motor.py @@ -740,3 +740,61 @@ def all_info(self): """Prints out all data and graphs available about the SolidMotor.""" self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update( + { + "nozzle_radius": self.nozzle_radius, + "throat_radius": self.throat_radius, + "grain_number": self.grain_number, + "grain_density": self.grain_density, + "grain_outer_radius": self.grain_outer_radius, + "grain_initial_inner_radius": self.grain_initial_inner_radius, + "grain_initial_height": self.grain_initial_height, + "grain_separation": self.grain_separation, + "grains_center_of_mass_position": self.grains_center_of_mass_position, + } + ) + + if include_outputs: + data.update( + { + "grain_inner_radius": self.grain_inner_radius, + "grain_height": self.grain_height, + "burn_area": self.burn_area, + "burn_rate": self.burn_rate, + "Kn": self.Kn, + } + ) + + return data + + @classmethod + def from_dict(cls, data): + return cls( + thrust_source=data["thrust_source"], + dry_mass=data["dry_mass"], + dry_inertia=( + data["dry_I_11"], + data["dry_I_22"], + data["dry_I_33"], + data["dry_I_12"], + data["dry_I_13"], + data["dry_I_23"], + ), + nozzle_radius=data["nozzle_radius"], + grain_number=data["grain_number"], + grain_density=data["grain_density"], + grain_outer_radius=data["grain_outer_radius"], + grain_initial_inner_radius=data["grain_initial_inner_radius"], + grain_initial_height=data["grain_initial_height"], + grain_separation=data["grain_separation"], + grains_center_of_mass_position=data["grains_center_of_mass_position"], + center_of_dry_mass_position=data["center_of_dry_mass_position"], + nozzle_position=data["nozzle_position"], + burn_time=data["burn_time"], + throat_radius=data["throat_radius"], + interpolation_method=data["interpolate"], + coordinate_system_orientation=data["coordinate_system_orientation"], + ) diff --git a/rocketpy/motors/tank.py b/rocketpy/motors/tank.py index a3b21f434..69870d3a9 100644 --- a/rocketpy/motors/tank.py +++ b/rocketpy/motors/tank.py @@ -480,6 +480,36 @@ def draw(self): """Draws the tank geometry.""" self.plots.draw() + def to_dict(self, include_outputs=True): + data = { + "name": self.name, + "geometry": self.geometry, + "flux_time": self.flux_time, + "liquid": self.liquid, + "gas": self.gas, + "discretize": self.discretize, + } + if include_outputs: + data.update( + { + "fluid_mass": self.fluid_mass, + "net_mass_flow_rate": self.net_mass_flow_rate, + "liquid_volume": self.liquid_volume, + "gas_volume": self.gas_volume, + "liquid_height": self.liquid_height, + "gas_height": self.gas_height, + "liquid_mass": self.liquid_mass, + "gas_mass": self.gas_mass, + "liquid_center_of_mass": self.liquid_center_of_mass, + "gas_center_of_mass": self.gas_center_of_mass, + "center_of_mass": self.center_of_mass, + "liquid_inertia": self.liquid_inertia, + "gas_inertia": self.gas_inertia, + "inertia": self.inertia, + } + ) + return data + class MassFlowRateBasedTank(Tank): """Class to define a tank based on mass flow rates inputs. This class @@ -816,10 +846,49 @@ def discretize_flow(self): """Discretizes the mass flow rate inputs according to the flux time and the discretize parameter. """ - self.liquid_mass_flow_rate_in.set_discrete(*self.flux_time, self.discretize) - self.gas_mass_flow_rate_in.set_discrete(*self.flux_time, self.discretize) - self.liquid_mass_flow_rate_out.set_discrete(*self.flux_time, self.discretize) - self.gas_mass_flow_rate_out.set_discrete(*self.flux_time, self.discretize) + self.liquid_mass_flow_rate_in.set_discrete( + *self.flux_time, self.discretize, "linear" + ) + self.gas_mass_flow_rate_in.set_discrete( + *self.flux_time, self.discretize, "linear" + ) + self.liquid_mass_flow_rate_out.set_discrete( + *self.flux_time, self.discretize, "linear" + ) + self.gas_mass_flow_rate_out.set_discrete( + *self.flux_time, self.discretize, "linear" + ) + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update( + { + "initial_liquid_mass": self.initial_liquid_mass, + "initial_gas_mass": self.initial_gas_mass, + "liquid_mass_flow_rate_in": self.liquid_mass_flow_rate_in, + "gas_mass_flow_rate_in": self.gas_mass_flow_rate_in, + "liquid_mass_flow_rate_out": self.liquid_mass_flow_rate_out, + "gas_mass_flow_rate_out": self.gas_mass_flow_rate_out, + } + ) + return data + + @classmethod + def from_dict(cls, data): + return cls( + name=data["name"], + geometry=data["geometry"], + flux_time=data["flux_time"], + liquid=data["liquid"], + gas=data["gas"], + initial_liquid_mass=data["initial_liquid_mass"], + initial_gas_mass=data["initial_gas_mass"], + liquid_mass_flow_rate_in=data["liquid_mass_flow_rate_in"], + gas_mass_flow_rate_in=data["gas_mass_flow_rate_in"], + liquid_mass_flow_rate_out=data["liquid_mass_flow_rate_out"], + gas_mass_flow_rate_out=data["gas_mass_flow_rate_out"], + discretize=data["discretize"], + ) class UllageBasedTank(Tank): @@ -1014,7 +1083,24 @@ def gas_height(self): def discretize_ullage(self): """Discretizes the ullage input according to the flux time and the discretize parameter.""" - self.ullage.set_discrete(*self.flux_time, self.discretize) + self.ullage.set_discrete(*self.flux_time, self.discretize, "linear") + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update({"ullage": self.ullage}) + return data + + @classmethod + def from_dict(cls, data): + return cls( + name=data["name"], + geometry=data["geometry"], + flux_time=data["flux_time"], + liquid=data["liquid"], + gas=data["gas"], + ullage=data["ullage"], + discretize=data["discretize"], + ) class LevelBasedTank(Tank): @@ -1224,7 +1310,24 @@ def discretize_liquid_height(self): """Discretizes the liquid height input according to the flux time and the discretize parameter. """ - self.liquid_level.set_discrete(*self.flux_time, self.discretize) + self.liquid_level.set_discrete(*self.flux_time, self.discretize, "linear") + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update({"liquid_height": self.liquid_level}) + return data + + @classmethod + def from_dict(cls, data): + return cls( + name=data["name"], + geometry=data["geometry"], + flux_time=data["flux_time"], + liquid=data["liquid"], + gas=data["gas"], + liquid_height=data["liquid_height"], + discretize=data["discretize"], + ) class MassBasedTank(Tank): @@ -1464,5 +1567,28 @@ def discretize_masses(self): """Discretizes the fluid mass inputs according to the flux time and the discretize parameter. """ - self.liquid_mass.set_discrete(*self.flux_time, self.discretize) - self.gas_mass.set_discrete(*self.flux_time, self.discretize) + self.liquid_mass.set_discrete(*self.flux_time, self.discretize, "linear") + self.gas_mass.set_discrete(*self.flux_time, self.discretize, "linear") + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data.update( + { + "liquid_mass": self.liquid_mass, + "gas_mass": self.gas_mass, + } + ) + return data + + @classmethod + def from_dict(cls, data): + return cls( + name=data["name"], + geometry=data["geometry"], + flux_time=data["flux_time"], + liquid=data["liquid"], + gas=data["gas"], + liquid_mass=data["liquid_mass"], + gas_mass=data["gas_mass"], + discretize=data["discretize"], + ) diff --git a/rocketpy/motors/tank_geometry.py b/rocketpy/motors/tank_geometry.py index 2471079d7..a26eb7fbf 100644 --- a/rocketpy/motors/tank_geometry.py +++ b/rocketpy/motors/tank_geometry.py @@ -345,22 +345,33 @@ def add_geometry(self, domain, radius_function): self._geometry[domain] = Function(radius_function) self.radius = PiecewiseFunction(self._geometry, "Height (m)", "radius (m)") - def to_dict(self): - """ - Returns a dictionary representation of the TankGeometry object. - - Returns - ------- - dict - Dictionary representation of the TankGeometry object. - """ - return { + def to_dict(self, include_outputs=True): + data = { "geometry": { - str(domain): function.to_dict() - for domain, function in self._geometry.items() + str(domain): function for domain, function in self._geometry.items() } } + if include_outputs: + data["outputs"] = { + "average_radius": self.average_radius, + "bottom": self.bottom, + "top": self.top, + "total_height": self.total_height, + "total_volume": self.total_volume, + } + + return data + + @classmethod + def from_dict(cls, data): + geometry_dict = {} + # Reconstruct tuple keys + for domain, radius_function in data["geometry"].items(): + domain = tuple(map(float, domain.strip("()").split(", "))) + geometry_dict[domain] = radius_function + return cls(geometry_dict) + class CylindricalTank(TankGeometry): """Class to define the geometry of a cylindrical tank. The cylinder has @@ -389,6 +400,7 @@ def __init__(self, radius, height, spherical_caps=False, geometry_dict=None): """ geometry_dict = geometry_dict or {} super().__init__(geometry_dict) + self.__input_radius = radius self.height = height self.has_caps = False if spherical_caps: @@ -408,11 +420,11 @@ def add_spherical_caps(self): "Warning: Adding spherical caps to the tank will not modify the " + f"total height of the tank {self.height} m. " + "Its cylindrical portion height will be reduced to " - + f"{self.height - 2*self.radius(0)} m." + + f"{self.height - 2*self.__input_radius} m." ) if not self.has_caps: - radius = self.radius(0) + radius = self.__input_radius height = self.height bottom_cap_range = (-height / 2, -height / 2 + radius) upper_cap_range = (height / 2 - radius, height / 2) @@ -429,6 +441,22 @@ def upper_cap_radius(h): else: raise ValueError("Tank already has caps.") + def to_dict(self, include_outputs=True): + data = { + "radius": self.__input_radius, + "height": self.height, + "spherical_caps": self.has_caps, + } + + if include_outputs: + data.update(super().to_dict(include_outputs)) + + return data + + @classmethod + def from_dict(cls, data): + return cls(data["radius"], data["height"], data["spherical_caps"]) + class SphericalTank(TankGeometry): """Class to define the geometry of a spherical tank. The sphere zero @@ -450,4 +478,17 @@ def __init__(self, radius, geometry_dict=None): """ geometry_dict = geometry_dict or {} super().__init__(geometry_dict) + self.__input_radius = radius self.add_geometry((-radius, radius), lambda h: (radius**2 - h**2) ** 0.5) + + def to_dict(self, include_outputs=True): + data = {"radius": self.__input_radius} + + if include_outputs: + data.update(super().to_dict(include_outputs)) + + return data + + @classmethod + def from_dict(cls, data): + return cls(data["radius"]) diff --git a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py index f0d5f926f..961418c38 100644 --- a/rocketpy/rocket/aero_surface/fins/elliptical_fins.py +++ b/rocketpy/rocket/aero_surface/fins/elliptical_fins.py @@ -316,3 +316,33 @@ def info(self): def all_info(self): self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + if include_outputs: + data.update( + { + "Af": self.Af, + "AR": self.AR, + "gamma_c": self.gamma_c, + "Yma": self.Yma, + "roll_geometrical_constant": self.roll_geometrical_constant, + "tau": self.tau, + "lift_interference_factor": self.lift_interference_factor, + "roll_damping_interference_factor": self.roll_damping_interference_factor, + "roll_forcing_interference_factor": self.roll_forcing_interference_factor, + } + ) + return data + + @classmethod + def from_dict(cls, data): + return cls( + n=data["n"], + root_chord=data["root_chord"], + span=data["span"], + rocket_radius=data["rocket_radius"], + cant_angle=data["cant_angle"], + airfoil=data["airfoil"], + name=data["name"], + ) diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index 29f4e8cff..bfdd82d71 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -426,6 +426,30 @@ def compute_forces_and_moments( M3 = M3_forcing - M3_damping return R1, R2, R3, M1, M2, M3 + def to_dict(self, include_outputs=True): + data = { + "n": self.n, + "root_chord": self.root_chord, + "span": self.span, + "rocket_radius": self.rocket_radius, + "cant_angle": self.cant_angle, + "airfoil": self.airfoil, + "name": self.name, + } + + if include_outputs: + data.update( + { + "cp": self.cp, + "cl": self.cl, + "roll_parameters": self.roll_parameters, + "d": self.d, + "ref_area": self.ref_area, + } + ) + + return data + def draw(self): """Draw the fin shape along with some important information, including the center line, the quarter line and the center of pressure position. diff --git a/rocketpy/rocket/aero_surface/fins/free_form_fins.py b/rocketpy/rocket/aero_surface/fins/free_form_fins.py index 3abdb84ad..40efb86a4 100644 --- a/rocketpy/rocket/aero_surface/fins/free_form_fins.py +++ b/rocketpy/rocket/aero_surface/fins/free_form_fins.py @@ -359,6 +359,39 @@ def evaluate_shape(self): x_array, y_array = zip(*self.shape_points) self.shape_vec = [np.array(x_array), np.array(y_array)] + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data["shape_points"] = self.shape_points + + if include_outputs: + data.update( + { + "Af": self.Af, + "AR": self.AR, + "gamma_c": self.gamma_c, + "Yma": self.Yma, + "mac_length": self.mac_length, + "mac_lead": self.mac_lead, + "roll_geometrical_constant": self.roll_geometrical_constant, + "tau": self.tau, + "lift_interference_factor": self.lift_interference_factor, + "roll_forcing_interference_factor": self.roll_forcing_interference_factor, + "roll_damping_interference_factor": self.roll_damping_interference_factor, + } + ) + return data + + @classmethod + def from_dict(cls, data): + return cls( + data["n"], + data["shape_points"], + data["rocket_radius"], + data["cant_angle"], + data["airfoil"], + data["name"], + ) + def info(self): self.prints.geometry() self.prints.lift() diff --git a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py index 7a915f3b1..42fe4bb21 100644 --- a/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py +++ b/rocketpy/rocket/aero_surface/fins/trapezoidal_fins.py @@ -347,3 +347,39 @@ def info(self): def all_info(self): self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = super().to_dict(include_outputs) + data["tip_chord"] = self.tip_chord + + if include_outputs: + data.update( + { + "sweep_length": self.sweep_length, + "sweep_angle": self.sweep_angle, + "shape_vec": self.shape_vec, + "Af": self.Af, + "AR": self.AR, + "gamma_c": self.gamma_c, + "Yma": self.Yma, + "roll_geometrical_constant": self.roll_geometrical_constant, + "tau": self.tau, + "lift_interference_factor": self.lift_interference_factor, + "roll_damping_interference_factor": self.roll_damping_interference_factor, + "roll_forcing_interference_factor": self.roll_forcing_interference_factor, + } + ) + return data + + @classmethod + def from_dict(cls, data): + return cls( + n=data["n"], + root_chord=data["root_chord"], + tip_chord=data["tip_chord"], + span=data["span"], + rocket_radius=data["rocket_radius"], + cant_angle=data["cant_angle"], + airfoil=data["airfoil"], + name=data["name"], + ) diff --git a/rocketpy/rocket/aero_surface/nose_cone.py b/rocketpy/rocket/aero_surface/nose_cone.py index 7d59473e3..9ea3754cb 100644 --- a/rocketpy/rocket/aero_surface/nose_cone.py +++ b/rocketpy/rocket/aero_surface/nose_cone.py @@ -515,3 +515,32 @@ def all_info(self): """ self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = { + "_length": self._length, + "_kind": self._kind, + "_base_radius": self._base_radius, + "_bluffness": self._bluffness, + "_rocket_radius": self._rocket_radius, + "_power": self._power, + "name": self.name, + } + if include_outputs: + data["cp"] = self.cp + data["clalpha"] = self.clalpha + data["cl"] = self.cl + + return data + + @classmethod + def from_dict(cls, data): + return cls( + length=data["_length"], + kind=data["_kind"], + base_radius=data["_base_radius"], + bluffness=data["_bluffness"], + rocket_radius=data["_rocket_radius"], + power=data["_power"], + name=data["name"], + ) diff --git a/rocketpy/rocket/aero_surface/rail_buttons.py b/rocketpy/rocket/aero_surface/rail_buttons.py index facd8b157..06ddea73e 100644 --- a/rocketpy/rocket/aero_surface/rail_buttons.py +++ b/rocketpy/rocket/aero_surface/rail_buttons.py @@ -100,6 +100,23 @@ def evaluate_geometrical_parameters(self): None """ + def to_dict(self, _): + return { + "buttons_distance": self.buttons_distance, + "angular_position": self.angular_position, + "name": self.name, + "rocket_radius": self.rocket_radius, + } + + @classmethod + def from_dict(cls, data): + return cls( + data["buttons_distance"], + data["angular_position"], + data["name"], + data["rocket_radius"], + ) + def info(self): """Prints out all the information about the Rail Buttons. diff --git a/rocketpy/rocket/aero_surface/tail.py b/rocketpy/rocket/aero_surface/tail.py index 4f2783e02..5c9308783 100644 --- a/rocketpy/rocket/aero_surface/tail.py +++ b/rocketpy/rocket/aero_surface/tail.py @@ -204,3 +204,34 @@ def info(self): def all_info(self): self.prints.all() self.plots.all() + + def to_dict(self, include_outputs=True): + data = { + "top_radius": self._top_radius, + "bottom_radius": self._bottom_radius, + "length": self._length, + "rocket_radius": self._rocket_radius, + "name": self.name, + } + + if include_outputs: + data.update( + { + "cp": self.cp, + "cl": self.clalpha, + "slant_length": self.slant_length, + "surface_area": self.surface_area, + } + ) + + return data + + @classmethod + def from_dict(cls, data): + return cls( + top_radius=data["top_radius"], + bottom_radius=data["bottom_radius"], + length=data["length"], + rocket_radius=data["rocket_radius"], + name=data["name"], + ) diff --git a/rocketpy/rocket/components.py b/rocketpy/rocket/components.py index 1bf36411a..3641aab3e 100644 --- a/rocketpy/rocket/components.py +++ b/rocketpy/rocket/components.py @@ -194,17 +194,17 @@ def sort_by_position(self, reverse=False): """ self._components.sort(key=lambda x: x.position.z, reverse=reverse) - def to_dict(self): - """Return a dictionary representation of the components. - - Returns - ------- - dict - A dictionary representation of the components. - """ + def to_dict(self, _): return { "components": [ {"component": c.component, "position": c.position} for c in self._components ] } + + @classmethod + def from_dict(cls, data): + components = cls() + for component in data["components"]: + components.add(component["component"], component["position"]) + return components diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index c465c4367..4abc37d1c 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -2,6 +2,8 @@ import numpy as np +from rocketpy._encoders import from_hex_decode, to_hex_encode + from ..mathutils.function import Function from ..prints.parachute_prints import _ParachutePrints @@ -248,3 +250,46 @@ def all_info(self): """Prints all information about the Parachute class.""" self.info() # self.plots.all() # Parachutes still doesn't have plots + + def to_dict(self, include_outputs=True): + trigger = self.trigger + + if callable(self.trigger) and not isinstance(self.trigger, Function): + trigger = to_hex_encode(trigger) + + data = { + "name": self.name, + "cd_s": self.cd_s, + "trigger": trigger, + "sampling_rate": self.sampling_rate, + "lag": self.lag, + "noise": self.noise, + } + + if include_outputs: + data["noise_signal"] = self.noise_signal + data["noise_function"] = to_hex_encode(self.noise_function) + data["noisy_pressure_signal"] = self.noisy_pressure_signal + data["clean_pressure_signal"] = self.clean_pressure_signal + + return data + + @classmethod + def from_dict(cls, data): + trigger = data["trigger"] + + try: + trigger = from_hex_decode(trigger) + except (TypeError, ValueError): + pass + + parachute = cls( + name=data["name"], + cd_s=data["cd_s"], + trigger=trigger, + sampling_rate=data["sampling_rate"], + lag=data["lag"], + noise=data["noise"], + ) + + return parachute diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index ed376f582..511d8c21f 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1888,3 +1888,116 @@ def all_info(self): """ self.info() self.plots.all() + + def to_dict(self, include_outputs=True): + rocket_dict = { + "radius": self.radius, + "mass": self.mass, + "I_11_without_motor": self.I_11_without_motor, + "I_22_without_motor": self.I_22_without_motor, + "I_33_without_motor": self.I_33_without_motor, + "I_12_without_motor": self.I_12_without_motor, + "I_13_without_motor": self.I_13_without_motor, + "I_23_without_motor": self.I_23_without_motor, + "power_off_drag": self.power_off_drag, + "power_on_drag": self.power_on_drag, + "center_of_mass_without_motor": self.center_of_mass_without_motor, + "coordinate_system_orientation": self.coordinate_system_orientation, + "motor": self.motor, + "motor_position": self.motor_position, + "aerodynamic_surfaces": self.aerodynamic_surfaces, + "rail_buttons": self.rail_buttons, + "parachutes": self.parachutes, + "air_brakes": self.air_brakes, + "_controllers": self._controllers, + "sensors": self.sensors, + } + + if include_outputs: + rocket_dict["area"] = self.area + rocket_dict["center_of_dry_mass_position"] = ( + self.center_of_dry_mass_position + ) + rocket_dict["center_of_mass_without_motor"] = ( + self.center_of_mass_without_motor + ) + rocket_dict["motor_center_of_mass_position"] = ( + self.motor_center_of_mass_position + ) + rocket_dict["motor_center_of_dry_mass_position"] = ( + self.motor_center_of_dry_mass_position + ) + rocket_dict["center_of_mass"] = self.center_of_mass + rocket_dict["reduced_mass"] = self.reduced_mass + rocket_dict["total_mass"] = self.total_mass + rocket_dict["total_mass_flow_rate"] = self.total_mass_flow_rate + rocket_dict["thrust_to_weight"] = self.thrust_to_weight + rocket_dict["cp_eccentricity_x"] = self.cp_eccentricity_x + rocket_dict["cp_eccentricity_y"] = self.cp_eccentricity_y + rocket_dict["thrust_eccentricity_x"] = self.thrust_eccentricity_x + rocket_dict["thrust_eccentricity_y"] = self.thrust_eccentricity_y + rocket_dict["cp_position"] = self.cp_position + rocket_dict["stability_margin"] = self.stability_margin + rocket_dict["static_margin"] = self.static_margin + rocket_dict["nozzle_position"] = self.nozzle_position + rocket_dict["nozzle_to_cdm"] = self.nozzle_to_cdm + rocket_dict["nozzle_gyration_tensor"] = self.nozzle_gyration_tensor + rocket_dict["center_of_propellant_position"] = ( + self.center_of_propellant_position + ) + + return rocket_dict + + @classmethod + def from_dict(cls, data): + rocket = cls( + radius=data["radius"], + mass=data["mass"], + inertia=( + data["I_11_without_motor"], + data["I_22_without_motor"], + data["I_33_without_motor"], + data["I_12_without_motor"], + data["I_13_without_motor"], + data["I_23_without_motor"], + ), + power_off_drag=data["power_off_drag"], + power_on_drag=data["power_on_drag"], + center_of_mass_without_motor=data["center_of_mass_without_motor"], + coordinate_system_orientation=data["coordinate_system_orientation"], + ) + + if (motor := data["motor"]) is not None: + rocket.add_motor( + motor=motor, + position=data["motor_position"], + ) + + for surface, position in data["aerodynamic_surfaces"]: + rocket.add_surfaces(surfaces=surface, positions=position) + + for button, position in data["rail_buttons"]: + rocket.set_rail_buttons( + upper_button_position=position[2] + button.buttons_distance, + lower_button_position=position[2], + angular_position=button.angular_position, + radius=button.rocket_radius, + ) + + for parachute in data["parachutes"]: + rocket.parachutes.append(parachute) + + for air_brakes in data["air_brakes"]: + rocket.add_air_brakes( + drag_coefficient_curve=air_brakes["drag_coefficient_curve"], + controller_function=air_brakes["controller_function"], + sampling_rate=air_brakes["sampling_rate"], + clamp=air_brakes["clamp"], + reference_area=air_brakes["reference_area"], + initial_observed_variables=air_brakes["initial_observed_variables"], + override_rocket_drag=air_brakes["override_rocket_drag"], + name=air_brakes["name"], + controller_name=air_brakes["controller_name"], + ) + + return rocket diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 706bcf534..42ca0f897 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3364,6 +3364,96 @@ def time_iterator(self, node_list): yield i, node_list[i] i += 1 + def to_dict(self, include_outputs=True): + data = { + "rocket": self.rocket, + "env": self.env, + "rail_length": self.rail_length, + "inclination": self.inclination, + "heading": self.heading, + "initial_solution": self.initial_solution, + "terminate_on_apogee": self.terminate_on_apogee, + "max_time": self.max_time, + "max_time_step": self.max_time_step, + "min_time_step": self.min_time_step, + "rtol": self.rtol, + "atol": self.atol, + "time_overshoot": self.time_overshoot, + "name": self.name, + "equations_of_motion": self.equations_of_motion, + } + + if include_outputs: + data.update( + { + "time": self.time, + "out_of_rail_time": self.out_of_rail_time, + "out_of_rail_velocity": self.out_of_rail_velocity, + "out_of_rail_state": self.out_of_rail_state, + "apogee": self.apogee, + "apogee_time": self.apogee_time, + "apogee_x": self.apogee_x, + "apogee_y": self.apogee_y, + "apogee_state": self.apogee_state, + "x_impact": self.x_impact, + "y_impact": self.y_impact, + "impact_velocity": self.impact_velocity, + "impact_state": self.impact_state, + "x": self.x, + "y": self.y, + "z": self.z, + "vx": self.vx, + "vy": self.vy, + "vz": self.vz, + "e0": self.e0, + "e1": self.e1, + "e2": self.e2, + "e3": self.e3, + "w1": self.w1, + "w2": self.w2, + "w3": self.w3, + "ax": self.ax, + "ay": self.ay, + "az": self.az, + "alpha1": self.alpha1, + "alpha2": self.alpha2, + "alpha3": self.alpha3, + "altitude": self.altitude, + "mach_number": self.mach_number, + "stream_velocity_x": self.stream_velocity_x, + "stream_velocity_y": self.stream_velocity_y, + "stream_velocity_z": self.stream_velocity_z, + "free_stream_speed": self.free_stream_speed, + "angle_of_attack": self.angle_of_attack, + "static_margin": self.static_margin, + "stability_margin": self.stability_margin, + "latitude": self.latitude, + "longitude": self.longitude, + } + ) + + return data + + @classmethod + def from_dict(cls, data): + return cls( + rocket=data["rocket"], + environment=data["env"], + rail_length=data["rail_length"], + inclination=data["inclination"], + heading=data["heading"], + initial_solution=None, + terminate_on_apogee=data["terminate_on_apogee"], + max_time=data["max_time"], + max_time_step=data["max_time_step"], + min_time_step=data["min_time_step"], + rtol=data["rtol"], + atol=data["atol"], + time_overshoot=data["time_overshoot"], + name=data["name"], + equations_of_motion=data["equations_of_motion"], + ) + class FlightPhases: """Class to handle flight phases. It is used to store the derivatives and callbacks for each flight phase. It is also used to handle the diff --git a/rocketpy/simulation/monte_carlo.py b/rocketpy/simulation/monte_carlo.py index c6cd1bed4..4ed051a4f 100644 --- a/rocketpy/simulation/monte_carlo.py +++ b/rocketpy/simulation/monte_carlo.py @@ -870,10 +870,10 @@ def export_ellipses_to_kml( # pylint: disable=too-many-statements for i, points in enumerate(kml_data): if i < len(impact_ellipses): - name = f"Impact Ellipse {i+1}" + name = f"Impact Ellipse {i + 1}" ellipse_color = colors[0] # default is blue else: - name = f"Apogee Ellipse {i +1- len(impact_ellipses)}" + name = f"Apogee Ellipse {i + 1 - len(impact_ellipses)}" ellipse_color = colors[1] # default is green mult_ell = kml.newmultigeometry(name=name) diff --git a/tests/fixtures/flight/flight_fixtures.py b/tests/fixtures/flight/flight_fixtures.py index c8fe437ca..0a9a39ff9 100644 --- a/tests/fixtures/flight/flight_fixtures.py +++ b/tests/fixtures/flight/flight_fixtures.py @@ -93,6 +93,61 @@ def flight_calisto_robust(calisto_robust, example_spaceport_env): ) +@pytest.fixture +def flight_calisto_liquid_modded(calisto_liquid_modded, example_plain_env): + """A rocketpy.Flight object of the Calisto rocket modded for a liquid + motor. The environment is the simplest possible, with no parameters set. + + Parameters + ---------- + calisto_liquid_modded : rocketpy.Rocket + An object of the Rocket class. This is a pytest fixture too. + example_plain_env : rocketpy.Environment + An object of the Environment class. This is a pytest fixture too. + + Returns + ------- + rocketpy.Flight + A rocketpy.Flight object. + """ + return Flight( + rocket=calisto_liquid_modded, + environment=example_plain_env, + rail_length=5, + inclination=85, + heading=0, + max_time_step=0.25, + ) + + +@pytest.fixture +def flight_calisto_hybrid_modded(calisto_hybrid_modded, example_plain_env): + """A rocketpy.Flight object of the Calisto rocket modded for a hybrid + motor. The environment is the simplest possible, with no parameters set. + + Parameters + ---------- + calisto_hybrid_modded : rocketpy.Rocket + An object of the Rocket class. This is a pytest fixture too. + example_plain_env : rocketpy.Environment + An object of the Environment class. This is a pytest fixture too. + + Returns + ------- + rocketpy.Flight + A rocketpy.Flight object. + """ + return Flight( + rocket=calisto_hybrid_modded, + environment=example_plain_env, + rail_length=5.2, + inclination=85, + heading=0, + time_overshoot=False, + terminate_on_apogee=True, + ) + + @pytest.fixture def flight_calisto_custom_wind(calisto_robust, example_spaceport_env): """A rocketpy.Flight object of the Calisto rocket. This uses the calisto diff --git a/tests/fixtures/hybrid/hybrid_fixtures.py b/tests/fixtures/hybrid/hybrid_fixtures.py index 9c3d8c8dc..833f62978 100644 --- a/tests/fixtures/hybrid/hybrid_fixtures.py +++ b/tests/fixtures/hybrid/hybrid_fixtures.py @@ -224,15 +224,14 @@ def spherical_oxidizer_tank(oxidizer_fluid, oxidizer_pressurant): ------- rocketpy.UllageBasedTank """ - geometry = SphericalTank(0.05) - liquid_level = Function(lambda t: 0.1 * np.exp(-t / 2) - 0.05) + geometry = SphericalTank(0.051) oxidizer_tank = LevelBasedTank( name="Lox Tank", flux_time=10, geometry=geometry, liquid=oxidizer_fluid, gas=oxidizer_pressurant, - liquid_height=liquid_level, + liquid_height=lambda t: 0.1 * np.exp(-t / 2) - 0.05, ) return oxidizer_tank diff --git a/tests/fixtures/motor/tanks_fixtures.py b/tests/fixtures/motor/tanks_fixtures.py index 4238e1e1e..6dd1334f3 100644 --- a/tests/fixtures/motor/tanks_fixtures.py +++ b/tests/fixtures/motor/tanks_fixtures.py @@ -123,7 +123,7 @@ def spherical_oxidizer_tank(oxidizer_fluid, oxidizer_pressurant): ------- rocketpy.UllageBasedTank """ - geometry = SphericalTank(0.05) + geometry = SphericalTank(0.051) liquid_level = Function(lambda t: 0.1 * np.exp(-t / 2) - 0.05) oxidizer_tank = LevelBasedTank( name="Lox Tank", diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py new file mode 100644 index 000000000..ebe305b61 --- /dev/null +++ b/tests/integration/test_encoding.py @@ -0,0 +1,150 @@ +import json +import os + +import numpy as np +import pytest + +from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder + + +@pytest.mark.slow +@pytest.mark.parametrize( + ["flight_name", "include_outputs"], + [ + ("flight_calisto", False), + ("flight_calisto", True), + ("flight_calisto_robust", True), + ("flight_calisto_liquid_modded", False), + ("flight_calisto_hybrid_modded", False), + ], +) +def test_flight_save_load(flight_name, include_outputs, request): + """Test encoding a ``rocketpy.Flight``. + + Parameters + ---------- + flight_name : str + Name flight fixture to encode. + request : pytest.FixtureRequest + Pytest request object. + """ + flight_to_save = request.getfixturevalue(flight_name) + + with open("flight.json", "w") as f: + json.dump( + flight_to_save, + f, + cls=RocketPyEncoder, + indent=2, + include_outputs=include_outputs, + ) + + with open("flight.json", "r") as f: + flight_loaded = json.load(f, cls=RocketPyDecoder) + + assert np.isclose(flight_to_save.t_initial, flight_loaded.t_initial) + assert np.isclose(flight_to_save.out_of_rail_time, flight_loaded.out_of_rail_time) + assert np.isclose(flight_to_save.apogee_time, flight_loaded.apogee_time) + + # Higher tolerance due to random parachute trigger + assert np.isclose(flight_to_save.t_final, flight_loaded.t_final, rtol=1e-3) + + os.remove("flight.json") + + +@pytest.mark.parametrize( + "function_name", ["lambda_quad_func", "spline_interpolated_func"] +) +def test_function_encoder(function_name, request): + """Test encoding a ``rocketpy.Function``. + + Parameters + ---------- + function_name : str + Name of the function to encode. + request : pytest.FixtureRequest + Pytest request object. + """ + function_to_encode = request.getfixturevalue(function_name) + + json_encoded = json.dumps(function_to_encode, cls=RocketPyEncoder) + + function_loaded = json.loads(json_encoded, cls=RocketPyDecoder) + + assert isinstance(function_loaded, type(function_to_encode)) + assert np.isclose(function_to_encode(0), function_loaded(0)) + + +@pytest.mark.parametrize( + "environment_name", ["example_plain_env", "environment_spaceport_america_2023"] +) +def test_environment_encoder(environment_name, request): + """Test encoding a ``rocketpy.Environment``. + + Parameters + ---------- + environment_name : str + Name of the environment fixture to encode. + request : pytest.FixtureRequest + Pytest request object. + """ + env_to_encode = request.getfixturevalue(environment_name) + + json_encoded = json.dumps(env_to_encode, cls=RocketPyEncoder) + + env_loaded = json.loads(json_encoded, cls=RocketPyDecoder) + + test_heights = np.linspace(0, 10000, 100) + + assert np.isclose(env_to_encode.elevation, env_loaded.elevation) + assert np.isclose(env_to_encode.latitude, env_loaded.latitude) + assert np.isclose(env_to_encode.longitude, env_loaded.longitude) + assert env_to_encode.datum == env_loaded.datum + assert np.allclose( + env_to_encode.wind_velocity_x(test_heights), + env_loaded.wind_velocity_x(test_heights), + ) + assert np.allclose( + env_to_encode.wind_velocity_y(test_heights), + env_loaded.wind_velocity_y(test_heights), + ) + assert np.allclose( + env_to_encode.temperature(test_heights), env_loaded.temperature(test_heights) + ) + assert np.allclose( + env_to_encode.pressure(test_heights), env_loaded.pressure(test_heights) + ) + assert np.allclose( + env_to_encode.density(test_heights), env_loaded.density(test_heights) + ) + + +@pytest.mark.parametrize( + "rocket_name", ["calisto_robust", "calisto_liquid_modded", "calisto_hybrid_modded"] +) +def test_rocket_encoder(rocket_name, request): + """Test encoding a ``rocketpy.Rocket``. + + Parameters + ---------- + rocket_name : str + Name of the rocket fixture to encode. + request : pytest.FixtureRequest + Pytest request object. + """ + rocket_to_encode = request.getfixturevalue(rocket_name) + + json_encoded = json.dumps(rocket_to_encode, cls=RocketPyEncoder) + + rocket_loaded = json.loads(json_encoded, cls=RocketPyDecoder) + + sample_times = np.linspace(0, 3.9, 100) + + assert np.allclose( + rocket_to_encode.evaluate_total_mass()(sample_times), + rocket_loaded.evaluate_total_mass()(sample_times), + ) + assert np.allclose( + rocket_to_encode.static_margin(sample_times), + rocket_loaded.static_margin(sample_times), + ) diff --git a/tests/integration/test_flight.py b/tests/integration/test_flight.py index fc1dd1956..17a2f4b0b 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from rocketpy import Environment, Flight +from rocketpy import Flight plt.rcParams.update({"figure.max_open_warning": 0}) @@ -154,7 +154,7 @@ def test_export_pressures(flight_calisto_robust): @patch("matplotlib.pyplot.show") def test_hybrid_motor_flight( - mock_show, calisto_hybrid_modded + mock_show, flight_calisto_hybrid_modded ): # pylint: disable=unused-argument """Test the flight of a rocket with a hybrid motor. This test only validates that a flight simulation can be performed with a hybrid motor; it does not @@ -164,24 +164,15 @@ def test_hybrid_motor_flight( ---------- mock_show : unittest.mock.MagicMock Mock object to replace matplotlib.pyplot.show - calisto_hybrid_modded : rocketpy.Rocket - Sample rocket to be simulated. See the conftest.py file for more info. + flight_calisto_hybrid_modded : rocketpy.Flight + Sample Flight to be tested. See the conftest.py file for more info. """ - test_flight = Flight( - rocket=calisto_hybrid_modded, - environment=Environment(), - rail_length=5, - inclination=85, - heading=0, - max_time_step=0.25, - ) - - assert test_flight.all_info() is None + assert flight_calisto_hybrid_modded.all_info() is None @patch("matplotlib.pyplot.show") def test_liquid_motor_flight( - mock_show, calisto_liquid_modded + mock_show, flight_calisto_liquid_modded ): # pylint: disable=unused-argument """Test the flight of a rocket with a liquid motor. This test only validates that a flight simulation can be performed with a liquid motor; it does not @@ -191,19 +182,10 @@ def test_liquid_motor_flight( ---------- mock_show : unittest.mock.MagicMock Mock object to replace matplotlib.pyplot.show - calisto_liquid_modded : rocketpy.Rocket - Sample Rocket to be simulated. See the conftest.py file for more info. + flight_calisto_liquid_modded : rocketpy.Flight + Sample Flight to be tested. See the conftest.py file for more info. """ - test_flight = Flight( - rocket=calisto_liquid_modded, - environment=Environment(), - rail_length=5, - inclination=85, - heading=0, - max_time_step=0.25, - ) - - assert test_flight.all_info() is None + assert flight_calisto_liquid_modded.all_info() is None @pytest.mark.slow diff --git a/tests/unit/test_encoding.py b/tests/unit/test_encoding.py deleted file mode 100644 index 8b58e71a3..000000000 --- a/tests/unit/test_encoding.py +++ /dev/null @@ -1,51 +0,0 @@ -import json - -import pytest - -from rocketpy._encoders import RocketPyEncoder - -# TODO: this tests should be improved with better validation and decoding - - -@pytest.mark.parametrize("flight_name", ["flight_calisto", "flight_calisto_robust"]) -def test_encode_flight(flight_name, request): - """Test encoding a ``rocketpy.Flight``. - - Parameters - ---------- - flight_name : str - Name flight fixture to encode. - request : pytest.FixtureRequest - Pytest request object. - """ - flight = request.getfixturevalue(flight_name) - - json_encoded = json.dumps(flight, cls=RocketPyEncoder) - - flight_dict = json.loads(json_encoded) - - assert json_encoded is not None - assert flight_dict is not None - - -@pytest.mark.parametrize( - "function_name", ["lambda_quad_func", "spline_interpolated_func"] -) -def test_encode_function(function_name, request): - """Test encoding a ``rocketpy.Function``. - - Parameters - ---------- - function_name : str - Name of the function to encode. - request : pytest.FixtureRequest - Pytest request object. - """ - function = request.getfixturevalue(function_name) - - json_encoded = json.dumps(function, cls=RocketPyEncoder) - - function_dict = json.loads(json_encoded) - - assert json_encoded is not None - assert function_dict is not None