diff --git a/tests/unit/rocket/test_rocket_plots.py b/tests/unit/rocket/test_rocket_plots.py new file mode 100644 index 000000000..fe23ae1b1 --- /dev/null +++ b/tests/unit/rocket/test_rocket_plots.py @@ -0,0 +1,122 @@ +"""Unit tests for the rocket drawing helpers in ``rocketpy.plots.rocket_plots``.""" + +from unittest.mock import patch + +import pytest + +from rocketpy import LinearGenericSurface +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.motors.ring_cluster_motor import RingClusterMotor + +REFERENCE_AREA = 0.005 +REFERENCE_LENGTH = 0.08 + + +@patch("matplotlib.pyplot.show") +@pytest.mark.parametrize("plane", ["xz", "yz"]) +@pytest.mark.parametrize( + "fin_fixture", + ["calisto_trapezoidal_fin", "calisto_elliptical_fin", "calisto_free_form_fin"], +) +def test_draw_a_rocket_carrying_one_fin( # pylint: disable=unused-argument + mock_show, request, calisto_robust, plane, fin_fixture +): + """A single ``Fin`` takes ``_draw_fin``, not the ``_draw_fins`` set branch. + + That method rotates one fin out of its own coordinate system into the body + frame, and it projects differently in each plane, so both are drawn here. + """ + fin = request.getfixturevalue(fin_fixture) + calisto_robust.add_surfaces(fin, Vector([0, 0, -1.04956])) + + assert calisto_robust.draw(plane=plane, filename=None) is None + + +@patch("matplotlib.pyplot.show") +@pytest.mark.parametrize("plane", ["xz", "yz"]) +def test_draw_a_rocket_carrying_a_generic_surface( # pylint: disable=unused-argument + mock_show, calisto_robust, plane +): + """A ``GenericSurface`` has no outline to trace, so it gets a scatter point. + + ``_draw_generic_surface`` is the only branch that reads the surface position + by index rather than by attribute, and it picks a different index per plane. + """ + surface = LinearGenericSurface( + reference_area=REFERENCE_AREA, + reference_length=REFERENCE_LENGTH, + coefficients={}, + name="Canard", + ) + calisto_robust.add_surfaces(surface, Vector([0, 0, -0.5])) + + assert calisto_robust.draw(plane=plane, filename=None) is None + + +@patch("matplotlib.pyplot.show") +def test_draw_a_rocket_with_a_hybrid_motor( # pylint: disable=unused-argument + mock_show, calisto_hybrid_modded, calisto_nose_cone +): + """The hybrid branch of ``_generate_motor_patches`` draws grains and tanks.""" + calisto_hybrid_modded.add_surfaces(calisto_nose_cone, 1.160) + + assert calisto_hybrid_modded.draw(filename=None) is None + + +@patch("matplotlib.pyplot.show") +def test_draw_a_rocket_with_a_liquid_motor( # pylint: disable=unused-argument + mock_show, calisto_liquid_modded, calisto_nose_cone +): + """The liquid branch draws positioned tanks and no combustion chamber.""" + calisto_liquid_modded.add_surfaces(calisto_nose_cone, 1.160) + + assert calisto_liquid_modded.draw(filename=None) is None + + +@patch("matplotlib.pyplot.show") +def test_draw_a_rocket_with_a_motor_cluster( # pylint: disable=unused-argument + mock_show, calisto_motorless, cesaroni_m1670, calisto_nose_cone +): + """A cluster repeats the grain patches around the ring. + + Only the first offset keeps its legend entry, so the loop that relabels the + rest runs solely when there is more than one motor. + """ + calisto_motorless.add_motor( + RingClusterMotor(motor=cesaroni_m1670, number=3, radius=0.05), + position=-1.373, + ) + calisto_motorless.add_surfaces(calisto_nose_cone, 1.160) + + assert calisto_motorless.draw(filename=None) is None + + +@patch("matplotlib.pyplot.show") +@pytest.mark.parametrize( + "rocket_fixture,nose_position", + [("calisto", 1.160), ("calisto_nose_to_tail", -1.160)], +) +def test_draw_a_rocket_whose_nozzle_sits_behind_its_last_surface( # pylint: disable=unused-argument + mock_show, request, rocket_fixture, nose_position, calisto_nose_cone +): + """``_draw_nozzle_tube`` only draws when the nozzle is past the last surface. + + A rocket carrying nothing but a nose cone leaves that gap open, and the + comparison flips with the coordinate system, so both orientations are drawn. + """ + rocket = request.getfixturevalue(rocket_fixture) + rocket.add_surfaces(calisto_nose_cone, nose_position) + + assert rocket.draw(filename=None) is None + + +def test_draw_refuses_a_rocket_with_no_aerodynamic_surfaces(calisto_motorless): + """There is nothing to draw the body around without at least one surface.""" + with pytest.raises(ValueError, match="at least one aerodynamic surface"): + calisto_motorless.draw(filename=None) + + +def test_draw_refuses_a_plane_it_cannot_project_onto(calisto_robust): + """Only the two longitudinal planes are defined.""" + with pytest.raises(ValueError, match="must be 'xz' or 'yz'"): + calisto_robust.draw(plane="xy", filename=None) diff --git a/tests/unit/simulation/test_monte_carlo_plots.py b/tests/unit/simulation/test_monte_carlo_plots.py new file mode 100644 index 000000000..6e049c0fb --- /dev/null +++ b/tests/unit/simulation/test_monte_carlo_plots.py @@ -0,0 +1,118 @@ +"""Unit tests for the ellipse plots in ``rocketpy.plots.monte_carlo_plots``. + +Background-map fetching is covered separately in +``test_monte_carlo_plots_background.py``. Everything here runs with +``background=None`` so no tile provider is contacted. +""" + +from unittest.mock import patch + +import numpy as np +import pytest + +from rocketpy.plots.monte_carlo_plots import _MonteCarloPlots +from rocketpy.simulation import MonteCarlo + +APOGEE = {"apogee_x": [100, 200, 300], "apogee_y": [100, 200, 300]} +IMPACT = {"x_impact": [1000, 2000, 3000], "y_impact": [1000, 2000, 3000]} + + +class MockMonteCarlo(MonteCarlo): + """A MonteCarlo carrying only the results the ellipse plots read.""" + + def __init__(self, results, filename="test"): + # pylint: disable=super-init-not-called + self.filename = filename + self.results = results + self.plots = _MonteCarloPlots(self) + + +@pytest.fixture(name="square_image") +def square_image_fixture(tmp_path): + """A small on-disk image for the ``image`` background path.""" + imageio = pytest.importorskip("imageio") + path = tmp_path / "launch_site.png" + imageio.imwrite(path, np.zeros((8, 8, 3), dtype=np.uint8)) + return str(path) + + +@patch("matplotlib.pyplot.show") +def test_ellipses_without_apogee_data_plots_the_impact_points(mock_show, caplog): # pylint: disable=unused-argument + """A results file may hold impacts and no apogees. + + The apogee lookup is allowed to miss; the method warns and draws what is + left rather than failing. + """ + monte_carlo = MockMonteCarlo(dict(IMPACT)) + + assert monte_carlo.plots.ellipses() is None + assert "No apogee data found" in caplog.text + + +@patch("matplotlib.pyplot.show") +def test_ellipses_without_impact_data_plots_the_apogee_points(mock_show, caplog): # pylint: disable=unused-argument + """The mirror case: apogees recorded, impacts missing.""" + monte_carlo = MockMonteCarlo(dict(APOGEE)) + + assert monte_carlo.plots.ellipses() is None + assert "No impact data found" in caplog.text + + +def test_ellipses_refuses_results_with_neither_apogee_nor_impact(): + """With both lookups missing there is nothing to draw an ellipse around.""" + monte_carlo = MockMonteCarlo({"t_final": [10, 11, 12]}) + + with pytest.raises(ValueError, match="No apogee or impact data found"): + monte_carlo.plots.ellipses() + + +def test_ellipses_reports_an_image_path_that_does_not_exist(tmp_path): + """The path is the user's input, so the failure names it rather than the read.""" + monte_carlo = MockMonteCarlo({**APOGEE, **IMPACT}) + + with pytest.raises(FileNotFoundError, match="image file was not found"): + monte_carlo.plots.ellipses(image=str(tmp_path / "absent.png")) + + +@patch("matplotlib.pyplot.show") +def test_ellipses_draws_over_an_image_and_marks_the_actual_landing_point( # pylint: disable=unused-argument + mock_show, square_image +): + """``image`` and ``actual_landing_point`` are separate optional branches.""" + monte_carlo = MockMonteCarlo({**APOGEE, **IMPACT}) + + assert ( + monte_carlo.plots.ellipses( + image=square_image, actual_landing_point=(1500, 1500) + ) + is None + ) + + +@patch("matplotlib.pyplot.show") +def test_ellipses_comparison_without_apogee_data(mock_show, caplog): # pylint: disable=unused-argument + """The comparison reads four series at once, so one miss drops all four.""" + monte_carlo = MockMonteCarlo(dict(IMPACT)) + other = MockMonteCarlo(dict(IMPACT), filename="other") + + assert monte_carlo.plots.ellipses_comparison(other) is None + assert "No apogee data found" in caplog.text + + +@patch("matplotlib.pyplot.show") +def test_ellipses_comparison_without_impact_data(mock_show, caplog): # pylint: disable=unused-argument + """The mirror case for the impact series.""" + monte_carlo = MockMonteCarlo(dict(APOGEE)) + other = MockMonteCarlo(dict(APOGEE), filename="other") + + assert monte_carlo.plots.ellipses_comparison(other) is None + assert "No impact data found" in caplog.text + + +@patch("matplotlib.pyplot.show") +def test_ellipses_comparison_draws_over_an_image(mock_show, square_image): # pylint: disable=unused-argument + """The comparison takes the same ``image`` branch as ``ellipses``.""" + monte_carlo = MockMonteCarlo({**APOGEE, **IMPACT}) + other = MockMonteCarlo({**APOGEE, **IMPACT}, filename="other") + + assert monte_carlo.plots.ellipses_comparison(other, image=square_image) is None