diff --git a/src/data_morph/bounds/bounding_box.py b/src/data_morph/bounds/bounding_box.py index 1fe77437..8aa4d8c2 100644 --- a/src/data_morph/bounds/bounding_box.py +++ b/src/data_morph/bounds/bounding_box.py @@ -170,25 +170,25 @@ def clone(self) -> BoundingBox: ) @property - def range(self) -> Iterable[Number]: + def range(self) -> tuple[Number, Number]: """ Calculate the range (width) of the bounding box in each direction. Returns ------- - Iterable[numbers.Number] + tuple[Number, Number] The range covered by the x and y bounds, respectively. """ return self.x_bounds.range, self.y_bounds.range @property - def center(self) -> Iterable[Number]: + def center(self) -> tuple[Number, Number]: """ Calculate the center of the bounding box. Returns ------- - Iterable[numbers.Number] + tuple[Number, Number] The center of the x and y bounds, respectively. """ return self.x_bounds.center, self.y_bounds.center diff --git a/src/data_morph/shapes/circles.py b/src/data_morph/shapes/circles.py deleted file mode 100644 index c251da1c..00000000 --- a/src/data_morph/shapes/circles.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Shapes that are circular in nature.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import matplotlib.pyplot as plt -import numpy as np - -from ..plotting.style import plot_with_custom_style -from .bases.shape import Shape - -if TYPE_CHECKING: - from numbers import Number - - from matplotlib.axes import Axes - - from ..data.dataset import Dataset - - -class Circle(Shape): - """ - Class representing a hollow circle. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.circles import Circle - - _ = Circle(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - radius : numbers.Number, optional - The radius of the circle. - """ - - def __init__(self, dataset: Dataset, radius: Number | None = None) -> None: - self.center: np.ndarray = dataset.data[['x', 'y']].mean().to_numpy() - """numpy.ndarray: The (x, y) coordinates of the circle's center.""" - - self.radius: Number = radius or dataset.data[['x', 'y']].std().mean() * 1.5 - """numbers.Number: The radius of the circle.""" - - def __repr__(self) -> str: - x, y = self.center - return f'<{self.__class__.__name__} center={(float(x), float(y))} radius={self.radius}>' - - def distance(self, x: Number, y: Number) -> float: - """ - Calculate the absolute distance between this circle's edge and a point (x, y). - - Parameters - ---------- - x, y : numbers.Number - Coordinates of a point in 2D space. - - Returns - ------- - float - The absolute distance between this circle's edge and the point (x, y). - """ - return abs( - self._euclidean_distance(self.center, np.array([x, y])) - self.radius - ) - - @plot_with_custom_style - def plot(self, ax: Axes | None = None) -> Axes: - """ - Plot the shape. - - Parameters - ---------- - ax : matplotlib.axes.Axes, optional - An optional :class:`~matplotlib.axes.Axes` object to plot on. - - Returns - ------- - matplotlib.axes.Axes - The :class:`~matplotlib.axes.Axes` object containing the plot. - """ - if not ax: - fig, ax = plt.subplots(layout='constrained') - fig.get_layout_engine().set(w_pad=0.2, h_pad=0.2) - _ = ax.axis('equal') - _ = ax.add_patch(plt.Circle(self.center, self.radius, ec='k', fill=False)) - _ = ax.autoscale() - return ax - - -class Rings(Shape): - """ - Class representing rings comprising multiple concentric circles. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.circles import Rings - - _ = Rings(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - num_rings : int, default ``4`` - The number of rings to include. Must be greater than 1. - - See Also - -------- - Circle : The individual rings are represented as circles. - """ - - def __init__(self, dataset: Dataset, num_rings: int = 4) -> None: - if not isinstance(num_rings, int): - raise TypeError('num_rings must be an integer') - if num_rings <= 1: - raise ValueError('num_rings must be greater than 1') - - stdev = dataset.data.std().mean() - self.circles: list[Circle] = [ - Circle(dataset, r) - for r in np.linspace(stdev / num_rings * 2, stdev * 2, num_rings) - ] - """list[Circle]: The individual rings represented by :class:`Circle` objects.""" - - self._centers = np.array([circle.center for circle in self.circles]) - self._radii = np.array([circle.radius for circle in self.circles]) - - def __repr__(self) -> str: - return self._recursive_repr('circles') - - def distance(self, x: Number, y: Number) -> float: - """ - Calculate the minimum absolute distance between any of this shape's - circles' edges and a point (x, y). - - Parameters - ---------- - x, y : numbers.Number - Coordinates of a point in 2D space. - - Returns - ------- - float - The minimum absolute distance between any of this shape's - circles' edges and the point (x, y). - - See Also - -------- - Circle.distance : - Rings consists of multiple circles, so we use the minimum - distance to one of the circles. - """ - point = np.array([x, y]) - return np.min( - np.abs(np.linalg.norm(self._centers - point, axis=1) - self._radii) - ) - - @plot_with_custom_style - def plot(self, ax: Axes | None = None) -> Axes: - """ - Plot the shape. - - Parameters - ---------- - ax : matplotlib.axes.Axes, optional - An optional :class:`~matplotlib.axes.Axes` object to plot on. - - Returns - ------- - matplotlib.axes.Axes - The :class:`~matplotlib.axes.Axes` object containing the plot. - """ - for circle in self.circles: - ax = circle.plot(ax) - return ax - - -class Bullseye(Rings): - """ - Class representing a bullseye shape comprising two concentric circles. - - .. plot:: - :scale: 75 - :caption: - This shape is generated using the panda dataset. - - from data_morph.data.loader import DataLoader - from data_morph.shapes.circles import Bullseye - - _ = Bullseye(DataLoader.load_dataset('panda')).plot() - - Parameters - ---------- - dataset : Dataset - The starting dataset to morph into other shapes. - - See Also - -------- - Rings : The Bullseye is a special case where we only have 2 rings. - """ - - def __init__(self, dataset: Dataset) -> None: - super().__init__(dataset=dataset, num_rings=2) diff --git a/src/data_morph/shapes/circles/__init__.py b/src/data_morph/shapes/circles/__init__.py new file mode 100644 index 00000000..7468cd8c --- /dev/null +++ b/src/data_morph/shapes/circles/__init__.py @@ -0,0 +1,11 @@ +"""Shapes made up of circles.""" + +from .bullseye import Bullseye +from .circle import Circle +from .rings import Rings + +__all__ = [ + 'Bullseye', + 'Circle', + 'Rings', +] diff --git a/src/data_morph/shapes/circles/bullseye.py b/src/data_morph/shapes/circles/bullseye.py new file mode 100644 index 00000000..b4fd6d09 --- /dev/null +++ b/src/data_morph/shapes/circles/bullseye.py @@ -0,0 +1,50 @@ +"""Bullseye shape.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from .rings import Rings + +if TYPE_CHECKING: + from ..data.dataset import Dataset + + +class Bullseye(Rings): + """ + Class representing a bullseye shape comprising two concentric circles. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.circles import Bullseye + + _ = Bullseye(DataLoader.load_dataset('panda')).plot() + + See Also + -------- + Circle : The individual rings are represented as circles. + """ + + @staticmethod + def _derive_radii(dataset: Dataset) -> np.ndarray: + """ + Derive the radii for the circles in the bullseye. + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into. + + Returns + ------- + np.ndarray + The radii for the circles in the bullseye. + """ + stdev = dataset.data[['x', 'y']].std().mean() * 1.5 + return np.linspace(stdev, 0, 2, endpoint=False) diff --git a/src/data_morph/shapes/circles/circle.py b/src/data_morph/shapes/circles/circle.py new file mode 100644 index 00000000..1aee9e2a --- /dev/null +++ b/src/data_morph/shapes/circles/circle.py @@ -0,0 +1,93 @@ +"""Circle shape.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +import numpy as np + +from ...plotting.style import plot_with_custom_style +from ..bases.shape import Shape + +if TYPE_CHECKING: + from numbers import Number + + from matplotlib.axes import Axes + + from ..data.dataset import Dataset + + +class Circle(Shape): + """ + Class representing a hollow circle. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.circles import Circle + + _ = Circle(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + radius : numbers.Number, optional + The radius of the circle. + """ + + def __init__(self, dataset: Dataset, radius: Number | None = None) -> None: + self.center: tuple[Number, Number] = dataset.data_bounds.center + """The (x, y) coordinates of the circle's center.""" + + self.radius: Number = radius or dataset.data[['x', 'y']].std().mean() * 1.5 + """The radius of the circle.""" + + def __repr__(self) -> str: + x, y = self.center + return f'<{self.__class__.__name__} center={(float(x), float(y))} radius={self.radius}>' + + def distance(self, x: Number, y: Number) -> float: + """ + Calculate the absolute distance between this circle's edge and a point (x, y). + + Parameters + ---------- + x, y : numbers.Number + Coordinates of a point in 2D space. + + Returns + ------- + float + The absolute distance between this circle's edge and the point (x, y). + """ + return abs( + self._euclidean_distance(self.center, np.array([x, y])) - self.radius + ) + + @plot_with_custom_style + def plot(self, ax: Axes | None = None) -> Axes: + """ + Plot the shape. + + Parameters + ---------- + ax : matplotlib.axes.Axes, optional + An optional :class:`~matplotlib.axes.Axes` object to plot on. + + Returns + ------- + matplotlib.axes.Axes + The :class:`~matplotlib.axes.Axes` object containing the plot. + """ + if not ax: + fig, ax = plt.subplots(layout='constrained') + fig.get_layout_engine().set(w_pad=0.2, h_pad=0.2) + _ = ax.axis('equal') + _ = ax.add_patch(plt.Circle(self.center, self.radius, ec='k', fill=False)) + _ = ax.autoscale() + return ax diff --git a/src/data_morph/shapes/circles/rings.py b/src/data_morph/shapes/circles/rings.py new file mode 100644 index 00000000..bf571c20 --- /dev/null +++ b/src/data_morph/shapes/circles/rings.py @@ -0,0 +1,113 @@ +"""Rings shape.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from ...plotting.style import plot_with_custom_style +from ..bases.shape import Shape +from .circle import Circle + +if TYPE_CHECKING: + from numbers import Number + + from matplotlib.axes import Axes + + from ..data.dataset import Dataset + + +class Rings(Shape): + """ + Class representing rings comprising three concentric circles. + + .. plot:: + :scale: 75 + :caption: + This shape is generated using the panda dataset. + + from data_morph.data.loader import DataLoader + from data_morph.shapes.circles import Rings + + _ = Rings(DataLoader.load_dataset('panda')).plot() + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into other shapes. + + See Also + -------- + Circle : The individual rings are represented as circles. + """ + + def __init__(self, dataset: Dataset) -> None: + self.circles: list[Circle] = [ + Circle(dataset, radius) for radius in self._derive_radii(dataset) + ] + """The individual rings represented by :class:`Circle` objects.""" + + self._centers = np.array([circle.center for circle in self.circles]) + self._radii = np.array([circle.radius for circle in self.circles]) + + def __repr__(self) -> str: + return self._recursive_repr('circles') + + @staticmethod + def _derive_radii(dataset: Dataset) -> np.ndarray: + """ + Derive the radii for the circles in the rings. + + Parameters + ---------- + dataset : Dataset + The starting dataset to morph into. + + Returns + ------- + np.ndarray + The radii for the circles in the rings. + """ + stdev = (min(dataset.data_bounds.range) + min(dataset.morph_bounds.range)) / 4 + return np.linspace(stdev, 0, 3, endpoint=False) + + def distance(self, x: Number, y: Number) -> float: + """ + Calculate the minimum absolute distance between any of this shape's + circles' edges and a point (x, y). + + Parameters + ---------- + x, y : numbers.Number + Coordinates of a point in 2D space. + + Returns + ------- + float + The minimum absolute distance between any of this shape's + circles' edges and the point (x, y). + """ + point = np.array([x, y]) + return np.min( + np.abs(np.linalg.norm(self._centers - point, axis=1) - self._radii) + ) + + @plot_with_custom_style + def plot(self, ax: Axes | None = None) -> Axes: + """ + Plot the shape. + + Parameters + ---------- + ax : matplotlib.axes.Axes, optional + An optional :class:`~matplotlib.axes.Axes` object to plot on. + + Returns + ------- + matplotlib.axes.Axes + The :class:`~matplotlib.axes.Axes` object containing the plot. + """ + for circle in self.circles: + ax = circle.plot(ax) + return ax diff --git a/tests/shapes/circles/bases.py b/tests/shapes/circles/bases.py index ca1912af..ae8210fd 100644 --- a/tests/shapes/circles/bases.py +++ b/tests/shapes/circles/bases.py @@ -5,8 +5,12 @@ import re from typing import TYPE_CHECKING +import matplotlib.pyplot as plt +import numpy as np import pytest +from data_morph.shapes.circles import Circle + if TYPE_CHECKING: from numbers import Number @@ -30,8 +34,38 @@ def test_distance(self, shape, test_point, expected_distance): Test the distance() method parametrized by distance_test_cases (see conftest.py). """ - assert pytest.approx(shape.distance(*test_point)) == expected_distance + actual_distance = shape.distance(*test_point) + assert pytest.approx(actual_distance) == expected_distance def test_repr(self, shape): """Test that the __repr__() method is working.""" assert re.match(self.repr_regex, repr(shape)) is not None + + @pytest.mark.parametrize('ax', [None, plt.subplots()[1]]) + def test_plot(self, shape, ax): + """Test that the plot() method is working.""" + if ax: + ax.clear() + + plot_ax = shape.plot(ax) + if ax: + assert plot_ax is ax + else: + assert plot_ax is not ax + + plotted_circles = plot_ax.patches + plotted_centers = {plotted_circle._center for plotted_circle in plotted_circles} + plotted_radii = { + plotted_circle._width / 2 for plotted_circle in plotted_circles + } + + if isinstance(shape, Circle): + assert len(plotted_circles) == 1 + assert plotted_centers == {shape.center} + assert plotted_radii == {shape.radius} + else: + assert len(plotted_circles) == len(shape.circles) + assert plotted_centers == {tuple(np.unique(shape._centers))} + assert plotted_radii.difference(shape._radii) == set() + + plt.close() diff --git a/tests/shapes/circles/test_bullseye.py b/tests/shapes/circles/test_bullseye.py index 3bd0d239..0b5053de 100644 --- a/tests/shapes/circles/test_bullseye.py +++ b/tests/shapes/circles/test_bullseye.py @@ -12,7 +12,44 @@ class TestBullseye(CirclesModuleTestBase): """Test the Bullseye class.""" shape_name = 'bullseye' - distance_test_cases = (((20, 50), 3.660254), ((10, 25), 9.08004)) + center_x, center_y = (20, 65) + inner_radius, outer_radius = (10.24519052838329, 20.49038105676658) + mid_radius = (outer_radius + inner_radius) / 2 + distance_test_cases = ( + ((center_x, center_y + outer_radius), 0), # north on outer ring + ((center_x, center_y + inner_radius), 0), # north on inner ring + ((center_x, center_y - outer_radius), 0), # south on outer ring + ((center_x, center_y - inner_radius), 0), # south on inner ring + ((center_x + outer_radius, center_y), 0), # east on outer ring + ((center_x + inner_radius, center_y), 0), # east on inner ring + ((center_x - outer_radius, center_y), 0), # west on outer ring + ((center_x - inner_radius, center_y), 0), # west on inner ring + ((center_x, center_y), inner_radius), # center of bullseye + ( + (center_x, center_y + mid_radius), + inner_radius / 2, + ), # between the circles (north) + ( + (center_x, center_y - mid_radius), + inner_radius / 2, + ), # between the circles (south) + ( + (center_x + mid_radius, center_y), + inner_radius / 2, + ), # between the circles (east) + ( + (center_x - mid_radius, center_y), + inner_radius / 2, + ), # between the circles (west) + ( + (center_x, center_y + outer_radius * 2), + outer_radius, + ), # north of both circles + ( + (center_x - outer_radius * 1.5, center_y), + inner_radius, + ), # west of both circles + ) repr_regex = ( r'^\n' r' circles=\n' diff --git a/tests/shapes/circles/test_circle.py b/tests/shapes/circles/test_circle.py index 7e1372a8..2daa251d 100644 --- a/tests/shapes/circles/test_circle.py +++ b/tests/shapes/circles/test_circle.py @@ -12,7 +12,17 @@ class TestCircle(CirclesModuleTestBase): """Test the Circle class.""" shape_name = 'circle' - distance_test_cases = (((20, 50), 10.490381), ((10, 25), 15.910168)) + center_x, center_y = (20, 65) + radius = 20.49038105676658 + distance_test_cases = ( + ((center_x, center_y + radius), 0), # north + ((center_x, center_y - radius), 0), # south + ((center_x + radius, center_y), 0), # east + ((center_x - radius, center_y), 0), # west + ((center_x, center_y), radius), # center of circle + ((10, 25), 20.740675199410028), # inside the circle + ((-20, 0), 55.831306555602154), # outside the circle + ) repr_regex = '^' + CIRCLE_REPR + '$' def test_is_circle(self, shape): diff --git a/tests/shapes/circles/test_rings.py b/tests/shapes/circles/test_rings.py index 5ef0776c..502a9901 100644 --- a/tests/shapes/circles/test_rings.py +++ b/tests/shapes/circles/test_rings.py @@ -12,34 +12,74 @@ class TestRings(CirclesModuleTestBase): """Test the Rings class.""" shape_name = 'rings' - distance_test_cases = (((20, 50), 3.16987), ((10, 25), 9.08004)) + center_x, center_y = (20, 65) + radii = (3.666666666666667, 7.333333333333334, 11) + mid_radii = (sum(radii[:2]) / 2, sum(radii[1:]) / 2) + distance_test_cases = ( + ((center_x, center_y + radii[0]), 0), # north on inner ring + ((center_x, center_y + radii[1]), 0), # north on middle ring + ((center_x, center_y + radii[2]), 0), # north on outer ring + ((center_x, center_y - radii[0]), 0), # south on inner ring + ((center_x, center_y - radii[1]), 0), # south on middle ring + ((center_x, center_y - radii[2]), 0), # south on outer ring + ((center_x + radii[0], center_y), 0), # east on inner ring + ((center_x + radii[1], center_y), 0), # east on middle ring + ((center_x + radii[2], center_y), 0), # east on outer ring + ((center_x - radii[0], center_y), 0), # west on inner ring + ((center_x - radii[1], center_y), 0), # west on middle ring + ((center_x - radii[2], center_y), 0), # west on outer ring + ((center_x, center_y), radii[0]), # center of all rings + ( + (center_x, center_y + mid_radii[0]), + radii[0] / 2, + ), # between the inner circles (north) + ( + (center_x, center_y - mid_radii[0]), + radii[0] / 2, + ), # between the inner circles (south) + ( + (center_x + mid_radii[0], center_y), + radii[0] / 2, + ), # between the inner circles (east) + ( + (center_x - mid_radii[0], center_y), + radii[0] / 2, + ), # between the inner circles (west) + ( + (center_x, center_y + mid_radii[1]), + radii[0] / 2, + ), # between the outer circles (north) + ( + (center_x, center_y - mid_radii[1]), + radii[0] / 2, + ), # between the outer circles (south) + ( + (center_x + mid_radii[1], center_y), + radii[0] / 2, + ), # between the outer circles (east) + ( + (center_x - mid_radii[1], center_y), + radii[0] / 2, + ), # between the outer circles (west) + ((center_x, center_y + radii[2] * 2), radii[2]), # north of all circles + ((center_x - radii[2] * 1.5, center_y), radii[2] / 2), # west of all circles + ) repr_regex = ( r'^\n' r' circles=\n' r' ' + CIRCLE_REPR + '\n' r' ' + CIRCLE_REPR + '\n' - r' ' + CIRCLE_REPR + '\n' r' ' + CIRCLE_REPR + '$' ) - @pytest.mark.parametrize('num_rings', [3, 5]) - def test_init(self, shape_factory, num_rings): - """Test that the Rings contains multiple concentric circles.""" - shape = shape_factory.generate_shape(self.shape_name, num_rings=num_rings) + def test_init(self, shape_factory): + """Test that the Rings contains three concentric circles.""" + shape = shape_factory.generate_shape(self.shape_name) + num_rings = 3 assert len(shape.circles) == num_rings assert all( np.array_equal(circle.center, shape.circles[0].center) for circle in shape.circles[1:] ) assert len({circle.radius for circle in shape.circles}) == num_rings - - @pytest.mark.parametrize('num_rings', ['3', -5, 1, True]) - def test_num_rings_is_valid(self, shape_factory, num_rings): - """Test that num_rings input validation is working.""" - if isinstance(num_rings, int): - with pytest.raises(ValueError, match='num_rings must be greater than 1'): - _ = shape_factory.generate_shape(self.shape_name, num_rings=num_rings) - else: - with pytest.raises(TypeError, match='num_rings must be an integer'): - _ = shape_factory.generate_shape(self.shape_name, num_rings=num_rings)