diff --git a/CHANGELOG.md b/CHANGELOG.md index c084940e1..0bc6754f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,17 +32,19 @@ Attention: The newest changes should be on top --> ### Added +- ENH: Adds GenericMotor.load_from_eng_file() method [#676](https://github.com/RocketPy-Team/RocketPy/pull/676) +- ENH: Introducing local sensitivity analysis [#575](https://github.com/RocketPy-Team/RocketPy/pull/575) - ENH: Add STFT function to Function class [#620](https://github.com/RocketPy-Team/RocketPy/pull/620) - ENH: Rocket Axis Definition [#635](https://github.com/RocketPy-Team/RocketPy/pull/635) ### Changed -- DOC: Fix documentation dependencies [#651](https://github.com/RocketPy-Team/RocketPy/pull/651) -- DOC: Fix documentation warnings [#645](https://github.com/RocketPy-Team/RocketPy/pull/645) - DOC: New Environment class docs pages [#644](https://github.com/RocketPy-Team/RocketPy/pull/644) ### Fixed +- DOC: Fix documentation dependencies [#651](https://github.com/RocketPy-Team/RocketPy/pull/651) +- DOC: Fix documentation warnings [#645](https://github.com/RocketPy-Team/RocketPy/pull/645) - BUG: Rotational EOMs Not Relative To CDM [#674](https://github.com/RocketPy-Team/RocketPy/pull/674) - BUG: Pressure ISA Extrapolation as "linear" [#675](https://github.com/RocketPy-Team/RocketPy/pull/675) - BUG: fix the Frequency Response plot of Flight class [#653](https://github.com/RocketPy-Team/RocketPy/pull/653) diff --git a/docs/user/motors/genericmotor.rst b/docs/user/motors/genericmotor.rst new file mode 100644 index 000000000..5bd14d548 --- /dev/null +++ b/docs/user/motors/genericmotor.rst @@ -0,0 +1,108 @@ +.. _genericmotor: + +GenericMotor Class Usage +======================== + +Here we explore different features of the GenericMotor class. + +Class that represents a simple motor defined mainly by its thrust curve. +There is no distinction between the propellant types (e.g. Solid, Liquid). +This class is meant for rough estimations of the motor performance, +therefore for more accurate results, use the ``SolidMotor``, ``HybridMotor`` +or ``LiquidMotor`` classes. + +Creating a Generic Motor +------------------------ + +To define a generic motor, we will need a few information about our motor: + +- The thrust source file, which is a file containing the thrust curve of the motor. \ + This file can be a .eng file, a .rse file, or a .csv file. See more details in \ + :doc:`Thrust Source Details ` +- A few physical parameters, which the most important are: + - The burn time of the motor. + - The combustion chamber radius; + - The combustion chamber height; + - The combustion chamber position; + - The propellant initial mass; + - The nozzle radius; + - The motor dry mass. + +The usage of the GenericMotor class is very similar to the other motor classes. +See more details in the +:doc:`SolidMotor Class Usage `, +:doc:`LiquidMotor Class Usage `, and +:doc:`HybridMotor Class Usage ` pages. + + +.. jupyter-execute:: + + from rocketpy.motors import GenericMotor + + # Define the motor parameters + motor = GenericMotor( + thrust_source = "../data/motors/Cesaroni_M1670.eng", + burn_time = 3.9, + chamber_radius = 33 / 100, + chamber_height = 600 / 1000, + chamber_position = 0, + propellant_initial_mass = 2.5, + nozzle_radius = 33 / 1000, + dry_mass = 1.815, + center_of_dry_mass_position = 0, + dry_inertia = (0.125, 0.125, 0.002), + nozzle_position = 0, + reshape_thrust_curve = False, + interpolation_method = "linear", + coordinate_system_orientation = "nozzle_to_combustion_chamber", + ) + + # Print the motor information + motor.info() + +.. note:: + + The GenericMotor is a simplified model of a rocket motor. If you need more \ + accurate results, use the ``SolidMotor``, ``HybridMotor`` or ``LiquidMotor`` classes. + + +The ``load_from_eng_file`` method +--------------------------------- + +The ``GenericMotor`` class has a method called ``load_from_eng_file`` that allows +the user to build a GenericMotor object by providing just the path to an .eng file. + +The parameters available in the method are the same as the ones used in the +constructor of the GenericMotor class. But the method will automatically read +the .eng file and extract the required information if the user does not +provide it. In this case, the following assumptions about the most +relevant parameters are made: + +- The ``chamber_radius`` is assumed to be the same as the motor diameter in the .eng file; +- The ``chamber_height`` is assumed to be the same as the motor length in the .eng file; +- The ``chamber_position`` is assumed to be 0; +- The ``propellant_initial_mass`` is assumed to be the same as the propellant mass in the .eng file; +- The ``nozzle_radius`` is assumed to be 85% of the ``chamber_radius``; +- The ``dry_mass`` is assumed to be the total mass minus the propellant mass in the .eng file; + +As an example, we can demonstrate: + +.. jupyter-execute:: + + from rocketpy.motors import GenericMotor + + + # Load the motor from an .eng file + motor = GenericMotor.load_from_eng_file("../data/motors/Cesaroni_M1670.eng") + + # Print the motor information + motor.info() + +Although the ``load_from_eng_file`` method is very useful, it is important to +note that the user can still provide the parameters manually if needed. + +.. tip:: + + The ``load_from_eng_file`` method is a very useful tool for simulating motors \ + when the user does not have all the information required to build a ``SolidMotor`` yet. + diff --git a/docs/user/motors/motors.rst b/docs/user/motors/motors.rst index b4c4e4f8c..a6bc6ef4b 100644 --- a/docs/user/motors/motors.rst +++ b/docs/user/motors/motors.rst @@ -24,6 +24,12 @@ Motors Usage :caption: Liquid Motors Liquid Motor Usage + +.. toctree:: + :maxdepth: 3 + :caption: Generic Motors + + Generic Motor Usage .. toctree:: :maxdepth: 3 diff --git a/rocketpy/motors/hybrid_motor.py b/rocketpy/motors/hybrid_motor.py index 37fa1b12c..1a1bbb3db 100644 --- a/rocketpy/motors/hybrid_motor.py +++ b/rocketpy/motors/hybrid_motor.py @@ -302,16 +302,16 @@ class Function. Thrust units are Newtons. None """ super().__init__( - thrust_source, - dry_mass, - dry_inertia, - nozzle_radius, - center_of_dry_mass_position, - nozzle_position, - burn_time, - reshape_thrust_curve, - interpolation_method, - coordinate_system_orientation, + thrust_source=thrust_source, + dry_inertia=dry_inertia, + nozzle_radius=nozzle_radius, + center_of_dry_mass_position=center_of_dry_mass_position, + dry_mass=dry_mass, + nozzle_position=nozzle_position, + burn_time=burn_time, + reshape_thrust_curve=reshape_thrust_curve, + interpolation_method=interpolation_method, + coordinate_system_orientation=coordinate_system_orientation, ) self.liquid = LiquidMotor( thrust_source, diff --git a/rocketpy/motors/liquid_motor.py b/rocketpy/motors/liquid_motor.py index e519346c3..09e3aadd5 100644 --- a/rocketpy/motors/liquid_motor.py +++ b/rocketpy/motors/liquid_motor.py @@ -230,16 +230,16 @@ class Function. Thrust units are Newtons. "nozzle_to_combustion_chamber". """ super().__init__( - thrust_source, - dry_mass, - dry_inertia, - nozzle_radius, - center_of_dry_mass_position, - nozzle_position, - burn_time, - reshape_thrust_curve, - interpolation_method, - coordinate_system_orientation, + thrust_source=thrust_source, + dry_inertia=dry_inertia, + nozzle_radius=nozzle_radius, + center_of_dry_mass_position=center_of_dry_mass_position, + dry_mass=dry_mass, + nozzle_position=nozzle_position, + burn_time=burn_time, + reshape_thrust_curve=reshape_thrust_curve, + interpolation_method=interpolation_method, + coordinate_system_orientation=coordinate_system_orientation, ) self.positioned_tanks = [] diff --git a/rocketpy/motors/motor.py b/rocketpy/motors/motor.py index b37172348..07d969e3d 100644 --- a/rocketpy/motors/motor.py +++ b/rocketpy/motors/motor.py @@ -2,6 +2,7 @@ import warnings from abc import ABC, abstractmethod from functools import cached_property +from os import path import numpy as np @@ -149,10 +150,10 @@ class Motor(ABC): def __init__( self, thrust_source, - dry_mass, dry_inertia, nozzle_radius, center_of_dry_mass_position, + dry_mass=None, nozzle_position=0, burn_time=None, reshape_thrust_curve=False, @@ -251,7 +252,6 @@ class Function. Thrust units are Newtons. ) # Motor parameters - self.dry_mass = dry_mass self.interpolate = interpolation_method self.nozzle_position = nozzle_position self.nozzle_radius = nozzle_radius @@ -267,9 +267,13 @@ class Function. Thrust units are Newtons. self.dry_I_23 = inertia[5] # Handle .eng file inputs + self.description_eng_file = None if isinstance(thrust_source, str): - if thrust_source[-3:] == "eng": - _, _, points = Motor.import_eng(thrust_source) + if ( + path.exists(thrust_source) + and path.splitext(path.basename(thrust_source))[1] == ".eng" + ): + _, self.description_eng_file, points = Motor.import_eng(thrust_source) thrust_source = points # Evaluate raw thrust source @@ -278,6 +282,9 @@ class Function. Thrust units are Newtons. thrust_source, "Time (s)", "Thrust (N)", self.interpolate, "zero" ) + # Handle dry_mass input + self.dry_mass = dry_mass + # Handle burn_time input self.burn_time = burn_time @@ -343,6 +350,38 @@ def burn_time(self, burn_time): " argument must be specified." ) + @property + def dry_mass(self): + """Dry mass of the motor in kg. + + Returns + ------- + self.dry_mass : float + Motor dry mass in kg. + """ + return self._dry_mass + + @dry_mass.setter + def dry_mass(self, dry_mass): + """Sets dry mass of the motor in kg. + + Parameters + ---------- + dry_mass : float + Motor dry mass in kg. + """ + if dry_mass: + if isinstance(dry_mass, (int, float)): + self._dry_mass = dry_mass + else: + raise ValueError("Dry mass must be a number.") + elif self.description_eng_file: + self._dry_mass = float(self.description_eng_file[-2]) - float( + self.description_eng_file[-3] + ) + else: + raise ValueError("Dry mass must be specified.") + @cached_property def total_impulse(self): """Calculates and returns total impulse by numerical integration @@ -1152,16 +1191,16 @@ def __init__( "nozzle_to_combustion_chamber". """ super().__init__( - thrust_source, - dry_mass, - dry_inertia, - nozzle_radius, - center_of_dry_mass_position, - nozzle_position, - burn_time, - reshape_thrust_curve, - interpolation_method, - coordinate_system_orientation, + thrust_source=thrust_source, + dry_inertia=dry_inertia, + nozzle_radius=nozzle_radius, + center_of_dry_mass_position=center_of_dry_mass_position, + dry_mass=dry_mass, + nozzle_position=nozzle_position, + burn_time=burn_time, + reshape_thrust_curve=reshape_thrust_curve, + interpolation_method=interpolation_method, + coordinate_system_orientation=coordinate_system_orientation, ) self.chamber_radius = chamber_radius @@ -1307,6 +1346,134 @@ def propellant_I_13(self): def propellant_I_23(self): return Function(0) + @staticmethod + def load_from_eng_file( + file_name, + nozzle_radius=None, + chamber_radius=None, + chamber_height=None, + chamber_position=0, + propellant_initial_mass=None, + dry_mass=None, + burn_time=None, + center_of_dry_mass_position=None, + dry_inertia=(0, 0, 0), + nozzle_position=0, + reshape_thrust_curve=False, + interpolation_method="linear", + coordinate_system_orientation="nozzle_to_combustion_chamber", + ): + """Loads motor data from a .eng file and processes it. + + Parameters + ---------- + file_name : string + Name of the .eng file. E.g. 'test.eng'. + nozzle_radius : int, float + Motor's nozzle outlet radius in meters. + chamber_radius : int, float, optional + The radius of a overall cylindrical chamber of propellant in meters. + chamber_height : int, float, optional + The height of a overall cylindrical chamber of propellant in meters. + chamber_position : int, float, optional + The position, in meters, of the centroid (half height) of the motor's + overall cylindrical chamber of propellant with respect to the motor's + coordinate system. + propellant_initial_mass : int, float, optional + The initial mass of the propellant in the motor. + dry_mass : int, float, optional + Same as in Motor class. See the :class:`Motor ` docs + burn_time: float, tuple of float, optional + Motor's burn time. + If a float is given, the burn time is assumed to be between 0 and + the given float, in seconds. + If a tuple of float is given, the burn time is assumed to be between + the first and second elements of the tuple, in seconds. + If not specified, automatically sourced as the range between the + first and last-time step of the motor's thrust curve. This can only + be used if the motor's thrust is defined by a list of points, such + as a .csv file, a .eng file or a Function instance whose source is a + list. + center_of_dry_mass_position : int, float, optional + The position, in meters, of the motor's center of mass with respect + to the motor's coordinate system when it is devoid of propellant. + If not specified, automatically sourced as the chamber position. + dry_inertia : tuple, list + Tuple or list containing the motor's dry mass inertia tensor + nozzle_position : int, float, optional + Motor's nozzle outlet position in meters, in the motor's coordinate + system. Default is 0, in which case the origin of the + coordinate system is placed at the motor's nozzle outlet. + reshape_thrust_curve : boolean, tuple, optional + If False, the original thrust curve supplied is not altered. If a + tuple is given, whose first parameter is a new burn out time and + whose second parameter is a new total impulse in Ns, the thrust + curve is reshaped to match the new specifications. May be useful + for motors whose thrust curve shape is expected to remain similar + in case the impulse and burn time varies slightly. Default is + False. Note that the Motor burn_time parameter must include the new + reshaped burn time. + interpolation_method : string, optional + Method of interpolation to be used in case thrust curve is given + coordinate_system_orientation : string, optional + Orientation of the motor's coordinate system. The coordinate system + is defined by the motor's axis of symmetry. The origin of the + coordinate system may be placed anywhere along such axis, such as + at the nozzle area, and must be kept the same for all other + positions specified. Options are "nozzle_to_combustion_chamber" and + "combustion_chamber_to_nozzle". Default is + "nozzle_to_combustion_chamber". + + Returns + ------- + Generic Motor object + """ + if isinstance(file_name, str): + if path.splitext(path.basename(file_name))[1] == ".eng": + _, description, thrust_source = Motor.import_eng(file_name) + else: + raise ValueError("File must be a .eng file.") + else: + raise ValueError("File name must be a string.") + + thrust = Function(thrust_source, "Time (s)", "Thrust (N)", "linear", "zero") + + # handle eng parameters + if not chamber_radius: + chamber_radius = ( + float(description[1]) / 1000 + ) # get motor diameter in meters + + if not chamber_height: + chamber_height = float(description[2]) / 1000 # get motor length in meters + + if not propellant_initial_mass: + propellant_initial_mass = float(description[-3]) + + if not dry_mass: + total_mass = float(description[-2]) + dry_mass = total_mass - propellant_initial_mass + + if not nozzle_radius: + nozzle_radius = 0.85 * chamber_radius + + return GenericMotor( + thrust_source=thrust, + burn_time=burn_time, + chamber_radius=chamber_radius, + chamber_height=chamber_height, + chamber_position=chamber_position, + propellant_initial_mass=propellant_initial_mass, + nozzle_radius=nozzle_radius, + dry_mass=dry_mass, + center_of_dry_mass_position=center_of_dry_mass_position, + dry_inertia=dry_inertia, + nozzle_position=nozzle_position, + reshape_thrust_curve=reshape_thrust_curve, + interpolation_method=interpolation_method, + coordinate_system_orientation=coordinate_system_orientation, + ) + def all_info(self): """Prints out all data and graphs available about the Motor.""" # Print motor details diff --git a/rocketpy/motors/solid_motor.py b/rocketpy/motors/solid_motor.py index fa56865f5..81faf453f 100644 --- a/rocketpy/motors/solid_motor.py +++ b/rocketpy/motors/solid_motor.py @@ -303,16 +303,16 @@ class Function. Thrust units are Newtons. None """ super().__init__( - thrust_source, - dry_mass, - dry_inertia, - nozzle_radius, - center_of_dry_mass_position, - nozzle_position, - burn_time, - reshape_thrust_curve, - interpolation_method, - coordinate_system_orientation, + thrust_source=thrust_source, + dry_inertia=dry_inertia, + nozzle_radius=nozzle_radius, + center_of_dry_mass_position=center_of_dry_mass_position, + dry_mass=dry_mass, + nozzle_position=nozzle_position, + burn_time=burn_time, + reshape_thrust_curve=reshape_thrust_curve, + interpolation_method=interpolation_method, + coordinate_system_orientation=coordinate_system_orientation, ) # Nozzle parameters self.throat_radius = throat_radius diff --git a/tests/unit/test_genericmotor.py b/tests/unit/test_genericmotor.py index c6321ae4d..67e464709 100644 --- a/tests/unit/test_genericmotor.py +++ b/tests/unit/test_genericmotor.py @@ -2,6 +2,8 @@ import pytest import scipy.integrate +from rocketpy import Function, Motor + BURN_TIME = (2, 7) @@ -129,3 +131,50 @@ def test_generic_motor_inertia(generic_motor): assert generic_motor.I_11.y_array == pytest.approx(I_11) assert generic_motor.I_22.y_array == pytest.approx(I_22) assert generic_motor.I_33.y_array == pytest.approx(I_33) + + +def test_load_from_eng_file(generic_motor): + """Tests the GenericMotor.load_from_eng_file method. + + Parameters + ---------- + generic_motor : rocketpy.GenericMotor + The GenericMotor object to be used in the tests. + """ + # using cesaroni data as example + burn_time = (0, 3.9) + dry_mass = 5.231 - 3.101 # 2.130 kg + propellant_initial_mass = 3.101 + chamber_radius = 75 / 1000 + chamber_height = 757 / 1000 + nozzle_radius = chamber_radius * 0.85 # 85% of chamber radius + + # Parameters from manual testing using the SolidMotor class as a reference + average_thrust = 1545.218 + total_impulse = 6026.350 + max_thrust = 2200.0 + exhaust_velocity = 1943.357 + + # creating motor from .eng file + generic_motor = generic_motor.load_from_eng_file("data/motors/Cesaroni_M1670.eng") + + # testing relevant parameters + assert generic_motor.burn_time == burn_time + assert generic_motor.dry_mass == dry_mass + assert generic_motor.propellant_initial_mass == propellant_initial_mass + assert generic_motor.chamber_radius == chamber_radius + assert generic_motor.chamber_height == chamber_height + assert generic_motor.chamber_position == 0 + assert generic_motor.average_thrust == pytest.approx(average_thrust) + assert generic_motor.total_impulse == pytest.approx(total_impulse) + assert generic_motor.exhaust_velocity.average(*burn_time) == pytest.approx( + exhaust_velocity + ) + assert generic_motor.max_thrust == pytest.approx(max_thrust) + assert generic_motor.nozzle_radius == pytest.approx(nozzle_radius) + + # testing thrust curve + _, _, points = Motor.import_eng("data/motors/Cesaroni_M1670.eng") + assert generic_motor.thrust.y_array == pytest.approx( + Function(points, "Time (s)", "Thrust (N)", "linear", "zero").y_array + )