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/CHANGELOG.md b/CHANGELOG.md index 9956a48a7..b61018f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Attention: The newest changes should be on top --> ### Added - +- 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/_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..27ad62361 100644 --- a/rocketpy/control/controller.py +++ b/rocketpy/control/controller.py @@ -1,4 +1,7 @@ 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 +184,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/rocket/aero_surface/air_brakes.py b/rocketpy/rocket/aero_surface/air_brakes.py index d0eb733d5..2fa5c782c 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): # pylint: disable=unused-argument + 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..1112a98f3 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -1,4 +1,6 @@ import math +import warnings +from typing import Iterable import numpy as np @@ -21,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 @@ -2070,17 +2076,29 @@ 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"]: + 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] + 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..0b44aeb18 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -271,6 +271,23 @@ 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, + "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 +591,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..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( @@ -3575,7 +3573,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 +3586,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..5683f9847 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1295,6 +1295,43 @@ 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_ : int + 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. + """ + + stack = [(obj, 0)] + while stack: + current_obj, current_depth = stack.pop() + if depth_limit is not None and current_depth > depth_limit: + continue + + if getattr(current_obj, "__rpy_hash", None) == hash_: + return current_obj + + if isinstance(current_obj, dict): + stack.extend((v, current_depth + 1) for v in current_obj.values()) + + elif isinstance(current_obj, (list, tuple, set)): + stack.extend((item, current_depth + 1) for item in current_obj) + + return None + + if __name__ == "__main__": # pragma: no cover import doctest diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py index c2c0474cb..f9dea05c5 100644 --- a/tests/integration/test_encoding.py +++ b/tests/integration/test_encoding.py @@ -1,14 +1,16 @@ import json import os +from unittest.mock import patch import numpy as np import pytest 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"], [ @@ -17,9 +19,13 @@ ("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(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 +56,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 +70,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 +103,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..7a36e0629 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -414,6 +414,7 @@ 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] + assert air_brakes.plots.all() is None assert air_brakes.prints.all() is None