From 2a5bc24236c43d19852362bea390219b26013e8f Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Fri, 13 Jun 2025 11:40:53 +0200 Subject: [PATCH 1/9] ENH: add an option to discretize callable sources encoding. --- rocketpy/_encoders.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rocketpy/_encoders.py b/rocketpy/_encoders.py index b68e36fda..35613d3eb 100644 --- a/rocketpy/_encoders.py +++ b/rocketpy/_encoders.py @@ -45,6 +45,7 @@ def __init__(self, *args, **kwargs): Default is True. """ self.include_outputs = kwargs.pop("include_outputs", False) + self.discretize = kwargs.pop("discretize", False) self.include_function_data = kwargs.pop("include_function_data", True) self.discretize = kwargs.pop("discretize", False) self.allow_pickle = kwargs.pop("allow_pickle", True) From 1afcfb0572f2425f35052fc377165f21b452d2b6 Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Fri, 13 Jun 2025 18:47:59 +0200 Subject: [PATCH 2/9] ENH: allow for disallowing pickle on encoding. --- rocketpy/_encoders.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rocketpy/_encoders.py b/rocketpy/_encoders.py index 35613d3eb..b68e36fda 100644 --- a/rocketpy/_encoders.py +++ b/rocketpy/_encoders.py @@ -45,7 +45,6 @@ def __init__(self, *args, **kwargs): Default is True. """ self.include_outputs = kwargs.pop("include_outputs", False) - self.discretize = kwargs.pop("discretize", False) self.include_function_data = kwargs.pop("include_function_data", True) self.discretize = kwargs.pop("discretize", False) self.allow_pickle = kwargs.pop("allow_pickle", True) From 8fa30efad3ae9c5aab7ca1d356bcc23f53dbe831 Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Sat, 14 Jun 2025 13:40:42 +0200 Subject: [PATCH 3/9] MNT: Update CHANGELOG. --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9956a48a7..f778e8cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added - +- ENH: Discretized and No-Pickle Encoding Options [#827] (https://github.com/RocketPy-Team/RocketPy/pull/827) + - EHN: Addition of ensemble variable to ECMWF dictionaries [#842] (https://github.com/RocketPy-Team/RocketPy/pull/842) - ENH: Added Crop and Clip Methods to Function Class [#817](https://github.com/RocketPy-Team/RocketPy/pull/817) - DOC: Add Flight class usage documentation and update index [#841](https://github.com/RocketPy-Team/RocketPy/pull/841) From 15be4137af28a25c2f6f5e06ddff9e82cfeecb0b Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Sat, 20 Sep 2025 14:46:34 +0200 Subject: [PATCH 4/9] ENH: support for air brakes, controller and sensors encoding. --- rocketpy/_encoders.py | 24 ++++++++--- rocketpy/control/controller.py | 45 +++++++++++++++++++++ rocketpy/mathutils/function.py | 7 ++-- rocketpy/rocket/aero_surface/air_brakes.py | 21 ++++++++++ rocketpy/rocket/aero_surface/fins/fins.py | 14 ++++++- rocketpy/rocket/rocket.py | 41 +++++++++++++------ rocketpy/sensors/accelerometer.py | 25 ++++++++++++ rocketpy/sensors/barometer.py | 17 ++++++++ rocketpy/sensors/gnss_receiver.py | 17 ++++++++ rocketpy/sensors/gyroscope.py | 25 ++++++++++++ rocketpy/sensors/sensor.py | 26 ++++++++++++ rocketpy/simulation/flight.py | 2 +- rocketpy/tools.py | 47 ++++++++++++++++++++++ tests/integration/test_encoding.py | 13 +++++- tests/integration/test_flight.py | 18 +++++++++ 15 files changed, 317 insertions(+), 25 deletions(-) diff --git a/rocketpy/_encoders.py b/rocketpy/_encoders.py index b68e36fda..58eeae809 100644 --- a/rocketpy/_encoders.py +++ b/rocketpy/_encoders.py @@ -107,15 +107,21 @@ def object_hook(self, obj): try: class_ = get_class_from_signature(signature) + hash_ = signature.get("hash", None) if class_.__name__ == "Flight" and not self.resimulate: new_flight = class_.__new__(class_) new_flight.prints = _FlightPrints(new_flight) new_flight.plots = _FlightPlots(new_flight) set_minimal_flight_attributes(new_flight, obj) + if hash_ is not None: + setattr(new_flight, "__rpy_hash", hash_) return new_flight elif hasattr(class_, "from_dict"): - return class_.from_dict(obj) + new_obj = class_.from_dict(obj) + if hash_ is not None: + setattr(new_obj, "__rpy_hash", hash_) + return new_obj else: # Filter keyword arguments kwargs = { @@ -123,8 +129,10 @@ def object_hook(self, obj): for key, value in obj.items() if key in class_.__init__.__code__.co_varnames } - - return class_(**kwargs) + new_obj = class_(**kwargs) + if hash_ is not None: + setattr(new_obj, "__rpy_hash", hash_) + return new_obj except (ImportError, AttributeError): return obj else: @@ -157,7 +165,6 @@ def set_minimal_flight_attributes(flight, obj): "x_impact", "y_impact", "t_final", - "flight_phases", "ax", "ay", "az", @@ -207,7 +214,14 @@ def get_class_signature(obj): class_ = obj.__class__ name = getattr(class_, "__qualname__", class_.__name__) - return {"module": class_.__module__, "name": name} + signature = {"module": class_.__module__, "name": name} + + try: + signature.update({"hash": hash(obj)}) + except TypeError: + pass + + return signature def get_class_from_signature(signature): diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index 8338e05b4..0469e7f1a 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -1,4 +1,6 @@ from inspect import signature +from typing import Iterable +from rocketpy.tools import from_hex_decode, to_hex_encode from ..prints.controller_prints import _ControllerPrints @@ -181,3 +183,46 @@ def info(self): def all_info(self): """Prints out all information about the controller.""" self.info() + + def to_dict(self, **kwargs): + allow_pickle = kwargs.get("allow_pickle", True) + + if allow_pickle: + controller_function = to_hex_encode(self.controller_function) + else: + controller_function = self.controller_function.__name__ + + return { + "controller_function": controller_function, + "sampling_rate": self.sampling_rate, + "initial_observed_variables": self.initial_observed_variables, + "name": self.name, + "_interactive_objects_hash": hash(self.interactive_objects) + if not isinstance(self.interactive_objects, Iterable) + else [hash(obj) for obj in self.interactive_objects], + } + + @classmethod + def from_dict(cls, data): + interactive_objects = data.get("interactive_objects", []) + controller_function = data.get("controller_function") + sampling_rate = data.get("sampling_rate") + initial_observed_variables = data.get("initial_observed_variables") + name = data.get("name", "Controller") + + try: + controller_function = from_hex_decode(controller_function) + except (TypeError, ValueError): + pass + + obj = cls( + interactive_objects=interactive_objects, + controller_function=controller_function, + sampling_rate=sampling_rate, + initial_observed_variables=initial_observed_variables, + name=name, + ) + setattr( + obj, "_interactive_objects_hash", data.get("_interactive_objects_hash", []) + ) + return obj diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 648cc16c9..5dbfe2b5b 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -1551,7 +1551,7 @@ def short_time_fft( ... ylabel=f"Freq. $f$ in Hz)", ... xlim=(t_lo, t_hi) ... ) - >>> _ = ax1.plot(t_x, f_i, 'r--', alpha=.5, label='$f_i(t)$') + # >>> _ = ax1.plot(t_x, f_i, 'r--', alpha=.5, label='$f_i(t)$') >>> _ = fig1.colorbar(im1, label="Magnitude $|S_x(t, f)|$") >>> # Shade areas where window slices stick out to the side >>> for t0_, t1_ in [(t_lo, 1), (49, t_hi)]: @@ -1856,8 +1856,7 @@ def plot_1d( # pylint: disable=too-many-statements None """ # Define a mesh and y values at mesh nodes for plotting - fig = plt.figure() - ax = fig.axes + fig, ax = plt.subplots() if self._source_type is SourceType.CALLABLE: # Determine boundaries domain = [0, 10] @@ -1895,9 +1894,9 @@ def plot_1d( # pylint: disable=too-many-statements plt.title(self.title) plt.xlabel(self.__inputs__[0].title()) plt.ylabel(self.__outputs__[0].title()) - show_or_save_plot(filename) if return_object: return fig, ax + show_or_save_plot(filename) @deprecated( reason="The `Function.plot2D` method is set to be deprecated and fully " diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index d0eb733d5..4d9ae217b 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -206,3 +206,24 @@ def all_info(self): """ self.info() self.plots.drag_coefficient_curve() + + def to_dict(self, **kwargs): + return { + "drag_coefficient_curve": self.drag_coefficient, + "reference_area": self.reference_area, + "clamp": self.clamp, + "override_rocket_drag": self.override_rocket_drag, + "deployment_level": self.initial_deployment_level, + "name": self.name, + } + + @classmethod + def from_dict(cls, data): + return cls( + drag_coefficient_curve=data.get("drag_coefficient_curve"), + reference_area=data.get("reference_area"), + clamp=data.get("clamp"), + override_rocket_drag=data.get("override_rocket_drag"), + deployment_level=data.get("deployment_level"), + name=data.get("name"), + ) diff --git a/rocketpy/rocket/aero_surface/fins/fins.py b/rocketpy/rocket/aero_surface/fins/fins.py index 1b0d36114..b401164a0 100644 --- a/rocketpy/rocket/aero_surface/fins/fins.py +++ b/rocketpy/rocket/aero_surface/fins/fins.py @@ -427,13 +427,25 @@ def compute_forces_and_moments( return R1, R2, R3, M1, M2, M3 def to_dict(self, **kwargs): + if self.airfoil: + if kwargs.get("discretize", False): + lower = -np.pi / 6 if self.airfoil[1] == "radians" else -30 + upper = np.pi / 6 if self.airfoil[1] == "radians" else 30 + airfoil = ( + self.airfoil_cl.set_discrete(lower, upper, 50, mutate_self=False), + self.airfoil[1], + ) + else: + airfoil = (self.airfoil_cl, self.airfoil[1]) if self.airfoil else None + else: + airfoil = None 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, + "airfoil": airfoil, "name": self.name, } diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index bb32ae7b2..6cd78110e 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,6 +1,8 @@ import math import numpy as np +from rocketpy.tools import find_obj_from_hash +from typing import Iterable from rocketpy.control.controller import _Controller from rocketpy.mathutils.function import Function @@ -2070,17 +2072,32 @@ def from_dict(cls, data): 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"], - ) + for sensor, position in data["sensors"]: + rocket.add_sensor(sensor, position) + + for air_brake in data["air_brakes"]: + rocket.air_brakes.append(air_brake) + + for controller in data["_controllers"]: + if ( + interactive_objects_hash := getattr( + controller, "_interactive_objects_hash" + ) + ) is not None: + is_iterable = isinstance(interactive_objects_hash, Iterable) + if not is_iterable: + interactive_objects_hash = [interactive_objects_hash] + for hash_ in interactive_objects_hash: + if (hashed_obj := find_obj_from_hash(data, hash_)) is not None: + if not is_iterable: + controller.interactive_objects = hashed_obj + else: + controller.interactive_objects.append(hashed_obj) + else: + warnings.warn( + "Could not find controller interactive objects." + "Deserialization will proceed, results may not be accurate." + ) + rocket._add_controllers(controller) return rocket diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index de249c173..1037c0e9a 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -275,3 +275,28 @@ def export_measured_data(self, filename, file_format="csv"): file_format=file_format, data_labels=("t", "ax", "ay", "az"), ) + + def to_dict(self, **kwargs): + data = super().to_dict(**kwargs) + data.update({"consider_gravity": self.consider_gravity}) + return data + + @classmethod + def from_dict(cls, data): + return cls( + sampling_rate=data["sampling_rate"], + orientation=data["orientation"], + measurement_range=data["measurement_range"], + resolution=data["resolution"], + noise_density=data["noise_density"], + noise_variance=data["noise_variance"], + random_walk_density=data["random_walk_density"], + random_walk_variance=data["random_walk_variance"], + constant_bias=data["constant_bias"], + operating_temperature=data["operating_temperature"], + temperature_bias=data["temperature_bias"], + temperature_scale_factor=data["temperature_scale_factor"], + cross_axis_sensitivity=data["cross_axis_sensitivity"], + consider_gravity=data["consider_gravity"], + name=data["name"], + ) diff --git a/rocketpy/sensors/barometer.py b/rocketpy/sensors/barometer.py index bf5b538e2..cc26bb056 100644 --- a/rocketpy/sensors/barometer.py +++ b/rocketpy/sensors/barometer.py @@ -190,3 +190,20 @@ def export_measured_data(self, filename, file_format="csv"): file_format=file_format, data_labels=("t", "pressure"), ) + + @classmethod + def from_dict(cls, data): + return cls( + sampling_rate=data["sampling_rate"], + measurement_range=data["measurement_range"], + resolution=data["resolution"], + noise_density=data["noise_density"], + noise_variance=data["noise_variance"], + random_walk_density=data["random_walk_density"], + random_walk_variance=data["random_walk_variance"], + constant_bias=data["constant_bias"], + operating_temperature=data["operating_temperature"], + temperature_bias=data["temperature_bias"], + temperature_scale_factor=data["temperature_scale_factor"], + name=data["name"], + ) diff --git a/rocketpy/sensors/gnss_receiver.py b/rocketpy/sensors/gnss_receiver.py index 686cd29e5..548f9c879 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -124,3 +124,20 @@ def export_measured_data(self, filename, file_format="csv"): file_format=file_format, data_labels=("t", "latitude", "longitude", "altitude"), ) + + def to_dict(self, **kwargs): + return { + "sampling_rate": self.sampling_rate, + "position_accuracy": self.position_accuracy, + "altitude_accuracy": self.altitude_accuracy, + "name": self.name, + } + + @classmethod + def from_dict(cls, data): + return cls( + sampling_rate=data["sampling_rate"], + position_accuracy=data["position_accuracy"], + altitude_accuracy=data["altitude_accuracy"], + name=data["name"], + ) diff --git a/rocketpy/sensors/gyroscope.py b/rocketpy/sensors/gyroscope.py index 5acbb6f50..6a18af601 100644 --- a/rocketpy/sensors/gyroscope.py +++ b/rocketpy/sensors/gyroscope.py @@ -296,3 +296,28 @@ def export_measured_data(self, filename, file_format="csv"): file_format=file_format, data_labels=("t", "wx", "wy", "wz"), ) + + def to_dict(self, **kwargs): + data = super().to_dict(**kwargs) + data.update({"acceleration_sensitivity": self.acceleration_sensitivity}) + return data + + @classmethod + def from_dict(cls, data): + return cls( + sampling_rate=data["sampling_rate"], + orientation=data["orientation"], + measurement_range=data["measurement_range"], + resolution=data["resolution"], + noise_density=data["noise_density"], + noise_variance=data["noise_variance"], + random_walk_density=data["random_walk_density"], + random_walk_variance=data["random_walk_variance"], + constant_bias=data["constant_bias"], + operating_temperature=data["operating_temperature"], + temperature_bias=data["temperature_bias"], + temperature_scale_factor=data["temperature_scale_factor"], + cross_axis_sensitivity=data["cross_axis_sensitivity"], + acceleration_sensitivity=data["acceleration_sensitivity"], + name=data["name"], + ) diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py index 416201019..2479ff3d3 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -271,6 +271,22 @@ def _generic_export_measured_data(self, filename, file_format, data_labels): print(f"Data saved to {filename}") return + def to_dict(self, **kwargs): + return { + "sampling_rate": self.sampling_rate, + "measurement_range": self.measurement_range, + "resolution": self.resolution, + "operating_temperature": self.operating_temperature, + "noise_density": self.noise_density, + "noise_variance": self.noise_variance, + "random_walk_density": self.random_walk_density, + "random_walk_variance": self.random_walk_variance, + "constant_bias": self.constant_bias, + "temperature_bias": self.temperature_bias, + "temperature_scale_factor": self.temperature_scale_factor, + "name": self.name, + } + class InertialSensor(Sensor): """Model of an inertial sensor (accelerometer, gyroscope, magnetometer). @@ -574,6 +590,16 @@ def apply_temperature_drift(self, value): ) return value & scale_factor + def to_dict(self, **kwargs): + data = super().to_dict(**kwargs) + data.update( + { + "orientation": self.orientation, + "cross_axis_sensitivity": self.cross_axis_sensitivity, + } + ) + return data + class ScalarSensor(Sensor): """Model of a scalar sensor (e.g. Barometer). Scalar sensors are used diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index 877fa2b4c..b6cd37c38 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3575,7 +3575,6 @@ def to_dict(self, **kwargs): "x_impact": self.x_impact, "y_impact": self.y_impact, "t_final": self.t_final, - "flight_phases": self.flight_phases, "function_evaluations": self.function_evaluations, "ax": self.ax, "ay": self.ay, @@ -3589,6 +3588,7 @@ def to_dict(self, **kwargs): "M1": self.M1, "M2": self.M2, "M3": self.M3, + "net_thrust": self.net_thrust, } if kwargs.get("include_outputs", False): diff --git a/rocketpy/tools.py b/rocketpy/tools.py index a5ffb1b50..f70447c41 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -24,6 +24,7 @@ from cftime import num2pydate from matplotlib.patches import Ellipse from packaging import version as packaging_version +from typing import Iterable # Mapping of module name and the name of the package that should be installed INSTALL_MAPPING = {"IPython": "ipython"} @@ -1295,6 +1296,52 @@ def from_hex_decode(obj_bytes, decoder=base64.b85decode): return dill.loads(decoder(bytes.fromhex(obj_bytes))) +def find_obj_from_hash(obj, hash_, depth_limit=None): + """Searches the object (and its children) for + an object whose '__rpy_hash' field has a particular hash value. + + Parameters + ---------- + obj : object + Object to search. + hash_ : str + Hash value to search for in the '__rpy_hash' field. + depth_limit : int, optional + Maximum depth to search recursively. If None, no limit. + + Returns + ------- + object + The object whose '__rpy_hash' matches hash_, or None if not found. + """ + + def _search(o, current_depth): + if depth_limit is not None and current_depth > depth_limit: + return None + + if getattr(o, "__rpy_hash", None) == hash_: + return o + + if isinstance(o, dict): + # Check if this dict has the '__rpy_hash' key + # Recurse into each value + for value in o.values(): + result = _search(value, current_depth + 1) + if result is not None: + return result + + elif isinstance(o, (list, tuple, set)): + for item in o: + result = _search(item, current_depth + 1) + if result is not None: + return result + + # Not a container or not matching + return None + + return _search(obj, 0) + + if __name__ == "__main__": # pragma: no cover import doctest diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py index c2c0474cb..a95bd0aea 100644 --- a/tests/integration/test_encoding.py +++ b/tests/integration/test_encoding.py @@ -3,12 +3,15 @@ import numpy as np import pytest +from unittest.mock import patch + from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder from rocketpy.tools import from_hex_decode +@patch("matplotlib.pyplot.show") @pytest.mark.parametrize( ["flight_name", "include_outputs"], [ @@ -19,7 +22,9 @@ ("flight_calisto_hybrid_modded", False), ], ) -def test_flight_save_load_no_resimulate(flight_name, include_outputs, request): +def test_flight_save_load_no_resimulate( + mock_show, flight_name, include_outputs, request +): """Test encoding a ``rocketpy.Flight``. Parameters @@ -50,6 +55,8 @@ def test_flight_save_load_no_resimulate(flight_name, include_outputs, request): # Higher tolerance due to random parachute trigger assert np.isclose(flight_to_save.t_final, flight_loaded.t_final, rtol=1e-3) + flight_loaded.all_info() + os.remove("flight.json") @@ -62,6 +69,8 @@ def test_flight_save_load_no_resimulate(flight_name, include_outputs, request): ("flight_calisto_robust", True), ("flight_calisto_liquid_modded", False), ("flight_calisto_hybrid_modded", False), + ("flight_calisto_air_brakes", False), + ("flight_calisto_with_sensors", False), ], ) def test_flight_save_load_resimulate(flight_name, include_outputs, request): @@ -93,7 +102,7 @@ def test_flight_save_load_resimulate(flight_name, include_outputs, request): 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) + assert np.isclose(flight_to_save.t_final, flight_loaded.t_final, rtol=5e-3) os.remove("flight.json") diff --git a/tests/integration/test_flight.py b/tests/integration/test_flight.py index c47b9b124..5e148a650 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -414,6 +414,24 @@ def test_air_brakes_flight(mock_show, flight_calisto_air_brakes): # pylint: dis """ test_flight = flight_calisto_air_brakes air_brakes = test_flight.rocket.air_brakes[0] + + import json + from rocketpy._encoders import RocketPyEncoder, RocketPyDecoder + + with open("test_decode_airbrake.json", "w") as f: + json.dump( + test_flight, + f, + cls=RocketPyEncoder, + indent=2, + discretize=True, + include_outputs=False, + allow_pickle=True, + ) + with open("test_decode_airbrake.json", "r") as f: + test_flight = json.load(f, cls=RocketPyDecoder) + air_brakes = test_flight.rocket.air_brakes[0] + assert air_brakes.plots.all() is None assert air_brakes.prints.all() is None From 2c7deb6325b1a7863540e24ebf90d008045be295 Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Sat, 20 Sep 2025 15:06:29 +0200 Subject: [PATCH 5/9] STY: solve linting and style remarks. --- rocketpy/control/controller.py | 1 + rocketpy/rocket/aero_surface/air_brakes.py | 1 + rocketpy/rocket/rocket.py | 10 +++++++--- rocketpy/sensors/sensor.py | 1 + rocketpy/tools.py | 1 - tests/integration/test_encoding.py | 5 ++--- tests/integration/test_flight.py | 17 ----------------- 7 files changed, 12 insertions(+), 24 deletions(-) diff --git a/rocketpy/control/controller.py b/rocketpy/control/controller.py index 0469e7f1a..27ad62361 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -1,5 +1,6 @@ from inspect import signature from typing import Iterable + from rocketpy.tools import from_hex_decode, to_hex_encode from ..prints.controller_prints import _ControllerPrints diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index 4d9ae217b..e641c050d 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -207,6 +207,7 @@ def all_info(self): self.info() self.plots.drag_coefficient_curve() + # pylint: disable=unused-argument def to_dict(self, **kwargs): return { "drag_coefficient_curve": self.drag_coefficient, diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 6cd78110e..9c303b289 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,8 +1,8 @@ import math +import warnings +from typing import Iterable import numpy as np -from rocketpy.tools import find_obj_from_hash -from typing import Iterable from rocketpy.control.controller import _Controller from rocketpy.mathutils.function import Function @@ -23,7 +23,11 @@ from rocketpy.rocket.aero_surface.generic_surface import GenericSurface from rocketpy.rocket.components import Components from rocketpy.rocket.parachute import Parachute -from rocketpy.tools import deprecated, parallel_axis_theorem_from_com +from rocketpy.tools import ( + deprecated, + find_obj_from_hash, + parallel_axis_theorem_from_com, +) # pylint: disable=too-many-instance-attributes, too-many-public-methods, too-many-instance-attributes diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py index 2479ff3d3..0b44aeb18 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -271,6 +271,7 @@ def _generic_export_measured_data(self, filename, file_format, data_labels): print(f"Data saved to {filename}") return + # pylint: disable=unused-argument def to_dict(self, **kwargs): return { "sampling_rate": self.sampling_rate, diff --git a/rocketpy/tools.py b/rocketpy/tools.py index f70447c41..e969f2e59 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -24,7 +24,6 @@ from cftime import num2pydate from matplotlib.patches import Ellipse from packaging import version as packaging_version -from typing import Iterable # Mapping of module name and the name of the package that should be installed INSTALL_MAPPING = {"IPython": "ipython"} diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py index a95bd0aea..79a7a5f28 100644 --- a/tests/integration/test_encoding.py +++ b/tests/integration/test_encoding.py @@ -1,16 +1,15 @@ import json import os +from unittest.mock import patch import numpy as np import pytest -from unittest.mock import patch - from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder - from rocketpy.tools import from_hex_decode +# pylint: disable=unused-argument @patch("matplotlib.pyplot.show") @pytest.mark.parametrize( ["flight_name", "include_outputs"], diff --git a/tests/integration/test_flight.py b/tests/integration/test_flight.py index 5e148a650..7a36e0629 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -415,23 +415,6 @@ def test_air_brakes_flight(mock_show, flight_calisto_air_brakes): # pylint: dis test_flight = flight_calisto_air_brakes air_brakes = test_flight.rocket.air_brakes[0] - import json - from rocketpy._encoders import RocketPyEncoder, RocketPyDecoder - - with open("test_decode_airbrake.json", "w") as f: - json.dump( - test_flight, - f, - cls=RocketPyEncoder, - indent=2, - discretize=True, - include_outputs=False, - allow_pickle=True, - ) - with open("test_decode_airbrake.json", "r") as f: - test_flight = json.load(f, cls=RocketPyDecoder) - air_brakes = test_flight.rocket.air_brakes[0] - assert air_brakes.plots.all() is None assert air_brakes.prints.all() is None From a92bfac8ac285e30cc363084318dafcd67a3d93a Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Sat, 20 Sep 2025 21:15:53 +0200 Subject: [PATCH 6/9] BUG: parachute callbacks attribute naming. --- CHANGELOG.md | 3 +-- rocketpy/mathutils/function.py | 7 ++++--- rocketpy/rocket/rocket.py | 7 ++----- rocketpy/simulation/flight.py | 6 ++---- rocketpy/tools.py | 5 +---- 5 files changed, 10 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f778e8cec..b61018f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added -- ENH: Discretized and No-Pickle Encoding Options [#827] (https://github.com/RocketPy-Team/RocketPy/pull/827) - +- ENH: Controller (AirBrakes) and Sensors Encoding [#849] (https://github.com/RocketPy-Team/RocketPy/pull/849) - EHN: Addition of ensemble variable to ECMWF dictionaries [#842] (https://github.com/RocketPy-Team/RocketPy/pull/842) - ENH: Added Crop and Clip Methods to Function Class [#817](https://github.com/RocketPy-Team/RocketPy/pull/817) - DOC: Add Flight class usage documentation and update index [#841](https://github.com/RocketPy-Team/RocketPy/pull/841) diff --git a/rocketpy/mathutils/function.py b/rocketpy/mathutils/function.py index 5dbfe2b5b..648cc16c9 100644 --- a/rocketpy/mathutils/function.py +++ b/rocketpy/mathutils/function.py @@ -1551,7 +1551,7 @@ def short_time_fft( ... ylabel=f"Freq. $f$ in Hz)", ... xlim=(t_lo, t_hi) ... ) - # >>> _ = ax1.plot(t_x, f_i, 'r--', alpha=.5, label='$f_i(t)$') + >>> _ = ax1.plot(t_x, f_i, 'r--', alpha=.5, label='$f_i(t)$') >>> _ = fig1.colorbar(im1, label="Magnitude $|S_x(t, f)|$") >>> # Shade areas where window slices stick out to the side >>> for t0_, t1_ in [(t_lo, 1), (49, t_hi)]: @@ -1856,7 +1856,8 @@ def plot_1d( # pylint: disable=too-many-statements None """ # Define a mesh and y values at mesh nodes for plotting - fig, ax = plt.subplots() + fig = plt.figure() + ax = fig.axes if self._source_type is SourceType.CALLABLE: # Determine boundaries domain = [0, 10] @@ -1894,9 +1895,9 @@ def plot_1d( # pylint: disable=too-many-statements plt.title(self.title) plt.xlabel(self.__inputs__[0].title()) plt.ylabel(self.__outputs__[0].title()) + show_or_save_plot(filename) if return_object: return fig, ax - show_or_save_plot(filename) @deprecated( reason="The `Function.plot2D` method is set to be deprecated and fully " diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index 9c303b289..1112a98f3 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -2083,11 +2083,8 @@ def from_dict(cls, data): rocket.air_brakes.append(air_brake) for controller in data["_controllers"]: - if ( - interactive_objects_hash := getattr( - controller, "_interactive_objects_hash" - ) - ) is not None: + interactive_objects_hash = getattr(controller, "_interactive_objects_hash") + if interactive_objects_hash is not None: is_iterable = isinstance(interactive_objects_hash, Iterable) if not is_iterable: interactive_objects_hash = [interactive_objects_hash] diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index b6cd37c38..e6861a820 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -762,12 +762,10 @@ def __simulate(self, verbose): lambda self, parachute_cd_s=parachute.cd_s: setattr( self, "parachute_cd_s", parachute_cd_s ), - lambda self, - parachute_radius=parachute.parachute_radius: setattr( + lambda self, parachute_radius=parachute.radius: setattr( self, "parachute_radius", parachute_radius ), - lambda self, - parachute_height=parachute.parachute_height: setattr( + lambda self, parachute_height=parachute.height: setattr( self, "parachute_height", parachute_height ), lambda self, parachute_porosity=parachute.porosity: setattr( diff --git a/rocketpy/tools.py b/rocketpy/tools.py index e969f2e59..8951e4c0a 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1303,7 +1303,7 @@ def find_obj_from_hash(obj, hash_, depth_limit=None): ---------- obj : object Object to search. - hash_ : str + hash_ : int Hash value to search for in the '__rpy_hash' field. depth_limit : int, optional Maximum depth to search recursively. If None, no limit. @@ -1322,8 +1322,6 @@ def _search(o, current_depth): return o if isinstance(o, dict): - # Check if this dict has the '__rpy_hash' key - # Recurse into each value for value in o.values(): result = _search(value, current_depth + 1) if result is not None: @@ -1335,7 +1333,6 @@ def _search(o, current_depth): if result is not None: return result - # Not a container or not matching return None return _search(obj, 0) From 27b4294fad5b6a033ca7b5e6f944630efd09b9c1 Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Sun, 21 Sep 2025 09:50:26 +0200 Subject: [PATCH 7/9] GIT: test agg backend for matplotlib workflows. --- .github/workflows/test-pytest-slow.yaml | 3 ++- .github/workflows/test_pytest.yaml | 1 + rocketpy/rocket/aero_surface/air_brakes.py | 3 +-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-pytest-slow.yaml b/.github/workflows/test-pytest-slow.yaml index 76e6e7e28..afbcea4df 100644 --- a/.github/workflows/test-pytest-slow.yaml +++ b/.github/workflows/test-pytest-slow.yaml @@ -5,7 +5,7 @@ on: - cron: "0 17 * * 5" # at 05:00 PM, only on Friday push: branches: - - main + - master paths: - "**.py" - ".github/**" @@ -24,6 +24,7 @@ jobs: python-version: [3.9, 3.13] env: PYTHON: ${{ matrix.python-version }} + MPLBACKEND: Agg steps: - uses: actions/checkout@main - name: Set up Python diff --git a/.github/workflows/test_pytest.yaml b/.github/workflows/test_pytest.yaml index 947a494b9..a47b2337a 100644 --- a/.github/workflows/test_pytest.yaml +++ b/.github/workflows/test_pytest.yaml @@ -23,6 +23,7 @@ jobs: env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python-version }} + MPLBACKEND: Agg steps: - uses: actions/checkout@main - name: Set up Python diff --git a/rocketpy/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index e641c050d..2fa5c782c 100644 --- a/rocketpy/rocket/aero_surface/air_brakes.py +++ b/rocketpy/rocket/aero_surface/air_brakes.py @@ -207,8 +207,7 @@ def all_info(self): self.info() self.plots.drag_coefficient_curve() - # pylint: disable=unused-argument - def to_dict(self, **kwargs): + def to_dict(self, **kwargs): # pylint: disable=unused-argument return { "drag_coefficient_curve": self.drag_coefficient, "reference_area": self.reference_area, From 759e1c211ff4adb74a4959817e06b5da39bfafd2 Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Sun, 21 Sep 2025 10:17:54 +0200 Subject: [PATCH 8/9] TST: include sensors and controllers encoding tests as non slow. --- tests/integration/test_encoding.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py index 79a7a5f28..f9dea05c5 100644 --- a/tests/integration/test_encoding.py +++ b/tests/integration/test_encoding.py @@ -19,6 +19,8 @@ ("flight_calisto_robust", True), ("flight_calisto_liquid_modded", False), ("flight_calisto_hybrid_modded", False), + ("flight_calisto_air_brakes", False), + ("flight_calisto_with_sensors", False), ], ) def test_flight_save_load_no_resimulate( From c7e94f8022a07e4522d722c9e549027ce4198bcd Mon Sep 17 00:00:00 2001 From: Pedro Bressan Date: Mon, 22 Sep 2025 14:12:18 +0200 Subject: [PATCH 9/9] MNT: change recursive to iterative approach on hash search. --- rocketpy/tools.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/rocketpy/tools.py b/rocketpy/tools.py index 8951e4c0a..5683f9847 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1314,28 +1314,22 @@ def find_obj_from_hash(obj, hash_, depth_limit=None): The object whose '__rpy_hash' matches hash_, or None if not found. """ - def _search(o, current_depth): + stack = [(obj, 0)] + while stack: + current_obj, current_depth = stack.pop() if depth_limit is not None and current_depth > depth_limit: - return None + continue - if getattr(o, "__rpy_hash", None) == hash_: - return o + if getattr(current_obj, "__rpy_hash", None) == hash_: + return current_obj - if isinstance(o, dict): - for value in o.values(): - result = _search(value, current_depth + 1) - if result is not None: - return result + if isinstance(current_obj, dict): + stack.extend((v, current_depth + 1) for v in current_obj.values()) - elif isinstance(o, (list, tuple, set)): - for item in o: - result = _search(item, current_depth + 1) - if result is not None: - return result + elif isinstance(current_obj, (list, tuple, set)): + stack.extend((item, current_depth + 1) for item in current_obj) - return None - - return _search(obj, 0) + return None if __name__ == "__main__": # pragma: no cover