From fb96b04d97c34412b741d1ac8e115a978d42bc59 Mon Sep 17 00:00:00 2001 From: "Rose K. Cersonsky" <47536110+rosecers@users.noreply.github.com> Date: Tue, 25 Jul 2023 10:28:49 -0500 Subject: [PATCH] Allow visualization of shapes in the structure viewer (#282) * Enable visualization of shapes in the structure viewer Three kinds of shapes are available: ellipsoids, spheres and custom shape defined by a list of vertices and indices/simplices. Co-authored-by: Guillaume Fraux --- .prettierrc.yml | 1 + docs/src/tutorial/input-reference.rst | 26 ++ docs/src/tutorial/input.rst | 2 + package-lock.json | 2 +- python/chemiscope/__init__.py | 1 + python/chemiscope/input.py | 342 +++++++++++++++--- python/chemiscope/structures/__init__.py | 2 + python/chemiscope/structures/_ase.py | 106 ++++++ python/tests/shapes.py | 242 +++++++++++++ src/dataset.ts | 104 +++++- src/map/options.html.in | 411 ++++++++++------------ src/structure/options.html.in | 310 ++++++++-------- src/structure/options.ts | 7 + src/structure/shapes.ts | 429 +++++++++++++++++++++++ src/structure/viewer.ts | 133 ++++++- 15 files changed, 1685 insertions(+), 433 deletions(-) mode change 100755 => 100644 python/chemiscope/input.py create mode 100644 python/tests/shapes.py create mode 100644 src/structure/shapes.ts diff --git a/.prettierrc.yml b/.prettierrc.yml index 7d9e8c34e..a97ba3693 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -15,3 +15,4 @@ overrides: - '*.html.in' options: printWidth: 150 + tabWidth: 3 diff --git a/docs/src/tutorial/input-reference.rst b/docs/src/tutorial/input-reference.rst index 255dbbc92..3d009af55 100644 --- a/docs/src/tutorial/input-reference.rst +++ b/docs/src/tutorial/input-reference.rst @@ -94,6 +94,32 @@ contains the following fields and values: // a, b, and c are the unit cell vectors. All values are // expressed in Angstroms. "cell": [10, 0, 0, 0, 10, 0, 0, 0, 10], + + // OPTIONAL: shapes to display on each site, if other than + // spheres. Multiple shapes groups with different names are + // supported. + // + // Each shape group should be an array of "size" elements, + // describing the different shapes. + "shapes": { + : [ + // Ellipsoid shapes, with the given `[ax, ay, az]` semi-axes + {"kind": "ellipsoid", "semiaxes": [1, 1, 2]}, + // Each shape can contain an OPTIONAL "orientation", + // given as a `[x, y, z, w]` quaternion. Defaults to + // [0, 0, 0, 1] + {"kind": "ellipsoid", "semiaxes": [1, 1, 2], "orientation": [0, 0, 0, 1]}, + // fully custom shape, from a list of vertices and + // simplices (also called "indices" in WebGL) + { + "kind": "custom", + "vertices": [[0, 0, 0], [1, 0, 0], [0, 1, 0]], + "indices": [[0, 1, 2]], + }, + // more shapes as needed + ... + ], + } }, // other structures as needed ... diff --git a/docs/src/tutorial/input.rst b/docs/src/tutorial/input.rst index 0fdd24503..efdbdd2ea 100644 --- a/docs/src/tutorial/input.rst +++ b/docs/src/tutorial/input.rst @@ -55,6 +55,8 @@ generate an output in chemiscope format. .. autofunction:: chemiscope.librascal_atomic_environments +.. autofunction:: chemiscope.extract_lammps_shapes_from_ase + .. _ase: https://wiki.fysik.dtu.dk/ase/index.html .. _ASAP: https://github.com/BingqingCheng/ASAP diff --git a/package-lock.json b/package-lock.json index fc6d7bdb6..346e395e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "plausible-tracker": "^0.3.8", "plotly.js": "2.23.2", "prettier": "2.8.8", - "raw-loader": "^4.0.2", + "raw-loader": "^4", "rimraf": "^5", "style-loader": "^3", "tmp": "^0.2", diff --git a/python/chemiscope/__init__.py b/python/chemiscope/__init__.py index 8b7f4185e..a7b59c564 100644 --- a/python/chemiscope/__init__.py +++ b/python/chemiscope/__init__.py @@ -3,6 +3,7 @@ from .structures import ( # noqa all_atomic_environments, composition_properties, + extract_lammps_shapes_from_ase, extract_properties, librascal_atomic_environments, ) diff --git a/python/chemiscope/input.py b/python/chemiscope/input.py old mode 100755 new mode 100644 index 3952ce8aa..ab3b0851b --- a/python/chemiscope/input.py +++ b/python/chemiscope/input.py @@ -28,29 +28,43 @@ def create_input( properties=None, environments=None, settings=None, + shapes=None, parameters=None, ): """ - Create a dictionary that can be saved to JSON using the format used by - the default chemiscope visualizer. + Create a dictionary that can be saved to JSON using the format used by the default + chemiscope visualizer. + + :param list frames: list of atomic structures. For now, only `ase.Atoms`_ objects + are supported - :param list frames: list of atomic structures. For now, only `ase.Atoms`_ - objects are supported :param dict meta: optional metadata of the dataset, see below + :param dict properties: optional dictionary of properties, see below - :param list environments: optional list of (structure id, atom id, cutoff) - specifying which atoms have properties attached and how far out - atom-centered environments should be drawn by default. Functions like + + :param list environments: optional list of ``(structure id, atom id, cutoff)`` + specifying which atoms have properties attached and how far out atom-centered + environments should be drawn by default. Functions like :py:func:`all_atomic_environments` or :py:func:`librascal_atomic_environments` can be used to generate the list of environments in simple cases. - :param dict settings: optional dictionary of settings to use when displaying - the data. Possible entries for the ``settings`` dictionary are documented - in the chemiscope input file reference. + + :param dict shapes: optional dictionary of shapes to have available for display, + see below. :py:func:`extract_lammps_shapes_from_ase` can automatically extract + shapes from a LAMMPS simulation. + + :param dict settings: optional dictionary of settings to use when displaying the + data. Possible entries for the ``settings`` dictionary are documented in the + chemiscope input file reference. + :param dict parameters: optional dictionary of parameters for multidimensional properties, see below - The dataset metadata should be given in the ``meta`` dictionary, the - possible keys are: + + Dataset metadata + ---------------- + + The dataset metadata should be given in the ``meta`` dictionary, the possible keys + are: .. code-block:: python @@ -65,16 +79,19 @@ def create_input( ], } - Properties can be added with the ``properties`` parameter. This parameter - should be a dictionary containing one entry for each property. Properties - can be extracted from structures with :py:func:`extract_properties` or + Dataset properties + ------------------ + + Properties can be added with the ``properties`` parameter. This parameter should be + a dictionary containing one entry for each property. Properties can be extracted + from structures with :py:func:`extract_properties` or :py:func:`composition_properties`, or manually defined by the user. Each entry in the ``properties`` dictionary contains a ``target`` attribute - (``'atom'`` or ``'structure'``) and a set of values. ``values`` can be a - Python list of float or string; a 1D numpy array of numeric values; or a 2D - numpy array of numeric values. In the later case, multiple properties will - be generated along the second axis. For example, passing + (``'atom'`` or ``'structure'``) and a set of values. ``values`` can be a Python list + of float or string; a 1D numpy array of numeric values; or a 2D numpy array of + numeric values. In the later case, multiple properties will be generated along the + second axis. For example, passing .. code-block:: python @@ -89,8 +106,8 @@ def create_input( } } - will generate four properties named ``cheese[1]``, ``cheese[2]``, - ``cheese[3]``, and ``cheese[4]``, each containing 300 values. + will generate four properties named ``cheese[1]``, ``cheese[2]``, ``cheese[3]``, and + ``cheese[4]``, each containing 300 values. It is also possible to pass shortened representation of the properties, for instance: @@ -101,13 +118,16 @@ def create_input( 'cheese': np.zeros((300, 4)), } - In this case, the type of property (structure or atom) would be deduced - by comparing the numbers atoms and structures in the dataset to the - length of provided list/np.ndarray. + In this case, the type of property (structure or atom) would be deduced by comparing + the numbers atoms and structures in the dataset to the length of provided + list/np.ndarray. + + Multi-dimensional properties + ---------------------------- - Finally, one can give 2D properties to be displayed as curves in the info - panel by setting a ``parameters`` in the property, and giving the corresponding - ``parameters`` values to this function. The previous example becomes: + One can give 2D properties to be displayed as curves in the info panel by setting a + ``parameters`` in the property, and giving the corresponding ``parameters`` values + to this function. The previous example becomes: .. code-block:: python @@ -123,9 +143,9 @@ def create_input( } } - This input describes a 2D property ``cheese`` with 300 samples and 4 values - taken by the ``origin`` parameter. We also need to provide the - ``parameters`` values to this function: + This input describes a 2D property ``cheese`` with 300 samples and 4 values taken by + the ``origin`` parameter. We also need to provide the ``parameters`` values to this + function: .. code-block:: python @@ -134,14 +154,68 @@ def create_input( # an array of numbers containing the values of the parameter # the size should correspond to the second dimension # of the corresponding multidimensional property - 'values': [0, 1, 2, 3] + 'values': [0, 1, 2, 3], # optional free-form description of the parameter as a string - 'name': 'a short description of this parameter' + 'name': 'a short description of this parameter', # optional units of the values in the values array - 'units': 'eV' + 'units': 'eV', } } + Custom shapes + ------------- + + The ``shapes`` parameter should have the format ``{"": list of list of + shapes}``, where the list of lists contains one list for each structure, itself + containing one shape dictionary for each atom/site. + + .. code-block:: python + + shapes = { + "shape name": [ + [{"kind": "sphere", "radius": 0.3} for atom in frame] + for frame in frames + ] + } + + The shape dictionary can have any of the following form: + + .. code-block:: python + + # Ellipsoid shape + shape = { + "kind": "ellipsoid", + "semiaxes": [float, float, float], + "orientation" [float, float, float, float], # optional + } + + # Spherical shape + shape = { + "kind": "sphere", + "radius": float, + } + + # Fully custom shape + shape = { + "kind": "custom", + "vertices": [ + [float, float, float], + ... + ], + # `simplices` is optional + "simplices": [ + [int, int, int], + ... + ], + # `orientation` is optional + "orientation" [float, float, float, float], + } + + where ``orientation`` is an optional parameter corresponding to a quaternion in + ``x, y, z, w`` format. For ``custom`` shapes, ``simplices``, referring to the + *indices* of the facets, is also optional, and will be determined by convex + triangulation when not provided. + .. _`ase.Atoms`: https://wiki.fysik.dtu.dk/ase/ase/atoms.html """ @@ -198,6 +272,9 @@ def create_input( data["environments"] = _normalize_environments(environments, data["structures"]) n_atoms = len(data["environments"]) + if shapes is not None: + _add_shapes(data["structures"], shapes) + data["properties"] = {} if properties is not None: properties = _expand_properties(properties, n_structures, n_atoms) @@ -214,6 +291,7 @@ def create_input( raise ValueError( f"expecting parameters to be a of type 'dict' not '{type(parameters)}'" ) + data["parameters"] = {} for key in parameters: param = {} @@ -295,42 +373,54 @@ def write_input( meta=None, properties=None, environments=None, + shapes=None, settings=None, parameters=None, ): """ - Create the input JSON file used by the default chemiscope visualizer, and - save it to the given ``path``. + Create the input JSON file used by the default chemiscope visualizer, and save it to + the given ``path``. + + :param str path: name of the file to use to save the json data. If it ends with + '.gz', a gzip compressed file will be written + + :param list frames: list of atomic structures. For now, only `ase.Atoms`_ objects + are supported - :param str path: name of the file to use to save the json data. If it ends - with '.gz', a gzip compressed file will be written - :param list frames: list of atomic structures. For now, only `ase.Atoms`_ - objects are supported :param dict meta: optional metadata of the dataset + :param dict properties: optional dictionary of additional properties - :param list environments: optional list of (structure id, atom id, cutoff) - specifying which atoms have properties attached and how far out - atom-centered environments should be drawn by default. - :param dict settings: optional dictionary of settings to use when displaying - the data. Possible entries for the ``settings`` dictionary are documented - in the chemiscope input file reference. + + :param list environments: optional list of ``(structure id, atom id, cutoff)`` + specifying which atoms have properties attached and how far out atom-centered + environments should be drawn by default. + + :param dict shapes: optional dictionary of shapes to have available for display. + See :py:func:`create_input` for more information on how to define shapes. + + :param dict settings: optional dictionary of settings to use when displaying the + data. Possible entries for the ``settings`` dictionary are documented in the + chemiscope input file reference. + :param dict parameters: optional dictionary of parameters of multidimensional properties - This function uses :py:func:`create_input` to generate the input data, see - the documentation of this function for more information. + This function uses :py:func:`create_input` to generate the input data, see the + documentation of this function for more information. - Here is a quick example of generating a chemiscope input reading the - structures from a file that `ase `_ can read, and performing PCA - using `sklearn`_ on a descriptor computed with another package. + Here is a quick example of generating a chemiscope input reading the structures from + a file that `ase `_ can read, and performing PCA using `sklearn`_ on a + descriptor computed with another package. .. code-block:: python import ase from ase import io import numpy as np + import sklearn from sklearn import decomposition + import chemiscope frames = ase.io.read('trajectory.xyz', ':') @@ -368,15 +458,16 @@ def write_input( dos_energy_grid = np.loadtxt(...) multidimensional_properties = { "DOS": { - target: "structure", - values: dos, - parameters: ["energy"], + "target": "structure", + "values": dos, + "parameters": ["energy"], } } + multidimensional_parameters = { "energy": { - "values": dos_energy_grid - "units": "eV" + "values": dos_energy_grid, + "units": "eV", } } @@ -406,6 +497,7 @@ def write_input( meta=meta, properties=properties, environments=environments, + shapes=shapes, settings=settings, parameters=parameters, ) @@ -710,3 +802,143 @@ def _typetransform(data, name): f"unsupported type in property '{name}' values: " "should be string or number" ) + + +def _add_shapes(structures, shapes): + if not isinstance(shapes, dict): + raise TypeError(f"`shapes` must be a dictionary, got {type(shapes)} instead") + + # validate type and number of element for each entries in the shapes + for key, shapes_for_key in shapes.items(): + if not isinstance(key, str): + raise TypeError( + f"the `shapes` dictionary keys must be strings, got {type(key)}" + ) + + if not isinstance(shapes_for_key, list): + raise TypeError( + "Each entry in `shapes` must be a list, " + f"got {type(shapes_for_key)} instead for '{key}'" + ) + + if len(shapes_for_key) != len(structures): + raise ValueError( + f"Each entry in `shapes` should be a list with {len(structures)} " + f"(number of frames) elements, got {len(shapes_for_key)} for '{key}'" + ) + + for structure_i in range(len(structures)): + shapes_for_structure = shapes_for_key[structure_i] + structure = structures[structure_i] + + if not isinstance(shapes_for_structure, list): + raise TypeError( + f"Shapes for structure {structure_i} must be a list, " + f"got {type(shapes_for_structure)} instead" + ) + + if len(shapes_for_structure) != structure["size"]: + raise ValueError( + f"Each entry in `shapes[{key}][{structure_i}]` should be a " + f"list with {structure['size']} (number of atoms) elements, " + f"got {len(shapes_for_structure)}" + ) + + for shape in shapes_for_structure: + _check_valid_shape(shape) + + # Add the shapes to the structures + for structure in structures: + structure["shapes"] = {} + + for key, values in shapes.items(): + for structure, shapes_data in zip(structures, values): + for shape in shapes_data: + if shape["kind"] == "custom" and "simplices" not in shape: + try: + import scipy.spatial + + except ImportError as e: + raise RuntimeError( + "Missing simplices in custom shape, and scipy is not " + "installed" + ) from e + + convex_hull = scipy.spatial.ConvexHull(shape["vertices"]) + shape["simplices"] = [s.tolist() for s in convex_hull.simplices] + + structure["shapes"][key] = shapes_data + + +def _check_valid_shape(shape): + if not isinstance(shape, dict): + raise TypeError( + f"individual shapes must be dictionaries, got {type(shape)} instead" + ) + + if shape["kind"] == "sphere": + for parameter in shape.keys(): + if parameter not in ["radius", "orientation"]: + raise ValueError( + f"unknown shape parameter '{parameter}' for 'sphere' shape kind" + ) + + if not isinstance(shape["radius"], float): + raise TypeError( + f"sphere shape 'radius' must be a float, got {type(shape['radius'])}" + ) + + elif shape["kind"] == "ellipsoid": + for parameter in shape.keys(): + if parameter not in ["semiaxes", "orientation"]: + raise ValueError( + f"unknown shape parameter '{parameter}' for 'ellipsoid' shape kind" + ) + + semiaxes_array = np.asarray(shape["semiaxes"]).astype( + np.float64, casting="safe", subok=False, copy=False + ) + + if not semiaxes_array.shape == (3,): + raise ValueError( + "'semiaxes' must be an array with 3 values for 'ellipsoid' shape kind" + ) + + elif shape["kind"] == "custom": + for parameter in shape.keys(): + if parameter not in ["vertices", "simplices", "orientation"]: + raise ValueError( + f"unknown shape parameter '{parameter}' for 'custom' shape kind" + ) + + vertices_array = np.asarray(shape["vertices"]).astype( + np.float64, casting="safe", subok=False, copy=False + ) + + if len(vertices_array.shape) != 2 or vertices_array.shape[1] != 3: + raise ValueError( + "'vertices' must be an Nx3 array values for 'custom' shape kind" + ) + + if "simplices" in shape: + simplices_array = np.asarray(shape["vertices"]).astype( + np.int32, casting="safe", subok=False, copy=False + ) + + if len(simplices_array.shape) != 2 or simplices_array.shape[1] != 3: + raise ValueError( + "'simplices' must be an Nx3 array values for 'custom' shape kind" + ) + + else: + raise ValueError(f"unknown shape kind '{shape['kind']}'") + + if "orientation" in shape: + orientation_array = np.asarray(shape["orientation"]).astype( + np.float64, casting="safe", subok=False, copy=False + ) + + if not orientation_array.shape == (4,): + raise ValueError( + "semiaxes must be an array with 4 values for 'ellipsoid' shape kind" + ) diff --git a/python/chemiscope/structures/__init__.py b/python/chemiscope/structures/__init__.py index 537340a9e..d70c82082 100644 --- a/python/chemiscope/structures/__init__.py +++ b/python/chemiscope/structures/__init__.py @@ -10,6 +10,8 @@ _ase_valid_structures, ) +from ._ase import extract_lammps_shapes_from_ase # noqa isort: skip + def _guess_adapter(frames): """ diff --git a/python/chemiscope/structures/_ase.py b/python/chemiscope/structures/_ase.py index 33667b2f2..3ae1b4b42 100644 --- a/python/chemiscope/structures/_ase.py +++ b/python/chemiscope/structures/_ase.py @@ -302,3 +302,109 @@ def _is_convertible_to_property(value): return True except Exception: return False + + +def extract_lammps_shapes_from_ase(frames, key="shape"): + """ + Extract shapes from a LAMMPS data file read by ASE. + + :param frames: list of ASE Atoms objects + :param key: name of the ASE property where the shape is stored + """ + + all_shapes = [_extract_lammps_shapes(frame, key=key) for frame in frames] + + n_without = len([shapes for shapes in all_shapes if shapes is None]) + if n_without != 0: + raise ValueError(f"{n_without} frame(s) do not contain shape information") + + keys = set([k for shapes in all_shapes for k in shapes.keys()]) + + universal_keys = [k for k in keys if all([k in shapes for shapes in all_shapes])] + + if len(universal_keys) != len(keys): + warnings.warn( + f"Only including shape keys [{', '.join(universal_keys)}], which are " + "present in all frames. All other shape keys are omitted." + ) + return {k: [shapes[k] for shapes in all_shapes] for k in universal_keys} + + +# Required parameters from different kinds of shapes +SHAPE_PARAMETERS = { + "ellipsoid": "semiaxes", + "sphere": "radius", +} + + +def _extract_lammps_shapes(frame, key): + if key in frame.info: + if frame.info[key] not in SHAPE_PARAMETERS: + raise KeyError( + "The currently-supported shape in `extract_lammps_shapes_from_ase` are " + f"{list(SHAPE_PARAMETERS.keys())}, received '{frame.info[key]}'" + ) + + shape = _get_shape_params(key, frame.info[key], frame.info) + if "orientation" in frame.arrays: + return { + key: [ + {**shape, "orientation": list(o)} + for o in frame.arrays["orientation"] + ] + } + else: + return {key: [shape for _ in frame]} + + elif key in frame.arrays: + shapes = [] + for atom_i, shape_key in enumerate(frame.arrays[key]): + if shape_key not in SHAPE_PARAMETERS: + raise KeyError( + "The currently-supported shape types are {}, received {}.".format( + ", ".join(SHAPE_PARAMETERS.keys()), shape_key + ) + ) + shape = _get_shape_params_atom( + key, + shape_key, + frame.arrays, + atom_i, + ) + + if "orientation" in frame.arrays: + shape["orientation"] = list(frame.arrays["orientation"][atom_i]) + + shapes.append(shape) + + return {key: shapes} + + +def _get_shape_params(prefix, shape_kind, dictionary): + shape = {"kind": shape_kind} + parameter = SHAPE_PARAMETERS[shape_kind] + try: + shape[parameter] = dictionary[f"{prefix}_{parameter}"] + except KeyError: + raise KeyError( + f"Missing required parameter '{prefix}_{parameter}' for " + f"'{shape_kind}' shape" + ) + + return shape + + +def _get_shape_params_atom(prefix, shape_kind, dictionary, atom_i): + """Extract shape parameters for a single atom""" + + shape = {"kind": shape_kind} + parameter = SHAPE_PARAMETERS[shape_kind] + try: + shape[parameter] = dictionary[f"{prefix}_{parameter}"][atom_i] + except KeyError: + raise KeyError( + f"Missing required parameter '{prefix}_{parameter}' for " + f"'{shape_kind}' shape" + ) + + return shape diff --git a/python/tests/shapes.py b/python/tests/shapes.py new file mode 100644 index 000000000..3f36f5b1c --- /dev/null +++ b/python/tests/shapes.py @@ -0,0 +1,242 @@ +import unittest + +import ase + +import chemiscope + +CUBE_VERTICES = [ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [1, 1, 0], + [0, 0, 1], + [1, 0, 1], + [0, 1, 1], + [1, 1, 1], +] + +CUBE_SIMPLICES = [ + [0, 1, 2], + [1, 2, 3], + [4, 5, 6], + [5, 6, 7], + [0, 1, 4], + [0, 2, 4], + [1, 3, 5], + [2, 3, 6], + [1, 4, 5], + [2, 4, 6], + [3, 5, 7], + [3, 6, 7], +] + +SHAPE_DEFAULTS = { + "ellipsoid": ("semiaxes", [1, 2, 1]), + "sphere": ("radius", 1.0), +} + + +class TestShapes(unittest.TestCase): + def test_custom_shapes(self): + frame = ase.Atoms( + numbers=[1, 1, 1], positions=[[0, 0, 0], [1, 1, 1], [2, 2, 5]] + ) + + shapes = { + "cubes": [ + [ + {"kind": "custom", "vertices": CUBE_VERTICES}, + { + "kind": "custom", + "vertices": CUBE_VERTICES, + "orientation": [1, 0, 0, 0], + }, + { + "kind": "custom", + "vertices": CUBE_VERTICES, + "simplices": CUBE_SIMPLICES, + }, + ], + ], + "other": [ + [ + {"kind": "sphere", "radius": 0.3}, + {"kind": "ellipsoid", "semiaxes": [0.3, 0.2, 0.1]}, + { + "kind": "ellipsoid", + "semiaxes": [0.3, 0.2, 0.1], + "orientation": [1, 0, 0, 0], + }, + ], + ], + } + + data = chemiscope.create_input(frames=[frame], shapes=shapes) + + result = data["structures"][0]["shapes"] + self.assertEqual(list(result.keys()), ["cubes", "other"]) + + for key, values in result.items(): + self.assertEqual(shapes[key][0], values) + + +class TestShapesFromASE(unittest.TestCase): + """Conversion of shape data in ASE to chemiscope JSON""" + + def setUp(self): + self.frame = ase.Atoms( + numbers=[1, 1, 1], positions=[[0, 0, 0], [1, 1, 1], [2, 2, 5]] + ) + self.frame.arrays["orientation"] = [ + [1, 0, 0, 0] for _ in range(len(self.frame)) + ] + + def test_no_shape(self): + data = chemiscope.create_input(frames=[self.frame]) + self.assertNotIn("shapes", data["structures"][0]) + + def test_bad_shape(self): + frame = self.frame.copy() + frame.info["shape"] = "invalid" + + with self.assertRaises(KeyError) as cm: + chemiscope.extract_lammps_shapes_from_ase([frame]) + + self.assertEqual( + cm.exception.args[0], + "The currently-supported shape in `extract_lammps_shapes_from_ase` are " + "['ellipsoid', 'sphere'], received 'invalid'", + ) + + def test_shape_by_frame(self): + for shape_kind in SHAPE_DEFAULTS: + with self.subTest(shape=shape_kind): + frame = self.frame.copy() + shape_name = "shape_name" + frame.info[shape_name] = shape_kind + + parameter, value = SHAPE_DEFAULTS[shape_kind] + frame.info[f"{shape_name}_{parameter}"] = value + shapes = chemiscope.extract_lammps_shapes_from_ase( + [frame], key=shape_name + ) + + data = chemiscope.create_input(frames=[frame], shapes=shapes) + self.assertIn("shapes", data["structures"][0]) + + self.assertTrue( + all( + [ + parameter in ss + for s in data["structures"] + for ss in s["shapes"][shape_name] + ] + ) + ) + + self.assertTrue( + all( + [ + "orientation" in ss + for s in data["structures"] + for ss in s["shapes"][shape_name] + ] + ) + ) + + self.assertTrue( + all( + [ + len(s["shapes"][shape_name]) == len(frame) + for s in data["structures"] + ] + ) + ) + + frame.info.pop(f"{shape_name}_{parameter}") + with self.assertRaises(KeyError) as cm: + chemiscope.create_input( + frames=[frame], + shapes=chemiscope.extract_lammps_shapes_from_ase( + [frame], key=shape_name + ), + ) + + self.assertEquals( + cm.exception.args[0], + f"Missing required parameter '{shape_name}_{parameter}' for " + f"'{shape_kind}' shape", + ) + + def test_by_index(self): + for shape_kind in SHAPE_DEFAULTS: + with self.subTest(shape=shape_kind): + frame = self.frame.copy() + shape_name = "shape_name" + frame.arrays[shape_name] = [shape_kind] * len(frame) + + parameter, value = SHAPE_DEFAULTS[shape_kind] + frame.arrays[f"{shape_name}_{parameter}"] = [value] * len(frame) + shapes = chemiscope.extract_lammps_shapes_from_ase( + [frame], key=shape_name + ) + + data = chemiscope.create_input(frames=[frame], shapes=shapes) + self.assertIn("shapes", data["structures"][0]) + + self.assertTrue( + all( + [ + parameter in ss + for s in data["structures"] + for ss in s["shapes"][shape_name] + ] + ) + ) + + self.assertTrue( + all( + [ + "orientation" in ss + for s in data["structures"] + for ss in s["shapes"][shape_name] + ] + ) + ) + + self.assertTrue( + all( + [ + len(s["shapes"][shape_name]) == len(frame) + for s in data["structures"] + ] + ) + ) + + frame.arrays.pop(f"{shape_name}_{parameter}") + with self.assertRaises(KeyError) as cm: + chemiscope.create_input( + frames=[frame], + shapes=chemiscope.extract_lammps_shapes_from_ase( + [frame], key=shape_name + ), + ) + + self.assertEquals( + cm.exception.args[0], + f"Missing required parameter '{shape_name}_{parameter}' for " + f"'{shape_kind}' shape", + ) + + def test_no_shapes(self): + with self.assertRaises(ValueError) as cm: + chemiscope.extract_lammps_shapes_from_ase([self.frame]) + + self.assertEqual( + cm.exception.args[0], + "1 frame(s) do not contain shape information", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/dataset.ts b/src/dataset.ts index 19fd31ff9..48e5740a9 100644 --- a/src/dataset.ts +++ b/src/dataset.ts @@ -3,6 +3,9 @@ * @module main */ +import { CustomShape, Ellipsoid, Sphere } from './structure/shapes'; +import { CustomShapeData, EllipsoidData, SphereData } from './structure/shapes'; + /** A dataset containing all the data to be displayed. */ export interface Dataset { /** metadata for this dataset */ @@ -98,6 +101,16 @@ export interface Structure { * expressed in Angströms. */ cell?: number[]; + /** + * possible shapes to display, multiple groups of shapes with different + * names are allowed + */ + shapes?: { + /** + * shapes of each particles / atoms + */ + [name: string]: Array; + }; } /** @@ -304,6 +317,42 @@ function checkStructures(o: JsObject[]): [number, number] { } } + // check to see if all structures have consistent shapes + // placed after structure check to ensure that all structures + // are first validated + if ('shapes' in o[0]) { + const shapeList = Object.keys(o[0].shapes as object); + for (let i = 0; i < o.length; i++) { + const structure = o[i]; + if (!('shapes' in structure)) { + throw Error(`error in structure ${i}: "shape" is not defined`); + } else { + const shapes = structure['shapes'] as Record; + for (const key of shapeList) { + if (!(key in shapes) || shapes[key] === undefined) { + throw Error(`error in structure ${i}: "${key}" is not defined`); + } + } + for (const key of Object.keys(shapes)) { + if (!shapeList.includes(key)) { + throw Error( + `error in structure ${i}: "${key}" is defined, but was not for previous structures` + ); + } + } + } + } + } else { + for (let i = 0; i < o.length; i++) { + const structure = o[i]; + if ('shapes' in structure) { + throw Error( + `error in structure ${i}: "shape" is defined, but was not for previous structures` + ); + } + } + } + return [o.length, atomsCount]; } @@ -313,7 +362,7 @@ function checkStructures(o: JsObject[]): [number, number] { * structure. */ export function checkStructure(s: JsObject): string { - if (typeof s !== 'object') { + if (typeof s !== 'object' || s === null) { throw Error('the structure must be a JavaScript object'); } @@ -341,6 +390,59 @@ export function checkStructure(s: JsObject): string { } } + if ('shapes' in s) { + const shapes = s.shapes; + + if (typeof shapes !== 'object' || shapes === null) { + return "'shapes' must be an object"; + } + + for (const [key, array] of Object.entries(s.shapes as object)) { + if (!Array.isArray(array)) { + return `shape['${key}'] must be an array`; + } + + if (s.size > 0 && array.length !== s.size) { + return `wrong size for "shape['${key}']", expected ${s.size}, got ${array.length}`; + } + + for (let i = 0; i < array.length; i++) { + const element = array[i] as unknown; + if (typeof element !== 'object' || element === null) { + return "'shapes' entries must be objects"; + } + const shape = element as JsObject; + + if (!('kind' in shape)) { + return `missing "kind" in shape for particle ${i}`; + } + + if (typeof shape.kind !== 'string') { + return `shapes 'kind' must be a string for particle ${i}`; + } + + if (shape.kind === 'sphere') { + const check = Sphere.validateParameters(shape); + if (check !== '') { + return check; + } + } else if (shape.kind === 'ellipsoid') { + const check = Ellipsoid.validateParameters(shape); + if (check !== '') { + return check; + } + } else if (shape.kind === 'custom') { + const check = CustomShape.validateParameters(shape); + if (check !== '') { + return check; + } + } else { + return `Chemiscope currently only supports custom, ellipsoid, or sphere shapes, got ${shape.kind}`; + } + } + } + } + return ''; } diff --git a/src/map/options.html.in b/src/map/options.html.in index 38f235279..128226037 100644 --- a/src/map/options.html.in +++ b/src/map/options.html.in @@ -1,222 +1,199 @@ diff --git a/src/structure/options.html.in b/src/structure/options.html.in index 6ad99b813..568a42ab7 100644 --- a/src/structure/options.html.in +++ b/src/structure/options.html.in @@ -1,159 +1,161 @@