Skip to content

Commit 4ba3929

Browse files
committed
smarter isosurface determination
1 parent 05b691b commit 4ba3929

File tree

2 files changed

+71
-27
lines changed

2 files changed

+71
-27
lines changed
+50-27
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any
3+
from typing import TYPE_CHECKING, Any, Literal
44

55
import numpy as np
66
from pymatgen.io.vasp import VolumetricData
77

88
from crystal_toolkit.core.scene import Scene, Surface
99

1010
if TYPE_CHECKING:
11-
from numpy.typing import ArrayLike
11+
from numpy.typing import ArrayLike, NDArray
12+
from pymatgen.core.structure import Lattice
1213

1314
_ANGS2_TO_BOHR3 = 1.88973**3
1415

1516

1617
def get_isosurface_scene(
17-
self,
18-
data_key: str = "total",
19-
isolvl: float = 0.05,
18+
data: NDArray,
19+
lattice: Lattice,
20+
isolvl: float | None = None,
2021
step_size: int = 4,
2122
origin: ArrayLike | None = None,
2223
**kwargs: Any,
2324
) -> Scene:
2425
"""Get the isosurface from a VolumetricData object.
2526
2627
Args:
27-
data_key (str, optional): Use the volumetric data from self.data[data_key]. Defaults to 'total'.
28-
isolvl (float, optional): The cutoff for the isosurface to using the same units as VESTA so
29-
e/bohr and kept grid size independent
28+
data (NDArray): The volumetric data array.
29+
lattice (Lattice): The lattice.
30+
isolvl (float, optional): The cutoff to compute the isosurface
3031
step_size (int, optional): step_size parameter for marching_cubes_lewiner. Defaults to 3.
3132
origin (ArrayLike, optional): The origin of the isosurface. Defaults to None.
3233
**kwargs: Passed to the Surface object.
@@ -36,42 +37,65 @@ def get_isosurface_scene(
3637
"""
3738
import skimage.measure
3839

39-
origin = origin or list(
40-
-self.structure.lattice.get_cartesian_coords([0.5, 0.5, 0.5])
41-
)
42-
vol_data = np.copy(self.data[data_key])
43-
vol = self.structure.volume
44-
vol_data = vol_data / vol / _ANGS2_TO_BOHR3
45-
46-
padded_data = np.pad(vol_data, (0, 1), "wrap")
47-
vertices, faces, normals, values = skimage.measure.marching_cubes(
48-
padded_data, level=isolvl, step_size=step_size, method="lewiner"
49-
)
40+
origin = origin or list(-lattice.get_cartesian_coords([0.5, 0.5, 0.5]))
41+
if isolvl is None:
42+
# get the value such that 20% of the weight is enclosed
43+
isolvl = np.percentile(data, 20)
44+
45+
padded_data = np.pad(data, (0, 1), "wrap")
46+
try:
47+
vertices, faces, normals, values = skimage.measure.marching_cubes(
48+
padded_data, level=isolvl, step_size=step_size, method="lewiner"
49+
)
50+
except (ValueError, RuntimeError) as err:
51+
if "Surface level" in str(err):
52+
raise ValueError(
53+
f"Isosurface level is not within data range. min: {data.min()}, max: {data.max()}"
54+
) from err
55+
raise err
5056
# transform to fractional coordinates
51-
vertices = vertices / (vol_data.shape[0], vol_data.shape[1], vol_data.shape[2])
52-
vertices = np.dot(vertices, self.structure.lattice.matrix) # transform to Cartesian
57+
vertices = vertices / (data.shape[0], data.shape[1], data.shape[2])
58+
vertices = np.dot(vertices, lattice.matrix) # transform to Cartesian
5359
pos = [vert for triangle in vertices[faces].tolist() for vert in triangle]
5460
return Scene(
5561
"isosurface", origin=origin, contents=[Surface(pos, show_edges=False, **kwargs)]
5662
)
5763

5864

59-
def get_volumetric_scene(self, data_key="total", isolvl=0.02, step_size=3, **kwargs):
65+
def get_volumetric_scene(
66+
self,
67+
data_key: str = "total",
68+
isolvl: float | None = None,
69+
step_size: int = 3,
70+
normalization: Literal["vol", "vesta"] | None = "vol",
71+
**kwargs,
72+
):
6073
"""Get the Scene object which contains a structure and a isosurface components.
6174
6275
Args:
6376
data_key (str, optional): Use the volumetric data from self.data[data_key]. Defaults to 'total'.
64-
isolvl (float, optional): The cutoff for the isosurface to using the same units as VESTA so e/bhor
65-
and kept grid size independent
77+
isolvl (float, optional): The cutoff for the isosurface if none is provided we default to
78+
a surface that encloses 20% of the weight.
6679
step_size (int, optional): step_size parameter for marching_cubes_lewiner. Defaults to 3.
80+
normalization (str, optional): Normalize the volumetric data by the volume of the unit cell.
81+
Default is 'vol', which divides the data by the volume of the unit cell, this is required
82+
for all VASP volumetric data formats. If normalization is 'vesta' we also change
83+
the units from Angstroms to Bohr.
6784
**kwargs: Passed to the Structure.get_scene() function.
6885
6986
Returns:
7087
Scene: object containing the structure and isosurface components
7188
"""
7289
struct_scene = self.structure.get_scene(**kwargs)
73-
iso_scene = self.get_isosurface_scene(
74-
data_key=data_key,
90+
vol_data = self.data[data_key]
91+
if normalization in ("vol", "vesta"):
92+
vol_data = vol_data / self.structure.volume
93+
if normalization == "vesta":
94+
vol_data = vol_data / _ANGS2_TO_BOHR3
95+
96+
iso_scene = get_isosurface_scene(
97+
data=vol_data,
98+
lattice=self.structure.lattice,
7599
isolvl=isolvl,
76100
step_size=step_size,
77101
origin=struct_scene.origin,
@@ -81,5 +105,4 @@ def get_volumetric_scene(self, data_key="total", isolvl=0.02, step_size=3, **kwa
81105

82106

83107
# todo: re-think origin, shift globally at end (scene.origin)
84-
VolumetricData.get_isosurface_scene = get_isosurface_scene
85108
VolumetricData.get_scene = get_volumetric_scene

tests/test_volumetric.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import pytest
2+
from pymatgen.io.vasp import Chgcar
3+
4+
5+
def test_volumetric(test_files):
6+
chgcar = Chgcar.from_file(test_files / "chgcar.vasp")
7+
max_val = chgcar.data["total"].max()
8+
9+
scene = chgcar.get_scene(isolvl=10, normalization=None)
10+
assert scene is not None
11+
12+
# out of range
13+
with pytest.raises(ValueError, match="Isosurface level is not within data range"):
14+
scene = chgcar.get_scene(isolvl=max_val * 2, normalization=None)
15+
16+
# cannot be computed
17+
with pytest.raises(RuntimeError):
18+
scene = chgcar.get_scene(isolvl=max_val / 2, normalization=None)
19+
20+
# vesta units
21+
scene = chgcar.get_scene(isolvl=0.001, normalization="vesta")

0 commit comments

Comments
 (0)