Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions tests/unit/rocket/test_rocket_plots.py
Original file line number Diff line number Diff line change
@@ -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)
118 changes: 118 additions & 0 deletions tests/unit/simulation/test_monte_carlo_plots.py
Original file line number Diff line number Diff line change
@@ -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
Loading