diff --git a/src/lammpsio/box.py b/src/lammpsio/box.py index 1e4e09a1..3965c743 100644 --- a/src/lammpsio/box.py +++ b/src/lammpsio/box.py @@ -63,6 +63,59 @@ def cast(cls, value): else: raise TypeError(f"Unable to cast boxlike object with shape {v.shape}") + @classmethod + def from_matrix(cls, low, matrix, force_triclinic=False): + """Create a Box from low and matrix. + + Parameters + ---------- + low : list + Origin of the box. + matrix : :class:`numpy.ndarray` + Upper triangular matrix in LAMMPS style:: + + [[lx, xy, xz], + [0, ly, yz], + [0, 0, lz]] + force_triclinic : bool + If ``True``, forces the box to be triclinic even if the tilt + factors are zero. Default is ``False``. + + Returns + ------- + :class:`Box` + A simulation box. + + Raises + ------ + TypeError + If `low` is not length 3. + TypeError + If `matrix` is not a 3x3 array. + ValueError + If `matrix` is not upper triangular. + + """ + low = numpy.array(low, dtype=float) + arr = numpy.array(matrix, dtype=float) + + if low.shape != (3,): + raise TypeError("Low must be a 3-tuple") + if arr.shape != (3, 3): + raise TypeError("Box matrix must be a 3x3 array") + if arr[1, 0] != 0 or arr[2, 0] != 0 or arr[2, 1] != 0: + raise ValueError("Box matrix must be upper triangular") + + # Calculate high from the matrix + high = low + numpy.diag(arr) + + # Extract tilt factors + tilt = [arr[0, 1], arr[0, 2], arr[1, 2]] + if not force_triclinic and not numpy.any(tilt): + tilt = None + + return cls(low, high, tilt) + @property def low(self): """:class:`numpy.ndarray`: Box low.""" diff --git a/tests/test_box.py b/tests/test_box.py index aaa612c5..5688a2e4 100644 --- a/tests/test_box.py +++ b/tests/test_box.py @@ -1,5 +1,8 @@ import numpy import pytest +from pytest_lazy_fixtures import lf + +import lammpsio def test_orthorhombic(orthorhombic): @@ -39,3 +42,40 @@ def test_triclinic(triclinic): assert numpy.allclose(box.tilt, [0, 0, 0]) with pytest.raises(TypeError): box.tilt = [0, 0] + + +@pytest.mark.parametrize("box", [lf("orthorhombic"), lf("triclinic")]) +@pytest.mark.parametrize("force_triclinic", [True, False]) +def test_from_matrix(box, force_triclinic): + lx, ly, lz = box.high - box.low + xy, xz, yz = box.tilt if box.tilt is not None else (0, 0, 0) + matrix = numpy.array([[lx, xy, xz], [0, ly, yz], [0, 0, lz]]) + new_box = lammpsio.Box.from_matrix(box.low, matrix, force_triclinic=force_triclinic) + + assert numpy.allclose(new_box.low, box.low) + assert numpy.allclose(new_box.high, box.high) + if force_triclinic: + assert new_box.tilt is not None + if box.tilt is not None: + assert numpy.allclose(new_box.tilt, box.tilt) + else: + assert numpy.allclose(new_box.tilt, [0, 0, 0]) + else: + if box.tilt is not None: + assert numpy.allclose(new_box.tilt, box.tilt) + else: + assert new_box.tilt is None + + # test with invalid low + with pytest.raises(TypeError): + lammpsio.Box.from_matrix([0, 0], matrix) + + # test with invalid matrix shape + invalid_matrix_shape = numpy.array([[lx, xy], [ly, yz]]) + with pytest.raises(TypeError): + lammpsio.Box.from_matrix(box.low, invalid_matrix_shape) + + # test with invalid matrix values + invalid_matrix = numpy.array([[lx, xy, xz], [ly, 0, yz], [lz, 0, 0]]) + with pytest.raises(ValueError): + lammpsio.Box.from_matrix(box.low, invalid_matrix)