diff --git a/CHANGELOG.md b/CHANGELOG.md index 074e28af0..7c50965e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Attention: The newest changes should be on top --> ### Added - ENH: Tank Fluids with Variable Density from Temperature and Pressure [#852](https://github.com/RocketPy-Team/RocketPy/pull/852) - 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) +- 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) - ENH: Discretized and No-Pickle Encoding Options [#827](https://github.com/RocketPy-Team/RocketPy/pull/827) @@ -43,6 +43,7 @@ Attention: The newest changes should be on top --> ### Changed - DOC: Update docs dependencies and sub dependencies [#851](https://github.com/RocketPy-Team/RocketPy/pull/851) +- MNT: extract flight data exporters [#845](https://github.com/RocketPy-Team/RocketPy/pull/845) - ENH: _MotorPrints inheritance - issue #460 [#828](https://github.com/RocketPy-Team/RocketPy/pull/828) - MNT: fix deprecations and warnings [#829](https://github.com/RocketPy-Team/RocketPy/pull/829) diff --git a/docs/user/first_simulation.rst b/docs/user/first_simulation.rst index d0e679e6e..1e341e9b1 100644 --- a/docs/user/first_simulation.rst +++ b/docs/user/first_simulation.rst @@ -565,12 +565,16 @@ Visualizing the Trajectory in Google Earth We can export the trajectory to ``.kml`` to visualize it in Google Earth: +Use the dedicated exporter class: + .. jupyter-input:: - test_flight.export_kml( + from rocketpy.simulation import FlightDataExporter + + FlightDataExporter(test_flight).export_kml( file_name="trajectory.kml", extrude=True, - altitude_mode="relative_to_ground", + altitude_mode="relativetoground", ) .. note:: @@ -578,6 +582,10 @@ We can export the trajectory to ``.kml`` to visualize it in Google Earth: To learn more about the ``.kml`` format, see `KML Reference `_. +.. note:: + + The legacy method ``Flight.export_kml`` is deprecated. Use + :meth:`rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_kml`. Manipulating results -------------------- @@ -610,17 +618,27 @@ In this section, we will explore how to export specific data from your RocketPy simulations to CSV files. This is particularly useful if you want to insert the data into spreadsheets or other software for further analysis. -The main method that is used to export data is the :meth:`rocketpy.Flight.export_data` method. This method exports selected flight attributes to a CSV file. In this first example, we will export the rocket angle of attack (see :meth:`rocketpy.Flight.angle_of_attack`) and the rocket mach number (see :meth:`rocketpy.Flight.mach_number`) to the file ``calisto_flight_data.csv``. +The recommended API is +:meth:`rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data`, +which exports selected flight attributes to a CSV file. In this first example, +we export the rocket angle of attack (see :meth:`rocketpy.Flight.angle_of_attack`) +and the rocket Mach number (see :meth:`rocketpy.Flight.mach_number`) to the file +``calisto_flight_data.csv``. .. jupyter-execute:: - test_flight.export_data( + from rocketpy.simulation import FlightDataExporter + + exporter = FlightDataExporter(test_flight) + exporter.export_data( "calisto_flight_data.csv", "angle_of_attack", "mach_number", ) -| As you can see, the first argument of the method is the name of the file to be created. The following arguments are the attributes to be exported. We can check the file that was created by reading it with the :func:`pandas.read_csv` function: +| As you can see, the first argument is the file name to be created. The following +arguments are the attributes to be exported. We can check the file by reading it +with :func:`pandas.read_csv`: .. jupyter-execute:: @@ -628,11 +646,13 @@ The main method that is used to export data is the :meth:`rocketpy.Flight.export pd.read_csv("calisto_flight_data.csv") -| The file header specifies the meaning of each column. The time samples are obtained from the simulation solver steps. Should you want to export the data at a different sampling rate, you can use the ``time_step`` argument of the :meth:`rocketpy.Flight.export_data` method as follows. +| The file header specifies the meaning of each column. The time samples are +obtained from the simulation solver steps. To export the data at a different +sampling rate, use the ``time_step`` argument: .. jupyter-execute:: - test_flight.export_data( + exporter.export_data( "calisto_flight_data.csv", "angle_of_attack", "mach_number", @@ -641,15 +661,16 @@ The main method that is used to export data is the :meth:`rocketpy.Flight.export pd.read_csv("calisto_flight_data.csv") -This will export the same data at a sampling rate of 1 second. The flight data will be interpolated to match the new sampling rate. +This exports the same data at a sampling rate of 1 second. The flight data is +interpolated to match the new sampling rate. -Finally, the :meth:`rocketpy.Flight.export_data` method also provides a convenient way to export the entire flight solution (see :meth:`rocketpy.Flight.solution_array`) to a CSV file. This is done by not passing any attributes names to the method. +Finally, ``FlightDataExporter.export_data`` also provides a convenient way to +export the entire flight solution (see :meth:`rocketpy.Flight.solution_array`) +by not passing any attribute names: .. jupyter-execute:: - test_flight.export_data( - "calisto_flight_data.csv", - ) + exporter.export_data("calisto_flight_data.csv") .. jupyter-execute:: :hide-code: @@ -659,6 +680,10 @@ Finally, the :meth:`rocketpy.Flight.export_data` method also provides a convenie import os os.remove("calisto_flight_data.csv") +.. note:: + + The legacy method ``Flight.export_data`` is deprecated. Use + :meth:`rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data`. Saving and Storing Plots ------------------------ @@ -670,7 +695,7 @@ For instance, we can save our rocket drawing as a ``.png`` file: calisto.draw(filename="calisto_drawing.png") -Also, if you want to save a specific rocketpy plot, every RocketPy +Also, if you want to save a specific rocketpy plot, every RocketPy attribute of type :class:`rocketpy.Function` is capable of saving its plot as an image file. For example, we can save our rocket's speed plot and the trajectory plot as ``.jpg`` files: diff --git a/rocketpy/simulation/__init__.py b/rocketpy/simulation/__init__.py index 6b98fdcf4..1ade0f16f 100644 --- a/rocketpy/simulation/__init__.py +++ b/rocketpy/simulation/__init__.py @@ -1,4 +1,5 @@ from .flight import Flight +from .flight_data_exporter import FlightDataExporter from .flight_data_importer import FlightDataImporter from .monte_carlo import MonteCarlo from .multivariate_rejection_sampler import MultivariateRejectionSampler diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index e6861a820..27043b892 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -1,20 +1,21 @@ # pylint: disable=too-many-lines -import json import math import warnings from copy import deepcopy from functools import cached_property import numpy as np -import simplekml from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, OdeSolver, Radau +from rocketpy.simulation.flight_data_exporter import FlightDataExporter + from ..mathutils.function import Function, funcify_method from ..mathutils.vector_matrix import Matrix, Vector from ..plots.flight_plots import _FlightPlots from ..prints.flight_prints import _FlightPrints from ..tools import ( calculate_cubic_hermite_coefficients, + deprecated, euler313_to_quaternions, find_closest, find_root_linear_interpolation, @@ -3264,191 +3265,53 @@ def calculate_stall_wind_velocity(self, stall_angle): # TODO: move to utilities + f" of attack exceeds {stall_angle:.3f}°: {w_v:.3f} m/s" ) - def export_pressures(self, file_name, time_step): # TODO: move out - """Exports the pressure experienced by the rocket during the flight to - an external file, the '.csv' format is recommended, as the columns will - be separated by commas. It can handle flights with or without - parachutes, although it is not possible to get a noisy pressure signal - if no parachute is added. - - If a parachute is added, the file will contain 3 columns: time in - seconds, clean pressure in Pascals and noisy pressure in Pascals. - For flights without parachutes, the third column will be discarded - - This function was created especially for the 'Projeto Jupiter' - Electronics Subsystems team and aims to help in configuring - micro-controllers. - - Parameters - ---------- - file_name : string - The final file name, - time_step : float - Time step desired for the final file - - Return - ------ - None + @deprecated( + reason="Moved to FlightDataExporter.export_pressures()", + version="v1.12.0", + alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_pressures", + ) + def export_pressures(self, file_name, time_step): + """ + .. deprecated:: 1.11 + Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter` + and call ``.export_pressures(...)``. """ - time_points = np.arange(0, self.t_final, time_step) - # pylint: disable=W1514, E1121 - with open(file_name, "w") as file: - if len(self.rocket.parachutes) == 0: - print("No parachutes in the rocket, saving static pressure.") - for t in time_points: - file.write(f"{t:f}, {self.pressure.get_value_opt(t):.5f}\n") - else: - for parachute in self.rocket.parachutes: - for t in time_points: - p_cl = parachute.clean_pressure_signal_function.get_value_opt(t) - p_ns = parachute.noisy_pressure_signal_function.get_value_opt(t) - file.write(f"{t:f}, {p_cl:.5f}, {p_ns:.5f}\n") - # We need to save only 1 parachute data - break + return FlightDataExporter(self).export_pressures(file_name, time_step) + @deprecated( + reason="Moved to FlightDataExporter.export_data()", + version="v1.12.0", + alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data", + ) def export_data(self, file_name, *variables, time_step=None): - """Exports flight data to a comma separated value file (.csv). - - Data is exported in columns, with the first column representing time - steps. The first line of the file is a header line, specifying the - meaning of each column and its units. - - Parameters - ---------- - file_name : string - The file name or path of the exported file. Example: flight_data.csv - Do not use forbidden characters, such as / in Linux/Unix and - `<, >, :, ", /, \\, | ?, *` in Windows. - variables : strings, optional - Names of the data variables which shall be exported. Must be Flight - class attributes which are instances of the Function class. Usage - example: test_flight.export_data('test.csv', 'z', 'angle_of_attack', - 'mach_number'). - time_step : float, optional - Time step desired for the data. If None, all integration time steps - will be exported. Otherwise, linear interpolation is carried out to - calculate values at the desired time steps. Example: 0.001. """ - # TODO: we should move this method to outside of class. - - # Fast evaluation for the most basic scenario - if time_step is None and len(variables) == 0: - np.savetxt( - file_name, - self.solution, - fmt="%.6f", - delimiter=",", - header="" - "Time (s)," - "X (m)," - "Y (m)," - "Z (m)," - "E0," - "E1," - "E2," - "E3," - "W1 (rad/s)," - "W2 (rad/s)," - "W3 (rad/s)", - ) - return - - # Not so fast evaluation for general case - if variables is None: - variables = [ - "x", - "y", - "z", - "vx", - "vy", - "vz", - "e0", - "e1", - "e2", - "e3", - "w1", - "w2", - "w3", - ] - - if time_step is None: - time_points = self.time - else: - time_points = np.arange(self.t_initial, self.t_final, time_step) - - exported_matrix = [time_points] - exported_header = "Time (s)" - - # Loop through variables, get points and names (for the header) - for variable in variables: - if variable in self.__dict__: - variable_function = self.__dict__[variable] - # Deal with decorated Flight methods - else: - try: - obj = getattr(self.__class__, variable) - variable_function = obj.__get__(self, self.__class__) - except AttributeError as exc: - raise AttributeError( - f"Variable '{variable}' not found in Flight class" - ) from exc - variable_points = variable_function(time_points) - exported_matrix += [variable_points] - exported_header += f", {variable_function.__outputs__[0]}" - - exported_matrix = np.array(exported_matrix).T # Fix matrix orientation - - np.savetxt( - file_name, - exported_matrix, - fmt="%.6f", - delimiter=",", - header=exported_header, - encoding="utf-8", + .. deprecated:: 1.11 + Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter` + and call ``.export_data(...)``. + """ + return FlightDataExporter(self).export_data( + file_name, *variables, time_step=time_step ) + @deprecated( + reason="Moved to FlightDataExporter.export_sensor_data()", + version="v1.12.0", + alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_sensor_data", + ) def export_sensor_data(self, file_name, sensor=None): - """Exports sensors data to a file. The file format can be either .csv or - .json. - - Parameters - ---------- - file_name : str - The file name or path of the exported file. Example: flight_data.csv - Do not use forbidden characters, such as / in Linux/Unix and - `<, >, :, ", /, \\, | ?, *` in Windows. - sensor : Sensor, string, optional - The sensor to export data from. Can be given as a Sensor object or - as a string with the sensor name. If None, all sensors data will be - exported. Default is None. """ - if sensor is None: - data_dict = {} - for used_sensor, measured_data in self.sensor_data.items(): - data_dict[used_sensor.name] = measured_data - else: - # export data of only that sensor - data_dict = {} - - if not isinstance(sensor, str): - data_dict[sensor.name] = self.sensor_data[sensor] - else: # sensor is a string - matching_sensors = [s for s in self.sensor_data if s.name == sensor] - - if len(matching_sensors) > 1: - data_dict[sensor] = [] - for s in matching_sensors: - data_dict[s.name].append(self.sensor_data[s]) - elif len(matching_sensors) == 1: - data_dict[sensor] = self.sensor_data[matching_sensors[0]] - else: - raise ValueError("Sensor not found in the Flight.sensor_data.") - - with open(file_name, "w") as file: - json.dump(data_dict, file) - print("Sensor data exported to: ", file_name) + .. deprecated:: 1.11 + Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter` + and call ``.export_sensor_data(...)``. + """ + return FlightDataExporter(self).export_sensor_data(file_name, sensor=sensor) - def export_kml( # TODO: should be moved out of this class. + @deprecated( + reason="Moved to FlightDataExporter.export_kml()", + version="v1.12.0", + alternative="rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_kml", + ) + def export_kml( self, file_name="trajectory.kml", time_step=None, @@ -3456,78 +3319,18 @@ def export_kml( # TODO: should be moved out of this class. color="641400F0", altitude_mode="absolute", ): - """Exports flight data to a .kml file, which can be opened with Google - Earth to display the rocket's trajectory. - - Parameters - ---------- - file_name : string - The file name or path of the exported file. Example: flight_data.csv - time_step : float, optional - Time step desired for the data. If None, all integration time steps - will be exported. Otherwise, linear interpolation is carried out to - calculate values at the desired time steps. Example: 0.001. - extrude: bool, optional - To be used if you want to project the path over ground by using an - extruded polygon. In case False only the linestring containing the - flight path will be created. Default is True. - color : str, optional - Color of your trajectory path, need to be used in specific kml - format. Refer to http://www.zonums.com/gmaps/kml_color/ for more - info. - altitude_mode: str - Select elevation values format to be used on the kml file. Use - 'relativetoground' if you want use Above Ground Level elevation, or - 'absolute' if you want to parse elevation using Above Sea Level. - Default is 'relativetoground'. Only works properly if the ground - level is flat. Change to 'absolute' if the terrain is to irregular - or contains mountains. """ - # Define time points vector - if time_step is None: - time_points = self.time - else: - time_points = np.arange(self.t_initial, self.t_final + time_step, time_step) - # Open kml file with simplekml library - kml = simplekml.Kml(open=1) - trajectory = kml.newlinestring(name="Rocket Trajectory - Powered by RocketPy") - - if altitude_mode == "relativetoground": - # In this mode the elevation data will be the Above Ground Level - # elevation. Only works properly if the ground level is similar to - # a plane, i.e. it might not work well if the terrain has mountains - coords = [ - ( - self.longitude.get_value_opt(t), - self.latitude.get_value_opt(t), - self.altitude.get_value_opt(t), - ) - for t in time_points - ] - trajectory.coords = coords - trajectory.altitudemode = simplekml.AltitudeMode.relativetoground - else: # altitude_mode == 'absolute' - # In this case the elevation data will be the Above Sea Level elevation - # Ensure you use the correct value on self.env.elevation, otherwise - # the trajectory path can be offset from ground - coords = [ - ( - self.longitude.get_value_opt(t), - self.latitude.get_value_opt(t), - self.z.get_value_opt(t), - ) - for t in time_points - ] - trajectory.coords = coords - trajectory.altitudemode = simplekml.AltitudeMode.absolute - # Modify style of trajectory linestring - trajectory.style.linestyle.color = color - trajectory.style.polystyle.color = color - if extrude: - trajectory.extrude = 1 - # Save the KML - kml.save(file_name) - print("File ", file_name, " saved with success!") + .. deprecated:: 1.11 + Use :class:`rocketpy.simulation.flight_data_exporter.FlightDataExporter` + and call ``.export_kml(...)``. + """ + return FlightDataExporter(self).export_kml( + file_name=file_name, + time_step=time_step, + extrude=extrude, + color=color, + altitude_mode=altitude_mode, + ) def info(self): """Prints out a summary of the data available about the Flight.""" diff --git a/rocketpy/simulation/flight_data_exporter.py b/rocketpy/simulation/flight_data_exporter.py new file mode 100644 index 000000000..d73c0897a --- /dev/null +++ b/rocketpy/simulation/flight_data_exporter.py @@ -0,0 +1,298 @@ +""" +Exports a rocketpy.Flight object's data to external files. +""" + +import json + +import numpy as np +import simplekml + + +class FlightDataExporter: + """Export data from a rocketpy.Flight object to various formats.""" + + def __init__(self, flight, name="Flight Data"): + """ + Parameters + ---------- + flight : rocketpy.simulation.flight.Flight + The Flight instance to export from. + name : str, optional + A label for this exporter instance. + """ + self.name = name + self._flight = flight + + def __repr__(self): + return f"FlightDataExporter(name='{self.name}', flight='{type(self._flight).__name__}')" + + def export_pressures(self, file_name, time_step): + """Exports the pressure experienced by the rocket during the flight to + an external file, the '.csv' format is recommended, as the columns will + be separated by commas. It can handle flights with or without + parachutes, although it is not possible to get a noisy pressure signal + if no parachute is added. + + If a parachute is added, the file will contain 3 columns: time in + seconds, clean pressure in Pascals and noisy pressure in Pascals. + For flights without parachutes, the third column will be discarded + + This function was created especially for the 'Projeto Jupiter' + Electronics Subsystems team and aims to help in configuring + micro-controllers. + + Parameters + ---------- + file_name : string + The final file name, + time_step : float + Time step desired for the final file + + Return + ------ + None + """ + f = self._flight + time_points = np.arange(0, f.t_final, time_step) + # pylint: disable=W1514, E1121 + with open(file_name, "w") as file: + if len(f.rocket.parachutes) == 0: + print("No parachutes in the rocket, saving static pressure.") + for t in time_points: + file.write(f"{t:f}, {f.pressure.get_value_opt(t):.5f}\n") + else: + for parachute in f.rocket.parachutes: + for t in time_points: + p_cl = parachute.clean_pressure_signal_function.get_value_opt(t) + p_ns = parachute.noisy_pressure_signal_function.get_value_opt(t) + file.write(f"{t:f}, {p_cl:.5f}, {p_ns:.5f}\n") + # We need to save only 1 parachute data + break + + def export_data(self, file_name, *variables, time_step=None): + """Exports flight data to a comma separated value file (.csv). + + Data is exported in columns, with the first column representing time + steps. The first line of the file is a header line, specifying the + meaning of each column and its units. + + Parameters + ---------- + file_name : string + The file name or path of the exported file. Example: flight_data.csv + Do not use forbidden characters, such as / in Linux/Unix and + `<, >, :, ", /, \\, | ?, *` in Windows. + variables : strings, optional + Names of the data variables which shall be exported. Must be Flight + class attributes which are instances of the Function class. Usage + example: test_flight.export_data('test.csv', 'z', 'angle_of_attack', + 'mach_number'). + time_step : float, optional + Time step desired for the data. If None, all integration time steps + will be exported. Otherwise, linear interpolation is carried out to + calculate values at the desired time steps. Example: 0.001. + """ + f = self._flight + + # Fast evaluation for the most basic scenario + if time_step is None and len(variables) == 0: + np.savetxt( + file_name, + f.solution, + fmt="%.6f", + delimiter=",", + header="" + "Time (s)," + "X (m)," + "Y (m)," + "Z (m)," + "E0," + "E1," + "E2," + "E3," + "W1 (rad/s)," + "W2 (rad/s)," + "W3 (rad/s)", + ) + return + + # Not so fast evaluation for general case + if variables is None: + variables = [ + "x", + "y", + "z", + "vx", + "vy", + "vz", + "e0", + "e1", + "e2", + "e3", + "w1", + "w2", + "w3", + ] + + if time_step is None: + time_points = f.time + else: + time_points = np.arange(f.t_initial, f.t_final, time_step) + + exported_matrix = [time_points] + exported_header = "Time (s)" + + # Loop through variables, get points and names (for the header) + for variable in variables: + if variable in f.__dict__: + variable_function = f.__dict__[variable] + # Deal with decorated Flight methods + else: + try: + variable_function = getattr(f, variable) + except AttributeError as exc: + raise AttributeError( + f"Variable '{variable}' not found in Flight class" + ) from exc + variable_points = variable_function(time_points) + exported_matrix += [variable_points] + exported_header += f", {variable_function.__outputs__[0]}" + + exported_matrix = np.array(exported_matrix).T # Fix matrix orientation + + np.savetxt( + file_name, + exported_matrix, + fmt="%.6f", + delimiter=",", + header=exported_header, + encoding="utf-8", + ) + + def export_sensor_data(self, file_name, sensor=None): + """Exports sensors data to a file. The file format can be either .csv or + .json. + + Parameters + ---------- + file_name : str + The file name or path of the exported file. Example: flight_data.csv + Do not use forbidden characters, such as / in Linux/Unix and + `<, >, :, ", /, \\, | ?, *` in Windows. + sensor : Sensor, string, optional + The sensor to export data from. Can be given as a Sensor object or + as a string with the sensor name. If None, all sensors data will be + exported. Default is None. + """ + f = self._flight + + if sensor is None: + data_dict = {} + for used_sensor, measured_data in f.sensor_data.items(): + data_dict[used_sensor.name] = measured_data + else: + # export data of only that sensor + data_dict = {} + + if not isinstance(sensor, str): + data_dict[sensor.name] = f.sensor_data[sensor] + else: # sensor is a string + matching_sensors = [s for s in f.sensor_data if s.name == sensor] + + if len(matching_sensors) > 1: + data_dict[sensor] = [] + for s in matching_sensors: + data_dict[s.name].append(f.sensor_data[s]) + elif len(matching_sensors) == 1: + data_dict[sensor] = f.sensor_data[matching_sensors[0]] + else: + raise ValueError("Sensor not found in the Flight.sensor_data.") + + with open(file_name, "w") as file: + json.dump(data_dict, file) + print("Sensor data exported to: ", file_name) + + def export_kml( + self, + file_name="trajectory.kml", + time_step=None, + extrude=True, + color="641400F0", + altitude_mode="absolute", + ): + """Exports flight data to a .kml file, which can be opened with Google + Earth to display the rocket's trajectory. + + Parameters + ---------- + file_name : string + The file name or path of the exported file. Example: flight_data.csv + time_step : float, optional + Time step desired for the data. If None, all integration time steps + will be exported. Otherwise, linear interpolation is carried out to + calculate values at the desired time steps. Example: 0.001. + extrude: bool, optional + To be used if you want to project the path over ground by using an + extruded polygon. In case False only the linestring containing the + flight path will be created. Default is True. + color : str, optional + Color of your trajectory path, need to be used in specific kml + format. Refer to http://www.zonums.com/gmaps/kml_color/ for more + info. + altitude_mode: str + Select elevation values format to be used on the kml file. Use + 'relativetoground' if you want use Above Ground Level elevation, or + 'absolute' if you want to parse elevation using Above Sea Level. + Default is 'relativetoground'. Only works properly if the ground + level is flat. Change to 'absolute' if the terrain is to irregular + or contains mountains. + """ + f = self._flight + + # Define time points vector + if time_step is None: + time_points = f.time + else: + time_points = np.arange(f.t_initial, f.t_final + time_step, time_step) + + kml = simplekml.Kml(open=1) + trajectory = kml.newlinestring(name="Rocket Trajectory - Powered by RocketPy") + + if altitude_mode == "relativetoground": + # In this mode the elevation data will be the Above Ground Level + # elevation. Only works properly if the ground level is similar to + # a plane, i.e. it might not work well if the terrain has mountains + coords = [ + ( + f.longitude.get_value_opt(t), + f.latitude.get_value_opt(t), + f.altitude.get_value_opt(t), + ) + for t in time_points + ] + trajectory.coords = coords + trajectory.altitudemode = simplekml.AltitudeMode.relativetoground + else: # altitude_mode == 'absolute' + # In this case the elevation data will be the Above Sea Level elevation + # Ensure you use the correct value on self.env.elevation, otherwise + # the trajectory path can be offset from ground + coords = [ + ( + f.longitude.get_value_opt(t), + f.latitude.get_value_opt(t), + f.z.get_value_opt(t), + ) + for t in time_points + ] + trajectory.coords = coords + trajectory.altitudemode = simplekml.AltitudeMode.absolute + + # Modify style of trajectory linestring + trajectory.style.linestyle.color = color + trajectory.style.polystyle.color = color + if extrude: + trajectory.extrude = 1 + + # Save the KML + kml.save(file_name) + print("File ", file_name, " saved with success!") diff --git a/tests/integration/test_flight.py b/tests/integration/test_flight.py index 7a36e0629..f40eb6b27 100644 --- a/tests/integration/test_flight.py +++ b/tests/integration/test_flight.py @@ -1,4 +1,3 @@ -import os from unittest.mock import patch import matplotlib as plt @@ -69,131 +68,6 @@ def test_all_info_different_solvers( assert test_flight.all_info() is None -class TestExportData: - """Tests the export_data method of the Flight class.""" - - def test_basic_export(self, flight_calisto): - """Tests basic export functionality""" - file_name = "test_export_data_1.csv" - flight_calisto.export_data(file_name) - self.validate_basic_export(flight_calisto, file_name) - os.remove(file_name) - - def test_custom_export(self, flight_calisto): - """Tests custom export functionality""" - file_name = "test_export_data_2.csv" - flight_calisto.export_data( - file_name, - "z", - "vz", - "e1", - "w3", - "angle_of_attack", - time_step=0.1, - ) - self.validate_custom_export(flight_calisto, file_name) - os.remove(file_name) - - def validate_basic_export(self, flight_calisto, file_name): - """Validates the basic export file content""" - test_data = np.loadtxt(file_name, delimiter=",") - assert np.allclose(flight_calisto.x[:, 0], test_data[:, 0], atol=1e-5) - assert np.allclose(flight_calisto.x[:, 1], test_data[:, 1], atol=1e-5) - assert np.allclose(flight_calisto.y[:, 1], test_data[:, 2], atol=1e-5) - assert np.allclose(flight_calisto.z[:, 1], test_data[:, 3], atol=1e-5) - assert np.allclose(flight_calisto.vx[:, 1], test_data[:, 4], atol=1e-5) - assert np.allclose(flight_calisto.vy[:, 1], test_data[:, 5], atol=1e-5) - assert np.allclose(flight_calisto.vz[:, 1], test_data[:, 6], atol=1e-5) - assert np.allclose(flight_calisto.e0[:, 1], test_data[:, 7], atol=1e-5) - assert np.allclose(flight_calisto.e1[:, 1], test_data[:, 8], atol=1e-5) - assert np.allclose(flight_calisto.e2[:, 1], test_data[:, 9], atol=1e-5) - assert np.allclose(flight_calisto.e3[:, 1], test_data[:, 10], atol=1e-5) - assert np.allclose(flight_calisto.w1[:, 1], test_data[:, 11], atol=1e-5) - assert np.allclose(flight_calisto.w2[:, 1], test_data[:, 12], atol=1e-5) - assert np.allclose(flight_calisto.w3[:, 1], test_data[:, 13], atol=1e-5) - - def validate_custom_export(self, flight_calisto, file_name): - """Validates the custom export file content""" - test_data = np.loadtxt(file_name, delimiter=",") - time_points = np.arange(flight_calisto.t_initial, flight_calisto.t_final, 0.1) - assert np.allclose(time_points, test_data[:, 0], atol=1e-5) - assert np.allclose(flight_calisto.z(time_points), test_data[:, 1], atol=1e-5) - assert np.allclose(flight_calisto.vz(time_points), test_data[:, 2], atol=1e-5) - assert np.allclose(flight_calisto.e1(time_points), test_data[:, 3], atol=1e-5) - assert np.allclose(flight_calisto.w3(time_points), test_data[:, 4], atol=1e-5) - assert np.allclose( - flight_calisto.angle_of_attack(time_points), test_data[:, 5], atol=1e-5 - ) - - -def test_export_kml(flight_calisto_robust): - """Tests weather the method Flight.export_kml is working as intended. - - Parameters: - ----------- - flight_calisto_robust : rocketpy.Flight - Flight object to be tested. See the conftest.py file for more info - regarding this pytest fixture. - """ - - test_flight = flight_calisto_robust - - # Basic export - test_flight.export_kml( - "test_export_data_1.kml", time_step=None, extrude=True, altitude_mode="absolute" - ) - - # Load exported files and fixtures and compare them - with open("test_export_data_1.kml", "r") as test_1: - for row in test_1: - if row[:29] == " ": - r = row[29:-15] - r = r.split(",") - for i, j in enumerate(r): - r[i] = j.split(" ") - lon, lat, z, coords = [], [], [], [] - for i in r: - for j in i: - coords.append(j) - for i in range(0, len(coords), 3): - lon.append(float(coords[i])) - lat.append(float(coords[i + 1])) - z.append(float(coords[i + 2])) - os.remove("test_export_data_1.kml") - - assert np.allclose(test_flight.latitude[:, 1], lat, atol=1e-3) - assert np.allclose(test_flight.longitude[:, 1], lon, atol=1e-3) - assert np.allclose(test_flight.z[:, 1], z, atol=1e-3) - - -def test_export_pressures(flight_calisto_robust): - """Tests if the method Flight.export_pressures is working as intended. - - Parameters - ---------- - flight_calisto_robust : Flight - Flight object to be tested. See the conftest.py file for more info - regarding this pytest fixture. - """ - file_name = "pressures.csv" - time_step = 0.5 - parachute = flight_calisto_robust.rocket.parachutes[0] - - flight_calisto_robust.export_pressures(file_name, time_step) - - with open(file_name, "r") as file: - contents = file.read() - - expected_data = "" - for t in np.arange(0, flight_calisto_robust.t_final, time_step): - p_cl = parachute.clean_pressure_signal_function(t) - p_ns = parachute.noisy_pressure_signal_function(t) - expected_data += f"{t:f}, {p_cl:.5f}, {p_ns:.5f}\n" - - assert contents == expected_data - os.remove(file_name) - - @patch("matplotlib.pyplot.show") def test_hybrid_motor_flight(mock_show, flight_calisto_hybrid_modded): # pylint: disable=unused-argument """Test the flight of a rocket with a hybrid motor. This test only validates diff --git a/tests/unit/test_flight_data_exporter.py b/tests/unit/test_flight_data_exporter.py new file mode 100644 index 000000000..b7f8f4e9d --- /dev/null +++ b/tests/unit/test_flight_data_exporter.py @@ -0,0 +1,194 @@ +"""Unit tests for FlightDataExporter class. + +This module tests the data export functionality of the FlightDataExporter class, +which exports flight simulation data to various formats (CSV, JSON, KML). +""" + +import json + +import numpy as np + +from rocketpy.simulation import FlightDataExporter + + +def test_export_pressures_writes_csv_rows(flight_calisto_robust, tmp_path): + """Test that export_pressures writes CSV rows with pressure data. + + Validates that the exported file contains multiple data rows in CSV format + with 2-3 columns (time and pressure values). + + Parameters + ---------- + flight_calisto_robust : rocketpy.Flight + Flight object with parachutes configured. + tmp_path : pathlib.Path + Pytest fixture for temporary directories. + """ + out = tmp_path / "pressures.csv" + FlightDataExporter(flight_calisto_robust).export_pressures(str(out), time_step=0.2) + lines = out.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) > 5 + # Basic CSV shape "t, value" or "t, clean, noisy" + parts = lines[0].split(",") + assert len(parts) in (2, 3) + + +def test_export_sensor_data_writes_json(flight_calisto, tmp_path, monkeypatch): + """Test that export_sensor_data writes JSON with sensor data. + + Validates that sensor data is exported as JSON with sensor names as keys + and measurement arrays as values. + + Parameters + ---------- + flight_calisto : rocketpy.Flight + Flight object to be tested. + tmp_path : pathlib.Path + Pytest fixture for temporary directories. + monkeypatch : pytest.MonkeyPatch + Pytest fixture for modifying attributes. + """ + + class DummySensor: + """Dummy sensor with name attribute for testing.""" + + def __init__(self, name): + self.name = name + + s1 = DummySensor("DummySensor") + monkeypatch.setattr( + flight_calisto, "sensor_data", {s1: [1.0, 2.0, 3.0]}, raising=False + ) + out = tmp_path / "sensors.json" + + FlightDataExporter(flight_calisto).export_sensor_data(str(out)) + + data = json.loads(out.read_text(encoding="utf-8")) + assert data["DummySensor"] == [1.0, 2.0, 3.0] + + +def test_export_data_default_variables(flight_calisto, tmp_path): + """Test export_data with default variables (full solution matrix). + + Validates that all state variables are exported correctly when no specific + variables are requested: position (x, y, z), velocity (vx, vy, vz), + quaternions (e0, e1, e2, e3), and angular velocities (w1, w2, w3). + + Parameters + ---------- + flight_calisto : rocketpy.Flight + Flight object to be tested. + tmp_path : pathlib.Path + Pytest fixture for temporary directories. + """ + file_name = tmp_path / "flight_data.csv" + FlightDataExporter(flight_calisto).export_data(str(file_name)) + + test_data = np.loadtxt(file_name, delimiter=",", skiprows=1) + + # Verify time column + assert np.allclose(flight_calisto.x[:, 0], test_data[:, 0], atol=1e-5) + + # Verify position + assert np.allclose(flight_calisto.x[:, 1], test_data[:, 1], atol=1e-5) + assert np.allclose(flight_calisto.y[:, 1], test_data[:, 2], atol=1e-5) + assert np.allclose(flight_calisto.z[:, 1], test_data[:, 3], atol=1e-5) + + # Verify velocity + assert np.allclose(flight_calisto.vx[:, 1], test_data[:, 4], atol=1e-5) + assert np.allclose(flight_calisto.vy[:, 1], test_data[:, 5], atol=1e-5) + assert np.allclose(flight_calisto.vz[:, 1], test_data[:, 6], atol=1e-5) + + # Verify quaternions + assert np.allclose(flight_calisto.e0[:, 1], test_data[:, 7], atol=1e-5) + assert np.allclose(flight_calisto.e1[:, 1], test_data[:, 8], atol=1e-5) + assert np.allclose(flight_calisto.e2[:, 1], test_data[:, 9], atol=1e-5) + assert np.allclose(flight_calisto.e3[:, 1], test_data[:, 10], atol=1e-5) + + # Verify angular velocities + assert np.allclose(flight_calisto.w1[:, 1], test_data[:, 11], atol=1e-5) + assert np.allclose(flight_calisto.w2[:, 1], test_data[:, 12], atol=1e-5) + assert np.allclose(flight_calisto.w3[:, 1], test_data[:, 13], atol=1e-5) + + +def test_export_data_custom_variables_and_time_step(flight_calisto, tmp_path): + """Test export_data with custom variables and time step. + + Validates that specific variables can be exported with custom time intervals, + including derived quantities like angle of attack. + + Parameters + ---------- + flight_calisto : rocketpy.Flight + Flight object to be tested. + tmp_path : pathlib.Path + Pytest fixture for temporary directories. + """ + file_name = tmp_path / "custom_flight_data.csv" + time_step = 0.1 + + FlightDataExporter(flight_calisto).export_data( + str(file_name), + "z", + "vz", + "e1", + "w3", + "angle_of_attack", + time_step=time_step, + ) + + test_data = np.loadtxt(file_name, delimiter=",", skiprows=1) + time_points = np.arange(flight_calisto.t_initial, flight_calisto.t_final, time_step) + + # Verify time column + assert np.allclose(time_points, test_data[:, 0], atol=1e-5) + + # Verify custom variables + assert np.allclose(flight_calisto.z(time_points), test_data[:, 1], atol=1e-5) + assert np.allclose(flight_calisto.vz(time_points), test_data[:, 2], atol=1e-5) + assert np.allclose(flight_calisto.e1(time_points), test_data[:, 3], atol=1e-5) + assert np.allclose(flight_calisto.w3(time_points), test_data[:, 4], atol=1e-5) + assert np.allclose( + flight_calisto.angle_of_attack(time_points), test_data[:, 5], atol=1e-5 + ) + + +def test_export_kml_trajectory(flight_calisto_robust, tmp_path): + """Test export_kml creates valid KML file with trajectory coordinates. + + Validates that the KML export contains correct latitude, longitude, and + altitude coordinates for the flight trajectory in absolute altitude mode. + + Parameters + ---------- + flight_calisto_robust : rocketpy.Flight + Flight object to be tested. + tmp_path : pathlib.Path + Pytest fixture for temporary directories. + """ + file_name = tmp_path / "trajectory.kml" + FlightDataExporter(flight_calisto_robust).export_kml( + str(file_name), time_step=None, extrude=True, altitude_mode="absolute" + ) + + # Parse KML coordinates + with open(file_name, "r") as kml_file: + for row in kml_file: + if row.strip().startswith(""): + coords_str = ( + row.strip() + .replace("", "") + .replace("", "") + ) + coords_list = coords_str.strip().split(" ") + + # Extract lon, lat, z from coordinates + parsed_coords = [c.split(",") for c in coords_list] + lon = [float(point[0]) for point in parsed_coords] + lat = [float(point[1]) for point in parsed_coords] + z = [float(point[2]) for point in parsed_coords] + + # Verify coordinates match flight data + assert np.allclose(flight_calisto_robust.latitude[:, 1], lat, atol=1e-3) + assert np.allclose(flight_calisto_robust.longitude[:, 1], lon, atol=1e-3) + assert np.allclose(flight_calisto_robust.z[:, 1], z, atol=1e-3) diff --git a/tests/unit/test_flight_export_deprecation.py b/tests/unit/test_flight_export_deprecation.py new file mode 100644 index 000000000..6fb6952b4 --- /dev/null +++ b/tests/unit/test_flight_export_deprecation.py @@ -0,0 +1,40 @@ +from unittest.mock import patch + +import pytest + +# TODO: these tests should be deleted after the deprecated methods are removed + + +def test_export_data_deprecated_emits_warning_and_delegates(flight_calisto, tmp_path): + """Expect: calling Flight.export_data emits DeprecationWarning and delegates to FlightDataExporter.export_data.""" + out = tmp_path / "out.csv" + with patch( + "rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_data" + ) as spy: + with pytest.warns(DeprecationWarning): + flight_calisto.export_data(str(out)) + spy.assert_called_once() + + +def test_export_pressures_deprecated_emits_warning_and_delegates( + flight_calisto_robust, tmp_path +): + """Expect: calling Flight.export_pressures emits DeprecationWarning and delegates to FlightDataExporter.export_pressures.""" + out = tmp_path / "p.csv" + with patch( + "rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_pressures" + ) as spy: + with pytest.warns(DeprecationWarning): + flight_calisto_robust.export_pressures(str(out), time_step=0.1) + spy.assert_called_once() + + +def test_export_kml_deprecated_emits_warning_and_delegates(flight_calisto, tmp_path): + """Expect: calling Flight.export_kml emits DeprecationWarning and delegates to FlightDataExporter.export_kml.""" + out = tmp_path / "traj.kml" + with patch( + "rocketpy.simulation.flight_data_exporter.FlightDataExporter.export_kml" + ) as spy: + with pytest.warns(DeprecationWarning): + flight_calisto.export_kml(str(out), time_step=0.5) + spy.assert_called_once()