diff --git a/.github/requirements-3.10.txt b/.github/requirements-3.10.txt
index a85a1736..986ca9e2 100644
--- a/.github/requirements-3.10.txt
+++ b/.github/requirements-3.10.txt
@@ -18,7 +18,7 @@ kiwisolver==1.4.8
# via matplotlib
matplotlib==3.10.0
# via ase
-more-itertools==10.5.0
+more-itertools==10.6.0
# via parsnip (pyproject.toml)
numpy==2.2.1
# via
@@ -32,7 +32,8 @@ packaging==24.2
# via
# matplotlib
# pytest
-pillow==11.0.0
+ # pytest-doctestplus
+pillow==11.1.0
# via matplotlib
pluggy==1.5.0
# via pytest
@@ -43,10 +44,14 @@ pycifrw==4.4.6
pyparsing==3.2.1
# via matplotlib
pytest==8.3.4
+ # via
+ # -r tests/requirements.in
+ # pytest-doctestplus
+pytest-doctestplus==1.3.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
# via matplotlib
-scipy==1.14.1
+scipy==1.15.1
# via ase
six==1.17.0
# via python-dateutil
diff --git a/.github/requirements-3.11.txt b/.github/requirements-3.11.txt
index 27d68091..4d1b928d 100644
--- a/.github/requirements-3.11.txt
+++ b/.github/requirements-3.11.txt
@@ -16,7 +16,7 @@ kiwisolver==1.4.8
# via matplotlib
matplotlib==3.10.0
# via ase
-more-itertools==10.5.0
+more-itertools==10.6.0
# via parsnip (pyproject.toml)
numpy==2.2.1
# via
@@ -30,7 +30,8 @@ packaging==24.2
# via
# matplotlib
# pytest
-pillow==11.0.0
+ # pytest-doctestplus
+pillow==11.1.0
# via matplotlib
pluggy==1.5.0
# via pytest
@@ -41,10 +42,14 @@ pycifrw==4.4.6
pyparsing==3.2.1
# via matplotlib
pytest==8.3.4
+ # via
+ # -r tests/requirements.in
+ # pytest-doctestplus
+pytest-doctestplus==1.3.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
# via matplotlib
-scipy==1.14.1
+scipy==1.15.1
# via ase
six==1.17.0
# via python-dateutil
diff --git a/.github/requirements-3.12.txt b/.github/requirements-3.12.txt
index 733d950a..1fa3c123 100644
--- a/.github/requirements-3.12.txt
+++ b/.github/requirements-3.12.txt
@@ -16,7 +16,7 @@ kiwisolver==1.4.8
# via matplotlib
matplotlib==3.10.0
# via ase
-more-itertools==10.5.0
+more-itertools==10.6.0
# via parsnip (pyproject.toml)
numpy==2.2.1
# via
@@ -30,7 +30,8 @@ packaging==24.2
# via
# matplotlib
# pytest
-pillow==11.0.0
+ # pytest-doctestplus
+pillow==11.1.0
# via matplotlib
pluggy==1.5.0
# via pytest
@@ -41,10 +42,14 @@ pycifrw==4.4.6
pyparsing==3.2.1
# via matplotlib
pytest==8.3.4
+ # via
+ # -r tests/requirements.in
+ # pytest-doctestplus
+pytest-doctestplus==1.3.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
# via matplotlib
-scipy==1.14.1
+scipy==1.15.1
# via ase
six==1.17.0
# via python-dateutil
diff --git a/.github/requirements-3.13.txt b/.github/requirements-3.13.txt
index 4af3370f..aaab9daf 100644
--- a/.github/requirements-3.13.txt
+++ b/.github/requirements-3.13.txt
@@ -16,7 +16,7 @@ kiwisolver==1.4.8
# via matplotlib
matplotlib==3.10.0
# via ase
-more-itertools==10.5.0
+more-itertools==10.6.0
# via parsnip (pyproject.toml)
numpy==2.2.1
# via
@@ -30,7 +30,8 @@ packaging==24.2
# via
# matplotlib
# pytest
-pillow==11.0.0
+ # pytest-doctestplus
+pillow==11.1.0
# via matplotlib
pluggy==1.5.0
# via pytest
@@ -41,10 +42,14 @@ pycifrw==4.4.6
pyparsing==3.2.1
# via matplotlib
pytest==8.3.4
+ # via
+ # -r tests/requirements.in
+ # pytest-doctestplus
+pytest-doctestplus==1.3.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
# via matplotlib
-scipy==1.14.1
+scipy==1.15.1
# via ase
six==1.17.0
# via python-dateutil
diff --git a/.github/requirements-3.7.txt b/.github/requirements-3.7.txt
index 0328d0fe..8b786c9d 100644
--- a/.github/requirements-3.7.txt
+++ b/.github/requirements-3.7.txt
@@ -44,9 +44,11 @@ pycifrw==4.4.6
pyparsing==3.1.4
# via matplotlib
pytest==7.4.4
+ # via matplotlib
+pytest-doctestplus==1.0.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
- # via matplotlib
+ # via -r tests/requirements.in
scipy==1.7.3
# via ase
six==1.17.0
diff --git a/.github/requirements-3.8.txt b/.github/requirements-3.8.txt
index 8d4e11cf..d753427e 100644
--- a/.github/requirements-3.8.txt
+++ b/.github/requirements-3.8.txt
@@ -34,6 +34,7 @@ packaging==24.2
# via
# matplotlib
# pytest
+ # pytest-doctestplus
pillow==10.4.0
# via matplotlib
pluggy==1.5.0
@@ -45,6 +46,10 @@ pycifrw==4.4.6
pyparsing==3.1.4
# via matplotlib
pytest==8.3.4
+ # via
+ # -r tests/requirements.in
+ # pytest-doctestplus
+pytest-doctestplus==1.3.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
# via matplotlib
diff --git a/.github/requirements-3.9.txt b/.github/requirements-3.9.txt
index 892013a4..8ee932f7 100644
--- a/.github/requirements-3.9.txt
+++ b/.github/requirements-3.9.txt
@@ -12,7 +12,7 @@ fonttools==4.55.3
# via matplotlib
gemmi==0.7.0
# via -r tests/requirements.in
-importlib-resources==6.4.5
+importlib-resources==6.5.2
# via matplotlib
iniconfig==2.0.0
# via pytest
@@ -20,7 +20,7 @@ kiwisolver==1.4.7
# via matplotlib
matplotlib==3.9.4
# via ase
-more-itertools==10.5.0
+more-itertools==10.6.0
# via parsnip (pyproject.toml)
numpy==2.0.2
# via
@@ -34,7 +34,8 @@ packaging==24.2
# via
# matplotlib
# pytest
-pillow==11.0.0
+ # pytest-doctestplus
+pillow==11.1.0
# via matplotlib
pluggy==1.5.0
# via pytest
@@ -45,6 +46,10 @@ pycifrw==4.4.6
pyparsing==3.2.1
# via matplotlib
pytest==8.3.4
+ # via
+ # -r tests/requirements.in
+ # pytest-doctestplus
+pytest-doctestplus==1.3.0
# via -r tests/requirements.in
python-dateutil==2.9.0.post0
# via matplotlib
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 7e429929..57ccded9 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -4,7 +4,31 @@ Changelog
The format is based on `Keep a Changelog `__.
This project adheres to `Semantic Versioning `__.
-v0.x.x - 20xx-xx-xx
+v1.0.0 - 20xx-xx-xx
+-------------------
Added
~~~~~
+- Support for nonsimple (';'-delimited) data entries.
+- Improved support for entries containing special characters.
+- Ability to query multiple keys or columns simultaneously.
+- Additional tests for AMCSD and zeolite databases.
+- Additional documentation and examples for the new interface
+
+Changed
+~~~~~~~
+- Primary interface is now the ``CifFile`` object, which supports all previously implemented features in addition to several new methods.
+- Files are now parsed lazily, and are traversed a single time.
+
+Dependencies
+~~~~~~~~~~~~
+- Added ``more-itertools`` as a dependency for ``peekable`` iterators
+
+
+v0.1.0 - 2024-12-20
+-------------------
+
+Added
+~~~~~
+- Unitcells module
+- Function-based parsing interface for key and table reading
diff --git a/doc/requirements.in b/doc/requirements.in
index d2586601..d64ee66d 100644
--- a/doc/requirements.in
+++ b/doc/requirements.in
@@ -2,3 +2,6 @@ autodocsumm
furo
numpy>=1.26.4
sphinx>=7.3.7
+sphinx-copybutton
+sphinx-notfound-page
+pytest-doctestplus
diff --git a/doc/requirements.txt b/doc/requirements.txt
index afdf45c7..edd5ca6e 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -10,7 +10,7 @@ beautifulsoup4==4.12.3
# via furo
certifi==2024.12.14
# via requests
-charset-normalizer==3.4.0
+charset-normalizer==3.4.1
# via requests
docutils==0.21.2
# via sphinx
@@ -20,20 +20,33 @@ idna==3.10
# via requests
imagesize==1.4.1
# via sphinx
-jinja2==3.1.4
+iniconfig==2.0.0
+ # via pytest
+jinja2==3.1.5
# via sphinx
markupsafe==3.0.2
# via jinja2
-numpy==2.2.0
+more-itertools==10.6.0
+ # via parsnip (pyproject.toml)
+numpy==2.2.1
# via
# -r doc/requirements.in
# parsnip (pyproject.toml)
packaging==24.2
- # via sphinx
-pygments==2.18.0
+ # via
+ # pytest
+ # pytest-doctestplus
+ # sphinx
+pluggy==1.5.0
+ # via pytest
+pygments==2.19.1
# via
# furo
# sphinx
+pytest==8.3.4
+ # via pytest-doctestplus
+pytest-doctestplus==1.3.0
+ # via -r doc/requirements.in
requests==2.32.3
# via sphinx
snowballstemmer==2.2.0
@@ -46,8 +59,14 @@ sphinx==8.1.3
# autodocsumm
# furo
# sphinx-basic-ng
+ # sphinx-copybutton
+ # sphinx-notfound-page
sphinx-basic-ng==1.0.0b2
# via furo
+sphinx-copybutton==0.5.2
+ # via -r doc/requirements.in
+sphinx-notfound-page==1.0.4
+ # via -r doc/requirements.in
sphinxcontrib-applehelp==2.0.0
# via sphinx
sphinxcontrib-devhelp==2.0.0
@@ -60,5 +79,5 @@ sphinxcontrib-qthelp==2.0.0
# via sphinx
sphinxcontrib-serializinghtml==2.0.0
# via sphinx
-urllib3==2.2.3
+urllib3==2.3.0
# via requests
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 5f271c70..c787f09a 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -1,5 +1,4 @@
"""Configuration file for the Sphinx documentation builder."""
-
import datetime
CURRENT_YEAR = datetime.date.today().year
@@ -22,11 +21,19 @@
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.autosummary",
+ "sphinx.ext.doctest",
"sphinx.ext.intersphinx",
"sphinx.ext.napoleon",
+ "sphinx_copybutton",
+ "pytest_doctestplus.sphinx.doctestplus",
"autodocsumm",
+ "notfound.extension",
]
+copybutton_prompt_text = ">>> "
+copybutton_remove_prompts = True
+copybutton_line_continuation_character = "\\"
+
templates_path = ["_templates"]
exclude_patterns = ["build", "Thumbs.db", ".DS_Store"]
intersphinx_mapping = {
diff --git a/doc/source/quickstart.rst b/doc/source/quickstart.rst
index 223042fc..f79f00f3 100644
--- a/doc/source/quickstart.rst
+++ b/doc/source/quickstart.rst
@@ -11,48 +11,51 @@ Let's assume we have the file my_file.cif in the current directory, and these ar
Reading Keys
^^^^^^^^^^^^
-
Now, let's read extract the key-value pairs from our cif file. This subset of data
usually contains information to reconstruct the system's unit cell, and provides
information regarding the origin of the data.
-.. code-block:: python
-
- from parsnip import CifFile
- filename = "my_file.cif"
- cif = CifFile(filename)
- print(cif.pairs)
- ... {
- ... '_journal_year': '1999',
- ... '_journal_page_first': '0',
- ... '_journal_page_last': '123',
- ... '_chemical_name_mineral': "'Copper FCC'",
- ... '_chemical_formula_sum': "'Cu'",
- ... '_cell_length_a': '3.6',
- ... '_cell_length_b': '3.6',
- ... '_cell_length_c': '3.6',
- ... '_cell_angle_alpha': '90.0',
- ... '_cell_angle_beta': '90.0',
- ... '_cell_angle_gamma': '90.0'
- ... '_symmetry_space_group_name_H-M': 'Fm-3m'
- ... }
+
+.. testsetup::
+
+ >>> import os
+ >>> if "doc/source" not in os.getcwd(): os.chdir("doc/source")
+
+.. doctest::
+
+ >>> from parsnip import CifFile
+ >>> filename = "example_file.cif"
+ >>> cif = CifFile(filename)
+
+ >>> cif.pairs
+ {'_journal_year': '1999',
+ '_journal_page_first': '0',
+ '_journal_page_last': '123',
+ '_chemical_name_mineral': "'Copper FCC'",
+ '_chemical_formula_sum': "'Cu'",
+ '_cell_length_a': '3.6',
+ '_cell_length_b': '3.6',
+ '_cell_length_c': '3.6',
+ '_cell_angle_alpha': '90.0',
+ '_cell_angle_beta': '90.0',
+ '_cell_angle_gamma': '90.0',
+ '_symmetry_space_group_name_H-M': "'Fm-3m'"}
+
A `dict`-like getter syntax is provided to key-value pairs. Single keys function exactly
as a python dict, while lists of keys return lists of values. Keys not present in the
:attr:`~.pairs` dict instead return :code:`None`.
-.. code-block:: python
+.. doctest::
- cif["_journal_year"]
- ... "1999"
+ >>> cif["_journal_year"]
+ '1999'
- cif["_not_in_pairs"]
- ... None
+ >>> assert cif["_not_in_pairs"] is None
# Multiple keys can be accessed simultaneously!
- cif[["_cell_length_a", "_cell_length_b", "_cell_length_c"]]
-
- ... ["3.6", "3.6", "3.6"]
+ >>> cif[["_cell_length_a", "_cell_length_b", "_cell_length_c"]]
+ ['3.6', '3.6', '3.6']
Note that all data is stored and returned as strings by default. It is not generally
feasible to determine whether a piece of data should be processed, as conversions may
@@ -60,16 +63,15 @@ be lossy. Setting the :attr:`~.cast_values` property to :code:`True` reprocesses
data, converting to float or int where possible. Note that once data is reprocessed,
a new CifFile object must be created to restore the original string data
-.. code-block:: python
-
- cf.cast_values = True # Reprocesses our `pairs` dict
+.. doctest::
- cif["_journal_year"]
- ... 1999
+ >>> cif.cast_values = True # Reprocess our `pairs` dict
- cif[["_cell_length_a", "_cell_length_b", "_cell_length_c"]]
+ >>> cif["_journal_year"]
+ 1999
- ... [3.6, 3.6, 3.6]
+ >>> cif[["_cell_length_a", "_cell_length_b", "_cell_length_c"]]
+ [3.6, 3.6, 3.6]
Reading Tables
@@ -93,37 +95,31 @@ list of such arrays, although the :attr:`~.get_from_loops` method is often more
convenient.
-.. code-block:: python
-
-
- len(cif.loops)
- ... 2
+.. doctest::
- cif.loops[0]
- ... array(
- ... [[('Cu1', '0.0000000000', '0.0000000000', '0.0000000000', 'Cu', 'a')]],
- ... dtype=[
- ... ('_atom_site_label', '>> len(cif.loops)
+ 2
- cif.loops[0]["_atom_site_label"]
- ... array([['Cu1']], dtype='>> cif.loops[0]
+ array([[('Cu1', '0.0000000000', '0.0000000000', '0.0000000000', 'Cu', 'a')]],
+ dtype=[('_atom_site_label', '>> cif.loops[0]["_atom_site_label"]
+ array([['Cu1']], dtype='>> xyz = cif.get_from_loops(["_atom_site_fract_x", "_atom_site_fract_y", "_atom_site_fract_z"])
- print(xyz)
- ... array([['0.0000000000', '0.0000000000', '0.0000000000']], dtype='>> xyz
+ array([['0.0000000000', '0.0000000000', '0.0000000000']], dtype='>> xyz.astype(float)
+ array([[0., 0., 0.]])
@@ -145,14 +141,26 @@ and symmetry-irreducible (Wyckoff) positions contained in the file.
Only one line is required to build a tilable unit cell! The positions returned here
are in fractional coordinates, and can be imported into tools like `freud`_ to rapidly
build out supercells. For absolute coordinates (based on cell parameters stored in the
-file), set :code:`fractional=False`
+file), set :code:`fractional=False`.
.. _`freud`: https://freud.readthedocs.io/en/latest/modules/data.html#freud.data.UnitCell
-.. code-block:: python
+.. doctest::
+
+ >>> pos = cif.build_unit_cell(fractional=True)
+ >>> print(pos)
+ [[0. 0. 0. ]
+ [0. 0.5 0.5]
+ [0.5 0. 0.5]
+ [0.5 0.5 0. ]]
+
+Once `freud`_ is installed, crystal structures can be easily replicated!
+
+.. doctest-requires:: freud
- pos = cif.build_unit_cell(fractional=True)
- ... array([[0. , 0. , 0. ],
- ... [0. , 0.5, 0.5],
- ... [0.5, 0. , 0.5],
- ... [0.5, 0.5, 0. ]])
+ >>> import freud
+ >>> box = freud.Box(*cif.box)
+ >>> uc = freud.data.UnitCell(box, basis_positions=pos)
+ >>> box, pos = uc.generate_system(num_replicas=2)
+ >>> assert len(pos) == 4 * 2**3
+ >>> np.testing.assert_allclose(box.L / 2, 3.6, atol=1e-15)
diff --git a/parsnip/conftest.py b/parsnip/conftest.py
index d2a7e29a..baff48df 100644
--- a/parsnip/conftest.py
+++ b/parsnip/conftest.py
@@ -13,5 +13,9 @@
# Set up doctests
@pytest.fixture(autouse=True)
def _setup_doctest(doctest_namespace):
+ import os
+
+ if "doc/source" not in os.getcwd():
+ os.chdir("doc/source")
doctest_namespace["np"] = np
- doctest_namespace["cif"] = CifFile("doc/source/example_file.cif")
+ doctest_namespace["cif"] = CifFile("example_file.cif")
diff --git a/parsnip/parsnip.py b/parsnip/parsnip.py
index 0349298d..616a1fb1 100644
--- a/parsnip/parsnip.py
+++ b/parsnip/parsnip.py
@@ -63,6 +63,7 @@
from parsnip._errors import ParseWarning
from parsnip.patterns import (
_accumulate_nonsimple_data,
+ _box_from_lengths_and_angles,
_dtype_from_int,
_is_data,
_is_key,
@@ -86,9 +87,9 @@ class CifFile:
To get started, simply provide a filename:
>>> from parsnip import CifFile
- >>> cif = CifFile("doc/source/example_file.cif")
+ >>> cif = CifFile("example_file.cif")
>>> print(cif)
- CifFile(fn=doc/source/example_file.cif) : 12 data entries, 2 data loops
+ CifFile(fn=example_file.cif) : 12 data entries, 2 data loops
Data entries are accessible via the :attr:`~.pairs` and :attr:`~.loops` attributes:
@@ -302,28 +303,13 @@ def __getitem__(self, index: str | Iterable[str]):
if isinstance(index, Iterable) and not isinstance(index, str):
return [self.pairs.get(k, None) for k in index]
- return self.pairs[index]
+ return self.pairs.get(index, None)
def read_cell_params(self, degrees: bool = True, mmcif: bool = False):
r"""Read the `unit cell parameters`_ (lengths and angles) from a CIF file.
.. _`unit cell parameters`: https://www.iucr.org/__data/iucr/cifdic_html/1/cif_core.dic/Ccell.html
-
- Example
- -------
- The data returned from this function can be used to create a `freud Box`_.
-
- .. _`freud Box`: https://freud.readthedocs.io/en/latest/gettingstarted/examples/module_intros/box.Box.html
-
- >>> cell = cif.read_cell_params(degrees=False)
- >>> print(cell)
- (3.6, 3.6, 3.6, 1.5707963, 1.5707963, 1.5707963)
- >>> assert cell==cif.cell
- >>> import freud # doctest: +SKIP
- >>> box = freud.box.Box.from_box_lengths_and_angles(cell) # doctest: +SKIP
- freud.box.Box(Lx=3.6, Ly=3.6, Lz=3.6, xy=0, xz=0, yz=0, ...) # doctest: +SKIP
-
Parameters
----------
degrees : bool, optional
@@ -337,7 +323,7 @@ def read_cell_params(self, degrees: bool = True, mmcif: bool = False):
Returns
-------
tuple[float]:
- The box vector lengths in angstroms, and angles in degrees or radians
+ The box vector lengths (in angstroms) and angles (in degrees or radians)
:math:`(L_1, L_2, L_3, \alpha, \beta, \gamma)`.
Raises
@@ -361,7 +347,10 @@ def read_cell_params(self, degrees: bool = True, mmcif: bool = False):
"_cell_length_c",
*angle_keys,
)
- cell_data = cast_array_to_float(arr=self[box_keys], dtype=np.float64)
+ if self.cast_values:
+ cell_data = [float(x) for x in self[box_keys]]
+ else:
+ cell_data = cast_array_to_float(arr=self[box_keys], dtype=np.float64)
def angle_is_invalid(x: float):
return x <= 0.0 or x >= 180.0
@@ -537,12 +526,41 @@ def cast_values(self, cast: bool):
self._cast_values = cast
@property
- def cell(self):
- """Read the unit cell lengths in Angstroms and angles in radians.
+ def box(self):
+ """Read the unit cell as a `freud`_ or HOOMD `box-like`_ object.
+
+ .. _`box-like`: https://hoomd-blue.readthedocs.io/en/v5.0.0/hoomd/module-box.html#hoomd.box.box_like
+ .. _`freud`: https://freud.readthedocs.io/en/latest/gettingstarted/examples/module_intros/box.Box.html
+
+ .. important::
- Alias for :code:`read_cell_params(degrees=False, mmcif=False)`
+ ``cif.box`` returns box extents and tilt factors, while
+ ``CifFile.read_cell_params`` returns unit cell vector lengths and angles.
+ See the `box-like`_ documentation linked above for more details.
+
+ Example
+ -------
+ This method provides a convinient interface to create box objects.
+
+ >>> box = cif.box
+ >>> print(box)
+ (3.6, 3.6, 3.6, 0.0, 0.0, 0.0)
+ >>> import freud, hoomd # doctest: +SKIP
+ >>> freud.Box(*box) # doctest: +SKIP
+ freud.box.Box(Lx=3.6, Ly=3.6, Lz=3.6, xy=0, xz=0, yz=0, ...)
+ >>> hoomd.Box(*box) # doctest: +SKIP
+ hoomd.box.Box(Lx=3.6, Ly=3.6, Lz=3.6, xy=0.0, xz=0.0, yz=0.0)
+
+
+ Returns
+ -------
+ tuple[float]:
+ The box vector lengths (in angstroms) and unitless tilt factors.
+ :math:`(L_1, L_2, L_3, xy, xz, yz)`.
"""
- return self.read_cell_params(degrees=False, mmcif=False)
+ return _box_from_lengths_and_angles(
+ *self.read_cell_params(degrees=False, mmcif=False)
+ )
@classmethod
def structured_to_unstructured(cls, arr: np.ndarray):
diff --git a/parsnip/patterns.py b/parsnip/patterns.py
index ab25310d..b3cddeca 100644
--- a/parsnip/patterns.py
+++ b/parsnip/patterns.py
@@ -99,6 +99,9 @@ def cast_array_to_float(arr: ArrayLike, dtype: type = np.float32):
-------
np.array[dtype]: Array with new dtype and no significant digit information.
"""
+ arr = [(el if el is not None else "nan") for el in arr]
+ # if any(el is None for el in arr):
+ # raise TypeError("Input array contains `None` and cannot be cast!")
return np.char.partition(arr, "(")[..., 0].astype(dtype)
@@ -177,5 +180,26 @@ def _matrix_from_lengths_and_angles(l1, l2, l3, alpha, beta, gamma):
if under_sqrt < 0:
raise ValueError("The provided angles can not form a valid box.")
a3z = np.sqrt(under_sqrt)
+ a2 = np.array([l2 * np.cos(gamma), l2 * np.sin(gamma), 0])
a3 = np.array([l3 * a3x, l3 * a3y, l3 * a3z])
+
return np.array([a1, a2, a3])
+
+
+def _box_from_lengths_and_angles(l1, l2, l3, alpha, beta, gamma):
+ lx = l1
+ ly = l2 * np.sin(gamma)
+
+ a3y = (np.cos(alpha) - np.cos(beta) * np.cos(gamma)) / np.sin(gamma)
+
+ lz = l3 * np.sqrt(1 - np.cos(beta) ** 2 - a3y**2)
+
+ a2x = (l3 * l1 * np.cos(beta)) / lx
+ b = l2 * np.cos(gamma)
+ c = b * a2x + ly * l3 * a3y
+
+ xy = np.cos(gamma) / np.sin(gamma)
+ xz = a2x / lz
+ yz = (c - b * a2x) / (ly * lz)
+
+ return tuple(float(x) for x in [lx, ly, lz, xy, xz, yz])
diff --git a/pyproject.toml b/pyproject.toml
index af2c61fd..52d4343e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,9 +36,11 @@ optional-dependencies = {tests = { file = ["tests/requirements.in"] }, doc = { f
[tool.pytest.ini_options]
testpaths = ["tests", "parsnip", "doc"]
+addopts = ["--doctest-plus", "--doctest-glob='*.rst'"]
+doctest_rst = true
+doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS", "FLOAT_CMP"]
console_output_style = "progress"
-addopts = ["--doctest-modules", "--doctest-glob='*.rst'"]
-doctest_optionflags = ["NORMALIZE_WHITESPACE", "ELLIPSIS", "NUMBER"]
+
[tool.ruff]
include = ["*.py", "*.ipynb"]
diff --git a/test/output.txt b/test/output.txt
new file mode 100644
index 00000000..39dfbb0f
--- /dev/null
+++ b/test/output.txt
@@ -0,0 +1,26 @@
+Results of doctest builder run on 2025-01-14 12:57:15
+=====================================================
+
+Document: package-parse
+-----------------------
+**********************************************************************
+File "../../parsnip/parsnip.py", line ?, in default
+Failed example:
+ print(cell)
+Expected:
+ (3.6, 3.6, 3.6, 1.5707963, 1.5707963, 1.5707963)
+Got:
+ (3.6, 3.6, 3.6, 1.5707963267948966, 1.5707963267948966, 1.5707963267948966)
+**********************************************************************
+1 items had failures:
+ 1 of 18 in default
+18 tests in 1 items.
+17 passed and 1 failed.
+***Test Failed*** 1 failures.
+
+Doctest summary
+===============
+ 18 tests
+ 1 failure in tests
+ 0 failures in setup code
+ 0 failures in cleanup code
diff --git a/tests/requirements.in b/tests/requirements.in
index bbe5f4d4..1902320f 100644
--- a/tests/requirements.in
+++ b/tests/requirements.in
@@ -2,3 +2,4 @@ ase
gemmi
pycifrw
pytest
+pytest-doctestplus
diff --git a/tests/test_patterns.py b/tests/test_patterns.py
index 088fc5b6..f941e707 100644
--- a/tests/test_patterns.py
+++ b/tests/test_patterns.py
@@ -1,8 +1,10 @@
import numpy as np
import pytest
+from conftest import cif_files_mark
from parsnip._errors import ParseWarning
from parsnip.patterns import (
+ _box_from_lengths_and_angles,
_dtype_from_int,
_is_data,
_is_key,
@@ -198,3 +200,27 @@ def test_try_cast_to_numeric(s):
assert isinstance(result, float)
else:
assert isinstance(result, int)
+
+
+@cif_files_mark
+def test_box(cif_data):
+ freud = pytest.importorskip("freud")
+
+ cif_box = cif_data.file.read_cell_params(
+ degrees=False, mmcif="PDB" in cif_data.filename
+ )
+
+ freud_box = freud.Box.from_box_lengths_and_angles(*cif_box)
+ freud_box_2 = freud.Box(*cif_data.file.box)
+ parsnip_box = _box_from_lengths_and_angles(*cif_box)
+
+ np.testing.assert_allclose(parsnip_box[:3], freud_box.L, atol=1e-15)
+ np.testing.assert_allclose(
+ parsnip_box[3:], [freud_box.xy, freud_box.xz, freud_box.yz], atol=1e-15
+ )
+ if "PDB" not in cif_data.filename:
+ np.testing.assert_allclose(
+ [*freud_box.L, freud_box.xy, freud_box.xz, freud_box.yz],
+ [*freud_box_2.L, freud_box_2.xy, freud_box_2.xz, freud_box_2.yz],
+ atol=1e-8,
+ )
diff --git a/tests/test_unitcells.py b/tests/test_unitcells.py
index 77596df2..27f103a4 100644
--- a/tests/test_unitcells.py
+++ b/tests/test_unitcells.py
@@ -39,11 +39,6 @@ def test_read_cell_params(cif_data, keys=box_keys):
gemmi_data = _gemmi_read_keys(cif_data.filename, keys)
np.testing.assert_array_equal(parsnip_data, gemmi_data)
- if not mmcif:
- np.testing.assert_array_equal(
- cif_data.file.cell[3:], np.deg2rad(gemmi_data[3:])
- )
-
@cif_files_mark
def test_read_symmetry_operations(cif_data):