diff --git a/.gitignore b/.gitignore index 0d6518eb2..cc9ac3788 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ cython_debug/ # Docs *.docx *.pdf +docs/*.gif # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can diff --git a/CHANGELOG.md b/CHANGELOG.md index 6815a39f7..70051de22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ Attention: The newest changes should be on top --> ### Fixed +- DOC: Fix documentation build [#908](https://github.com/RocketPy-Team/RocketPy/pull/908) - BUG: energy_data plot not working for 3 dof sims [[#906](https://github.com/RocketPy-Team/RocketPy/issues/906)] - BUG: Fix CSV column header spacing in FlightDataExporter [#864](https://github.com/RocketPy-Team/RocketPy/issues/864) - BUG: Fix parallel Monte Carlo simulation showing incorrect iteration count [#806](https://github.com/RocketPy-Team/RocketPy/pull/806) diff --git a/docs/conf.py b/docs/conf.py index e3444233d..ae8a4b17d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,6 +61,12 @@ # Don't run notebooks nbsphinx_execute = "never" +# Configure jupyter_sphinx execution behavior +jupyter_execute_kwargs = { + "timeout": 300, # 5 minutes timeout per cell + "allow_errors": True, # Continue building even if cells raise errors +} + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/docs/user/compare_flights.rst b/docs/user/compare_flights.rst index 1d08b626d..70f5fc7bb 100644 --- a/docs/user/compare_flights.rst +++ b/docs/user/compare_flights.rst @@ -226,4 +226,5 @@ Alternatively, we can plot the results altogether by calling one simple method: .. jupyter-execute:: - comparison.all() + # commented to avoid long output + # comparison.all() diff --git a/docs/user/flight_comparator.rst b/docs/user/flight_comparator.rst index 162d4c173..298fbc83b 100644 --- a/docs/user/flight_comparator.rst +++ b/docs/user/flight_comparator.rst @@ -1,45 +1,65 @@ +.. _flightcomparator: + Flight Comparator ================= -This example demonstrates how to use the RocketPy ``FlightComparator`` class to -compare a Flight simulation against external data sources. +The :class:`rocketpy.simulation.FlightComparator` class enables users to compare +a RocketPy Flight simulation against external data sources, providing error +metrics and visualization tools for validation and analysis. + +.. seealso:: -Users must explicitly create a `FlightComparator` instance. + For comparing multiple RocketPy simulations together, see the + :doc:`Compare Flights ` guide. +Overview +-------- This class is designed to compare a RocketPy Flight simulation against external data sources, such as: -- Real flight data (avionics logs, altimeter CSVs) -- Simulations from other software (OpenRocket, RASAero) -- Theoretical models or manual calculations +- **Real flight data** - Avionics logs, altimeter CSVs, GPS tracking data +- **Other simulators** - OpenRocket, RASAero, or custom simulation tools +- **Theoretical models** - Analytical solutions or manual calculations + +Unlike :class:`rocketpy.plots.compare.CompareFlights` (which compares multiple +RocketPy simulations), ``FlightComparator`` specifically handles the challenge +of aligning different time steps and calculating error metrics (RMSE, MAE, etc.) +between a "Reference" simulation and "External" data. + +Key Features +------------ + +- Automatic time-step alignment between different data sources +- Error metric calculations (RMSE, MAE, etc.) +- Side-by-side visualization of flight variables +- Support for multiple external data sources +- Direct comparison with other RocketPy Flight objects + -Unlike ``CompareFlights`` (which compares multiple RocketPy simulations), -``FlightComparator`` specifically handles the challenge of aligning different -time steps and calculating error metrics (RMSE, MAE, etc.) between a -"Reference" simulation and "External" data. +Creating a Reference Simulation +-------------------------------- -Importing classes ------------------ +First, import the necessary classes and modules: -We will start by importing the necessary classes and modules: +Importing Classes +~~~~~~~~~~~~~~~~~ .. jupyter-execute:: import numpy as np - from rocketpy import Environment, Rocket, SolidMotor, Flight from rocketpy.simulation import FlightComparator, FlightDataImporter -Create Simulation (Reference) ------------------------------ +Setting Up the Environment +~~~~~~~~~~~~~~~~~~~~~~~~~~ First, let's create the standard RocketPy simulation that will serve as our -"Ground Truth" or reference model. This follows the standard setup. +reference model. This follows the same pattern as in :ref:`firstsimulation`. .. jupyter-execute:: - # 1. Setup Environment + # Create Environment (Spaceport America, NM) env = Environment( date=(2022, 10, 1, 12), latitude=32.990254, @@ -48,7 +68,12 @@ First, let's create the standard RocketPy simulation that will serve as our ) env.set_atmospheric_model(type="standard_atmosphere") - # 2. Setup Motor +Setting Up the Motor +~~~~~~~~~~~~~~~~~~~~~ + +.. jupyter-execute:: + + # Create a Cesaroni M1670 Solid Motor Pro75M1670 = SolidMotor( thrust_source="../data/motors/cesaroni/Cesaroni_M1670.eng", burn_time=3.9, @@ -69,22 +94,35 @@ First, let's create the standard RocketPy simulation that will serve as our nozzle_position=0, ) - # 3. Setup Rocket +Setting Up the Rocket +~~~~~~~~~~~~~~~~~~~~~~ + +.. jupyter-execute:: + + # Create Calisto rocket calisto = Rocket( radius=127 / 2000, mass=19.197 - 2.956, inertia=(6.321, 6.321, 0.034), - power_off_drag="../data/calisto/powerOffDragCurve.csv", - power_on_drag="../data/calisto/powerOnDragCurve.csv", + power_off_drag="../data/rockets/calisto/powerOffDragCurve.csv", + power_on_drag="../data/rockets/calisto/powerOnDragCurve.csv", center_of_mass_without_motor=0, coordinate_system_orientation="tail_to_nose", ) + # Add rail buttons calisto.set_rail_buttons(0.0818, -0.618, 45) + + # Add motor to rocket calisto.add_motor(Pro75M1670, position=-1.255) # Add aerodynamic surfaces - nosecone = calisto.add_nose(length=0.55829, kind="vonKarman", position=0.71971) + nosecone = calisto.add_nose( + length=0.55829, + kind="vonKarman", + position=0.71971, + ) + fin_set = calisto.add_trapezoidal_fins( n=4, root_chord=0.120, @@ -92,8 +130,9 @@ First, let's create the standard RocketPy simulation that will serve as our span=0.100, position=-1.04956, cant_angle=0.5, - airfoil=("../data/calisto/fins/NACA0012-radians.txt", "radians"), + airfoil=("../data/airfoils/NACA0012-radians.txt", "radians"), ) + tail = calisto.add_tail( top_radius=0.0635, bottom_radius=0.0435, @@ -101,22 +140,36 @@ First, let's create the standard RocketPy simulation that will serve as our position=-1.194656, ) - # 4. Simulate - flight = Flight( - rocket=calisto, - environment=env, - rail_length=5.2, - inclination=85, - heading=0, - ) +Running the Simulation +~~~~~~~~~~~~~~~~~~~~~~~ + +.. jupyter-execute:: + + # Create and run flight simulation + flight = Flight( + rocket=calisto, + environment=env, + rail_length=5.2, + inclination=85, + heading=0, + ) + +Creating the FlightComparator +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. jupyter-execute:: + + # Initialize FlightComparator with reference flight + comparator = FlightComparator(flight) - # 5. Create FlightComparator instance - comparator = FlightComparator(flight) -Adding Another Flight Object ----------------------------- +Adding Comparison Data +---------------------- -You can compare against another RocketPy Flight simulation directly: +Comparing with Another Flight +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can compare against another RocketPy :class:`rocketpy.Flight` object directly: .. jupyter-execute:: @@ -129,55 +182,90 @@ You can compare against another RocketPy Flight simulation directly: heading=0, ) - # Add the second flight directly + # Add the second flight to comparator comparator.add_data("Alternative Sim", flight2) - print(f"Added variables from flight2: {list(comparator.data_sources['Alternative Sim'].keys())}") - -Importing External Data (dict) ------------------------------- +Comparing with External Data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The primary data format expected by ``FlightComparator.add_data`` is a dictionary -where keys are variable names (e.g. ``"z"``, ``"vz"``, ``"altitude"``) and values +where keys are variable names (e.g., ``"z"``, ``"vz"``, ``"altitude"``) and values are either: -- A RocketPy ``Function`` object, or -- A tuple of ``(time_array, data_array)``. +- A RocketPy :class:`rocketpy.Function` object, or +- A tuple of ``(time_array, data_array)`` -Let's create some synthetic external data to compare against our reference -simulation: +Let's create some synthetic external data to demonstrate the comparison process: .. jupyter-execute:: # Generate synthetic external data with realistic noise time_external = np.linspace(0, flight.t_final, 80) # Different time steps - external_altitude = flight.z(time_external) + np.random.normal(0, 3, 80) # 3m noise - external_velocity = flight.vz(time_external) + np.random.normal(0, 0.5, 80) # 0.5 m/s noise + external_altitude = flight.z(time_external) + np.random.normal(0, 3, 80) # Add 3 m noise + external_velocity = flight.vz(time_external) + np.random.normal(0, 0.5, 80) # Add 0.5 m/s noise - # Add the external data to our comparator + # Add external data to comparator comparator.add_data( - "External Simulator", + "External Simulator", { "altitude": (time_external, external_altitude), "vz": (time_external, external_velocity), } ) -Running Comparisons -------------------- +.. tip:: + External data does not need to have the same time steps as the reference + simulation. The ``FlightComparator`` automatically handles interpolation + and alignment for comparison. + -Now we can run the various comparison methods: +Analyzing Comparison Results +----------------------------- + +Summary Report +~~~~~~~~~~~~~~ + +Generate a comprehensive summary of the comparison: .. jupyter-execute:: - # Generate summary with key events + # Display comparison summary with key flight events comparator.summary() - # Compare specific variable +Comparing Specific Variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can compare individual flight variables: + +.. jupyter-execute:: + + # Compare altitude data across all sources comparator.compare("altitude") - # Compare all available variables +The comparison plot shows the reference simulation alongside all external data +sources, making it easy to identify discrepancies and validate the simulation. + +Comparing All Variables +~~~~~~~~~~~~~~~~~~~~~~~~ + +To get a complete overview, compare all available variables at once: + +.. jupyter-execute:: + + # Generate plots for all common variables comparator.all() - # Plot 2D trajectory +Trajectory Visualization +~~~~~~~~~~~~~~~~~~~~~~~~~ + +Visualize 2D trajectory projections for spatial comparison: + +.. jupyter-execute:: + + # Plot trajectory in the XZ plane (side view) comparator.trajectories_2d(plane="xz") + +.. seealso:: + + For detailed API documentation and additional methods, see + :class:`rocketpy.simulation.FlightComparator` in the API reference. diff --git a/docs/user/motors/liquidmotor.rst b/docs/user/motors/liquidmotor.rst index 6a9a22635..754f8b57e 100644 --- a/docs/user/motors/liquidmotor.rst +++ b/docs/user/motors/liquidmotor.rst @@ -1,21 +1,21 @@ LiquidMotor Class Usage ======================= -Here we explore different features of the LiquidMotor class. +Here we explore different features of the LiquidMotor class. Creating a Liquid Motor ----------------------- To define a liquid 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 +- 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 ` -- Tank objects, which will define propellant tanks. See more details in +- Tank objects, which will define propellant tanks. See more details in :doc:`Tank Usage` - Position related parameters, such as the position of the center of mass of the - dry mass, the position of the center of mass of the grains, and the position - of the nozzle. See more details in + dry mass, the position of the center of mass of the grains, and the position + of the nozzle. See more details in :ref:`Motor Coordinate Systems ` Let's first import the necessary modules: @@ -75,15 +75,15 @@ Then we must first define the tanks: Here we define two tanks, one for the oxidizer and one for the fuel. We use the :ref:`MassFlowRateBasedTank `, which means that the mass flow rates of the liquid and gas are defined by the - user. - + user. + In this case, we are using a lambda functions to define the mass flow rates, - but .csv files can also be used. See more details in + but .csv files can also be used. See more details in :class:`rocketpy.motors.Tank.MassFlowRateBasedTank.__init__` Now we can define our liquid motor and add the tanks. We are using a lambda function as the thrust -curve, but keep in mind that you can use -:doc:`different formats ` here. +curve, but keep in mind that you can use +:doc:`different formats ` here. .. jupyter-execute:: @@ -107,19 +107,19 @@ curve, but keep in mind that you can use - ``dry_inertia`` is defined as a tuple of the form ``(I11, I22, I33)``. Where ``I11`` and ``I22`` are the inertia of the dry mass around the perpendicular axes to the motor, and ``I33`` is the inertia around the - motor center axis. - - ``dry inertia`` is defined in relation to the **center of dry mass**, and + motor center axis. + - ``dry inertia`` is defined in relation to the **center of dry mass**, and not in relation to the coordinate system origin. - ``center_of_dry_mass_position``, ``nozzle_position`` and the tanks - ``position`` are defined in relation to the + ``position`` are defined in relation to the :ref:`coordinate system origin `, which is the nozzle outlet in this case. - Both ``dry_mass`` **and** ``center_of_dry_mass_position`` must consider the mass of the tanks. -.. seealso:: - - You can find details on each of the initialization parameters in +.. seealso:: + + You can find details on each of the initialization parameters in :class:`rocketpy.LiquidMotor.__init__` And you can find details on adding tanks in :ref:`Adding Tanks` @@ -163,20 +163,39 @@ For example: The tanks added to a ``LiquidMotor`` can now be animated to visualize how the liquid and gas volumes evolve during the burn. -To animate the tanks, we can use the ``animate_fluid_volume()`` method: +To animate a specific tank, use its plotter ``animate_fluid_volume()`` method: .. jupyter-execute:: - example_liquid.animate_fluid_volume(fps=30) + oxidizer_tank.plots.animate_fluid_volume(fps=30) Optionally, the animation can be saved to a GIF file: .. jupyter-execute:: - example_liquid.animate_fluid_volume(fps=30, save_as="liquid_motor.gif") + oxidizer_tank.plots.animate_fluid_volume(fps=30, filename="oxidizer.gif") + +If multiple tanks are present, you can loop over them: + +.. jupyter-execute:: + + for positioned in example_liquid.positioned_tanks: + tank = positioned["tank"] + tank.plots.animate_fluid_volume( + fps=30, filename=f"{tank.name.replace(' ', '_')}.gif" + ) Alternatively, you can plot all the information at once: .. jupyter-execute:: example_liquid.all_info() + +.. jupyter-execute:: + :hide-code: + :hide-output: + + from pathlib import Path + import glob + for gif_file in glob.glob("*.gif"): + Path(gif_file).unlink(missing_ok=True) diff --git a/docs/user/motors/tanks.rst b/docs/user/motors/tanks.rst index f57383476..2c63cabaf 100644 --- a/docs/user/motors/tanks.rst +++ b/docs/user/motors/tanks.rst @@ -37,8 +37,8 @@ The different types of tanks are: - ``class UllageBasedTank``: flow is described by ullage. See `Ullage Based Tank`_ for more information. -To summarize, the ``UllageBasedTank`` and ``LevelBasedTank`` are less accurate -than the ``MassFlowRateBasedTank`` and ``MassBasedTank``, since they assume +To summarize, the ``UllageBasedTank`` and ``LevelBasedTank`` are less accurate +than the ``MassFlowRateBasedTank`` and ``MassBasedTank``, since they assume uniform gas distribution filling all the portion that is not occupied by liquid. Therefore, these tanks can only model the tank state until the liquid runs out. @@ -50,7 +50,7 @@ in the tank. This object is defined in the `Fluid`_ section. .. seealso:: Tanks are added to motors using the ``add_tank`` method of the motor. You can - find more information about this method in the + find more information about this method in the :ref:`Adding Tanks ` section. .. attention:: @@ -63,7 +63,7 @@ Fluid ------ Fluid are a very simple class which describes the properties of a fluid. They -are used to define the propellant in a tank. A Fluid is defined by its name and +are used to define the propellant in a tank. A Fluid is defined by its name and density as such: .. jupyter-execute:: @@ -74,10 +74,10 @@ density as such: Fluid are then passed to tanks when they are defined. .. note:: - - One may define the fluid density as a function of temperature (K) and + + One may define the fluid density as a function of temperature (K) and pressure (Pa). The data can be imported from an external source, such as - a dataset or external libraries. + a dataset or external libraries. In this case, the fluid would be defined as such: >>> Fluid(name="N2O", density=lambda t, p: 44 * p / (8.314 * t)) @@ -85,7 +85,7 @@ Fluid are then passed to tanks when they are defined. >>> Fluid(name="N2O", density=lambda t, p: PropsSI('D', 'T', t, 'P', p, 'N2O')) In fact, the density parameter can be any ``Function`` source, such as a - ``callable``, csv file or an array of points. See more on + ``callable``, csv file or an array of points. See more on :class:`rocketpy.Function`. Tank Geometry @@ -125,8 +125,8 @@ The predefined ``SphericalTank`` is defined with: Custom Tank Geometry ~~~~~~~~~~~~~~~~~~~~ -The ``TankGeometry`` class can be used to define a custom geometry by passing -the ``geometry_dict`` parameter, which is a dictionary with its *keys* as tuples +The ``TankGeometry`` class can be used to define a custom geometry by passing +the ``geometry_dict`` parameter, which is a dictionary with its *keys* as tuples containing the lower and upper bound of the tank, while the *values* correspond to the radius function of that section of the tank. @@ -141,13 +141,13 @@ To exemplify, lets define a cylindrical tank with the same dimensions as the } ) -This defines a cylindrical tank with a 2 m lengths (from -1 m to 1 m) and a +This defines a cylindrical tank with a 2 m lengths (from -1 m to 1 m) and a constant radius of 0.1 m. .. note:: The center of coordinate is always at the exact geometrical center of the tank. -We can also define a tank with a parabolic cross-section by using a +We can also define a tank with a parabolic cross-section by using a variable radius, for example: .. jupyter-execute:: @@ -163,7 +163,7 @@ variable radius, for example: Mass Flow Rate Based Tank ------------------------- -A ``MassFlowRateBasedTank`` has its flow described by the variation of liquid +A ``MassFlowRateBasedTank`` has its flow described by the variation of liquid and gas masses through time and is defined as such: .. jupyter-execute:: @@ -265,18 +265,25 @@ We can see some outputs with: All tank types now include a built-in method for animating the evolution of liquid and gas volumes over time. This visualization aids in understanding the dynamic behavior -of the tank's contents. To animate the tanks, we can use the -``animate_fluid_volume()`` method: +of the tank's contents. To animate the tanks, we can use the +``animate_fluid_volume()`` method from the tank's plotter: .. jupyter-execute:: - N2O_mass_tank.animate_fluid_volume(fps=30) + N2O_mass_tank.plots.animate_fluid_volume(fps=30) Optionally, the animation can be saved to a GIF file: .. jupyter-execute:: - N2O_mass_tank.animate_fluid_volume(fps=30, save_as="mass_based_tank.gif") + N2O_mass_tank.plots.animate_fluid_volume(fps=30, filename="mass_based_tank.gif") + +.. jupyter-execute:: + :hide-code: + :hide-output: + + from pathlib import Path + Path("mass_based_tank.gif").unlink(missing_ok=True) Ullage Based Tank @@ -286,7 +293,7 @@ An ``UllageBasedTank`` has its flow described by the ullage volume, i.e., the volume of the tank that is not occupied by the liquid. It assumes that the ullage volume is uniformly filled by the gas. -To define it, lets first calculate the ullage volume by using the +To define it, lets first calculate the ullage volume by using the ``MassFlowRateBasedTank`` we defined above: .. jupyter-execute:: @@ -329,7 +336,7 @@ We can see some outputs with: N2O_ullage_tank.net_mass_flow_rate.plot() .. jupyter-execute:: - + # Evolution of the Propellant center of mass position N2O_ullage_tank.center_of_mass.plot() @@ -341,7 +348,7 @@ A ``LevelBasedTank`` has its flow described by liquid level, i.e., the height of the liquid inside the tank. It assumes that the volume above the liquid level is uniformly occupied by gas. -To define it, lets first calculate the liquid height by using the +To define it, lets first calculate the liquid height by using the ``MassFlowRateBasedTank`` we defined above: .. jupyter-execute:: @@ -374,7 +381,7 @@ We can see some outputs with: # Draw the tank N20_level_tank.draw() -| +| .. jupyter-execute:: @@ -383,7 +390,7 @@ We can see some outputs with: N20_level_tank.net_mass_flow_rate.plot() .. jupyter-execute:: - + # Evolution of the Propellant center of mass position N20_level_tank.center_of_mass.plot() diff --git a/docs/user/mrs.rst b/docs/user/mrs.rst index e58f7a784..cb3a369c9 100644 --- a/docs/user/mrs.rst +++ b/docs/user/mrs.rst @@ -3,12 +3,12 @@ Multivariate Rejection Sampling =============================== -Multivariate Rejection Sampling allows you to quickly subsample the results of a -previous Monte Carlo simulation to obtain the results when one or more variables +Multivariate Rejection Sampling allows you to quickly subsample the results of a +previous Monte Carlo simulation to obtain the results when one or more variables have a different probability distribution without having to re-run the simulation. -We will show you how to use the :class:`rocketpy.MultivariateRejectionSampler` -class to possibly save time. It is highly recommended that you read about Monte +We will show you how to use the :class:`rocketpy.MultivariateRejectionSampler` +class to possibly save time. It is highly recommended that you read about Monte Carlo simulations. Motivation @@ -18,32 +18,32 @@ As discussed in :ref:`sensitivity-practical`, there are several sources of uncertainty that can affect the flight of a rocket, notably the weather and the measurements errors in design parameters. Still, it is desirable that the flight accomplishes its goal, for instance reaching a certain apogee, as well as staying under -some safety restrictions, such as ensuring that the landing point is outside +some safety restrictions, such as ensuring that the landing point is outside of a given area. Monte Carlo simulation is a technique that allows us to quantify the uncertainty and -give objective answers to those types of questions in terms of probabilities and -statistics. It relies on running several simulations under different conditions +give objective answers to those types of questions in terms of probabilities and +statistics. It relies on running several simulations under different conditions specified by probability distributions provided by the user. Hence, depending on the inputs and number of samples, running these Monte Carlo simulations might take a while. -Now, imagine that you ran and saved the Monte Carlo simulations. Later, you need new a -Monte Carlo simulation with different probability distributions that are somewhat close -to the original simulation. The first straightforward option is to just re-run the +Now, imagine that you ran and saved the Monte Carlo simulations. Later, you need new a +Monte Carlo simulation with different probability distributions that are somewhat close +to the original simulation. The first straightforward option is to just re-run the monte carlo with the new arguments, but this might be time consuming. A second option is to use a sub-sampler that leverages the existing simulation to produce a new sample that conforms to the new probability distributions. The latter completely avoids the necessity of re-running the simulations and is, therefore, much faster. -The Multivariate Rejection Sampler, or just MRS, is an algorithm that sub-samples the -original results based on weights proportional to the ratio of the new and old +The Multivariate Rejection Sampler, or just MRS, is an algorithm that sub-samples the +original results based on weights proportional to the ratio of the new and old probability distributions that have changed. The final result has a smaller sample size, but their distribution matches the one newly specified without having to re-run the simulation. The time efficiency of the MRS is especially interesting in two scenarios: quick testing -and tight schedules. Imagine you have an initial design and ran a huge robust monte -carlo simulation but you are also interested in minor variations of the original +and tight schedules. Imagine you have an initial design and ran a huge robust monte +carlo simulation but you are also interested in minor variations of the original design. Instead of having to run an expensive monte carlo for each of these variations, you can just re-sample the original accordingly. For tight schedules, it is not unheard of cases where last minute changes have to be made to simulations. The MRS might @@ -53,7 +53,7 @@ full simulation might just take more time than available. Importing and using the MRS --------------------------- -We now show how to actually use the :class:`rocketpy.MultivariateRejectionSampler` +We now show how to actually use the :class:`rocketpy.MultivariateRejectionSampler` class. We begin by importing it along with other utilities .. jupyter-execute:: @@ -62,7 +62,7 @@ class. We begin by importing it along with other utilities import numpy as np from scipy.stats import norm -The reference monte carlo simulation used is the one from the +The reference monte carlo simulation used is the one from the "monte_carlo_class_usage.ipynb" notebook with a 1000 samples. A MultivariateRejectionSampler object is initialized by giving two file paths, one for the prefix of the original monte carlo simulation, and one for the output of the @@ -80,7 +80,7 @@ sub-samples. The code below defines these strings and initializes the MRS object mrs_filepath=mrs_filepath, ) -Running a monte carlo simulation requires you to specify the distribution of +Running a monte carlo simulation requires you to specify the distribution of all parameters that have uncertainty. The MRS, however, only needs the previous and new distributions of the parameters whose distribution changed. All other random parameters in the original monte carlo simulation retain their original distribution. @@ -88,7 +88,7 @@ in the original monte carlo simulation retain their original distribution. In the original simulation, the mass of the rocket had a normal distribution with mean :math:`15.426` and standard deviation of :math:`0.5`. Assume that the mean of this distribution changed to :math:`15` and the standard deviation remained the same. To -run the mrs, we create a dictionary whose keys are the name of the parameter and the +run the mrs, we create a dictionary whose keys are the name of the parameter and the value is a 2-tuple: the first entry contains the pdf of the old distribution, and the second entry contains the pdf of the new distribution. The code below shows how to create these distributions and the dictionary @@ -103,20 +103,20 @@ create these distributions and the dictionary Finally, we execute the `sample` method, as shown below -.. jupyter-execute:: - :hide-code: - :hide-output: +.. jupyter-execute:: + :hide-code: + :hide-output: - np.random.seed(seed=42) + np.random.seed(seed=42) .. jupyter-execute:: mrs.sample(distribution_dict=distribution_dict) And that is it! The MRS has saved a file that has the same structure as the results of -a monte carlo simulation but now the mass has been sampled from the newly stated +a monte carlo simulation but now the mass has been sampled from the newly stated distribution. To see that it is actually the case, let us import the results of the MRS -and check the mean and standard deviation of the mass. First, we import in the same +and check the mean and standard deviation of the mass. First, we import in the same way we import the results from a monte carlo simulation @@ -125,8 +125,8 @@ way we import the results from a monte carlo simulation mrs_results = MonteCarlo(mrs_filepath, None, None, None) mrs_results.import_results() -Notice that the sample size is now smaller than 1000 samples. Albeit the sample size is -now random, we can check the expected number of samples by printing the +Notice that the sample size is now smaller than 1000 samples. Albeit the sample size is +now random, we can check the expected number of samples by printing the `expected_sample_size` attribute .. jupyter-execute:: @@ -140,7 +140,7 @@ Now we check the mean and standard deviation of the mass. mrs_mass_list = [] for single_input_dict in mrs_results.inputs_log: mrs_mass_list.append(single_input_dict["mass"]) - + print(f"MRS mass mean after resample: {np.mean(mrs_mass_list)}") print(f"MRS mass std after resample: {np.std(mrs_mass_list)}") @@ -151,7 +151,7 @@ Comparing Monte Carlo Results Alright, now that we have the results for this new configuration, how does it compare to the original one? Our rocket has, on average, decreased its mass in about 400 grams -while maintaining all other aspects. How do you think, for example, that the distribution +while maintaining all other aspects. How do you think, for example, that the distribution of the apogee has changed? Let us find out. First, we import the original results @@ -177,7 +177,7 @@ earlier, compare the mean and median of the apogee between both cases. Is it wha expected? -Histogram and boxplots +Histogram and boxplots ^^^^^^^^^^^^^^^^^^^^^^ Besides printed comparison, we can also provide a comparison for the distributions in @@ -194,14 +194,14 @@ in the legend, the third comes from the overlap of the two. Ellipses ^^^^^^^^ -Finally, we can compare the ellipses for the apogees and landing points using the +Finally, we can compare the ellipses for the apogees and landing points using the `compare_ellipses` method .. jupyter-execute:: original_results.compare_ellipses(mrs_results, ylim=(-4000, 3000)) -Note we can pass along parameters used in the usual `ellipses` method of the +Note we can pass along parameters used in the usual `ellipses` method of the `MonteCarlo` class, in this case the `ylim` argument to expand the y-axis limits. @@ -220,7 +220,7 @@ mean of the apogee using the ``mrs_results`` object created earlier: # Calculate the 95% Confidence Interval for the mean apogee # We pass np.mean as the statistic to be evaluated - apogee_ci = mrs_results.calculate_confidence_interval( + apogee_ci = mrs_results.estimate_confidence_interval( attribute="apogee", statistic=np.mean, confidence_level=0.95, @@ -239,16 +239,16 @@ Time Comparison --------------- Is the MRS really much faster than just re-running a Monte Carlo simulation? -Let us take a look at some numbers. All tests ran in a Dell G15 5530, with 16 +Let us take a look at some numbers. All tests ran in a Dell G15 5530, with 16 13th Gen Intel® Core™ i5-13450HX CPUs, 16GB RAM, running Ubuntu 22.04. Each function -ran 10 times, and no parallelization was used. +ran 10 times, and no parallelization was used. To run the original monte carlo simulation with 1000 samples it took, on average, about 644 seconds, that is, 10 minutes and 44 seconds. For the MRS described here, it took, on average, 0.15 seconds, with an expected sample size of 117. To re-run the monte carlo simulations with 117 samples it took, on average, 76.3 seconds. Hence, -the MRS was, on average, (76.3 / 0.15) ~ 500 times faster than re-running the monte -carlo simulations with the same sample size provided by the MRS. +the MRS was, on average, (76.3 / 0.15) ~ 500 times faster than re-running the monte +carlo simulations with the same sample size provided by the MRS. Sampling from nested parameters @@ -256,9 +256,9 @@ Sampling from nested parameters To sample from parameters that are nested in inner levels, use a key name formed by the name of the key of the outer level concatenated with a "_" and -the key of the inner level. For instance, to change the distribution -from the "total_impulse" parameter, which is nested within the "motors" -parameter dictionary, we have to use the key name "motors_total_impulse". +the key of the inner level. For instance, to change the distribution +from the "total_impulse" parameter, which is nested within the "motors" +parameter dictionary, we have to use the key name "motors_total_impulse". .. jupyter-execute:: @@ -270,7 +270,7 @@ parameter dictionary, we have to use the key name "motors_total_impulse". } mrs.sample(distribution_dict=distribution_dict) -Finally, if there are multiple named nested structures, we need to use a key +Finally, if there are multiple named nested structures, we need to use a key name formed by outer level concatenated with "_", the structure name, "_" and the inner parameter name. For instance, if we want to change the distribution of the 'root_chord' of the 'Fins', we have to pass as key @@ -292,8 +292,8 @@ A word of caution Albeit the MRS provides results way faster than running the simulation again, it might reduce the sample size drastically. If several variables undergo -changes in their distribution and the more discrepant these are from the original -ones, the more pronounced will be this sample size reduction. If you need the monte +changes in their distribution and the more discrepant these are from the original +ones, the more pronounced will be this sample size reduction. If you need the monte carlo simulations to have the same sample size as before or if the expected sample size from the MRS is too low for you current application, then it might be better to re-run the simulations. @@ -301,9 +301,9 @@ re-run the simulations. References ---------- -[1] CEOTTO, Giovani H., SCHMITT, Rodrigo N., ALVES, Guilherme F., et al. RocketPy: six -degree-of-freedom rocket trajectory simulator. Journal of Aerospace Engineering, 2021, +[1] CEOTTO, Giovani H., SCHMITT, Rodrigo N., ALVES, Guilherme F., et al. RocketPy: six +degree-of-freedom rocket trajectory simulator. Journal of Aerospace Engineering, 2021, vol. 34, no 6, p. 04021093. -[2] CASELLA, George, ROBERT, Christian P., et WELLS, Martin T. Generalized accept-reject +[2] CASELLA, George, ROBERT, Christian P., et WELLS, Martin T. Generalized accept-reject sampling schemes. Lecture notes-monograph series, 2004, p. 342-347. \ No newline at end of file diff --git a/rocketpy/plots/monte_carlo_plots.py b/rocketpy/plots/monte_carlo_plots.py index 3c9123870..e9ce4ef3a 100644 --- a/rocketpy/plots/monte_carlo_plots.py +++ b/rocketpy/plots/monte_carlo_plots.py @@ -1,10 +1,10 @@ -from pathlib import Path import urllib +from pathlib import Path -from PIL import UnidentifiedImageError import matplotlib.pyplot as plt import numpy as np from matplotlib.transforms import offset_copy +from PIL import UnidentifiedImageError from ..tools import ( convert_local_extent_to_wgs84, diff --git a/rocketpy/plots/motor_plots.py b/rocketpy/plots/motor_plots.py index 7e8a072da..88d57d10a 100644 --- a/rocketpy/plots/motor_plots.py +++ b/rocketpy/plots/motor_plots.py @@ -1,9 +1,9 @@ import matplotlib.pyplot as plt import numpy as np -from matplotlib.patches import Polygon from matplotlib.animation import FuncAnimation +from matplotlib.patches import Polygon -from ..plots.plot_helpers import show_or_save_plot, show_or_save_animation +from ..plots.plot_helpers import show_or_save_animation, show_or_save_plot class _MotorPlots: diff --git a/rocketpy/plots/tank_plots.py b/rocketpy/plots/tank_plots.py index 68df643e7..a4c67c1eb 100644 --- a/rocketpy/plots/tank_plots.py +++ b/rocketpy/plots/tank_plots.py @@ -1,11 +1,11 @@ import matplotlib.pyplot as plt import numpy as np -from matplotlib.patches import Polygon from matplotlib.animation import FuncAnimation +from matplotlib.patches import Polygon from rocketpy.mathutils.function import Function -from .plot_helpers import show_or_save_plot, show_or_save_animation +from .plot_helpers import show_or_save_animation, show_or_save_plot class _TankPlots: diff --git a/rocketpy/simulation/__init__.py b/rocketpy/simulation/__init__.py index 1ade0f16f..39afd9166 100644 --- a/rocketpy/simulation/__init__.py +++ b/rocketpy/simulation/__init__.py @@ -1,5 +1,15 @@ from .flight import Flight +from .flight_comparator import FlightComparator from .flight_data_exporter import FlightDataExporter from .flight_data_importer import FlightDataImporter from .monte_carlo import MonteCarlo from .multivariate_rejection_sampler import MultivariateRejectionSampler + +__all__ = [ + "Flight", + "FlightComparator", + "FlightDataExporter", + "FlightDataImporter", + "MonteCarlo", + "MultivariateRejectionSampler", +] diff --git a/rocketpy/simulation/flight.py b/rocketpy/simulation/flight.py index a7e0374b2..a139a84ee 100644 --- a/rocketpy/simulation/flight.py +++ b/rocketpy/simulation/flight.py @@ -3720,6 +3720,142 @@ def max_rail_button2_shear_force(self): """Maximum lower rail button shear force, in Newtons.""" return np.abs(self.rail_button2_shear_force.y_array).max() + @cached_property + def calculate_rail_button_bending_moments(self): + """Calculate internal bending moments at rail button attachment points. + + Uses beam theory to determine the internal structural moments for + stress analysis of the rail button attachments (fasteners and airframe). + + The bending moment at each button attachment consists of: + + 1. Normal force moment: $M = N \\times d$, where $N$ is the normal + reaction force and $d$ is the distance from button to center of + dry mass. + 2. Shear force cantilever moment: $M = S \\times h$, where $S$ is the + shear (tangential) force and $h$ is the button standoff height. + + Returns + ------- + tuple + rail_button1_bending_moment : Function + Bending moment at upper rail button as a function of time (N·m). + max_rail_button1_bending_moment : float + Maximum upper rail button bending moment (N·m). + rail_button2_bending_moment : Function + Bending moment at lower rail button as a function of time (N·m). + max_rail_button2_bending_moment : float + Maximum lower rail button bending moment (N·m). + + Notes + ----- + - Calculated only during the rail phase of flight + - Maximum values use absolute values for worst-case stress analysis + - The bending moments represent internal stresses in the rocket + airframe at the rail button attachment points + + **Assumptions:** + + - Rail buttons act as simple supports: provide reaction forces (normal + and shear) but no moment reaction at the rail contact point + - The rocket acts as a beam supported at two points (rail buttons) + - Bending moments arise from the lever arm effect of reaction forces + and the cantilever moment from button standoff height + """ + # Check if rail buttons exist + null_moment = Function(0) + if len(self.rocket.rail_buttons) == 0: + warnings.warn( + "Trying to calculate rail button bending moments without " + "rail buttons defined. Setting moments to zero.", + UserWarning, + ) + return (null_moment, 0.0, null_moment, 0.0) + + # Get rail button geometry + rail_buttons_tuple = self.rocket.rail_buttons[0] + # Rail button standoff height + h_button = rail_buttons_tuple.component.button_height + if h_button is None: + warnings.warn( + "Rail button height not defined. Bending moments cannot be " + "calculated. Setting moments to zero.", + UserWarning, + ) + return (null_moment, 0.0, null_moment, 0.0) + upper_button_position = ( + rail_buttons_tuple.component.buttons_distance + + rail_buttons_tuple.position.z + ) + lower_button_position = rail_buttons_tuple.position.z + + # Get center of dry mass (handle both callable and property) + if callable(self.rocket.center_of_dry_mass_position): + cdm = self.rocket.center_of_dry_mass_position(self.rocket._csys) + else: + cdm = self.rocket.center_of_dry_mass_position + + # Distances from buttons to center of dry mass + d1 = abs(upper_button_position - cdm) + d2 = abs(lower_button_position - cdm) + + # forces + N1 = self.rail_button1_normal_force + N2 = self.rail_button2_normal_force + S1 = self.rail_button1_shear_force + S2 = self.rail_button2_shear_force + t = N1.source[:, 0] + + # Calculate bending moments at attachment points + # Primary contribution from shear force acting at button height + # Secondary contribution from normal force creating moment about attachment + m1_values = N2.source[:, 1] * d2 + S1.source[:, 1] * h_button + m2_values = N1.source[:, 1] * d1 + S2.source[:, 1] * h_button + + rail_button1_bending_moment = Function( + np.column_stack([t, m1_values]), + inputs="Time (s)", + outputs="Bending Moment (N·m)", + interpolation="linear", + ) + rail_button2_bending_moment = Function( + np.column_stack([t, m2_values]), + inputs="Time (s)", + outputs="Bending Moment (N·m)", + interpolation="linear", + ) + + # Maximum bending moments (absolute value for stress calculations) + max_rail_button1_bending_moment = float(np.max(np.abs(m1_values))) + max_rail_button2_bending_moment = float(np.max(np.abs(m2_values))) + + return ( + rail_button1_bending_moment, + max_rail_button1_bending_moment, + rail_button2_bending_moment, + max_rail_button2_bending_moment, + ) + + @property + def rail_button1_bending_moment(self): + """Upper rail button bending moment as a Function of time.""" + return self.calculate_rail_button_bending_moments[0] + + @property + def max_rail_button1_bending_moment(self): + """Maximum upper rail button bending moment, in N·m.""" + return self.calculate_rail_button_bending_moments[1] + + @property + def rail_button2_bending_moment(self): + """Lower rail button bending moment as a Function of time.""" + return self.calculate_rail_button_bending_moments[2] + + @property + def max_rail_button2_bending_moment(self): + """Maximum lower rail button bending moment, in N·m.""" + return self.calculate_rail_button_bending_moments[3] + @funcify_method( "Time (s)", "Horizontal Distance to Launch Point (m)", "spline", "constant" ) @@ -4516,142 +4652,3 @@ def __lt__(self, other): otherwise. """ return self.t < other.t - - @cached_property - def calculate_rail_button_bending_moments(self): - """ - Calculate internal bending moments at rail button attachment points. - - Uses beam theory to determine internal structural moments for stress - analysis of the rail button attachments (fasteners and airframe). - - The bending moment at each button attachment consists of: - 1. Bending from shear force at button contact point: M = S × h - where S is the shear (tangential) force and h is button height - 2. Direct moment contribution from the button's reaction forces - - Assumptions - ----------- - - Rail buttons act as simple supports: provide reaction forces (normal - and shear) but no moment reaction at the rail contact point. - - The rocket acts as a beam supported at two points (rail buttons). - - Bending moments arise from the lever arm effect of reaction forces - and the cantilever moment from button standoff height. - - The bending moment at each button attachment consists of: - 1. Normal force moment: M = N x d, where N is normal reaction force - and d is distance from button to center of dry mass - 2. Shear force cantilever moment: M = S x h, where S is shear force - and h is button standoff height - - Notes - ----- - - Calculated only during the rail phase of flight - - Maximum values use absolute values for worst-case stress analysis - - The bending moments represent internal stresses in the rocket - airframe at the rail button attachment points - - Returns - ------- - tuple - (rail_button1_bending_moment : Function, - max_rail_button1_bending_moment : float, - rail_button2_bending_moment : Function, - max_rail_button2_bending_moment : float) - - Where rail_button1/2_bending_moment are Function objects of time - in N·m, and max values are floats in N·m. - """ - # Check if rail buttons exist - null_moment = Function(0) - if len(self.rocket.rail_buttons) == 0: - warnings.warn( - "Trying to calculate rail button bending moments without " - "rail buttons defined. Setting moments to zero.", - UserWarning, - ) - return (null_moment, 0.0, null_moment, 0.0) - - # Get rail button geometry - rail_buttons_tuple = self.rocket.rail_buttons[0] - # Rail button standoff height - h_button = rail_buttons_tuple.component.button_height - if h_button is None: - warnings.warn( - "Rail button height not defined. Bending moments cannot be " - "calculated. Setting moments to zero.", - UserWarning, - ) - return (null_moment, 0.0, null_moment, 0.0) - upper_button_position = ( - rail_buttons_tuple.component.buttons_distance - + rail_buttons_tuple.position.z - ) - lower_button_position = rail_buttons_tuple.position.z - - # Get center of dry mass (handle both callable and property) - if callable(self.rocket.center_of_dry_mass_position): - cdm = self.rocket.center_of_dry_mass_position(self.rocket._csys) - else: - cdm = self.rocket.center_of_dry_mass_position - - # Distances from buttons to center of dry mass - d1 = abs(upper_button_position - cdm) - d2 = abs(lower_button_position - cdm) - - # forces - N1 = self.rail_button1_normal_force - N2 = self.rail_button2_normal_force - S1 = self.rail_button1_shear_force - S2 = self.rail_button2_shear_force - t = N1.source[:, 0] - - # Calculate bending moments at attachment points - # Primary contribution from shear force acting at button height - # Secondary contribution from normal force creating moment about attachment - m1_values = N2.source[:, 1] * d2 + S1.source[:, 1] * h_button - m2_values = N1.source[:, 1] * d1 + S2.source[:, 1] * h_button - - rail_button1_bending_moment = Function( - np.column_stack([t, m1_values]), - inputs="Time (s)", - outputs="Bending Moment (N·m)", - interpolation="linear", - ) - rail_button2_bending_moment = Function( - np.column_stack([t, m2_values]), - inputs="Time (s)", - outputs="Bending Moment (N·m)", - interpolation="linear", - ) - - # Maximum bending moments (absolute value for stress calculations) - max_rail_button1_bending_moment = float(np.max(np.abs(m1_values))) - max_rail_button2_bending_moment = float(np.max(np.abs(m2_values))) - - return ( - rail_button1_bending_moment, - max_rail_button1_bending_moment, - rail_button2_bending_moment, - max_rail_button2_bending_moment, - ) - - @property - def rail_button1_bending_moment(self): - """Upper rail button bending moment as a Function of time.""" - return self.calculate_rail_button_bending_moments[0] - - @property - def max_rail_button1_bending_moment(self): - """Maximum upper rail button bending moment, in N·m.""" - return self.calculate_rail_button_bending_moments[1] - - @property - def rail_button2_bending_moment(self): - """Lower rail button bending moment as a Function of time.""" - return self.calculate_rail_button_bending_moments[2] - - @property - def max_rail_button2_bending_moment(self): - """Maximum lower rail button bending moment, in N·m.""" - return self.calculate_rail_button_bending_moments[3] diff --git a/tests/integration/simulation/test_monte_carlo_plots_background.py b/tests/integration/simulation/test_monte_carlo_plots_background.py index cb4183995..b56bcec65 100644 --- a/tests/integration/simulation/test_monte_carlo_plots_background.py +++ b/tests/integration/simulation/test_monte_carlo_plots_background.py @@ -6,12 +6,13 @@ import os import shutil -import numpy as np + import matplotlib.pyplot as plt +import numpy as np import pytest -from rocketpy.simulation import MonteCarlo from rocketpy.plots.monte_carlo_plots import _MonteCarloPlots +from rocketpy.simulation import MonteCarlo plt.rcParams.update({"figure.max_open_warning": 0}) diff --git a/tests/unit/simulation/test_monte_carlo_plots_background.py b/tests/unit/simulation/test_monte_carlo_plots_background.py index 50957a140..8a9de5cf6 100644 --- a/tests/unit/simulation/test_monte_carlo_plots_background.py +++ b/tests/unit/simulation/test_monte_carlo_plots_background.py @@ -1,15 +1,16 @@ # pylint: disable=unused-argument,assignment-from-no-return import os -import urllib.error # pylint: disable=unused-import +import urllib.error from unittest.mock import MagicMock, patch -import pytest - import matplotlib.pyplot as plt -from PIL import UnidentifiedImageError # pylint: disable=unused-import +import numpy as np +import pytest +from PIL import UnidentifiedImageError from rocketpy.plots.monte_carlo_plots import _MonteCarloPlots from rocketpy.simulation import MonteCarlo +from rocketpy.tools import import_optional_dependency plt.rcParams.update({"figure.max_open_warning": 0}) @@ -67,100 +68,53 @@ def __init__(self, latitude=32.990254, longitude=-106.974998): self.longitude = longitude +@pytest.mark.parametrize( + "background_type", + [None, "satellite", "street", "terrain", "CartoDB.Positron"], +) @patch("matplotlib.pyplot.show") -def test_ellipses_background_none(mock_show): - """Test default behavior when background=None (no background map displayed). - - Parameters - ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. - """ - mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - # Test that background=None does not raise an error - result = mock_monte_carlo.plots.ellipses(background=None) - assert result is None - +def test_ellipses_background_types_display_successfully(mock_show, background_type): + """Test that different background map types display without errors. -@patch("matplotlib.pyplot.show") -def test_ellipses_background_satellite(mock_show): - """Test using satellite map when background="satellite". + This parameterized test verifies that the ellipses method works with: + - None (no background map) + - "satellite" (Esri.WorldImagery) + - "street" (OpenStreetMap.Mapnik) + - "terrain" (Esri.WorldTopoMap) + - Custom provider (e.g., CartoDB.Positron) Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. + background_type : str or None + The background map type to test. """ mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - # Test that background="satellite" does not raise an error - result = mock_monte_carlo.plots.ellipses(background="satellite") - assert result is None + result = mock_monte_carlo.plots.ellipses(background=background_type) -@patch("matplotlib.pyplot.show") -def test_ellipses_background_street(mock_show): - """Test using street map when background="street". - - Parameters - ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. - """ - mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - # Test that background="street" does not raise an error - result = mock_monte_carlo.plots.ellipses(background="street") assert result is None @patch("matplotlib.pyplot.show") -def test_ellipses_background_terrain(mock_show): - """Test using terrain map when background="terrain". - - Parameters - ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. - """ - mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - # Test that background="terrain" does not raise an error - result = mock_monte_carlo.plots.ellipses(background="terrain") - assert result is None - +def test_ellipses_image_takes_precedence_over_background(mock_show, tmp_path): + """Test that image parameter takes precedence over background parameter. -@patch("matplotlib.pyplot.show") -def test_ellipses_background_custom_provider(mock_show): - """Test using custom contextily provider for background. + When both image and background are provided, the image should be used + and the background map should not be downloaded. Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. + tmp_path : pathlib.Path + Pytest fixture providing a temporary directory. """ - mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - # Test that custom provider does not raise an error - result = mock_monte_carlo.plots.ellipses(background="CartoDB.Positron") - assert result is None - -@patch("matplotlib.pyplot.show") -def test_ellipses_image_takes_precedence_over_background(mock_show, tmp_path): - """Test that image takes precedence when both image and background are provided. - - Parameters - ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. - tmp_path : - pytest fixture providing a temporary directory. - """ mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) dummy_image_path = tmp_path / "dummy_image.png" dummy_image_path.write_bytes(b"dummy") - - # Test that when both image and background are provided, image takes precedence - # This should not attempt to download background map - import numpy as np # pylint: disable=import-outside-toplevel - mock_image = np.zeros((100, 100, 3), dtype=np.uint8) # RGB image with patch("imageio.imread") as mock_imread: @@ -173,62 +127,67 @@ def test_ellipses_image_takes_precedence_over_background(mock_show, tmp_path): @patch("matplotlib.pyplot.show") -def test_ellipses_background_no_environment(mock_show): - """Test that ValueError is raised when MonteCarlo object has no environment attribute. +def test_ellipses_background_raises_error_when_no_environment(mock_show): + """Test that ValueError is raised when environment attribute is missing. - This test creates a MonteCarlo object without an environment attribute. - The function should raise ValueError when trying to fetch background map. + Parameters + ---------- + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. """ + mock_monte_carlo = MockMonteCarlo(environment=None) with pytest.raises(ValueError) as exc_info: mock_monte_carlo.plots.ellipses(background="satellite") - assert "environment" in str(exc_info.value).lower() - assert "automatically fetching the background map" in str(exc_info.value) + + error_message = str(exc_info.value).lower() + assert "environment" in error_message + assert "automatically fetching the background map" in error_message @patch("matplotlib.pyplot.show") -def test_ellipses_background_no_latitude_longitude(mock_show): - """Test that ValueError is raised when environment has no latitude or longitude attributes. +def test_ellipses_background_raises_error_when_missing_coordinates(mock_show): + """Test that ValueError is raised when environment lacks latitude or longitude. - This test creates a mock environment without latitude and longitude attributes. - The function should raise ValueError when trying to fetch background map. + Parameters + ---------- + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. """ - # Create a simple environment object without latitude and longitude class EmptyEnvironment: """Empty environment object without latitude and longitude attributes.""" - def __init__(self): - pass - mock_environment = EmptyEnvironment() mock_monte_carlo = MockMonteCarlo(environment=mock_environment) with pytest.raises(ValueError) as exc_info: mock_monte_carlo.plots.ellipses(background="satellite") - assert "latitude" in str(exc_info.value).lower() - assert "longitude" in str(exc_info.value).lower() - assert "automatically fetching the background map" in str(exc_info.value) + + error_message = str(exc_info.value).lower() + assert "latitude" in error_message + assert "longitude" in error_message + assert "automatically fetching the background map" in error_message @patch("matplotlib.pyplot.show") -def test_ellipses_background_contextily_not_installed(mock_show): +def test_ellipses_background_raises_error_when_contextily_not_installed(mock_show): """Test that ImportError is raised when contextily is not installed. Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. """ + mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - from rocketpy.tools import import_optional_dependency as original_import # pylint: disable=import-outside-toplevel - # Create a mock function that only raises exception when importing contextily def mock_import_optional_dependency(name): + """Mock function that raises ImportError for contextily.""" if name == "contextily": raise ImportError("No module named 'contextily'") - return original_import(name) + return import_optional_dependency(name) with patch( "rocketpy.plots.monte_carlo_plots.import_optional_dependency", @@ -240,42 +199,44 @@ def mock_import_optional_dependency(name): @patch("matplotlib.pyplot.show") -def test_ellipses_background_with_custom_xlim_ylim(mock_show): - """Test using background with custom xlim and ylim. +def test_ellipses_background_works_with_custom_limits(mock_show): + """Test that background maps work with custom axis limits. Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. """ + mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - # Test using custom xlim and ylim + result = mock_monte_carlo.plots.ellipses( background="satellite", xlim=(-5000, 5000), ylim=(-5000, 5000), ) + assert result is None @patch("matplotlib.pyplot.show") -def test_ellipses_background_save(mock_show): - """Test using background with save=True. +def test_ellipses_background_saves_file_successfully(mock_show): + """Test that plots with background maps can be saved to file. Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. """ + filename = "monte_carlo_test.png" + mock_monte_carlo = MockMonteCarlo( + environment=SimpleEnvironment(), filename="monte_carlo_test" + ) + try: - mock_monte_carlo = MockMonteCarlo( - environment=SimpleEnvironment(), filename="monte_carlo_test" - ) - # Test save functionality result = mock_monte_carlo.plots.ellipses(background="satellite", save=True) assert result is None - # Verify file was created assert os.path.exists(filename) finally: if os.path.exists(filename): @@ -283,27 +244,28 @@ def test_ellipses_background_save(mock_show): @patch("matplotlib.pyplot.show") -def test_ellipses_background_invalid_provider(mock_show): - """Test that ValueError is raised when an invalid map provider is specified. +def test_ellipses_background_raises_error_for_invalid_provider(mock_show): + """Test that ValueError is raised for invalid map provider names. Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. """ + mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) + invalid_provider = "Invalid.Provider.Name" + with pytest.raises(ValueError) as exc_info: - mock_monte_carlo.plots.ellipses(background="Invalid.Provider.Name") - assert "Invalid map provider" in str(exc_info.value) - assert "Invalid.Provider.Name" in str(exc_info.value) - assert ( - "satellite" in str(exc_info.value) - or "street" in str(exc_info.value) - or "terrain" in str(exc_info.value) - ) + mock_monte_carlo.plots.ellipses(background=invalid_provider) + + error_message = str(exc_info.value) + assert "Invalid map provider" in error_message + assert invalid_provider in error_message + # Check that error message includes built-in options + assert any(option in error_message for option in ["satellite", "street", "terrain"]) -@patch("matplotlib.pyplot.show") @pytest.mark.parametrize( "exception_factory,expected_exception,expected_messages", [ @@ -372,46 +334,48 @@ def test_ellipses_background_invalid_provider(mock_show): ), ], ) -def test_ellipses_background_bounds2img_failure( +@patch("matplotlib.pyplot.show") +def test_ellipses_background_handles_bounds2img_failures( mock_show, exception_factory, expected_exception, expected_messages ): """Test that appropriate exceptions are raised when bounds2img fails. - This is a parameterized test that covers all exception types handled in - the _fetch_background_map method: - - ValueError: invalid coordinates or zoom level - - ConnectionError: network errors (URLError, HTTPError, TimeoutError) - - RuntimeError: UnidentifiedImageError (invalid image data) - - RuntimeError: other unexpected exceptions + This parameterized test verifies error handling for all exception types + that can occur during background map fetching: + - ValueError: Invalid coordinates or zoom level + - ConnectionError: Network errors (URLError, HTTPError, TimeoutError) + - RuntimeError: Invalid image data (UnidentifiedImageError) + - RuntimeError: Other unexpected exceptions Parameters ---------- - mock_show : - Mocks the matplotlib.pyplot.show() function to avoid showing the plots. + mock_show : unittest.mock.MagicMock + Mocks the matplotlib.pyplot.show() function to avoid displaying plots. exception_factory : callable A function that returns the exception to raise in mock_bounds2img. expected_exception : type The expected exception type to be raised. - expected_messages : list[str] + expected_messages : list of str List of expected message substrings in the raised exception. """ - mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) - from rocketpy.tools import import_optional_dependency as original_import # pylint: disable=import-outside-toplevel + mock_monte_carlo = MockMonteCarlo(environment=SimpleEnvironment()) contextily = pytest.importorskip("contextily") mock_contextily = MagicMock() mock_contextily.providers = contextily.providers def mock_bounds2img(*args, **kwargs): + """Mock bounds2img that raises the specified exception.""" raise exception_factory() mock_contextily.bounds2img = mock_bounds2img def mock_import_optional_dependency(name): + """Mock import function that returns mock contextily.""" if name == "contextily": return mock_contextily - return original_import(name) + return import_optional_dependency(name) with patch( "rocketpy.plots.monte_carlo_plots.import_optional_dependency", diff --git a/tests/unit/test_plots.py b/tests/unit/test_plots.py index 3883c9459..fc412d3b9 100644 --- a/tests/unit/test_plots.py +++ b/tests/unit/test_plots.py @@ -2,14 +2,14 @@ from unittest.mock import MagicMock, patch import matplotlib.pyplot as plt -from matplotlib.animation import FuncAnimation import pytest +from matplotlib.animation import FuncAnimation from rocketpy.plots.compare import Compare from rocketpy.plots.plot_helpers import ( + show_or_save_animation, show_or_save_fig, show_or_save_plot, - show_or_save_animation, )