From f210db762d2e71e6b93cac20991ce0b0c663f716 Mon Sep 17 00:00:00 2001 From: Toon Verstraelen Date: Thu, 27 Jun 2024 06:31:53 +0200 Subject: [PATCH 1/5] Complete prepare_dump API and apply to occs_aminusb + cleanups --- iodata/__main__.py | 4 +- iodata/api.py | 56 ++++++++--- iodata/formats/fchk.py | 18 +++- iodata/formats/json.py | 17 +++- iodata/formats/molden.py | 22 ++++- iodata/formats/molekel.py | 36 +++++-- iodata/formats/wfn.py | 22 ++++- iodata/formats/wfx.py | 22 ++++- iodata/orbitals.py | 37 +++++++- iodata/prepare.py | 94 +++++++++++++++++++ iodata/test/data/water_wrong_spinmult.mkl | 91 ++++++++++++++++++ iodata/test/test_molekel.py | 11 ++- iodata/test/test_orbitals.py | 96 ++++++++++++++++++- iodata/test/test_prepare.py | 109 ++++++++++++++++++++++ iodata/utils.py | 5 + 15 files changed, 600 insertions(+), 40 deletions(-) create mode 100644 iodata/prepare.py create mode 100644 iodata/test/data/water_wrong_spinmult.mkl create mode 100644 iodata/test/test_prepare.py diff --git a/iodata/__main__.py b/iodata/__main__.py index e8228ffe..07944179 100755 --- a/iodata/__main__.py +++ b/iodata/__main__.py @@ -113,9 +113,9 @@ def convert(infn, outfn, many, infmt, outfmt): """ if many: - dump_many((data for data in load_many(infn, infmt)), outfn, outfmt) + dump_many((data for data in load_many(infn, fmt=infmt)), outfn, fmt=outfmt) else: - dump_one(load_one(infn, infmt), outfn, outfmt) + dump_one(load_one(infn, fmt=infmt), outfn, fmt=outfmt) def main(): diff --git a/iodata/api.py b/iodata/api.py index 2669df2f..a18df856 100644 --- a/iodata/api.py +++ b/iodata/api.py @@ -247,7 +247,14 @@ def _check_required(filename: str, iodata: IOData, dump_func: Callable): @_reissue_warnings -def dump_one(iodata: IOData, filename: str, fmt: Optional[str] = None, **kwargs): +def dump_one( + iodata: IOData, + filename: str, + *, + fmt: Optional[str] = None, + allow_changes: bool = False, + **kwargs, +): """Write data to a file. This routine uses the extension or prefix of the filename to determine @@ -263,25 +270,35 @@ def dump_one(iodata: IOData, filename: str, fmt: Optional[str] = None, **kwargs) fmt The name of the file format module to use. When not given, it is guessed from the filename. + allow_changes + Whether conversion is allowed or not. **kwargs Keyword arguments are passed on to the format-specific dump_one function. + Returns + ------- + data + The given ``IOData`` object or a shallow copy with some new attributes if converted. + Raises ------ DumpError When an error is encountered while dumping to a file. If the output file already existed, it is (partially) overwritten. PrepareDumpError - When the iodata object is not compatible with the file format, - e.g. due to missing attributes, and not conversion is available or allowed + When the ``IOData`` object is not compatible with the file format, + e.g. due to missing attributes, and no conversion is available or allowed to make it compatible. If the output file already existed, it is not overwritten. + PrepareDumpWarning + When the ``IOData`` object is not compatible with the file format, + but it was converted to fix the compatibility issue. """ format_module = _select_format_module(filename, "dump_one", fmt) try: _check_required(filename, iodata, format_module.dump_one) if hasattr(format_module, "prepare_dump"): - format_module.prepare_dump(filename, iodata) + iodata = format_module.prepare_dump(iodata, allow_changes, filename) except PrepareDumpError: raise except Exception as exc: @@ -295,10 +312,18 @@ def dump_one(iodata: IOData, filename: str, fmt: Optional[str] = None, **kwargs) raise except Exception as exc: raise DumpError("Uncaught exception while dumping to a file", filename) from exc + return iodata @_reissue_warnings -def dump_many(iodatas: Iterable[IOData], filename: str, fmt: Optional[str] = None, **kwargs): +def dump_many( + iodatas: Iterable[IOData], + filename: str, + *, + fmt: Optional[str] = None, + allow_changes: bool = False, + **kwargs, +): """Write multiple IOData instances to a file. This routine uses the extension or prefix of the filename to determine @@ -313,6 +338,8 @@ def dump_many(iodatas: Iterable[IOData], filename: str, fmt: Optional[str] = Non The file to write the data to. fmt The name of the file format module to use. + allow_changes + Whether conversion is allowed or not. **kwargs Keyword arguments are passed on to the format-specific dump_many function. @@ -322,12 +349,15 @@ def dump_many(iodatas: Iterable[IOData], filename: str, fmt: Optional[str] = Non When an error is encountered while dumping to a file. If the output file already existed, it (partially) overwritten. PrepareDumpError - When the iodata object is not compatible with the file format, - e.g. due to missing attributes, and not conversion is available or allowed + When an ``IOData`` object is not compatible with the file format, + e.g. due to missing attributes, and no conversion is available or allowed to make it compatible. If the output file already existed, it is not overwritten when this error - is raised while processing the first IOData instance in the ``iodatas`` argument. + is raised while processing the first ``IOData`` instance in the ``iodatas`` argument. When the exception is raised in later iterations, any existing file is overwritten. + PrepareDumpWarning + When an ``IOData`` object is not compatible with the file format, + but it was converted to fix the compatibility issue. """ format_module = _select_format_module(filename, "dump_many", fmt) @@ -342,7 +372,7 @@ def dump_many(iodatas: Iterable[IOData], filename: str, fmt: Optional[str] = Non try: _check_required(filename, first, format_module.dump_many) if hasattr(format_module, "prepare_dump"): - format_module.prepare_dump(filename, first) + first = format_module.prepare_dump(first, allow_changes, filename) except PrepareDumpError: raise except Exception as exc: @@ -356,9 +386,11 @@ def checking_iterator(): yield first for other in iter_iodatas: _check_required(filename, other, format_module.dump_many) - if hasattr(format_module, "prepare_dump"): - format_module.prepare_dump(filename, other) - yield other + yield ( + format_module.prepare_dump(other, allow_changes, filename) + if hasattr(format_module, "prepare_dump") + else other + ) with open(filename, "w") as f: try: diff --git a/iodata/formats/fchk.py b/iodata/formats/fchk.py index e35f91f5..977bf3a3 100644 --- a/iodata/formats/fchk.py +++ b/iodata/formats/fchk.py @@ -542,15 +542,28 @@ def _dump_real_arrays(name: str, val: NDArray[float], f: TextIO): k = 0 -def prepare_dump(filename: str, data: IOData): +def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: """Check the compatibility of the IOData object with the FCHK format. Parameters ---------- + data + The IOData instance to be checked. + allow_changes + Whether conversion is allowed or not. + (not relevant for FCHK, present for API consistency) filename The file to be written to, only used for error messages. + + Returns + ------- data - The IOData instance to be checked. + The given ``IOData`` object. + + Raises + ------ + PrepareDumpError + If the given ``IOData`` instance is not compatible with the WFN format. """ if data.mo is not None: if data.mo.kind == "generalized": @@ -569,6 +582,7 @@ def prepare_dump(filename: str, data: IOData): "followed by fully virtual ones.", filename, ) + return data @document_dump_one( diff --git a/iodata/formats/json.py b/iodata/formats/json.py index 019ecd18..137f9adc 100644 --- a/iodata/formats/json.py +++ b/iodata/formats/json.py @@ -1446,16 +1446,28 @@ def _parse_provenance( return base_provenance -def prepare_dump(filename: str, data: IOData): +def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: """Check the compatibility of the IOData object with QCScheme. Parameters ---------- + data + The IOData instance to be checked. + allow_changes + Whether conversion is allowed or not. + (not relevant for QCSchema JSON, present for API consistency) filename The file to be written to, only used for error messages. + + Returns + ------- data - The IOData instance to be checked. + The given ``IOData`` object. + Raises + ------ + PrepareDumpError + If the given ``IOData`` instance is not compatible with the WFN format. """ if "schema_name" not in data.extra: raise PrepareDumpError( @@ -1464,6 +1476,7 @@ def prepare_dump(filename: str, data: IOData): schema_name = data.extra["schema_name"] if schema_name == "qcschema_basis": raise PrepareDumpError(f"{schema_name} not yet implemented in IOData.", filename) + return data @document_dump_one( diff --git a/iodata/formats/molden.py b/iodata/formats/molden.py index 14528094..11a0db2e 100644 --- a/iodata/formats/molden.py +++ b/iodata/formats/molden.py @@ -46,6 +46,7 @@ from ..orbitals import MolecularOrbitals from ..overlap import compute_overlap, gob_cart_normalization from ..periodic import num2sym, sym2num +from ..prepare import prepare_unrestricted_aminusb from ..utils import DumpError, LineIterator, LoadError, LoadWarning, PrepareDumpError, angstrom __all__ = [] @@ -768,24 +769,37 @@ def _fix_molden_from_buggy_codes(result: dict, lit: LineIterator, norm_threshold ) -def prepare_dump(filename: str, data: IOData): +def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: """Check the compatibility of the IOData object with the Molden format. Parameters ---------- + data + The IOData instance to be checked. + allow_changes + Whether conversion is allowed or not. filename The file to be written to, only used for error messages. + + Returns + ------- data - The IOData instance to be checked. + The given ``IOData`` object or a shallow copy with some new attributes. + + Raises + ------ + PrepareDumpError + If the given ``IOData`` instance is not compatible with the WFN format. + PrepareDumpWarning + If the a converted ``IOData`` instance is returned. """ if data.mo is None: raise PrepareDumpError("The Molden format requires molecular orbitals.", filename) if data.obasis is None: raise PrepareDumpError("The Molden format requires an orbital basis set.", filename) - if data.mo.occs_aminusb is not None: - raise PrepareDumpError("Cannot write Molden file when mo.occs_aminusb is set.", filename) if data.mo.kind == "generalized": raise PrepareDumpError("Cannot write Molden file with generalized orbitals.", filename) + return prepare_unrestricted_aminusb(data, allow_changes, filename, "Molden") @document_dump_one("Molden", ["atcoords", "atnums", "mo", "obasis"], ["atcorenums", "title"]) diff --git a/iodata/formats/molekel.py b/iodata/formats/molekel.py index c6c3e353..71649a3d 100644 --- a/iodata/formats/molekel.py +++ b/iodata/formats/molekel.py @@ -24,6 +24,7 @@ """ from typing import TextIO +from warnings import warn import numpy as np from numpy.typing import NDArray @@ -32,7 +33,8 @@ from ..docstrings import document_dump_one, document_load_one from ..iodata import IOData from ..orbitals import MolecularOrbitals -from ..utils import DumpError, LineIterator, LoadError, PrepareDumpError, angstrom +from ..prepare import prepare_unrestricted_aminusb +from ..utils import DumpError, LineIterator, LoadError, LoadWarning, PrepareDumpError, angstrom from .molden import CONVENTIONS, _fix_molden_from_buggy_codes __all__ = [] @@ -235,7 +237,16 @@ def load_one(lit: LineIterator, norm_threshold: float = 1e-4) -> dict: ) nalpha = int(np.round(occsa.sum())) nbeta = int(np.round(occsb.sum())) - assert abs(spinpol - abs(nalpha - nbeta)) < 1e-7 + if abs(spinpol - abs(nalpha - nbeta)) > 1e-7: + warn( + LoadWarning( + f"The spin polarization ({spinpol}) is inconsistent with the" + f"difference between alpha and beta occupation numbers ({nalpha} - {nbeta}). " + "The spin polarization will be rederived from the occupation numbers.", + lit, + ), + stacklevel=2, + ) assert nelec == nalpha + nbeta assert coeffsa.shape == coeffsb.shape assert energiesa.shape == energiesb.shape @@ -261,24 +272,37 @@ def load_one(lit: LineIterator, norm_threshold: float = 1e-4) -> dict: return result -def prepare_dump(filename: str, data: IOData): +def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: """Check the compatibility of the IOData object with the Molekel format. Parameters ---------- + data + The IOData instance to be checked. + allow_changes + Whether conversion is allowed or not. filename The file to be written to, only used for error messages. + + Returns + ------- data - The IOData instance to be checked. + The given ``IOData`` object or a shallow copy with some new attributes. + + Raises + ------ + PrepareDumpError + If the given ``IOData`` instance is not compatible with the WFN format. + PrepareDumpWarning + If the a converted ``IOData`` instance is returned. """ if data.mo is None: raise PrepareDumpError("The Molekel format requires molecular orbitals.", filename) if data.obasis is None: raise PrepareDumpError("The Molekel format requires an orbital basis set.", filename) - if data.mo.occs_aminusb is not None: - raise PrepareDumpError("Cannot write Molekel file when mo.occs_aminusb is set.", filename) if data.mo.kind == "generalized": raise PrepareDumpError("Cannot write Molekel file with generalized orbitals.", filename) + return prepare_unrestricted_aminusb(data, allow_changes, filename, "Molekel") @document_dump_one("Molekel", ["atcoords", "atnums", "mo", "obasis"], ["atcharges"]) diff --git a/iodata/formats/wfn.py b/iodata/formats/wfn.py index 05fce829..8ab69468 100644 --- a/iodata/formats/wfn.py +++ b/iodata/formats/wfn.py @@ -38,6 +38,7 @@ from ..orbitals import MolecularOrbitals from ..overlap import gob_cart_normalization from ..periodic import num2sym, sym2num +from ..prepare import prepare_unrestricted_aminusb from ..utils import LineIterator, LoadError, PrepareDumpError __all__ = [] @@ -496,15 +497,29 @@ def _dump_helper_section(f: TextIO, data: NDArray, fmt: str, skip: int, step: in DEFAULT_WFN_TTL = "WFN auto-generated by IOData" -def prepare_dump(filename: str, data: IOData): +def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: """Check the compatibility of the IOData object with the WFN format. Parameters ---------- + data + The IOData instance to be checked. + allow_changes + Whether conversion is allowed or not. filename The file to be written to, only used for error messages. + + Returns + ------- data - The IOData instance to be checked. + The given ``IOData`` object or a shallow copy with some new attributes. + + Raises + ------ + PrepareDumpError + If the given ``IOData`` instance is not compatible with the WFN format. + PrepareDumpWarning + If the a converted ``IOData`` instance is returned. """ if data.mo is None: raise PrepareDumpError("The WFN format requires molecular orbitals", filename) @@ -512,13 +527,12 @@ def prepare_dump(filename: str, data: IOData): raise PrepareDumpError("The WFN format requires an orbital basis set", filename) if data.mo.kind == "generalized": raise PrepareDumpError("Cannot write WFN file with generalized orbitals.", filename) - if data.mo.occs_aminusb is not None: - raise PrepareDumpError("Cannot write WFN file when mo.occs_aminusb is set.", filename) for shell in data.obasis.shells: if any(kind != "c" for kind in shell.kinds): raise PrepareDumpError( "The WFN format only supports Cartesian MolecularBasis.", filename ) + return prepare_unrestricted_aminusb(data, allow_changes, filename, "WFN") @document_dump_one( diff --git a/iodata/formats/wfx.py b/iodata/formats/wfx.py index 2a15501f..bd834df2 100644 --- a/iodata/formats/wfx.py +++ b/iodata/formats/wfx.py @@ -32,6 +32,7 @@ from ..iodata import IOData from ..orbitals import MolecularOrbitals from ..periodic import num2sym +from ..prepare import prepare_unrestricted_aminusb from ..utils import LineIterator, LoadError, LoadWarning, PrepareDumpError from .wfn import CONVENTIONS, build_obasis, get_mocoeff_scales @@ -329,15 +330,29 @@ def load_one(lit: LineIterator) -> dict: } -def prepare_dump(filename: str, data: IOData): +def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: """Check the compatibility of the IOData object with the WFX format. Parameters ---------- + data + The IOData instance to be checked. + allow_changes + Whether conversion is allowed or not. filename The file to be written to, only used for error messages. + + Returns + ------- data - The IOData instance to be checked. + The given ``IOData`` object or a shallow copy with some new attributes. + + Raises + ------ + PrepareDumpError + If the given ``IOData`` instance is not compatible with the WFN format. + PrepareDumpWarning + If the a converted ``IOData`` instance is returned. """ if data.mo is None: raise PrepareDumpError("The WFX format requires molecular orbitals.", filename) @@ -345,13 +360,12 @@ def prepare_dump(filename: str, data: IOData): raise PrepareDumpError("The WFX format requires an orbital basis set.", filename) if data.mo.kind == "generalized": raise PrepareDumpError("Cannot write WFX file with generalized orbitals.", filename) - if data.mo.occs_aminusb is not None: - raise PrepareDumpError("Cannot write WFX file when mo.occs_aminusb is set.", filename) for shell in data.obasis.shells: if any(kind != "c" for kind in shell.kinds): raise PrepareDumpError( "The WFX format only supports Cartesian MolecularBasis.", filename ) + return prepare_unrestricted_aminusb(data, allow_changes, filename, "WFX") @document_dump_one( diff --git a/iodata/orbitals.py b/iodata/orbitals.py index beb5a4a8..bb39a8dd 100644 --- a/iodata/orbitals.py +++ b/iodata/orbitals.py @@ -26,7 +26,7 @@ from .attrutils import convert_array_to, validate_shape -__all__ = ["MolecularOrbitals"] +__all__ = ("MolecularOrbitals", "convert_to_unrestricted") def validate_norbab(mo, attribute, value): @@ -200,7 +200,7 @@ def norb(self): return None @property - def spinpol(self) -> float: + def spinpol(self) -> float | None: """Return the spin polarization of the Slater determinant.""" if self.kind == "generalized": raise NotImplementedError @@ -381,3 +381,36 @@ def irrepsb(self): if self.kind == "restricted": return self.irreps return self.irreps[self.norba :] + + +def convert_to_unrestricted(mo: MolecularOrbitals) -> MolecularOrbitals: + """Convert orbitals to ``kind="unrestricted"``. + + Parameters + ---------- + mo + Restricted molecular orbitals to be converted. + + Returns + ------- + new_mo + The given object if the orbitals were already unrestricted or a new unrestricted copy. + + Raises + ------ + ValueError + When the given orbitals are generalized. + """ + if mo.kind == "generalized": + raise ValueError("Generalized orbitals cannot be converted to unrestricted.") + if mo.kind == "unrestricted": + return mo + return MolecularOrbitals( + "unrestricted", + mo.norba, + mo.norbb, + None if mo.occs is None else np.concatenate([mo.occsa, mo.occsb]), + None if mo.coeffs is None else np.concatenate([mo.coeffs, mo.coeffs], axis=1), + None if mo.energies is None else np.concatenate([mo.energies, mo.energies]), + None if mo.irreps is None else np.concatenate([mo.irreps, mo.irreps]), + ) diff --git a/iodata/prepare.py b/iodata/prepare.py new file mode 100644 index 00000000..a0577946 --- /dev/null +++ b/iodata/prepare.py @@ -0,0 +1,94 @@ +# IODATA is an input and output module for quantum chemistry. +# Copyright (C) 2011-2019 The IODATA Development Team +# +# This file is part of IODATA. +# +# IODATA is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# IODATA is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# -- +"""Preparation of IOData instances before they are dumped. + +The ``prepare_*`` functions below can be used as building blocks in ``prepare_dump`` functions. +When the ``allow_changes`` argument is set to ``True``, +they can return a new IOData instance with some modified attributes. +Otherwise, they can raise an error if a conversion is needed. +When no conversion is needed, no errors or warnings are raised, +and the same IOData object is returned. +""" + +from warnings import warn + +import attrs + +from .iodata import IOData +from .orbitals import convert_to_unrestricted +from .utils import PrepareDumpError, PrepareDumpWarning + +__all__ = ("prepare_unrestricted_aminusb",) + + +def prepare_unrestricted_aminusb(data: IOData, allow_changes: bool, filename: str, fmt: str): + """If molecular orbitals have aminusb set, they are converted to unrestricted. + + Parameters + ---------- + data + The IOData instance with the molecular orbitals. + allow_changes + Whether conversion is allowed or not. + filename + The file to be written to, only used for error messages. + fmt + The file format whose dump function is calling this function, only used for error messages. + + + Returns + ------- + data + The given data object if no conversion took place, + or a shallow copy with some new attriubtes. + + Raises + ------ + ValueError + If the given data object has no molecular orbitals, + or if the orbitals are generalized. + PrepareDumpError + If ``allow_changes == False`` and a conversion is required. + PrepareDumpWarning + If ``allow_changes == True`` and a conversion is required. + + """ + # Check: possible, needed? + if data.mo is None: + raise ValueError("The given IOData instance has no molecular orbitals.") + if data.mo.kind == "generalized": + raise ValueError("prepare_unrestricted_aminusb is not applicable to generalized orbitals.") + if data.mo.kind == "unrestricted": + return data + if data.mo.occs_aminusb is None: + return data + + # Raise error or warning + message = f"The {fmt} format does not support restricted orbitals with mo.occs_aminusb. " + if not allow_changes: + raise PrepareDumpError( + message + "Set allow_change=True to enable conversion to unrestricted.", filename + ) + warn( + PrepareDumpWarning(message + "The orbitals are converted to unrestricted", filename), + stacklevel=2, + ) + + # Convert + return attrs.evolve(data, mo=convert_to_unrestricted(data.mo)) diff --git a/iodata/test/data/water_wrong_spinmult.mkl b/iodata/test/data/water_wrong_spinmult.mkl new file mode 100644 index 00000000..747641e2 --- /dev/null +++ b/iodata/test/data/water_wrong_spinmult.mkl @@ -0,0 +1,91 @@ +$MKL +# +# A cooked-up MKL file to trigger a warning about spin +# +$CHAR_MULT + 2 4 +$END + +$COORD + 8 0.000000 0.000000 0.000000 + 1 0.000000 0.000000 0.950000 + 1 0.895670 0.000000 -0.316663 +$END + +$BASIS + 1 S 1.00 + 130.7093210000 0.1543289670 + 23.8088661000 0.5353281420 + 6.4436083100 0.4446345420 + 1 S 1.00 + 5.0331513200 -0.0999672292 + 1.1695961200 0.3995128260 + 0.3803889600 0.7001154690 + 3 P 1.00 + 5.0331513200 0.1559162750 + 1.1695961200 0.6076837190 + 0.3803889600 0.3919573930 +$$ + 1 S 1.00 + 3.4252509100 0.1543289670 + 0.6239137300 0.5353281420 + 0.1688554040 0.4446345420 +$$ + 1 S 1.00 + 3.4252509100 0.1543289670 + 0.6239137300 0.5353281420 + 0.1688554040 0.4446345420 + +$END + +$COEFF_ALPHA +a1g a1g a1g a1g a1g + -20.233394200000 -1.265839420000 -0.629365088000 -0.441724988000 -0.387671783000 + 0.994099882000 -0.232889095000 0.000000016550 0.100235366000 0.000000000000 + 0.026779921300 0.831788042000 -0.000000090302 -0.523423149000 -0.000000000000 + 0.003466300040 0.103349385000 -0.346565859000 0.648259144000 0.000000000000 + 0.000000000000 -0.000000000000 0.000000000000 -0.000000000000 1.000000000000 + 0.002451056010 0.073079409700 0.490116062000 0.458390414000 0.000000000000 + -0.006083938420 0.160223990000 0.441542336000 0.269085788000 0.000000000000 + -0.006083936930 0.160223948000 -0.441542341000 0.269085849000 0.000000000000 +a1g a1g + 0.603082408000 0.766134805000 + -0.135631600000 0.000000056766 + 0.908581133000 -0.000000429452 + 0.583295647000 0.582525068000 + 0.000000000000 -0.000000000000 + 0.412453695000 -0.823811720000 + -0.807337352000 0.842614916000 + -0.807337875000 -0.842614243000 + $END + +$OCC_ALPHA + 1.0000000 1.0000000 1.0000000 1.0000000 0.8500000 + 0.6500000 0.0000000 + $END + +$COEFF_BETA +a1g a1g a1g a1g a1g + -20.233394200000 -1.265839420000 -0.629365088000 -0.441724988000 -0.387671783000 + 0.994099882000 -0.232889095000 0.000000016550 0.100235366000 0.000000000000 + 0.026779921300 0.831788042000 -0.000000090302 -0.523423149000 -0.000000000000 + 0.003466300040 0.103349385000 -0.346565859000 0.648259144000 0.000000000000 + 0.000000000000 -0.000000000000 0.000000000000 -0.000000000000 1.000000000000 + 0.002451056010 0.073079409700 0.490116062000 0.458390414000 0.000000000000 + -0.006083938420 0.160223990000 0.441542336000 0.269085788000 0.000000000000 + -0.006083936930 0.160223948000 -0.441542341000 0.269085849000 0.000000000000 +a1g a1g + 0.603082408000 0.766134805000 + -0.135631600000 0.000000056766 + 0.908581133000 -0.000000429452 + 0.583295647000 0.582525068000 + 0.000000000000 -0.000000000000 + 0.412453695000 -0.823811720000 + -0.807337352000 0.842614916000 + -0.807337875000 -0.842614243000 + $END + +$OCC_BETA + 1.0000000 1.0000000 0.0000000 0.0000000 0.1500000 + 0.3500000 0.0000000 + $END diff --git a/iodata/test/test_molekel.py b/iodata/test/test_molekel.py index ed524ff5..014c73d5 100644 --- a/iodata/test/test_molekel.py +++ b/iodata/test/test_molekel.py @@ -29,7 +29,7 @@ from ..api import dump_one, load_one from ..basis import convert_conventions from ..overlap import compute_overlap -from ..utils import PrepareDumpError, angstrom +from ..utils import LoadWarning, PrepareDumpError, angstrom from .common import ( check_orthonormal, compare_mols, @@ -170,3 +170,12 @@ def test_generalized(): data = create_generalized() with pytest.raises(PrepareDumpError): dump_one(data, "generalized.mkl") + + +def test_load_wrong_spin_mult(): + with ( + as_file(files("iodata.test.data").joinpath("water_wrong_spinmult.mkl")) as fn_molekel, + pytest.warns(LoadWarning), + ): + data = load_one(fn_molekel) + assert_allclose(data.spinpol, 3) diff --git a/iodata/test/test_orbitals.py b/iodata/test/test_orbitals.py index fd2f845e..be06c9dd 100644 --- a/iodata/test/test_orbitals.py +++ b/iodata/test/test_orbitals.py @@ -22,7 +22,7 @@ import pytest from numpy.testing import assert_allclose, assert_equal -from ..orbitals import MolecularOrbitals +from ..orbitals import MolecularOrbitals, convert_to_unrestricted def test_wrong_kind(): @@ -518,3 +518,97 @@ def test_generalized_irreps(): _ = mo.irrepsa with pytest.raises(NotImplementedError): _ = mo.irrepsb + + +def test_convert_to_unrestricted_generalized(): + with pytest.raises(ValueError): + convert_to_unrestricted(MolecularOrbitals("generalized", None, None)) + + +def test_convert_to_unrestricted_pass_through(): + mo1 = MolecularOrbitals("unrestricted", 5, 3, occs=[1, 1, 0, 0, 0, 1, 0, 0]) + mo2 = convert_to_unrestricted(mo1) + assert mo1 is mo2 + + +def test_convert_to_unrestricted_minimal(): + mo1 = MolecularOrbitals("restricted", 5, 5) + mo2 = convert_to_unrestricted(mo1) + assert mo1 is not mo2 + assert mo2.kind == "unrestricted" + assert mo2.norba == 5 + assert mo2.norbb == 5 + assert mo2.coeffs is None + assert mo2.occs is None + assert mo2.coeffs is None + assert mo2.energies is None + assert mo2.irreps is None + + +def test_convert_to_unrestricted_aminusb(): + mo1 = MolecularOrbitals( + "restricted", + 5, + 5, + occs=np.array([2.0, 0.8, 0.2, 0.0, 0.0]), + occs_aminusb=np.array([0.0, 0.2, 0.2, 0.0, 0.0]), + ) + assert_allclose(mo1.spinpol, 0.4) + mo2 = convert_to_unrestricted(mo1) + assert mo1 is not mo2 + assert mo2.kind == "unrestricted" + assert_allclose(mo2.occsa, [1.0, 0.5, 0.2, 0.0, 0.0]) + assert_allclose(mo2.occsb, [1.0, 0.3, 0.0, 0.0, 0.0]) + assert_allclose(mo2.spinpol, 0.4) + + +def test_convert_to_unrestricted_occ_integer(): + mo1 = MolecularOrbitals( + "restricted", + 5, + 5, + occs=np.array([2.0, 1.0, 1.0, 0.0, 0.0]), + ) + mo2 = convert_to_unrestricted(mo1) + assert mo1 is not mo2 + assert mo2.kind == "unrestricted" + assert_allclose(mo2.occsa, [1.0, 1.0, 1.0, 0.0, 0.0]) + assert_allclose(mo2.occsb, [1.0, 0.0, 0.0, 0.0, 0.0]) + + +def test_convert_to_unrestricted_occ_float(): + mo1 = MolecularOrbitals( + "restricted", + 5, + 5, + occs=np.array([2.0, 1.6, 1.0, 0.0, 0.0]), + ) + mo2 = convert_to_unrestricted(mo1) + assert mo1 is not mo2 + assert mo2.kind == "unrestricted" + assert_allclose(mo2.occsa, [1.0, 0.8, 0.5, 0.0, 0.0]) + assert_allclose(mo2.occsb, mo2.occsa) + + +def test_convert_to_unrestricted_full(): + rng = np.random.default_rng(42) + mo1 = MolecularOrbitals( + "restricted", + 5, + 5, + occs=rng.uniform(0, 1, 5), + coeffs=rng.uniform(0, 1, (8, 5)), + energies=rng.uniform(-1, 0, 5), + irreps=["A"] * 5, + ) + mo2 = convert_to_unrestricted(mo1) + assert mo1 is not mo2 + assert mo2.kind == "unrestricted" + assert_allclose(mo2.occsa, mo1.occsa) + assert_allclose(mo2.occsb, mo1.occsb) + assert_allclose(mo2.coeffsa, mo1.coeffsa) + assert_allclose(mo2.coeffsb, mo1.coeffsb) + assert_allclose(mo2.energiesa, mo1.energiesa) + assert_allclose(mo2.energiesb, mo1.energiesb) + assert_equal(mo2.irrepsa, mo1.irrepsa) + assert_equal(mo2.irrepsb, mo1.irrepsb) diff --git a/iodata/test/test_prepare.py b/iodata/test/test_prepare.py new file mode 100644 index 00000000..217f3021 --- /dev/null +++ b/iodata/test/test_prepare.py @@ -0,0 +1,109 @@ +# IODATA is an input and output module for quantum chemistry. +# Copyright (C) 2011-2019 The IODATA Development Team +# +# This file is part of IODATA. +# +# IODATA is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 3 +# of the License, or (at your option) any later version. +# +# IODATA is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see +# -- +"""Unit tests for iodata.prepare.""" + +from importlib.resources import as_file, files +from pathlib import Path + +import pytest +from numpy.testing import assert_allclose, assert_equal + +from ..api import dump_one, load_one +from ..iodata import IOData +from ..orbitals import MolecularOrbitals +from ..prepare import prepare_unrestricted_aminusb +from ..utils import PrepareDumpError, PrepareDumpWarning + + +def test_unrestricted_aminusb_no_mo(): + data = IOData() + with pytest.raises(ValueError): + prepare_unrestricted_aminusb(data, False, "foo.wfn", "wfn") + + +def test_unrestricted_aminusb_generaized(): + data = IOData(mo=MolecularOrbitals("generalized", None, None)) + with pytest.raises(ValueError): + prepare_unrestricted_aminusb(data, False, "foo.wfn", "wfn") + + +def test_unrestricted_aminusb_pass_through1(): + data1 = IOData(mo=MolecularOrbitals("unrestricted", 5, 3)) + data2 = prepare_unrestricted_aminusb(data1, False, "foo.wfn", "wfn") + assert data1 is data2 + + +def test_unrestricted_aminusb_pass_through2(): + data1 = IOData(mo=MolecularOrbitals("restricted", 4, 4, occs=[2, 1, 0, 0])) + data2 = prepare_unrestricted_aminusb(data1, False, "foo.wfn", "wfn") + assert data1 is data2 + + +def test_unrestricted_aminusb_pass_error(): + data = IOData( + mo=MolecularOrbitals( + "restricted", 4, 4, occs=[2, 1, 0, 0], occs_aminusb=[0.7, 0.3, 0.0, 0.0] + ) + ) + with pytest.raises(PrepareDumpError): + prepare_unrestricted_aminusb(data, False, "foo.wfn", "wfn") + + +def test_unrestricted_aminusb_pass_warning(): + data1 = IOData( + atnums=[1, 1], + mo=MolecularOrbitals( + "restricted", 4, 4, occs=[2, 1, 0, 0], occs_aminusb=[0.7, 0.3, 0.0, 0.0] + ), + ) + with pytest.warns(PrepareDumpWarning): + data2 = prepare_unrestricted_aminusb(data1, True, "foo.wfn", "wfn") + assert data1 is not data2 + assert data1.atnums is data2.atnums + assert data1.mo is not data2.mo + assert data2.mo.kind == "unrestricted" + assert_equal(data2.mo.occsa, data1.mo.occsa) + assert_equal(data2.mo.occsb, data1.mo.occsb) + + +@pytest.mark.parametrize("fmt", ["wfn", "wfx", "molden", "molekel"]) +def test_dump_occs_aminusb(tmpdir, fmt): + # Load a restricted spin-paired wfn and alter it.abs + with as_file(files("iodata.test.data").joinpath("water_sto3g_hf_g03.fchk")) as fn_fchk: + data1 = load_one(fn_fchk) + assert data1.mo.kind == "restricted" + data1.mo.occs = [2, 2, 2, 1, 1, 1, 0] + data1.mo.occs_aminusb = [0, 0, 0, 1, 0.3, 0.2, 0] + assert_allclose(data1.spinpol, data1.mo.spinpol) + + # Dump and load again + path_foo = Path(tmpdir) / "foo" + with pytest.raises(PrepareDumpError): + dump_one(data1, path_foo, fmt=fmt) + with pytest.warns(PrepareDumpWarning): + dump_one(data1, path_foo, allow_changes=True, fmt=fmt) + import os + + os.system(f"cat {path_foo}") + data2 = load_one(path_foo, fmt=fmt) + + # Check the loaded file + assert data2.mo.kind == "unrestricted" + assert_allclose(data2.mo.occsa, data1.mo.occsa) + assert_allclose(data2.mo.occsb, data1.mo.occsb) diff --git a/iodata/utils.py b/iodata/utils.py index def01638..54b61f83 100644 --- a/iodata/utils.py +++ b/iodata/utils.py @@ -38,6 +38,7 @@ "WriteInputError", "LoadWarning", "DumpWarning", + "PrepareDumpWarning", "Cube", "set_four_index_element", "volume", @@ -229,6 +230,10 @@ class DumpWarning(BaseFileWarning): """Raised when an IOData object is made compatible with a format when dumping to a file.""" +class PrepareDumpWarning(BaseFileWarning): + """Raised when an IOData object is made compatible with a format before dumping to a file.""" + + @attrs.define class Cube: """The volumetric data from a cube (or similar) file.""" From cf975307f44d0a3f76aef093eac77f9288e99863 Mon Sep 17 00:00:00 2001 From: Toon Verstraelen Date: Thu, 27 Jun 2024 12:32:44 +0200 Subject: [PATCH 2/5] Clarify doscstrings --- iodata/api.py | 4 ++-- iodata/formats/fchk.py | 2 +- iodata/formats/json.py | 2 +- iodata/formats/molden.py | 2 +- iodata/formats/molekel.py | 2 +- iodata/formats/wfn.py | 2 +- iodata/formats/wfx.py | 2 +- iodata/prepare.py | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/iodata/api.py b/iodata/api.py index a18df856..0936f3db 100644 --- a/iodata/api.py +++ b/iodata/api.py @@ -271,7 +271,7 @@ def dump_one( The name of the file format module to use. When not given, it is guessed from the filename. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. **kwargs Keyword arguments are passed on to the format-specific dump_one function. @@ -339,7 +339,7 @@ def dump_many( fmt The name of the file format module to use. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. **kwargs Keyword arguments are passed on to the format-specific dump_many function. diff --git a/iodata/formats/fchk.py b/iodata/formats/fchk.py index 977bf3a3..edc8b525 100644 --- a/iodata/formats/fchk.py +++ b/iodata/formats/fchk.py @@ -550,7 +550,7 @@ def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: data The IOData instance to be checked. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. (not relevant for FCHK, present for API consistency) filename The file to be written to, only used for error messages. diff --git a/iodata/formats/json.py b/iodata/formats/json.py index 137f9adc..b882f289 100644 --- a/iodata/formats/json.py +++ b/iodata/formats/json.py @@ -1454,7 +1454,7 @@ def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: data The IOData instance to be checked. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. (not relevant for QCSchema JSON, present for API consistency) filename The file to be written to, only used for error messages. diff --git a/iodata/formats/molden.py b/iodata/formats/molden.py index 11a0db2e..495a259f 100644 --- a/iodata/formats/molden.py +++ b/iodata/formats/molden.py @@ -777,7 +777,7 @@ def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: data The IOData instance to be checked. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. filename The file to be written to, only used for error messages. diff --git a/iodata/formats/molekel.py b/iodata/formats/molekel.py index 71649a3d..cf57d0bd 100644 --- a/iodata/formats/molekel.py +++ b/iodata/formats/molekel.py @@ -280,7 +280,7 @@ def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: data The IOData instance to be checked. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. filename The file to be written to, only used for error messages. diff --git a/iodata/formats/wfn.py b/iodata/formats/wfn.py index 8ab69468..99663c63 100644 --- a/iodata/formats/wfn.py +++ b/iodata/formats/wfn.py @@ -505,7 +505,7 @@ def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: data The IOData instance to be checked. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. filename The file to be written to, only used for error messages. diff --git a/iodata/formats/wfx.py b/iodata/formats/wfx.py index bd834df2..81aa759a 100644 --- a/iodata/formats/wfx.py +++ b/iodata/formats/wfx.py @@ -338,7 +338,7 @@ def prepare_dump(data: IOData, allow_changes: bool, filename: str) -> IOData: data The IOData instance to be checked. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. filename The file to be written to, only used for error messages. diff --git a/iodata/prepare.py b/iodata/prepare.py index a0577946..12958893 100644 --- a/iodata/prepare.py +++ b/iodata/prepare.py @@ -45,7 +45,7 @@ def prepare_unrestricted_aminusb(data: IOData, allow_changes: bool, filename: st data The IOData instance with the molecular orbitals. allow_changes - Whether conversion is allowed or not. + Whether conversion of the IOData object to a compatible form is allowed or not. filename The file to be written to, only used for error messages. fmt From 2950705e62f0de29509d85f5f2e63e58dcc7f3a3 Mon Sep 17 00:00:00 2001 From: Toon Verstraelen Date: Thu, 27 Jun 2024 12:35:01 +0200 Subject: [PATCH 3/5] AI suggestion for simplification --- iodata/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iodata/__main__.py b/iodata/__main__.py index 07944179..4f4a097c 100755 --- a/iodata/__main__.py +++ b/iodata/__main__.py @@ -113,7 +113,7 @@ def convert(infn, outfn, many, infmt, outfmt): """ if many: - dump_many((data for data in load_many(infn, fmt=infmt)), outfn, fmt=outfmt) + dump_many(load_many(infn, fmt=infmt), outfn, fmt=outfmt) else: dump_one(load_one(infn, fmt=infmt), outfn, fmt=outfmt) From b70bb8b25ccac7668383276d64fb5e15eceb799f Mon Sep 17 00:00:00 2001 From: Toon Verstraelen Date: Thu, 27 Jun 2024 12:47:54 +0200 Subject: [PATCH 4/5] Fix for py 3.9 --- iodata/orbitals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iodata/orbitals.py b/iodata/orbitals.py index bb39a8dd..5369ab8a 100644 --- a/iodata/orbitals.py +++ b/iodata/orbitals.py @@ -200,7 +200,7 @@ def norb(self): return None @property - def spinpol(self) -> float | None: + def spinpol(self) -> Optional[float]: """Return the spin polarization of the Slater determinant.""" if self.kind == "generalized": raise NotImplementedError From 565cfdf9f9616fa9553d525373bec57e001cf793 Mon Sep 17 00:00:00 2001 From: Toon Verstraelen Date: Thu, 27 Jun 2024 12:55:32 +0200 Subject: [PATCH 5/5] Fix issue detected by codefactor --- iodata/test/test_prepare.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/iodata/test/test_prepare.py b/iodata/test/test_prepare.py index 217f3021..da2e443b 100644 --- a/iodata/test/test_prepare.py +++ b/iodata/test/test_prepare.py @@ -98,9 +98,6 @@ def test_dump_occs_aminusb(tmpdir, fmt): dump_one(data1, path_foo, fmt=fmt) with pytest.warns(PrepareDumpWarning): dump_one(data1, path_foo, allow_changes=True, fmt=fmt) - import os - - os.system(f"cat {path_foo}") data2 = load_one(path_foo, fmt=fmt) # Check the loaded file