diff --git a/ChangeLog.rst b/ChangeLog.rst index d2e70122..a83f6055 100644 --- a/ChangeLog.rst +++ b/ChangeLog.rst @@ -13,6 +13,7 @@ Added - ``simplices``, ``equations``, and ``face_centroids`` properties for the ConvexPolyhedron class. - Additional pytests for surface area, volume, centroid, moment of inertia, and equations properties. +- Added ``to_hoomd`` export method for use with simulation tools Changed ~~~~~~~ diff --git a/Credits.rst b/Credits.rst index 476fb38b..7a72c4b1 100644 --- a/Credits.rst +++ b/Credits.rst @@ -123,6 +123,7 @@ Jen Bradley * Added ``simplices``, ``equations``, and ``face_centroids`` properties to the ConvexPolyhedron class. * Optimized pytest configurations for more efficient use of local and remote resources. +* Added ``to_json`` and ``to_hoomd`` export methods. Domagoj Fijan diff --git a/coxeter/shapes/base_classes.py b/coxeter/shapes/base_classes.py index e66506d2..03c60a49 100644 --- a/coxeter/shapes/base_classes.py +++ b/coxeter/shapes/base_classes.py @@ -197,6 +197,30 @@ def to_plato_scene(self, backend="matplotlib", scene=None, scene_kwargs=None): scene.add_primitive(prim) return scene + def to_json(self, attributes: list): + """Get a JSON-serializable subset of shape properties. + + Args: + attributes (list): + List of attributes to export. Each element must be a valid attribute + of the class. + + Returns + ------- + dict + A dict containing the requested attributes. + + Raises + ------ + AttributeError: + If any keys in the input list are invalid. + """ + export = {} + for attribute in attributes: + # If an invalid key is passed, this will raise an attribute error + export.update({attribute: getattr(self, attribute)}) + return export + class Shape2D(Shape): """An abstract representation of a shape in 2 dimensions.""" diff --git a/coxeter/shapes/convex_spheropolygon.py b/coxeter/shapes/convex_spheropolygon.py index 7de3ec0c..35652fa3 100644 --- a/coxeter/shapes/convex_spheropolygon.py +++ b/coxeter/shapes/convex_spheropolygon.py @@ -12,6 +12,7 @@ from .base_classes import Shape2D from .convex_polygon import ConvexPolygon, _is_convex from .polygon import _align_points_by_normal +from .utils import _hoomd_dict_mapping, _map_dict_keys class ConvexSpheropolygon(Shape2D): @@ -267,3 +268,37 @@ def _plato_primitive(self, backend): vertices=verts[:, :2], radius=self.radius, ) + + def to_hoomd(self): + """Get a JSON-serializable subset of ConvexSpheropolygon properties. + + The JSON-serializable output of the to_hoomd method can be directly imported + into data management tools like signac. This data can then be queried for use in + HOOMD simulations. Key naming matches HOOMD integrators: for example, the + moment_inertia key links to data from coxeter's inertia_tensor. Stored values + are based on the shape with its centroid at the origin. + + For a ConvexSpheropolygon, the following properties are stored: + + * vertices (list(list)): + The vertices of the shape. + * centroid (list(float)) + The centroid of the shape. + This is set to [0,0,0] per HOOMD's spec. + * sweep_radius (float): + The rounding radius of the shape. + * area (float) + The area of the shape. + + Returns + ------- + dict + Dict containing a subset of shape properties required for HOOMD function. + """ + old_centroid = self._polygon.centroid + data = self.to_json(["vertices", "radius", "area"]) + hoomd_dict = _map_dict_keys(data, key_mapping=_hoomd_dict_mapping) + hoomd_dict["centroid"] = [0, 0, 0] + + self._polygon.centroid = old_centroid + return hoomd_dict diff --git a/coxeter/shapes/convex_spheropolyhedron.py b/coxeter/shapes/convex_spheropolyhedron.py index eab11733..56a52cea 100644 --- a/coxeter/shapes/convex_spheropolyhedron.py +++ b/coxeter/shapes/convex_spheropolyhedron.py @@ -11,6 +11,7 @@ from .base_classes import Shape3D from .convex_polyhedron import ConvexPolyhedron +from .utils import _hoomd_dict_mapping, _map_dict_keys class ConvexSpheropolyhedron(Shape3D): @@ -328,3 +329,38 @@ def _plato_primitive(self, backend): vertices=self.vertices, radius=self.radius, ) + + def to_hoomd(self): + """Get a JSON-serializable subset of ConvexSpheropolyhedron properties. + + The JSON-serializable output of the to_hoomd method can be directly imported + into data management tools like signac. This data can then be queried for use in + HOOMD simulations. Key naming matches HOOMD integrators: for example, the + moment_inertia key links to data from coxeter's inertia_tensor. Stored values + are based on the shape with its centroid at the origin. + + For a ConvexSpheropolyhedron, the following properties are stored: + + * vertices (list(list)): + The vertices of the shape. + * centroid (list(float)) + The centroid of the shape. + This is set to [0,0,0] per HOOMD's spec. + * sweep_radius (float): + The rounding radius of the shape. + * volume (float) + The volume of the shape. + + Returns + ------- + dict + Dict containing a subset of shape properties required for HOOMD function. + """ + old_centroid = self._polyhedron.centroid + self._polyhedron.centroid = np.array([0, 0, 0]) + data = self.to_json(["vertices", "radius", "volume"]) + hoomd_dict = _map_dict_keys(data, key_mapping=_hoomd_dict_mapping) + hoomd_dict["centroid"] = [0, 0, 0] + + self._polyhedron.centroid = old_centroid + return hoomd_dict diff --git a/coxeter/shapes/ellipsoid.py b/coxeter/shapes/ellipsoid.py index 18799e52..37ace1dc 100644 --- a/coxeter/shapes/ellipsoid.py +++ b/coxeter/shapes/ellipsoid.py @@ -8,7 +8,7 @@ from .base_classes import Shape3D from .sphere import Sphere -from .utils import translate_inertia_tensor +from .utils import _hoomd_dict_mapping, _map_dict_keys, translate_inertia_tensor class Ellipsoid(Shape3D): @@ -225,3 +225,41 @@ def __repr__(self): f"coxeter.shapes.Ellipsoid(a={self.a}, b={self.b}, c={self.c}, " f"center={self.centroid.tolist()})" ) + + def to_hoomd(self): + """Get a JSON-serializable subset of Ellipsoid properties. + + The JSON-serializable output of the to_hoomd method can be directly imported + into data management tools like signac. This data can then be queried for use in + HOOMD simulations. Key naming matches HOOMD integrators: for example, the + moment_inertia key links to data from coxeter's inertia_tensor. Stored values + are based on the shape with its centroid at the origin. + + For an Ellipsoid, the following properties are stored: + + * a (float): + half axis of ellipsoid in the x direction + * b (float): + half axis of ellipsoid in the y direction + * c (float): + half axis of ellipsoid in the z direction + * centroid (list(float)) + The centroid of the shape. + This is set to [0,0,0] per HOOMD's spec. + * volume (float) + The volume of the shape. + * moment_inertia (list(list)) + The shape's inertia tensor. + + Returns + ------- + dict + Dict containing a subset of shape properties required for HOOMD function. + """ + old_centroid = self.centroid + self.centroid = np.array([0, 0, 0]) + data = self.to_json(["a", "b", "c", "centroid", "volume", "inertia_tensor"]) + hoomd_dict = _map_dict_keys(data, key_mapping=_hoomd_dict_mapping) + + self.centroid = old_centroid + return hoomd_dict diff --git a/coxeter/shapes/polygon.py b/coxeter/shapes/polygon.py index 0e16f759..3f0a32d9 100644 --- a/coxeter/shapes/polygon.py +++ b/coxeter/shapes/polygon.py @@ -12,7 +12,13 @@ from ..extern.polytri import polytri from .base_classes import Shape2D from .circle import Circle -from .utils import _generate_ax, rotate_order2_tensor, translate_inertia_tensor +from .utils import ( + _generate_ax, + _hoomd_dict_mapping, + _map_dict_keys, + rotate_order2_tensor, + translate_inertia_tensor, +) try: import miniball @@ -761,3 +767,40 @@ def _plato_primitive(self, backend): colors=np.array([[0.5, 0.5, 0.5, 1]]), vertices=verts[:, :2], ) + + def to_hoomd(self): + """Get a JSON-serializable subset of Polygon properties. + + The JSON-serializable output of the to_hoomd method can be directly imported + into data management tools like signac. This data can then be queried for use in + HOOMD simulations. Key naming matches HOOMD integrators: for example, the + moment_inertia key links to data from coxeter's inertia_tensor. Stored values + are based on the shape with its centroid at the origin. + + For a Polygon or ConvexPolygon, the following properties are stored: + + * vertices (list(list)): + The vertices of the shape. + * centroid (list(float)) + The centroid of the shape. + This is set to [0,0,0] per HOOMD's spec. + * sweep_radius (float): + The rounding radius of the shape (0.0). + * area (float) + The area of the shape. + * moment_inertia (list(list)) + The shape's inertia tensor. + + Returns + ------- + dict + Dict containing a subset of shape properties required for HOOMD function. + """ + old_centroid = self.centroid + self.centroid = np.array([0, 0, 0]) + data = self.to_json(["vertices", "centroid", "area", "inertia_tensor"]) + hoomd_dict = _map_dict_keys(data, key_mapping=_hoomd_dict_mapping) + hoomd_dict["sweep_radius"] = 0.0 + + self.centroid = old_centroid + return hoomd_dict diff --git a/coxeter/shapes/polyhedron.py b/coxeter/shapes/polyhedron.py index 155fae7b..6e817aad 100644 --- a/coxeter/shapes/polyhedron.py +++ b/coxeter/shapes/polyhedron.py @@ -15,7 +15,13 @@ from .convex_polygon import ConvexPolygon, _is_convex from .polygon import Polygon, _is_simple from .sphere import Sphere -from .utils import _generate_ax, _set_3d_axes_equal, translate_inertia_tensor +from .utils import ( + _generate_ax, + _hoomd_dict_mapping, + _map_dict_keys, + _set_3d_axes_equal, + translate_inertia_tensor, +) try: import miniball @@ -973,3 +979,44 @@ def _plato_primitive(self, backend): indices=self.faces, shape_colors=np.array([[0.5, 0.5, 0.5, 1]]), ) + + def to_hoomd(self): + """Get a JSON-serializable subset of Polyhedron properties. + + The JSON-serializable output of the to_hoomd method can be directly imported + into data management tools like signac. This data can then be queried for use in + HOOMD simulations. Key naming matches HOOMD integrators: for example, the + moment_inertia key links to data from coxeter's inertia_tensor. Stored values + are based on the shape with its centroid at the origin. + + For a Polyhedron or ConvexPolyhedron, the following properties are stored: + + * vertices (list(list)): + The vertices of the shape. + * faces (list(list)): + The faces of the shape. + * centroid (list(float)) + The centroid of the shape. + This is set to [0,0,0] per HOOMD's spec. + * sweep_radius (float): + The rounding radius of the shape (0.0). + * volume (float) + The volume of the shape. + * moment_inertia (list(list)) + The shape's inertia tensor. + + Returns + ------- + dict + Dict containing a subset of shape properties required for HOOMD function. + """ + old_centroid = self.centroid + self.centroid = np.array([0, 0, 0]) + data = self.to_json( + ["vertices", "faces", "centroid", "volume", "inertia_tensor"] + ) + hoomd_dict = _map_dict_keys(data, key_mapping=_hoomd_dict_mapping) + hoomd_dict["sweep_radius"] = 0.0 + + self.centroid = old_centroid + return hoomd_dict diff --git a/coxeter/shapes/sphere.py b/coxeter/shapes/sphere.py index f0f92c59..35d9fcf0 100644 --- a/coxeter/shapes/sphere.py +++ b/coxeter/shapes/sphere.py @@ -6,7 +6,7 @@ import numpy as np from .base_classes import Shape3D -from .utils import translate_inertia_tensor +from .utils import _hoomd_dict_mapping, _map_dict_keys, translate_inertia_tensor class Sphere(Shape3D): @@ -68,6 +68,18 @@ def radius(self, value): else: raise ValueError("Radius must be greater than zero.") + @property + def diameter(self): + """float: Get or set the radius of the sphere.""" + return 2 * self._radius + + @diameter.setter + def diameter(self, value): + if value > 0: + self._radius = value / 2 + else: + raise ValueError("Diameter must be greater than zero.") + def _rescale(self, scale): """Multiply length scale. @@ -202,3 +214,37 @@ def _plato_primitive(self, backend): colors=np.array([[0.5, 0.5, 0.5, 1]]), radii=[self.radius], ) + + def to_hoomd(self): + """Get a dict of JSON-serializable subset of Sphere properties. + + The JSON-serializable output of the to_hoomd method can be directly imported + into data management tools like signac. This data can then be queried for use in + HOOMD simulations. Key naming matches HOOMD integrators: for example, the + moment_inertia key links to data from coxeter's inertia_tensor. Stored values + are based on the shape with its centroid at the origin. + + For a Sphere, the following properties are stored: + + * diameter (float): + The diameter of the sphere, equal to twice the radius. + * centroid (list(float)) + The centroid of the shape. + This is set to [0,0,0] per HOOMD's spec. + * volume (float) + The volume of the shape. + * moment_inertia (list(list)) + The shape's inertia tensor. + + Returns + ------- + dict + Dict containing a subset of shape properties required for HOOMD function. + """ + old_centroid = self.centroid + self.centroid = np.array([0, 0, 0]) + data = self.to_json(["diameter", "centroid", "volume", "inertia_tensor"]) + hoomd_dict = _map_dict_keys(data, key_mapping=_hoomd_dict_mapping) + + self.centroid = old_centroid + return hoomd_dict diff --git a/coxeter/shapes/utils.py b/coxeter/shapes/utils.py index 71e74ef0..dcff789a 100644 --- a/coxeter/shapes/utils.py +++ b/coxeter/shapes/utils.py @@ -90,3 +90,27 @@ def _set_3d_axes_equal(ax, limits=None): ax.set_ylim3d([origin[1] - radius, origin[1] + radius]) ax.set_zlim3d([origin[2] - radius, origin[2] + radius]) return ax + + +def _map_dict_keys(data, key_mapping): + """Rename a dict's keys based on a mapping dict. + + If an instance of :class:`matplotlib.axes.Axes` is provided, it will be + passed through. + + Args: + data (dict): + A dict with keys to be remapped + key_mapping (dict): + A dict of keys that should be renamed. The keys of this dict should + correspond with the keys of data that are to be changed, and the values + should correspond with the desired new keys. + + Returns + ------- + dict: A dict with the select keys renamed to the mapped values. + """ + return {key_mapping.get(key, key): value for key, value in data.items()} + + +_hoomd_dict_mapping = {"inertia_tensor": "moment_inertia", "radius": "sweep_radius"} diff --git a/tests/test_ellipsoid.py b/tests/test_ellipsoid.py index 90274754..350b7e30 100644 --- a/tests/test_ellipsoid.py +++ b/tests/test_ellipsoid.py @@ -252,3 +252,20 @@ def test_get_set_minimal_centered_bounding_circle_radius(a, b, c, center): def test_repr(): ellipsoid = Ellipsoid(1, 2, 3, [1, 2, 3]) assert str(ellipsoid), str(eval(repr(ellipsoid))) + + +@given(floats(0.1, 1000), floats(0.1, 1000), floats(0.1, 1000)) +def test_to_hoomd(a, b, c): + ellipsoid = Ellipsoid(a, b, c) + dict_keys = ["a", "b", "c", "centroid", "volume", "moment_inertia"] + dict_vals = [ + ellipsoid.a, + ellipsoid.b, + ellipsoid.c, + [0, 0, 0], + ellipsoid.volume, + ellipsoid.inertia_tensor, + ] + hoomd_dict = ellipsoid.to_hoomd() + for key, val in zip(dict_keys, dict_vals): + assert np.allclose(hoomd_dict[key], val), f"{key}" diff --git a/tests/test_polygon.py b/tests/test_polygon.py index 4db0d233..b451637a 100644 --- a/tests/test_polygon.py +++ b/tests/test_polygon.py @@ -595,3 +595,21 @@ def test_repr_nonconvex(square): def test_repr_convex(convex_square): assert str(convex_square), str(eval(repr(convex_square))) + + +@given(EllipseSurfaceStrategy) +def test_to_hoomd(points): + hull = ConvexHull(points) + poly = polygon_from_hull(points[hull.vertices]) + poly.centroid = [0, 0, 0] + dict_keys = ["vertices", "centroid", "sweep_radius", "area", "moment_inertia"] + dict_vals = [ + poly.vertices, + [0, 0, 0], + 0, + poly.area, + poly.inertia_tensor, + ] + hoomd_dict = poly.to_hoomd() + for key, val in zip(dict_keys, dict_vals): + assert np.allclose(hoomd_dict[key], val), f"{key}" diff --git a/tests/test_polyhedron.py b/tests/test_polyhedron.py index 25cd2a8b..4be7ccf1 100644 --- a/tests/test_polyhedron.py +++ b/tests/test_polyhedron.py @@ -934,3 +934,22 @@ def test_find_equations_and_normals(poly): ppoly._find_equations() assert np.allclose(poly.equations, ppoly._equations) assert np.allclose(poly.normals, ppoly.normals) + + +@named_solids_mark +def test_to_hoomd(poly): + poly.centroid = [0, 0, 0] + dict_keys = ["vertices", "centroid", "sweep_radius", "volume", "moment_inertia"] + dict_vals = [ + poly.vertices, + [0, 0, 0], + 0, + poly.volume, + poly.inertia_tensor, + ] + hoomd_dict = poly.to_hoomd() + for key, val in zip(dict_keys, dict_vals): + assert np.allclose(hoomd_dict[key], val), f"{key}" + + for i, face in enumerate(poly.faces): + assert np.allclose(face, hoomd_dict["faces"][i]) diff --git a/tests/test_sphere.py b/tests/test_sphere.py index aed3b4d1..d4d48a4f 100644 --- a/tests/test_sphere.py +++ b/tests/test_sphere.py @@ -21,6 +21,14 @@ def test_radius_getter_setter(r): assert sphere.radius == r + 1 +@given(floats(0.1, 1000)) +def test_diameter_getter_setter(r): + sphere = Sphere(r) + assert sphere.diameter == 2 * r + sphere.diameter = 2 * r + 1 + assert sphere.diameter == 2 * r + 1 + + @given(floats(-1000, -1)) def test_invalid_radius_setter(r): """Test setting an invalid Volume.""" @@ -230,3 +238,18 @@ def test_get_set_minimal_centered_bounding_circle_radius(r, center): def test_repr(): sphere = Sphere(1, [1, 2, 3]) assert str(sphere), str(eval(repr(sphere))) + + +@given(floats(0.1, 1000)) +def test_to_hoomd(r): + sphere = Sphere(r) + dict_keys = ["diameter", "centroid", "volume", "moment_inertia"] + dict_vals = [ + sphere.radius * 2, + [0, 0, 0], + sphere.volume, + sphere.inertia_tensor, + ] + hoomd_dict = sphere.to_hoomd() + for key, val in zip(dict_keys, dict_vals): + assert np.allclose(hoomd_dict[key], val), f"{key}" diff --git a/tests/test_spheropolygon.py b/tests/test_spheropolygon.py index a1d34b6e..0d4c18af 100644 --- a/tests/test_spheropolygon.py +++ b/tests/test_spheropolygon.py @@ -219,3 +219,18 @@ def test_inertia(unit_rounded_square): def test_repr(unit_rounded_square): assert str(unit_rounded_square), str(eval(repr(unit_rounded_square))) + + +def test_to_hoomd(unit_rounded_square): + """Test hoomd JSON calculation.""" + shape = unit_rounded_square + dict_keys = ["vertices", "centroid", "sweep_radius", "area"] + dict_vals = [ + shape.vertices, + [0, 0, 0], + 1, + shape.area, + ] + hoomd_dict = shape.to_hoomd() + for key, val in zip(dict_keys, dict_vals): + assert np.allclose(hoomd_dict[key], val), f"{key}" diff --git a/tests/test_spheropolyhedron.py b/tests/test_spheropolyhedron.py index 3a486a87..80fb6522 100644 --- a/tests/test_spheropolyhedron.py +++ b/tests/test_spheropolyhedron.py @@ -6,7 +6,8 @@ from hypothesis import given, settings from hypothesis.strategies import floats -from conftest import make_sphero_cube +from conftest import make_sphero_cube, named_catalan_mark +from coxeter.shapes import ConvexSpheropolyhedron @given(radius=floats(0.1, 1)) @@ -140,3 +141,20 @@ def test_inside_boundaries(): def test_repr(): sphero_cube = make_sphero_cube(radius=1) assert str(sphero_cube), str(eval(repr(sphero_cube))) + + +@given(r=floats(0.01, 1)) +@named_catalan_mark +def test_to_hoomd(poly, r): + poly.centroid = [0, 0, 0] + poly = ConvexSpheropolyhedron(poly.vertices, r) + dict_keys = ["vertices", "centroid", "sweep_radius", "volume"] + dict_vals = [ + poly.vertices, + [0, 0, 0], + poly.radius, + poly.volume, + ] + hoomd_dict = poly.to_hoomd() + for key, val in zip(dict_keys, dict_vals): + assert np.allclose(hoomd_dict[key], val), f"{key}"