From a446db2439ea5e040563f45576840d7121e05a2c Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 18 Jan 2024 14:58:50 -0500 Subject: [PATCH 1/3] Improved pytests for shape families --- coxeter/families/plane_shape_families.py | 5 +- tests/test_shape_families.py | 153 ++++++++++++++++------- 2 files changed, 112 insertions(+), 46 deletions(-) diff --git a/coxeter/families/plane_shape_families.py b/coxeter/families/plane_shape_families.py index 965f105f..b7b939cf 100644 --- a/coxeter/families/plane_shape_families.py +++ b/coxeter/families/plane_shape_families.py @@ -30,7 +30,10 @@ class TruncationPlaneShapeFamily(ShapeFamily): - :math:`c` See :cite:`Chen2014` for descriptions of these parameters. The bounds of - each parameter are set by the subclasses. + each parameter are set by the subclasses. Note that values of a, b, and c within + ~1e-6 of the minimum or maximum values for that parameter can result in nonconvex + shapes due to floating point imprecision. Setting the parameter to an exact value + (e.g. :math:`c`=3) for `Family423` will avoid these errors. """ # Documentation for developers: diff --git a/tests/test_shape_families.py b/tests/test_shape_families.py index bae11230..64918988 100644 --- a/tests/test_shape_families.py +++ b/tests/test_shape_families.py @@ -3,9 +3,13 @@ # This software is licensed under the BSD 3-Clause License. import numpy as np import pytest +from hypothesis import given, settings +from hypothesis.strategies import floats +from conftest import _catalan_shape_names from coxeter.families import ( DOI_SHAPE_REPOSITORIES, + CatalanFamily, Family323Plus, Family423, Family523, @@ -13,6 +17,21 @@ TruncatedTetrahedronFamily, ) +MIN_REALISTIC_PRECISION = 2e-6 + + +def _test_parameters_outside_precision(params_list): + """Certain input values for plane shape families can result in untriangulable + shapes due to numeric imprecision. Filtering input values can avoid these + unlikely cases. Note that integer inputs or inputs exactly on a boundary will + pass, but points very close to these will not.""" + + def is_close_to_shape_space_boundary(param): + nearest_multiple = round(param / 0.25) * 0.25 + return abs(param - nearest_multiple) <= MIN_REALISTIC_PRECISION + + return any(is_close_to_shape_space_boundary(param) for param in params_list) + @pytest.mark.parametrize("n", range(3, 100)) def test_regular_ngon(n): @@ -33,73 +52,117 @@ def test_regular_ngon(n): def test_shape_repos(): family = DOI_SHAPE_REPOSITORIES["10.1126/science.1220869"][0] - for key in family.data: - if family.data[key]["name"] == "Cube": - break - else: - raise AssertionError("Could not find a cube in the dataset.") - - cube = family.get_shape(key) - assert len(cube.vertices) == 8 - assert len(cube.faces) == 6 + for shape in _catalan_shape_names: + for key in family.data: + if family.data[key]["name"] == shape: + break + else: + raise AssertionError(f"Could not find {shape} in the dataset.") + reference_poly = CatalanFamily.get_shape(shape) + test_poly = family.get_shape(key) + test_poly.merge_faces(1e-3) + assert reference_poly.num_vertices == test_poly.num_vertices + assert reference_poly.num_faces == test_poly.num_faces def test_shape323(): family = Family323Plus - # Octahedron (6) - assert len(family.get_shape(1, 1).vertices) == 6 - assert len(family.get_shape(1, 1).faces) == 8 - # Tetrahedron (4) - assert len(family.get_shape(1, 3).vertices) == 4 - assert len(family.get_shape(1, 3).faces) == 4 - # Tetrahedron (4) - assert len(family.get_shape(3, 1).vertices) == 4 - assert len(family.get_shape(3, 1).faces) == 4 - # Cube (8) - assert len(family.get_shape(3, 3).vertices) == 8 - assert len(family.get_shape(3, 3).faces) == 6 + # Octahedron (6, 8) + assert len(family.get_shape(1.0, 1.0).vertices) == 6 + assert len(family.get_shape(1.0, 1.0).faces) == 8 + # Tetrahedron (4, 4) + assert len(family.get_shape(1.0, 3.0).vertices) == 4 + assert len(family.get_shape(1.0, 3.0).faces) == 4 + # Tetrahedron (4, 4) + assert len(family.get_shape(3.0, 1.0).vertices) == 4 + assert len(family.get_shape(3.0, 1.0).faces) == 4 + # Cube (8, 6) + assert len(family.get_shape(3.0, 3.0).vertices) == 8 + assert len(family.get_shape(3.0, 3.0).faces) == 6 + + +@settings(max_examples=1000) +@given(a=floats(1, 3), c=floats(1, 3)) +def test_shape323_intermediates(a, c): + if _test_parameters_outside_precision([a, c]): + return + Family323Plus.get_shape(a, c) def test_shape423(): family = Family423 - # Cuboctahedron (12) - assert len(family.get_shape(1, 2).vertices) == 12 - assert len(family.get_shape(1, 2).faces) == 14 - # Octahedron (6) - assert len(family.get_shape(2, 2).vertices) == 6 - assert len(family.get_shape(2, 2).faces) == 8 - # Cube (8) - assert len(family.get_shape(1, 3).vertices) == 8 - assert len(family.get_shape(1, 3).faces) == 6 - # Rhombic Dodecahedron (14) - assert len(family.get_shape(2, 3).vertices) == 14 - assert len(family.get_shape(2, 3).faces) == 12 + # Cuboctahedron (12, 14) + assert len(family.get_shape(1.0, 2.0).vertices) == 12 + assert len(family.get_shape(1.0, 2.0).faces) == 14 + # Octahedron (6, 8) + assert len(family.get_shape(2.0, 2.0).vertices) == 6 + assert len(family.get_shape(2.0, 2.0).faces) == 8 + # Cube (8, 6) + assert len(family.get_shape(1.0, 3.0).vertices) == 8 + assert len(family.get_shape(1.0, 3.0).faces) == 6 + # Rhombic Dodecahedron (14, 12) + assert len(family.get_shape(2.0, 3.0).vertices) == 14 + assert len(family.get_shape(2.0, 3.0).faces) == 12 + + +@settings(max_examples=1000) +@given(a=floats(1, 2), c=floats(2, 3)) +def test_shape423_intermediates(a, c): + if _test_parameters_outside_precision([a, c]): + return + Family423.get_shape(a, c) def test_shape523(): family = Family523 s = family.s - # Icosidodecahedron - assert len(family.get_shape(1, family.S**2).vertices) == 30 - assert len(family.get_shape(1, family.S**2).faces) == 32 - # Icosahedron + + # s is the inverse golden ratio. Check we have the correct value + assert np.isclose(s, (np.sqrt(5) - 1) / 2) + + # Icosidodecahedron (30, 32) + assert len(family.get_shape(1.0, family.S**2).vertices) == 30 + assert len(family.get_shape(1.0, family.S**2).faces) == 32 + # Icosahedron (12, 20) assert len(family.get_shape(1 * s * np.sqrt(5), family.S**2).vertices) == 12 assert len(family.get_shape(1 * s * np.sqrt(5), family.S**2).faces) == 20 - # Dodecahedron - assert len(family.get_shape(1, 3).vertices) == 20 - assert len(family.get_shape(1, 3).faces) == 12 - # Rhombic Triacontahedron - assert len(family.get_shape(1 * s * np.sqrt(5), 3).vertices) == 32 - assert len(family.get_shape(1 * s * np.sqrt(5), 3).faces) == 30 + # Dodecahedron (20, 12) + assert len(family.get_shape(1.0, 3.0).vertices) == 20 + assert len(family.get_shape(1.0, 3.0).faces) == 12 + # Rhombic Triacontahedron (32, 30) + assert len(family.get_shape(1 * s * np.sqrt(5), 3.0).vertices) == 32 + assert len(family.get_shape(1 * s * np.sqrt(5), 3.0).faces) == 30 + + +@settings(max_examples=1000) +@given(a=floats(1, Family523.s * np.sqrt(5)), c=floats(Family523.S**2, 3)) +def test_shape523_intermediates(a, c): + if ( + _test_parameters_outside_precision([a, c]) + or c % Family523.S**2 < MIN_REALISTIC_PRECISION + or c % Family523.S**2 < (1 - MIN_REALISTIC_PRECISION) + ): + return + Family523.get_shape(a, c) def test_truncated_tetrahedron(): family = TruncatedTetrahedronFamily # Test the endpoints (tetrahedron or octahedron). - tet = family.get_shape(0) + # Tetrahedron (4, 4) + tet = family.get_shape(0.0) assert len(tet.vertices) == 4 assert len(tet.faces) == 4 - tet = family.get_shape(1) + # Octahedron (6, 8) + tet = family.get_shape(1.0) assert len(tet.vertices) == 6 assert len(tet.faces) == 8 + + +@settings(max_examples=1000) +@given(t=floats(0, 1)) +def test_truncated_tetrahedron_intermediates(t): + if _test_parameters_outside_precision([t]) or np.abs(np.round(t, 15) - t) < 2e-16: + return + TruncatedTetrahedronFamily.get_shape(t) From 0a337ab35460f4db70a4d56ce1d4b391a9af1c84 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 18 Jan 2024 15:10:08 -0500 Subject: [PATCH 2/3] Updated credits --- Credits.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/Credits.rst b/Credits.rst index 530e8159..21405c42 100644 --- a/Credits.rst +++ b/Credits.rst @@ -110,6 +110,7 @@ Jen Bradley * Fixed error where ``__repr__`` would fail for polyhedra with multiple face types. * Increased accuracy of stored data for PlatonicFamily solids * Added shape families for Archimedean, Catalan, and Johnson solids. +* Expanded on tests for shape families * Added shape family for prisms and antiprisms. * Added shape family for equilateral pyramids and dipyramids. * Added edges, edge_vectors, edge_lengths, and num_edges methods. From 73efbae5ed1b5fc67b6ddff6de31e175d86c3438 Mon Sep 17 00:00:00 2001 From: janbridley Date: Thu, 18 Jan 2024 17:27:30 -0500 Subject: [PATCH 3/3] Decreased hypothesis example count --- tests/test_shape_families.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_shape_families.py b/tests/test_shape_families.py index 64918988..43ccdabd 100644 --- a/tests/test_shape_families.py +++ b/tests/test_shape_families.py @@ -3,7 +3,7 @@ # This software is licensed under the BSD 3-Clause License. import numpy as np import pytest -from hypothesis import given, settings +from hypothesis import given from hypothesis.strategies import floats from conftest import _catalan_shape_names @@ -81,7 +81,6 @@ def test_shape323(): assert len(family.get_shape(3.0, 3.0).faces) == 6 -@settings(max_examples=1000) @given(a=floats(1, 3), c=floats(1, 3)) def test_shape323_intermediates(a, c): if _test_parameters_outside_precision([a, c]): @@ -105,7 +104,6 @@ def test_shape423(): assert len(family.get_shape(2.0, 3.0).faces) == 12 -@settings(max_examples=1000) @given(a=floats(1, 2), c=floats(2, 3)) def test_shape423_intermediates(a, c): if _test_parameters_outside_precision([a, c]): @@ -134,7 +132,6 @@ def test_shape523(): assert len(family.get_shape(1 * s * np.sqrt(5), 3.0).faces) == 30 -@settings(max_examples=1000) @given(a=floats(1, Family523.s * np.sqrt(5)), c=floats(Family523.S**2, 3)) def test_shape523_intermediates(a, c): if ( @@ -160,7 +157,6 @@ def test_truncated_tetrahedron(): assert len(tet.faces) == 8 -@settings(max_examples=1000) @given(t=floats(0, 1)) def test_truncated_tetrahedron_intermediates(t): if _test_parameters_outside_precision([t]) or np.abs(np.round(t, 15) - t) < 2e-16: