diff --git a/.idea/GitlabLint.xml b/.idea/GitlabLint.xml new file mode 100644 index 0000000..84f547c --- /dev/null +++ b/.idea/GitlabLint.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/excitingtools.iml b/.idea/excitingtools.iml index 785e876..7373a50 100644 --- a/.idea/excitingtools.iml +++ b/.idea/excitingtools.iml @@ -2,7 +2,7 @@ - + diff --git a/.idea/misc.xml b/.idea/misc.xml index 82070cd..cdbd5eb 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/.idea/ruff.xml b/.idea/ruff.xml new file mode 100644 index 0000000..17f297d --- /dev/null +++ b/.idea/ruff.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/README.md b/README.md index b62ddf8..9a5de3f 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,79 @@ # excitingtools - - -**excitingtools** is a collection of +**excitingtools** is a collection of modules to facilitate the generation of **exciting** -inputs and the post-processing of **exciting** outputs. +inputs and the post-processing of **exciting** outputs. **excitingtools** currently provides functionality for: -* Generation of the **exciting** input XML file +* Generation of the **exciting** input XML file using Python classes: - * Currently supported for `groundstate`, `structure` and `BSE` + - Automatically supported for the whole input file through dynamic class construction + - Currently tested for `groundstate`, `structure`, `BSE` and `bandstructure` + * Parsing of **exciting** outputs into Python dictionaries + * High-level class API for interacting with results: - * Currently implemented for eigenvalues, band structure and DOS (without SO coupling) + - Currently implemented for eigenvalues, band structure and DOS (without SO coupling) -making it is possible to define a calculation, run it, and parse the relevant outputs all from within Python. +making it is possible to define a calculation, run it, and parse the relevant outputs all from within Python. **excitingtools** is used by, or in conjunction with: - * **exciting's** regression-testing framework * Parsing of output data -* **exciting's** Jupyter notebook tutorials +* **exciting's** Jupyter notebook tutorials * Data handling * [Atomic Simulation Environment (ASE)](https://wiki.fysik.dtu.dk/ase/) * Input and output handling in ASE's **exciting** calculator * [Jobflow](https://github.com/materialsproject/jobflow) * For the development of complex, automated **exciting** workflows - ## Installation +If one wishes to import **excitingtools** in their own scripts, it can be installed from this project's root directory +(`$EXCITING_ROOT/tools/exciting_tools`). + +Although not strictly necessary, it is strongly recommended to use a virtual environment to manage Python packages. +There are several solutions available, including [venv](https://docs.python.org/3/library/venv.html), (mini)[conda](https://docs.conda.io/en/latest/miniconda.html) +and [pipx](https://pypa.github.io/pipx/). + +To set up a venv: + +```bash +# Create a directory `excitingvenv` containing our venv +python3 -m venv excitingvenv +# Activate the environment +source excitingvenv/bin/activate +``` + +Before installing excitingtools, you should upgrade `pip` and `setuptools`: +```bash +python3 -m pip install --upgrade --force pip +pip install --upgrade setuptools +``` + +No matter which type of environment or none is used, it should always be verified that the Python version is compatible with **excitingtools** (>=3.7) and that `pip` is up-to-date (>=22.0) and also points to the same Python version. -If one wishes to import **excitingtools** in their own scripts, it can be installed from this project's root directory -(`$EXCITING_ROOT/tools/exciting_tools`) with: +Now you can proceed to install **excitingtools** +from **exciting's**'s root: ```bash -pip install -e . +python3 -m pip install tools/exciting_tools ``` -or downloaded directly from pip: +Alternatively, you can download it directly from PyPI: ```bash pip install excitingtools ``` ## External Package Dependencies - -If a new external dependency is introduced to the package, this also requires adding to `setup.py` such that pip is aware +If a new external dependency is introduced to the package, this also requires adding to `pyproject.toml` such that pip is aware of the new dependency. -## Basic File Structure - +## Basic File Structure In general, modules should begin with a docstring giving an overview of the module's purpose. External python -libraries should then be imported, followed by a space, then local modules belonging to **excitingtools**. Local modules +libraries should then be imported, followed by a space, then local modules belonging to **excitingtools**. Local modules should be loaded with absolute paths rather than relative paths or prepending the system path `sys.path.insert(0,'/path/to/module_directory')`: ```angular2html @@ -64,29 +84,35 @@ import numpy as np from excitingtools.maths.math_utils import triple_product ``` - Exposed modules, forming user API, should be defined in `__init__.py` where ever possible. -## Code Formatting - -We currently favour [yapf](https://github.com/google/yapf) formatter, which by default applies PEP8 formatting to -the code. - -After installing yapf, if you are in the root directory of excitingtools, you can simply type: +## Code Checking and Formatting +We currently favour the [ruff](https://github.com/astral-sh/ruff) formatter, which by default applies PEP8 formatting to +the code. Additional rule selection and configuration can be found in the `pyproject.toml` file. +After installing ruff via pip, if you are in the root directory of excitingtools, you can simply type: ```bash -yapf -i excitingtools/path/to/file.py +ruff check excitingtools/path/to/file.py ``` +This will check the selected file for common errors. Without a path the whole project will be checked. -and it will do the formatting for you. Note: This will automatically use our custom `.style.yapf` style-file. +Some problems can be solved automatically with enabling the `--fix` feature: +```bash +ruff check --fix excitingtools/path/to/file.py +``` +Afterward, you can type +```bash +ruff format excitingtools/path/to/file.py +``` +and it will do the formatting for you for the selected file, or again for the whole project without an argument. +Check out the [docs](https://docs.astral.sh/ruff/) for more options. -## Documentation +## Documentation ### Writing Documentation - All functions and classes should be documented. The favoured docstring is *reStructuredText*: -```python +```python3 class SimpleEquation: def demo(self, a: int, b: int, c: int) -> list: """Function definition. @@ -98,13 +124,12 @@ class SimpleEquation: :return list y: Function values """ ``` - where the type can be specified in the `param` description, or separately using the `type` tag. For more details on the documentation syntax, please refer to this [link](https://devguide.python.org/documenting/). The [google style guide]( -) for *reStructuredText* docstrings is also -acceptable to follow. +https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) for *reStructuredText* docstrings is also +acceptable to follow. -### Generating Documentation +### Generating Documentation Documentation can straightforwardly be generated using the [pdoc](https://docs.python.org/3/library/pydoc.html) package: @@ -113,21 +138,21 @@ pip install pdoc pdoc -o documentation -d restructuredtext --math excitingtools/ ``` +- [ ] TODO(Alex) Issue 57. Set up generation of documentation from docstrings, with Sphinx + ### Basic Usage #### Input XML Generation -**excitingtools** maps the XML tags and attributes -of `input.xml` onto Python classes, enabling the generation of XML-formatted inputs directly from Python. A simple +**excitingtools** maps the XML tags and attributes +of `input.xml` onto Python classes, enabling the generation of XML-formatted inputs directly from Python. A simple ground state calculation could like this: -```python +```python3 import ase import numpy as np -from excitingtools.input.structure import ExcitingStructure -from excitingtools.input.ground_state import ExcitingGroundStateInput -from excitingtools.input.input_xml import exciting_input_xml_str +from excitingtools import ExcitingStructure, ExcitingGroundStateInput, ExcitingInputXML # Lattice and positions in angstrom, as expected by ASE lattice = np.array([[3.168394160510246, 0.0, 0.0], @@ -154,17 +179,17 @@ ground_state = ExcitingGroundStateInput( nosource=False ) -input_xml_str = exciting_input_xml_str(structure, ground_state, title="My exciting Crystal") +input_xml = ExcitingInputXML(structure=structure, + groundstate=ground_state, + title="My exciting Crystal") -with open("input.xml", "w") as fid: - fid.write(input_xml_str) +input_xml.write("input.xml") ``` - -Here we defined the attributes required to perform a ground state calculation as seperate classes, and composed the -final XML string with `exciting_input_xml_str`. If the user does not have access to ASE, they can instead use a +Here we defined the attributes required to perform a ground state calculation as seperate classes, and composed the +final XML string with `ExcitingInputXML` class. If the user does not have access to ASE, they can instead use a `List[dict]` to define the container with atoms data: -```python +```python3 atoms = [{'species': 'W', 'position': [0.00000000, 0.00000000, 16.68421565]}, {'species': 'S', 'position': [1.58419708, 0.91463661, 18.25982194]}, {'species': 'S', 'position': [1.58419708, 0.91463661, 15.10652203]}, @@ -175,34 +200,33 @@ atoms = [{'species': 'W', 'position': [0.00000000, 0.00000000, 16.68421565]}, structure = ExcitingStructure(atoms, lattice, species_path='.') ``` -Additional examples can be found in the test cases, `exciting_tools/tests/input`. We note that not all XML tags -currently map onto Python classes. One can consult `exciting_tools/excitingtools/input` to see what is available. -Development follows a continuous integration and deployment workflow, therefore if one wishes for additional features, -please make a request on Github issues or open a merge request. +Additional examples can be found in the test cases, `exciting_tools/tests/input`. We note that not all XML tags +currently map onto Python classes. One can consult `exciting_tools/excitingtools/input` to see what is available. +Development follows a continuous integration and deployment workflow, therefore if one wishes for additional features, +please make a request on GitHub issues or open a merge request. #### Binary Execution Next we can define a runner and run our calculation: - -```python +```python3 from excitingtools.runner.runner import BinaryRunner -runner = BinaryRunner('exciting_smp', run_cmd=[''], omp_num_threads=4, time_out=500) +runner = BinaryRunner('exciting_smp', run_cmd='', omp_num_threads=4, time_out=500) run_status = runner.run() ``` #### Parsing Outputs After the successful completion of the calculation, we can parse the relevant output files as dictionaries, using -`parser_chooser`. These are the main files one would be interested in after performing a ground state calculation, for +`parse`. These are the main files one would be interested in after performing a ground state calculation, for example: -```python -from excitingtools import parser_chooser +```python3 +from excitingtools import parse -info_out: dict = parser_chooser("INFO.OUT") -eigval_info: dict = parser_chooser("eigval.xml") -atoms_info: dict = parser_chooser("atoms.xml") +info_out: dict = parse("INFO.OUT") +eigval_info: dict = parse("eigval.xml") +atoms_info: dict = parse("atoms.xml") ``` A full list of parsers is provided in `excitingtools/exciting_dict_parsers/parser_factory.py`. If we wish to perform @@ -249,13 +273,13 @@ for ib in range(0, band_data.n_bands): ``` Tests demonstrating further usage are present in `excitingtools/tests/dataclasses`. We note that the high-level objects -and their parsers are separated. In principle, the data classes should only define a sensible schema or API for +and their parsers are separated. In principle, the data classes should only define a sensible schema or API for accepting relevant data, rather than know anything about the parsing. Object parsers (defined in `obj_parsers`) by definition -should return to data classes, but the data classes dictate the format of the data, not vice versa. +should return to data classes, but the data classes dictate the format of the data, not vice versa. -## Testing +## Testing -Every function should have a test where possible, unless the function is correct by inspection. The naming convention +Every function should have a test where possible, unless the function is correct by inspection. The naming convention for a module called `module.py` is to prepend it with `test_`, which allows it to be automatically recognised and run by *pytest*: @@ -264,27 +288,26 @@ excitingtools/module.py # Collection of functions tests/test_module.py # Collection of tests for functions in module.py ``` -Tests are intended to be run using *pytest*, for which the documentation can be found [here](https://docs.pytest.org/en/stable/index.html). +Tests are intended to be run using *pytest*, for which the documentation can be found [here](https://docs.pytest.org/en/stable/index.html). One is able to run `pytest` from the `exciting_tools` root with no arguments. By default, all test files, classes and functions defined in the specification, -`exciting_tools/pytest.ini`, will get executed. +`exciting_tools/pytest.ini`, will get executed. -## Parsers -The parsers are used in the test suite. Therefore, they should only return dictionaries with a specific structure. +## Parsers -The tolerance comparison will only evaluate the values of lowest-nested keys. As such, one should consider how they structure the parsed data. +The parsers are used in the test suite. Therefore, they should only return dictionaries with a specific structure. + +The tolerance comparison will only evaluate the values of lowest-nested keys. As such, one should consider how they structure the parsed data. For example, it makes more sense to structure data like: -```python +```python3 {‘wannier1’: {‘localisation_vector’: np.array(shape=(3)), ‘Omega’: float } } ``` - such that the tolerances will be w.r.t. `localisation_vector`, and `Omega`, rather than using the structure: - -```python +```python3 {‘localisation_vector’: {‘wannier1’: np.array(shape=(3)) ‘wannier2’: np.array(shape=(3)) }, @@ -293,11 +316,10 @@ such that the tolerances will be w.r.t. `localisation_vector`, and `Omega`, rath } } ``` - which will results in tolerances defined w.r.t. `wannier1` and `wannier2`. One can see in the latter case, there is no distinction between `localisation_vector` and `Omega`. In general, we’re more likely to want to set different tolerances for different properties, rather than for different functions with the same set of properties. One could also structure the data like: -```python +```python3 {‘localisation_vector’: np.array(shape=(n_wannier, 3)), ‘Omega’: : np.array(shape=(n_wannier) } @@ -305,50 +327,16 @@ One could also structure the data like: where the less serialised data removes the key nesting. -## Usage in Workflow Engines - -**excitingtools** has been designed with materials -workflows in mind, and can be used to as a means of interacting with **exciting** -from python, to define calculations or parse results. A workflow can be imagined as a series of single-responsibility function calls, -forming a recipe of computational steps. For example, one might wish to design a workflow to converge a quantity such as -k-sampling. Abstractly, this might look like: - -```python -"""Simple convergence workflow -""" - -# Read input from input file: -specified_input = read_input(Path("input.yml").absolute()) - -# Define convergence criterion -convergence_criteria = get_convergence_criteria(specified_input) - -# Set up jobs -exciting_calc = setup_exciting_calculation(specified_input) -convergence_job = converge_ngridk(exciting_calc.output, convergence_criteria, []) - -# Run workflow using Jobflow -responses = run_locally(Flow([exciting_calc, convergence_job]), -store=JobStore(MemoryStore())) -``` - -where an **exciting** calculation or calculator can -be defined using **excitingtools** functionality. -It is then down to the developer to determine how to concretely implement a means of constructing -calculators with different k-sampling, and how to evaluate convergence. For each step in a workflow, Jobflow can be used -as a decorator, allowing it to capture steps and serialise the information passed between functions. Tutorials on -developing a workflow using Jobflow can be found on [here](https://materialsproject.github.io/jobflow/tutorials.html). - - -## Uploading to PyPi (for developers) +## Uploading to PyPi excitingtools is available as a separate package on PyPi. In order to upload a new version: ```bash -# Ensure build and twine are installed -pip3 install twine -# Build the wheel, in excitingtools root -python -m build +# Ensure twine is installed +pip3 install --upgrade twine build +# Build the wheels +cd $EXCITINGROOT/tools/exciting_tools +python3 -m build # Test the distribution and uploading (one requires a test-PyPi account) twine check dist/* @@ -358,28 +346,17 @@ twine upload --repository-url https://test.pypi.org/legacy/ dist/* twine upload dist/* ``` -Before doing so, please ensure the semantic versioning is appropriately updated in `setup.py`. - - -## Citing - -To cite **excitingtools**, please refer to the -[CITATION.cff](CITATION.cff). - -**excitingtools** tag "1.3.0" is published in -the [Journal of Open Source Software](https://joss.theoj.org) and archived on Zenodo: - -[![DOI](https://zenodo.org/badge/564833158.svg)](https://zenodo.org/badge/latestdoi/564833158) +Before doing so, please ensure the semantic versioning is appropriately updated in `pyproject.toml`. ## Contributors - The following people (in alphabetic order by their family names) have contributed to excitingtools: * Alexander Buccheri * Hannah Kleine * Martin Kuban * Benedikt Maurer +* Ronaldo Pela * Fabian Peschel * Daniel Speckhard * Elisa Stephan diff --git a/excitingtools/__init__.py b/excitingtools/__init__.py index e765d9e..9089ade 100644 --- a/excitingtools/__init__.py +++ b/excitingtools/__init__.py @@ -1,14 +1,54 @@ # Units from excitingtools.constants.units import Unit + +# User-level objects +from excitingtools.dataclasses import BandData, EigenValues + +# old deprecated API # Parsers returning to dicts # Questionable whether one should expose this - required for test framework recursive comparisons # Typically not the API one wants to expose to the user, as parsed dict keys are subject to change -from excitingtools.exciting_dict_parsers.parser_factory import parser_chooser +from excitingtools.exciting_dict_parsers.parser_factory import parse, parser_chooser + # Parsers returning to objects -from excitingtools.exciting_obj_parsers import * -# User-level objects -from excitingtools.dataclasses import * +from excitingtools.exciting_obj_parsers import parse_band_structure + +# Input objects +from excitingtools.input import ( + ExcitingEPHInput, + ExcitingGroundStateInput, + ExcitingGWInput, + ExcitingInputXML, + ExcitingMDInput, + ExcitingPhononsInput, + ExcitingPropertiesInput, + ExcitingRelaxInput, + ExcitingStructure, + ExcitingXSInput, +) + +try: + from importlib import metadata +except ImportError: + import importlib_metadata as metadata -from pkg_resources import get_distribution +__version__ = metadata.version("excitingtools") -__version__ = get_distribution('excitingtools').version +__all__ = [ + "Unit", + "BandData", + "EigenValues", + "parse", + "parser_chooser", + "parse_band_structure", + "ExcitingGroundStateInput", + "ExcitingXSInput", + "ExcitingPropertiesInput", + "ExcitingRelaxInput", + "ExcitingPhononsInput", + "ExcitingGWInput", + "ExcitingMDInput", + "ExcitingEPHInput", + "ExcitingInputXML", + "ExcitingStructure", +] diff --git a/excitingtools/constants/units.py b/excitingtools/constants/units.py index 08f26a4..b2a77d1 100644 --- a/excitingtools/constants/units.py +++ b/excitingtools/constants/units.py @@ -1,7 +1,15 @@ -""" Units. +"""Units. + +Physical constants, defined according to [CODATA 2018])(http://physics.nist.gov/constants) +One could also consider importing them from scipy """ + import enum +bohr_to_angstrom = 0.529177210903 +angstrom_to_bohr = 1.0 / bohr_to_angstrom +Hartree_to_eV = 27.211396641308 + class Unit(enum.Enum): """ @@ -11,6 +19,7 @@ class Unit(enum.Enum): This could be replaced with [PINT](https://pint.readthedocs.io/en/stable/), as used by NOMAD, however it's currently not required. If/when we wish to do unit manipulation, it should be reconsidered. """ + hartree = enum.auto() inv_hartree = enum.auto() ev = enum.auto() @@ -25,25 +34,29 @@ class Unit(enum.Enum): GK_max = enum.auto() electron_rest_mass = enum.auto() bohr_velocity_over_bohr_radius = enum.auto() + bohr_velocity = enum.auto() + force = enum.auto() null = enum.auto() # Map Unit enums to strings. # Required because JSON cannot dump objects to file. enum_to_string = { - Unit.hartree: 'Hartree', - Unit.inv_hartree: '1/Hartree', - Unit.ev: 'eV', - Unit.inv_ev: 'eV^-1', - Unit.kelvin: 'K', - Unit.bohr: 'Bohr', - Unit.bohr_pow_3: 'Bohr^3', - Unit.inv_bohr: 'Bohr^-1', - Unit.inv_bohr_pow_3: 'Bohr^-3', - Unit.au: 'a.u.', - Unit.degrees: 'degrees', - Unit.GK_max: 'GK_max', - Unit.electron_rest_mass: 'm_electron', - Unit.bohr_velocity_over_bohr_radius: 'v_Bohr/r_Bohr', - Unit.null: 'null' + Unit.hartree: "Hartree", + Unit.inv_hartree: "1/Hartree", + Unit.ev: "eV", + Unit.inv_ev: "eV^-1", + Unit.kelvin: "K", + Unit.bohr: "Bohr", + Unit.bohr_pow_3: "Bohr^3", + Unit.inv_bohr: "Bohr^-1", + Unit.inv_bohr_pow_3: "Bohr^-3", + Unit.au: "a.u.", + Unit.degrees: "degrees", + Unit.GK_max: "GK_max", + Unit.electron_rest_mass: "m_electron", + Unit.bohr_velocity_over_bohr_radius: "v_Bohr/r_Bohr", + Unit.bohr_velocity: "Bohr/t_Bohr", + Unit.force: "Hartree/Bohr", + Unit.null: "null", } diff --git a/excitingtools/dataclasses/__init__.py b/excitingtools/dataclasses/__init__.py index 44db3eb..af62f2c 100644 --- a/excitingtools/dataclasses/__init__.py +++ b/excitingtools/dataclasses/__init__.py @@ -1,2 +1,4 @@ from excitingtools.dataclasses.band_structure import BandData -from excitingtools.dataclasses.eigenvalues import EigenValues \ No newline at end of file +from excitingtools.dataclasses.eigenvalues import EigenValues + +__all__ = ["BandData", "EigenValues"] diff --git a/excitingtools/dataclasses/band_structure.py b/excitingtools/dataclasses/band_structure.py index 0d60fb4..11f14a4 100644 --- a/excitingtools/dataclasses/band_structure.py +++ b/excitingtools/dataclasses/band_structure.py @@ -1,21 +1,25 @@ -""" Band structure class. -""" -from typing import Tuple, List, Optional, Union -from excitingtools.eigenstates.eigenstates import get_k_point_index +"""Band structure class.""" + +from typing import List, Optional, Tuple, Union + import numpy as np +from excitingtools.eigenstates.eigenstates import get_k_point_index + class BandData: ticks_and_labels = Tuple[np.ndarray, List[str]] - vertex_keys = ['distance', 'label', 'coord'] - - def __init__(self, - bands: np.ndarray, - k_points: np.ndarray, - e_fermi: float, - flattened_k_points: Optional[np.ndarray] = None, - vertices: Optional[List[dict]] = None): - """ Initialise BandData. + vertex_keys = ["distance", "label", "coord"] + + def __init__( + self, + bands: np.ndarray, + k_points: np.ndarray, + e_fermi: float, + flattened_k_points: Optional[np.ndarray] = None, + vertices: Optional[List[dict]] = None, + ): + """Initialise BandData. :param bands: Band energies with shape (n_k_points, n_bands). :param: k_points: k-points at which the band energies have been computed. @@ -34,7 +38,7 @@ def __init__(self, self.i_vbm, self.i_cbm = self.get_band_edges() def band_path(self) -> ticks_and_labels: - """ Get an array of points in the k-path that correspond to high symmetry points, + """Get an array of points in the k-path that correspond to high symmetry points, and a list of their labels. vertices expected to have the form @@ -47,8 +51,7 @@ def band_path(self) -> ticks_and_labels: if self.vertices is None: return np.empty(shape=1), [] - assert list(self.vertices[0].keys()) == self.vertex_keys, \ - f'Expect a vertex to have the keys {self.vertex_keys}' + assert list(self.vertices[0].keys()) == self.vertex_keys, f"Expect a vertex to have the keys {self.vertex_keys}" vertices = [self.vertices[0]["distance"]] labels = [self.vertices[0]["label"]] @@ -60,22 +63,21 @@ def band_path(self) -> ticks_and_labels: # Handle discontinuities in the band path if np.isclose(vertex, vertices[-1]): vertices.pop() - label = labels.pop() + ',' + label + label = labels.pop() + "," + label vertices.append(vertex) labels.append(label) # Replace for plotting purposes - unicode_gamma = '\u0393' - for label in ['Gamma', 'gamma', 'G']: + unicode_gamma = "\u0393" + for label in ["Gamma", "gamma", "G"]: labels = list(map(lambda x: x.replace(label, unicode_gamma), labels)) return np.asarray(vertices), labels def get_k_point_index(self, k_point: Union[List[float], np.ndarray]) -> int: - if len(k_point) != 3: - raise TypeError('Expected type for k-point: list or NumPy array of length 3') + raise TypeError("Expected type for k-point: list or NumPy array of length 3") return get_k_point_index(k_point, self.k_points, verbose=False) @@ -96,18 +98,16 @@ def get_band_edges(self) -> Tuple[int, int]: i_vbm = n_occupied - 1 if i_vbm + 1 >= self.n_bands: - raise ValueError(f'Fermi level {self.e_fermi} larger than highest band energy {np.amax(self.bands)}') + raise ValueError(f"Fermi level {self.e_fermi} larger than highest band energy {np.amax(self.bands)}") return i_vbm, i_vbm + 1 def get_valence_band_maximum(self) -> float: - """Get the value of the valence band maximum. - """ + """Get the value of the valence band maximum.""" return np.amax(self.bands[:, self.i_vbm]) def get_conduction_band_minimum(self) -> float: - """Get the value of the conduction band minimum. - """ + """Get the value of the conduction band minimum.""" return np.amin(self.bands[:, self.i_cbm]) def get_fundamental_band_gap(self) -> float: @@ -125,7 +125,7 @@ def get_fundamental_band_gap(self) -> float: return self.bands[ik_c, self.i_cbm] - self.bands[ik_v, self.i_vbm] def get_band_gap(self, k_valence, k_conduction): - """ Get the value of the band gap calculated between two given k-points. + """Get the value of the band gap calculated between two given k-points. :param k_valence: k-point for the valence band. :param k_conduction: k-point for the valence band. diff --git a/excitingtools/dataclasses/data_structs.py b/excitingtools/dataclasses/data_structs.py index 584403e..1ec7ac2 100644 --- a/excitingtools/dataclasses/data_structs.py +++ b/excitingtools/dataclasses/data_structs.py @@ -1,26 +1,30 @@ -""" Data Structures. +"""Data Structures. Data structure is defined as a container for data. Many of these classes could be @dataclass, however excitingtools retains support for python 3.6. """ + class PointIndex: - """ Container for (point, index) pair - """ + """Container for (point, index) pair""" + def __init__(self, point, index: int): self.point = point self.index = index + class BandIndices: """Indices of valence band maximum and conduction band minimum""" + def __init__(self, VBM: int, CBm: int): self.VBM = VBM self.CBm = CBm + class NumberOfStates: - """Number of states. Useful when indexing does not start at 0/1 - """ + """Number of states. Useful when indexing does not start at 0/1""" + def __init__(self, first_state: int, last_state: int): self.first_state = first_state self.last_state = last_state diff --git a/excitingtools/dataclasses/density_of_states.py b/excitingtools/dataclasses/density_of_states.py index 9d30703..b396c6a 100644 --- a/excitingtools/dataclasses/density_of_states.py +++ b/excitingtools/dataclasses/density_of_states.py @@ -1,7 +1,7 @@ import numpy as np -class DOS: +class DOS: def __init__(self, energy: np.ndarray, dos: np.ndarray): self.energy = energy self.dos = dos diff --git a/excitingtools/dataclasses/eigenvalues.py b/excitingtools/dataclasses/eigenvalues.py index 1ae21f3..2c45a67 100644 --- a/excitingtools/dataclasses/eigenvalues.py +++ b/excitingtools/dataclasses/eigenvalues.py @@ -1,10 +1,12 @@ -""" Eigenvalue class. -""" +"""Eigenvalue class.""" + import warnings -from typing import List, Union, Optional +from itertools import permutations +from typing import List, Optional, Union + import numpy as np -from excitingtools.dataclasses.data_structs import PointIndex, BandIndices, NumberOfStates +from excitingtools.dataclasses.data_structs import BandIndices, NumberOfStates, PointIndex class EigenValues: @@ -13,22 +15,26 @@ class EigenValues: # If a k-index is not matched NO_MATCH = -1 - def __init__(self, - state_range: NumberOfStates, - k_points: point_type, - k_indices: index_type, - all_eigenvalues: np.ndarray, - weights=None): + def __init__( + self, + state_range: NumberOfStates, + k_points: point_type, + k_indices: index_type, + all_eigenvalues: np.ndarray, + weights=None, + occupations: Optional[np.ndarray] = None, + ): self.state_range = state_range self.k_points = k_points self.k_indices = k_indices self.all_eigenvalues = all_eigenvalues self.weights = weights + self.occupations = occupations if all_eigenvalues.shape != (len(self.k_points), self.state_range.n_states): - raise ValueError('Shape of all_eigenvalues does not match (n_k, n_states)') + raise ValueError("Shape of all_eigenvalues does not match (n_k, n_states)") def get_array_index(self, i_state: int): - """ Given the state index, get the corresponding index in the eigenvalue array. + """Given the state index, get the corresponding index in the eigenvalue array. :param i_state: State index using fortran indexing :return array index, using zero-indexing @@ -37,7 +43,7 @@ def get_array_index(self, i_state: int): return i_state - self.state_range.first_state def get_k_point(self, ik: int): - """ Get the k-point associated with ik index. + """Get the k-point associated with ik index. :param ik: k-point index. :return k-point in fractional coordinates. @@ -46,7 +52,7 @@ def get_k_point(self, ik: int): return self.k_points[ik - 1] def get_index(self, k_point, verbose=False) -> int: - """ Find the corresponding index of a k-point. + """Find the corresponding index of a k-point. If no k-point is found, NO_MATCH is returned. @@ -58,17 +64,17 @@ def get_index(self, k_point, verbose=False) -> int: k_point = np.asarray(k_point) for i, point in enumerate(self.k_points): diff[i] = np.linalg.norm(np.asarray(point) - k_point) - indices = np.argwhere(diff < 1.e-8) + indices = np.argwhere(diff < 1.0e-8) if len(indices) == 0: if verbose: - warnings.warn(f'{k_point} not present in list of k-points') + warnings.warn(f"{k_point} not present in list of k-points") return self.NO_MATCH indices = indices[0] if len(indices) > 1: - raise ValueError(f'Found degenerate k-points at {indices}') + raise ValueError(f"Found degenerate k-points at {indices}") ik = indices[0] + 1 return ik @@ -93,7 +99,7 @@ def get_eigenvalues(self, ik: Optional[int] = None, k_point=None) -> np.ndarray: """ if ik is None: if k_point is None: - raise ValueError('Must provide either k-index or k-point') + raise ValueError("Must provide either k-index or k-point") ik = self.get_index(k_point) if ik == self.NO_MATCH: @@ -102,7 +108,7 @@ def get_eigenvalues(self, ik: Optional[int] = None, k_point=None) -> np.ndarray: return self.all_eigenvalues[ik - 1, :] def band_gap(self, band_indices: BandIndices, k_points=None, k_indices=None) -> Union[float, ValueError]: - """ Get a band gap for two k-points in the valence band top + """Get a band gap for two k-points in the valence band top and conduction band bottom, respectively. TODO(Alex) Consider adding objects to hold k_points and k_indices. @@ -115,12 +121,12 @@ def band_gap(self, band_indices: BandIndices, k_points=None, k_indices=None) -> """ if k_indices is None: if k_points is None: - raise ValueError('Must pass either k_points or k_indices to band_gap method') + raise ValueError("Must pass either k_points or k_indices to band_gap method") k_indices = (self.get_index(k_points[0]), self.get_index(k_points[1])) if self.NO_MATCH in k_indices: indices = np.argwhere(k_indices == self.NO_MATCH)[0] - err_msg = f"".join(f'Requested k-point {k_points[i]} not present. \n' for i in indices) + err_msg = "".join(f"Requested k-point {k_points[i]} not present. \n" for i in indices) return ValueError(err_msg) i_vbm = self.get_array_index(band_indices.VBM) @@ -130,3 +136,31 @@ def band_gap(self, band_indices: BandIndices, k_points=None, k_indices=None) -> ik_cbm = k_indices[1] - 1 return self.all_eigenvalues[ik_cbm, i_cbm] - self.all_eigenvalues[ik_vbm, i_vbm] + + def get_transition_energy(self, valence_k_point: point_type, conduction_k_point: point_type) -> float: + """Determine transition energy between two k-points in the valence band top and conduction band bottom, + respectively. + + This function accounts for all different permutations in which the k-points can be found within the exciting + eigenvalue output files and determines band indices using occupation values. + + :param valence_k_point: k-point for valence band in fractional coordinates. + :param conduction_k_point: k-point for conduction band in fractional coordinates. + :return: Transition energy in Hartree. + """ + indices_valence = [self.get_index(k_point) for k_point in permutations(valence_k_point)] + try: + ik = next(i for i in indices_valence if i != self.NO_MATCH) + except StopIteration: + raise ValueError(f"Requested valence k-point {valence_k_point} not present.") + + indices_conduction = [self.get_index(k_point) for k_point in permutations(conduction_k_point)] + try: + jk = next(i for i in indices_conduction if i != self.NO_MATCH) + except StopIteration: + raise ValueError(f"Requested conduction k-point {conduction_k_point} not present.") + + iVBM = np.where(self.occupations[ik - 1] == 0)[0][0] + self.state_range.first_state - 1 + jCBm = np.where(self.occupations[jk - 1] == 0)[0][0] + self.state_range.first_state + + return self.band_gap(BandIndices(iVBM, jCBm), k_indices=[ik, jk]) diff --git a/excitingtools/eigenstates/eigenstates.py b/excitingtools/eigenstates/eigenstates.py index 9027ae2..c1ac526 100644 --- a/excitingtools/eigenstates/eigenstates.py +++ b/excitingtools/eigenstates/eigenstates.py @@ -1,11 +1,12 @@ -""" Function for finding the corresponding index of a k-point. -""" -import numpy as np +"""Function for finding the corresponding index of a k-point.""" + import warnings +import numpy as np + def get_k_point_index(k_point: np.ndarray, k_points_to_search: np.ndarray, verbose=False) -> int: - """ Find the corresponding index of a k-point. + """Find the corresponding index of a k-point. If no k-point is found, NO_MATCH is returned. @@ -14,21 +15,21 @@ def get_k_point_index(k_point: np.ndarray, k_points_to_search: np.ndarray, verbo :param verbose: Print warning, if no k-point found. :return ik: Corresponding index w.r.t. exciting. """ - NO_MATCH = np.NaN + NO_MATCH = np.nan diff = np.empty(shape=len(k_points_to_search)) k_point = np.asarray(k_point) for i, point in enumerate(k_points_to_search): diff[i] = np.linalg.norm(np.asarray(point) - k_point) - indices = np.argwhere(diff < 1.e-8) + indices = np.argwhere(diff < 1.0e-8) if len(indices) == 0: if verbose: - warnings.warn(f'{k_point} not present in list of k-points') + warnings.warn(f"{k_point} not present in list of k-points") return NO_MATCH if len(indices) > 1: - raise ValueError(f'Found degenerate k-points at {indices}'.replace('\n', ',')) + raise ValueError(f"Found degenerate k-points at {indices}".replace("\n", ",")) ik = indices[0] return ik diff --git a/excitingtools/exciting_dict_parsers/RT_TDDFT_parser.py b/excitingtools/exciting_dict_parsers/RT_TDDFT_parser.py index 526583d..b44a0c4 100644 --- a/excitingtools/exciting_dict_parsers/RT_TDDFT_parser.py +++ b/excitingtools/exciting_dict_parsers/RT_TDDFT_parser.py @@ -1,9 +1,11 @@ """ Parsers for real-time TDDFT output files """ + +from typing import List from xml.etree.ElementTree import ParseError + import numpy as np -from typing import List from excitingtools.parser_utils.grep_parser import grep @@ -14,13 +16,13 @@ def parse_nexc(name, skiprows=1): """ try: data = np.genfromtxt(name, skip_header=skiprows) - except: + except Exception: raise ParseError out = { "Time": data[:, 0], "number_electrons_GroundState": data[:, 1], "number_electrons_ExcitedState": data[:, 2], - "sum": data[:, 3] + "sum": data[:, 3], } return out @@ -32,14 +34,9 @@ def parse_jind(name, skiprows=0): """ try: data = np.genfromtxt(name, skip_header=skiprows) - except: + except Exception: raise ParseError - out = { - "Time": data[:, 0], - "Jx": data[:, 1], - "Jy": data[:, 2], - "Jz": data[:, 3] - } + out = {"Time": data[:, 0], "Jx": data[:, 1], "Jy": data[:, 2], "Jz": data[:, 3]} return out @@ -50,7 +47,7 @@ def parse_etot(name): """ try: data = np.genfromtxt(name, skip_header=1) - except: + except Exception: raise ParseError out = { "Time": data[:, 0], @@ -61,7 +58,7 @@ def parse_etot(name): "Exchange": data[:, 5], "Correlation": data[:, 6], "XC-potential": data[:, 7], - "Coulomb pot. energy": data[:, 8] + "Coulomb pot. energy": data[:, 8], } return out @@ -98,8 +95,8 @@ def get_k_point_blocks(name: str, fortran_index=False) -> List[dict]: """ # Lines for which a new k-point block starts - raw_k_point_lines = grep("ik", name, options={'n': ''}).splitlines() - k_point_lines = [int(line.split(':')[0]) for line in raw_k_point_lines] + raw_k_point_lines = grep("ik", name, options={"n": ""}).splitlines() + k_point_lines = [int(line.split(":")[0]) for line in raw_k_point_lines] if fortran_index: offset = 1 @@ -114,34 +111,31 @@ def get_k_point_blocks(name: str, fortran_index=False) -> List[dict]: k_start = 0 + offset for ik in k_point_lines[1:]: k_end = ik - 2 - k_blocks.append({'start': k_start, 'end': k_end}) + k_blocks.append({"start": k_start, "end": k_end}) k_start = k_end + 2 # Account for final k-block - k_blocks.append({'start': k_start, 'end': n_lines - 2}) + k_blocks.append({"start": k_start, "end": n_lines - 2}) return k_blocks k_blocks = get_k_point_blocks(name) # Parse file - with open(name, 'r') as f: + with open(name) as f: file = f.readlines() data = {} kpoints = [] for indices in k_blocks: kpoint = {} - ik = int(file[indices['start']].split()[-1]) - eigenvalues = [ - float(file[i].split()[-1]) - for i in range(indices['start'] + 1, indices['end'] + 1) - ] - kpoint['ik'] = ik - kpoint['eigenvalues'] = eigenvalues + ik = int(file[indices["start"]].split()[-1]) + eigenvalues = [float(file[i].split()[-1]) for i in range(indices["start"] + 1, indices["end"] + 1)] + kpoint["ik"] = ik + kpoint["eigenvalues"] = eigenvalues kpoints.append(kpoint) - data['kpoints'] = kpoints + data["kpoints"] = kpoints return data @@ -154,8 +148,8 @@ def parse_proj_screenshots(name: str) -> dict: between blocks differs by 1. """ - raw_k_point_lines = grep("ik", name, options={'n': ''}).splitlines() - k_point_lines = [int(line.split(':')[0]) - 1 for line in raw_k_point_lines] + raw_k_point_lines = grep("ik", name, options={"n": ""}).splitlines() + k_point_lines = [int(line.split(":")[0]) - 1 for line in raw_k_point_lines] last_line = sum(1 for line in open(name)) k_blocks = [] @@ -170,19 +164,55 @@ def parse_proj_screenshots(name: str) -> dict: with open(name) as f: file = f.readlines() - data = {} - kpoints = [] + data = {"ik": [], "projection": []} for i in k_blocks: - kpoint = {} start = i[0] end = i[1] ik = int(file[start].split()[-1]) - projection = [] - for j in range(start + 1, end): - projection.append([float(x) for x in file[j].split()]) - kpoint['ik'] = ik - kpoint['projection'] = np.asarray(projection) + projection = [[float(x) for x in file[j].split()] for j in range(start + 1, end)] + data["ik"].append(ik) + data["projection"].append(np.asarray(projection)) + return data - data['kpoints'] = kpoints - return data +def parse_atom_position_velocity_force(name: str) -> dict: + """Parser for ATOM_????.OUT + :param str name: name of file to parse + :return dict out: each dict key corresponds to time, position (3 columns), velocity (3 columns), total force (3 columns). + The 3 columns refer to the x, y, z components + """ + try: + data = np.genfromtxt(name, skip_header=0) + except Exception: + raise ParseError + out = { + "Time": data[:, 0], + "x": data[:, 1], + "y": data[:, 2], + "z": data[:, 3], + "vx": data[:, 4], + "vy": data[:, 5], + "vz": data[:, 6], + "Fx": data[:, 7], + "Fy": data[:, 8], + "Fz": data[:, 9], + } + + return out + + +def parse_force(name, skiprows=0): + """ + Parser for X_????.OUT, where X can be: + - FCR: core corrections to forces + - FEXT: external forces (due to e.g. an electric field) + - FHF: Hellman-Feynman term of forces + - FVAL: valence corrections to forces + """ + try: + data = np.genfromtxt(name, skip_header=skiprows) + except Exception: + raise ParseError + out = {"Time": data[:, 0], "Fx": data[:, 1], "Fy": data[:, 2], "Fz": data[:, 3]} + + return out diff --git a/excitingtools/exciting_dict_parsers/bse_parser.py b/excitingtools/exciting_dict_parsers/bse_parser.py index a6dcfa5..a42bc76 100644 --- a/excitingtools/exciting_dict_parsers/bse_parser.py +++ b/excitingtools/exciting_dict_parsers/bse_parser.py @@ -1,12 +1,13 @@ -"""Parsers for BSE output files. -""" +"""Parsers for BSE output files.""" + import re -import numpy as np from typing import Optional +import numpy as np + def numpy_gen_from_txt(name: str, skip_header: Optional[int] = 0) -> np.ndarray: - """ Numpy genfromtxt, dressed in try/expect. + """Numpy genfromtxt, dressed in try/expect. Not worth generalising, as would need to support genfromtxt's API. @@ -17,48 +18,40 @@ def numpy_gen_from_txt(name: str, skip_header: Optional[int] = 0) -> np.ndarray: try: data = np.genfromtxt(name, skip_header=skip_header) except ValueError: - raise ValueError(f'Failed to parse {name}') + raise ValueError(f"Failed to parse {name}") return data def parse_EPSILON_NAR(name: str) -> dict: - """ - Parser for: - EPSILON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC.OUT.xml, - EPSILON_NAR_FXCMB1_OC_QMT001.OUT.xml, - EPSILON_NAR_NLF_FXCMB1_OC_QMT001.OUT.xml, - LOSS_NAR_FXCMB1_OC_QMT001.OUT.xml + """Parser for: + EPSILON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC.OUT.xml, + EPSILON_NAR_FXCMB1_OC_QMT001.OUT.xml, + EPSILON_NAR_NLF_FXCMB1_OC_QMT001.OUT.xml, + LOSS_NAR_FXCMB1_OC_QMT001.OUT.xml """ data = numpy_gen_from_txt(name, skip_header=14) out = { "frequency": data[:, 0], "real_oscillator_strength": data[:, 1], "imag_oscillator_strength": data[:, 2], - "real_oscillator_strength_kkt": data[:, 3] + "real_oscillator_strength_kkt": data[:, 3], } return out def parse_LOSS_NAR(name): - """ - Parser for: - LOSS_NAR_FXCMB1_OC_QMT001.OUT.xml, - LOSS_NAR_NLF_FXCMB1_OC_QMT001.OUT.xml + """Parser for: + LOSS_NAR_FXCMB1_OC_QMT001.OUT.xml, + LOSS_NAR_NLF_FXCMB1_OC_QMT001.OUT.xml """ data = numpy_gen_from_txt(name, skip_header=14) - out = { - "frequency": data[:, 0], - "real_oscillator_strength": data[:, 1], - "imag_oscillator_strength": data[:, 2] - } + out = {"frequency": data[:, 0], "real_oscillator_strength": data[:, 1], "imag_oscillator_strength": data[:, 2]} return out def parse_EXCITON_NAR_BSE(name): - """ - Parser for EXCITON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC.OUT - """ + """Parser for EXCITON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC.OUT""" data = numpy_gen_from_txt(name, skip_header=14) out = {} out["state"] = data[:, 0] @@ -71,7 +64,7 @@ def parse_EXCITON_NAR_BSE(name): return out -def parse_infoxs_out(name: str) -> dict: +def parse_infoxs_out(name: str, parse_timing: bool = False) -> dict: """ Parser for INFOXS.OUT file. Parses only the started and stopped tasks. Searches for lines like: @@ -83,8 +76,11 @@ def parse_infoxs_out(name: str) -> dict: If the task is found to be finished afterwards, the status finished is set to True. For success, the last started tasks has to be finished after that (in the file). - Last finished task is the last task if calculation was successful, the task before that if it finished, else None. + Last finished task is the last task if calculation was successful, the task before that + if it finished, else None. :param name: path of the file to parse + :param parse_timing: parse also timing information for the tasks. By default this is set to + False. If the task has not finished None is returned as timing. :returns: dictionary containing parsed file """ with open(name) as file: @@ -94,33 +90,121 @@ def parse_infoxs_out(name: str) -> dict: current_task = -1 lines = "\n".join(lines) - lines_with_tasks = re.findall(r'EXCITING .* started for task .*\)|' - r'EXCITING .* stopped for task .* \d+', lines) - for line in lines_with_tasks: - split_line = line.split() - if split_line[2] == 'started': - tasks.append({ - 'name': split_line[5], - 'number': int(split_line[6][1:-1]), - 'finished': False - }) + all_tasks = re.findall( + r"EXCITING .* (started) for task (.*) \( ?(\d+)\)|EXCITING .* stopped for task .* (\d+)", lines + ) + + for task in all_tasks: + if task[0] == "started": + tasks.append({"name": task[1], "number": int(task[2]), "finished": False}) current_task += 1 else: # asserts shouldn't happen with Exciting: - assert tasks != [], 'No tasks started!' - assert tasks[current_task]['number'] == int(split_line[5]), \ - 'Wrong task stopped.' - tasks[current_task]['finished'] = True + assert tasks, "No tasks started!" + assert tasks[current_task]["number"] == int(task[3]), "Wrong task stopped." + tasks[current_task]["finished"] = True - success = tasks[-1]['finished'] + success = tasks[-1]["finished"] last_finished_task = None if success: - last_finished_task = tasks[-1]['name'] - else: - if len(tasks) > 1: - if tasks[-2]['finished']: - last_finished_task = tasks[-2]['name'] - - return {'tasks': tasks, - 'success': success, - 'last_finished_task': last_finished_task} + last_finished_task = tasks[-1]["name"] + elif len(tasks) > 1 and tasks[-2]["finished"]: + last_finished_task = tasks[-2]["name"] + + if parse_timing: + times = parse_times(lines) + finished_tasks = [task for task in tasks if task["finished"]] + assert len(times["cpu"]) == len(finished_tasks), "Numbers of finished tasks and parsed times are not the same." + + for index, task in enumerate(finished_tasks): + task["cpu_time"] = float(times["cpu"][index]) + task["wall_time"] = float(times["wall"][index]) + task["cpu_time_cum"] = float(times["cpu_cum"][index]) + task["wall_time_cum"] = float(times["wall_cum"][index]) + + return {"tasks": tasks, "success": success, "last_finished_task": last_finished_task} + + +def parse_times(infoxs_string: str) -> dict: + """Parse the run times in INFOXS.OUT for each task. + :param infoxs_string: String that contains the INFOXS.OUT file. + :returns: dictionary containing a list of run times for each measurement. + """ + cpu_times = re.findall(r"CPU time \s*: ([\d\.\d]+) sec", infoxs_string) + wall_times = re.findall(r"wall time \s*: ([\d\.\d]+) sec", infoxs_string) + cpu_times_cum = re.findall(r"CPU time \s* \(cumulative\) \s*: ([\d\.\d]+) sec", infoxs_string) + wall_times_cum = re.findall(r"wall time \(cumulative\) \s*: ([\d\.\d]+) sec", infoxs_string) + + assert len(cpu_times) == len(wall_times), "Numbers of parsed timings are not consistent." + assert len(cpu_times) == len(cpu_times_cum), "Numbers of parsed timings are not consistent." + assert len(cpu_times) == len(wall_times_cum), "Numbers of parsed timings are not consistent." + + return {"cpu": cpu_times, "wall": wall_times, "cpu_cum": cpu_times_cum, "wall_cum": wall_times_cum} + + +def parse_fastBSE_absorption_spectrum_out(name: str) -> dict: + """Parser for fastBSE_absorption_spectrum.out file. + + :param name: path of the file to parse + :returns: dictionary containing parsed file + """ + + n_lines_description = 6 + description = "" + with open(name) as file: + for _ in range(n_lines_description): + description += file.readline() + + try: + energy_unit = float(re.findall(r"# Energy unit:\s*(.*) *Hartree", description)[0]) + except IndexError: + raise RuntimeError("Could match regular expression for energy unit. Has the file header changed?") + + try: + broadening = float(re.findall(r"# Broadening:\s*(.*) energy unit", description)[0]) + except IndexError: + raise RuntimeError("Could match regular expression for broadening. Has the file header changed?") + + data = numpy_gen_from_txt(name, n_lines_description) + + return {"energy_unit": energy_unit, "broadening": broadening, "frequency": data[:, 0], "imag_epsilon": data[:, 1:4]} + + +def parse_fastBSE_exciton_energies_out(name: str) -> dict: + """Parser for fastBSE_exciton_energies.out and fastBSE_gauss_quadrature_energies.out files. + + :param name: path of the file to parse + :returns: dictionary containing parsed file + """ + + n_lines_description = 8 + description = "" + with open(name) as file: + for _ in range(n_lines_description): + description += file.readline() + + try: + energy_unit = float(re.findall(r"# Energy unit:\s*(.*) *Hartree", description)[0]) + except IndexError: + raise RuntimeError("Could match regular expression for energy unit. Has the file header changed?") + + try: + ip_band_gap = float(re.findall(r"# IP band gap:\s*(.*) energy unit", description)[0]) + except IndexError: + raise RuntimeError("Could match regular expression for ip band gap. Has the file header changed?") + + return { + "energy_unit": energy_unit, + "ip_band_gap": ip_band_gap, + "exciton_energies": numpy_gen_from_txt(name, n_lines_description), + } + + +def parse_fastBSE_oscillator_strength_out(name: str) -> dict: + """Parser for fastBSE_gauss_quadrature_oscillator_strengths.out.out file. + + :param name: path of the file to parse + :returns: dictionary containing parsed file + """ + + return {"oscillator_strength": numpy_gen_from_txt(name, 5)} diff --git a/excitingtools/exciting_dict_parsers/groundstate_parser.py b/excitingtools/exciting_dict_parsers/groundstate_parser.py index eace280..14f94eb 100644 --- a/excitingtools/exciting_dict_parsers/groundstate_parser.py +++ b/excitingtools/exciting_dict_parsers/groundstate_parser.py @@ -2,12 +2,18 @@ All functions in this module could benefit from refactoring. """ + +import re import xml.etree.ElementTree as ET +import numpy as np + from excitingtools.parser_utils.erroneous_file_error import ErroneousFileError +from excitingtools.parser_utils.parser_decorators import set_return_values, xml_root -def parse_info_out(name: str) -> dict: +@set_return_values +def parse_info_out(name: str) -> dict: # noqa: PLR0912, PLR0915 """ Parser exciting INFO.OUT into a dictionary. In: @@ -25,16 +31,18 @@ def parse_info_out(name: str) -> dict: # Get line numbers for SCF iteration blocks for i, line in enumerate(lines): # stores the number of the first and last line of every iteration into a list - if ('SCF iteration number' in line) or ('Hybrids iteration number' - in line): + if ( + ("SCF iteration number" in line) + or ("Hybrids iteration number" in line) + or ("Reached self-consistent loops maximum" in line) + ): nscl.append(i) - if ('Convergence criteria checked for the last 2 iterations' - in line) or ('Self-consistent loop stopped' in line): + if ("Convergency criteria checked for the last" in line) or ("Self-consistent loop stopped" in line): nscl.append(i) # stores the number of the first and last line of the initialization into a list - if 'Starting initialization' in line: + if "Starting initialization" in line: nini.append(i + 2) - if 'Ending initialization' in line: + if "Ending initialization" in line: nini.append(i - 2) calculation_failed = True @@ -47,7 +55,7 @@ def parse_info_out(name: str) -> dict: raise ErroneousFileError() INFO = {} - INFO['initialization'] = {} + INFO["initialization"] = {} ini = [] inits = {} k = 0 @@ -56,59 +64,56 @@ def parse_info_out(name: str) -> dict: # loops through all lines of the initialization for i in range(nini[0], nini[1]): # stores the lines, which have the format "variable : value" into a list - if (':' in lines[i]): - lines[i] = lines[i].split(':') + if ":" in lines[i]: + lines[i] = lines[i].split(":") ini.append(lines[i]) - if ini[k][0][1] != ' ' and speci != 0: # if indentation stops, species part is ended + if ini[k][0][1] != " " and speci != 0: # if indentation stops, species part is ended speci = 0 ini[k][0] = ini[k][0].strip() ini[k][1] = ini[k][1].strip() - if ('Lattice vectors' - in ini[k][0]) or ('Reciprocal lattice vectors' - in ini[k][0]): + if ("Lattice vectors" in ini[k][0]) or ("Reciprocal lattice vectors" in ini[k][0]): ini[k][1] = [] - lines[i + 1] = (lines[i + 1].split()) - lines[i + 2] = (lines[i + 2].split()) - lines[i + 3] = (lines[i + 3].split()) + lines[i + 1] = lines[i + 1].split() + lines[i + 2] = lines[i + 2].split() + lines[i + 3] = lines[i + 3].split() for j in range(3): ini[k][1].append(lines[i + 1][j]) ini[k][1].append(lines[i + 2][j]) ini[k][1].append(lines[i + 3][j]) - if ' ' in ini[k][1]: + if " " in ini[k][1]: ini[k][1] = ini[k][1].split() # initialize species subdict if key Species is found: - if ini[k][0] == 'Species': + if ini[k][0] == "Species": speci = ini[k][1][0] speci_name = ini[k][1][3:-1] - inits.update({'Species ' + speci: {'Species symbol': speci_name}}) + inits.update({"Species " + speci: {"Species symbol": speci_name}}) # stores variable-value pairs in a dictionary, in species subdict if necessary if speci != 0: - if ini[k][0][:16] == 'atomic positions': + if ini[k][0][:16] == "atomic positions": split_key = ini[k][0].split() unit = split_key[2][1:-1] - inits['Species ' + speci].update({'Atomic positions': {}}) + inits["Species " + speci].update({"Atomic positions": {}}) else: try: - key_name = 'Atom ' + str(int(ini[k][0])) - inits['Species ' + speci]['Atomic positions'].update({key_name: ini[k][1]}) + key_name = "Atom " + str(int(ini[k][0])) + inits["Species " + speci]["Atomic positions"].update({key_name: ini[k][1]}) except ValueError: - inits['Species ' + speci].update({ini[k][0]: ini[k][1]}) + inits["Species " + speci].update({ini[k][0]: ini[k][1]}) else: inits.update({ini[k][0]: ini[k][1]}) k = k + 1 # type of mixing is stored in the dictionary too - if 'mixing' in lines[i]: + if "mixing" in lines[i]: lines[i] = lines[i].strip() - inits.update({'mixing': lines[i]}) - inits.update({'units': {'positions': unit}}) + inits.update({"mixing": lines[i]}) + inits.update({"units": {"positions": unit}}) - INFO['initialization'] = inits + INFO["initialization"] = inits - scl1 = [] - INFO['scl'] = {} + INFO["scl"] = {} # loops through all scl's for j in range(len(nscl) - 1): @@ -118,23 +123,22 @@ def parse_info_out(name: str) -> dict: # loops through all lines of the scl for i in range(nscl[j], nscl[j + 1]): # stores the lines, which have the format "variable : value" into a list - if (':' in lines[i]) and ('+' - not in lines[i]) and ('(target)' - not in lines[i]): - lines[i] = lines[i].split(':') + if (":" in lines[i]) and ("+" not in lines[i]) and ("(target)" not in lines[i]): + lines[i] = lines[i].split(":") scl.append(lines[i]) scl[k][0] = scl[k][0].strip() scl[k][1] = scl[k][1].strip() - if ' ' in scl[k][1]: + if " " in scl[k][1]: scl[k][1] = scl[k][1].split() # stores variable-value pairs in a dictionary scls.update({scl[k][0]: scl[k][1]}) k = k + 1 - INFO['scl'][str(j + 1)] = scls + INFO["scl"][str(j + 1)] = scls return INFO +@set_return_values def parse_info_xml(name) -> dict: """ Parser exciting info.xml into a python dictionary. @@ -148,143 +152,140 @@ def parse_info_xml(name) -> dict: except AttributeError: raise ErroneousFileError - info = root.find('groundstate').attrib + info = root.find("groundstate").attrib excitingRun = [] i = 0 - for node in root.find('groundstate').find('scl').iter('iter'): + for node in root.find("groundstate").find("scl").iter("iter"): excitingRun.append(node.attrib) - excitingRun[i]['energies'] = node.find('energies').attrib - excitingRun[i]['charges'] = node.find('charges').attrib - atom_nr = 0 + excitingRun[i]["energies"] = node.find("energies").attrib + excitingRun[i]["charges"] = node.find("charges").attrib atomic_charge = [] species = [] - for atoms in node.find('charges').iter('atom'): - if atom_nr == 0: species_old = atoms.get('species') - atom_nr = atom_nr + 1 - if atoms.get('species') == species_old: - species.append({'muffin-tin': atoms.get('muffin-tin')}) + for atom_nr, atoms in enumerate(node.find("charges").iter("atom")): + if atom_nr == 0: + species_old = atoms.get("species") + if atoms.get("species") == species_old: + species.append({"muffin-tin": atoms.get("muffin-tin")}) else: - species_old = atoms.get('species') + species_old = atoms.get("species") atomic_charge.append(species) - species = [{'muffin-tin': atoms.get('muffin-tin')}] + species = [{"muffin-tin": atoms.get("muffin-tin")}] atomic_charge.append(species) - excitingRun[i]['charges']['atomic'] = atomic_charge - excitingRun[i]['timing'] = node.find('timing').attrib - if node.find('moments') is not None: - moments = {} - moments['momtot'] = node.find('moments').find('momtot').attrib - moments['interstitial'] = node.find('moments').find( - 'momtot').attrib - moments['mommttot'] = node.find('moments').find( - 'interstitial').attrib - excitingRun[i]['moments'] = moments + excitingRun[i]["charges"]["atomic"] = atomic_charge + excitingRun[i]["timing"] = node.find("timing").attrib + if node.find("moments") is not None: + moments = { + "momtot": node.find("moments").find("momtot").attrib, + "interstitial": node.find("moments").find("momtot").attrib, + "mommttot": node.find("moments").find("interstitial").attrib, + } + excitingRun[i]["moments"] = moments atom_nr = 0 atomic_moment = [] species = [] - for atoms in node.find('moments').iter('atom'): - if atom_nr == 0: species_old = atoms.get('species') - atom_nr = atom_nr + 1 - if atoms.get('species') == species_old: - species.append(atoms.find('mommt').attrib) + for atoms in node.find("moments").iter("atom"): + if atom_nr == 0: + species_old = atoms.get("species") + atom_nr += 1 + if atoms.get("species") == species_old: + species.append(atoms.find("mommt").attrib) else: - species_old = atoms.get('species') + species_old = atoms.get("species") atomic_moment.append(species) - species = [atoms.find('mommt').attrib] + species = [atoms.find("mommt").attrib] atomic_moment.append(species) - excitingRun[i]['moments']['atomic'] = atomic_moment + excitingRun[i]["moments"]["atomic"] = atomic_moment i = i + 1 - info['scl'] = {} + info["scl"] = {} for item in excitingRun: # converts list of scl-iterations into a dictionary - name = item['iteration'] - info['scl'][name] = item + name = item["iteration"] + info["scl"][name] = item return info +@set_return_values def parse_atoms(name) -> dict: - """ - Parser exciting atoms.xml into a python dictionary. - In: - name string path of the file to parse - Out: - info dict contains the content of the file to parse + """ + Parser exciting atoms.xml into a python dictionary. + In: + name string path of the file to parse + Out: + info dict contains the content of the file to parse """ root = ET.parse(name) atoms = {} - atoms['Hamiltonian'] = root.find('Hamiltonian').attrib + atoms["Hamiltonian"] = root.find("Hamiltonian").attrib atom = [] i = 0 - for node in root.findall('atom'): + for node in root.findall("atom"): atom.append(node.attrib) - spectrum = [] - states = node.find('spectrum') - for state in states.findall('state'): - spectrum.append(state.attrib) + states = node.find("spectrum") + spectrum = [state.attrib for state in states.findall("state")] - atom[i]['NumericalSetup'] = node.find('NumericalSetup').attrib - atom[i]['spectrum'] = {} + atom[i]["NumericalSetup"] = node.find("NumericalSetup").attrib + atom[i]["spectrum"] = {} j = 0 for item in spectrum: # converts list of states into a dictionary name = str(j) - atom[i]['spectrum'][name] = item + atom[i]["spectrum"][name] = item j = j + 1 i = i + 1 - atoms['atom'] = {} + atoms["atom"] = {} for item in atom: # converts list of atoms into a dictionary - name = item['chemicalSymbol'] - atoms['atom'][name] = item + name = item["chemicalSymbol"] + atoms["atom"][name] = item return atoms -def parse_eigval(name) -> dict: - """ - Parser exciting eigval.xml into a python dictionary. - In: - name string path of the file to parse - Out: - info dict contains the content of the file to parse - """ +@set_return_values +@xml_root +def parse_eigval(root) -> dict: + """Parse eigenvalues from eigval.xml file. - root = ET.parse(name).getroot() + :param root: XML file name, XML string or ElementTree.Element as input. + :return: dict output: Parsed data. + """ eigval = root.attrib kpts = [] - for node in root.findall('kpt'): + for node in root.findall("kpt"): kpt = node.attrib state = [] for subnode in node: state.append(subnode.attrib) - kpt['state'] = {} # converts list of states into a dictionary + kpt["state"] = {} # converts list of states into a dictionary for item in state: - name = item['ist'] - kpt['state'][name] = item + name = item["ist"] + kpt["state"][name] = item kpts.append(kpt) - eigval['kpt'] = {} + eigval["kpt"] = {} for item in kpts: # converts list of kpts into a dictionary - name = item['ik'] - eigval['kpt'][name] = item + name = item["ik"] + eigval["kpt"][name] = item return eigval +@set_return_values def parse_evalcore(name) -> dict: - """ - Parser exciting evalcore.xml into a python dictionary. - In: - name string path of the file to parse - Out: - info dict contains the content of the file to parse + """ + Parser exciting evalcore.xml into a python dictionary. + In: + name string path of the file to parse + Out: + info dict contains the content of the file to parse """ root = ET.parse(name).getroot() evalcore = root.attrib speciess = [] - for node in root.findall('species'): + for node in root.findall("species"): species = node.attrib atoms = [] for subnode in node: @@ -293,67 +294,138 @@ def parse_evalcore(name) -> dict: for subnode1 in subnode: state = subnode1.attrib states.append(state) - atom['state'] = {} + atom["state"] = {} for item in states: # converts list of states into a dictionary - name = item['ist'] - atom['state'][name] = item + name = item["ist"] + atom["state"][name] = item atoms.append(atom) - species['atom'] = {} + species["atom"] = {} for item in atoms: # converts list of atoms into a dictionary - name = item['ia'] - species['atom'][name] = item + name = item["ia"] + species["atom"][name] = item speciess.append(species) - evalcore['species'] = {} + evalcore["species"] = {} for item in speciess: # converts list of species into a dictionary - name = item['chemicalSymbol'] - evalcore['species'][name] = item + name = item["chemicalSymbol"] + evalcore["species"][name] = item return evalcore +@set_return_values def parse_geometry(name) -> dict: - """ - Parser exciting geometry.xml into a python dictionary. - In: - name string path of the file to parse - Out: - info dict contains the content of the file to parse + """ + Parser exciting geometry.xml into a python dictionary. + In: + name string path of the file to parse + Out: + info dict contains the content of the file to parse """ root = ET.parse(name).getroot() - structure = root.find('structure').attrib - crystal = root.find('structure').find('crystal').attrib - geometry = {'structure': structure} - structure['crystal'] = crystal + structure = root.find("structure").attrib + crystal = root.find("structure").find("crystal").attrib + geometry = {"structure": structure} + structure["crystal"] = crystal speciess = [] - for node in root.find('structure').findall('species'): + for node in root.find("structure").findall("species"): species = node.attrib atoms = [] for subnode in node: atom = subnode.attrib atoms.append(atom) - species['atom'] = {} + species["atom"] = {} i = 1 for item in atoms: name = str(i) - species['atom'][name] = item['coord'].split() + species["atom"][name] = item["coord"].split() i = i + 1 speciess.append(species) - structure['species'] = {} + structure["species"] = {} j = 1 for item in speciess: name = str(j) - structure['species'][name] = item + structure["species"][name] = item j = j + 1 basevects = [] - for node in root.find('structure').find('crystal').findall('basevect'): + for node in root.find("structure").find("crystal").findall("basevect"): basevect = node.text basevects.append(basevect) - structure['crystal']['basevect'] = {} + structure["crystal"]["basevect"] = {} k = 1 for item in basevects: name = str(k) - structure['crystal']['basevect'][name] = item + structure["crystal"]["basevect"][name] = item k = k + 1 return geometry + + +@set_return_values +def parse_linengy(name: str) -> dict: + """ + Parser for: LINENGY.OUT + + :param name: path of the file to parse + :returns: dictionary containing parsed file + """ + + linengy = {} + + with open(file=name) as fid: + lines = fid.readlines() + + apw_line = [] + lo_line = [] + + for i, line in enumerate(lines): + if "APW functions" in line: + apw_line.append(i) + if "local-orbital functions" in line: + lo_line.append(i) + + apw_line.append(len(lines)) + + for i in range(len(lo_line)): + linengy[str(i)] = {} + + apw = [lines[j].split(":")[1].strip() for j in range(apw_line[i], lo_line[i]) if "l =" in lines[j]] + linengy[str(i)]["apw"] = apw + + lo = [lines[j].split(":")[1].strip() for j in range(lo_line[i], apw_line[i + 1]) if "l =" in lines[j]] + linengy[str(i)]["lo"] = lo + + return linengy + + +@set_return_values +def parse_lo_recommendation(name: str) -> dict: + """ + Parser for: LO_RECOMMENDATION.OUT + + :param name: path of the file to parse + :returns: dictionary containing parsed file + """ + with open(file=name) as fid: + lines = fid.readlines() + + n_species = int(lines[2].split(":")[1]) + n_l_channels = int(lines[3].split(":")[1]) + n_nodes = int(lines[4].split(":")[1]) + + lo_recommendation = {"n_species": n_species, "n_l_channels": n_l_channels, "n_nodes": n_nodes} + + blocks = lines[6:] + block_size = n_nodes + 3 + n_blocks = n_l_channels * n_species + + for iblock in range(n_blocks): + offset = iblock * block_size + matches = re.findall(r":\s(.*?)(?:,|$)", blocks[offset + 0]) + species, l = matches[0], int(matches[1]) + # Package (node, n, energy) as one likes + if species not in lo_recommendation: + lo_recommendation.update({species: {}}) + lo_recommendation[species].update({l: np.loadtxt(blocks[offset + 2 : offset + n_nodes + 2]).tolist()}) + + return lo_recommendation diff --git a/excitingtools/exciting_dict_parsers/gw_eigenvalues_parser.py b/excitingtools/exciting_dict_parsers/gw_eigenvalues_parser.py index 681fa9c..599d0f6 100644 --- a/excitingtools/exciting_dict_parsers/gw_eigenvalues_parser.py +++ b/excitingtools/exciting_dict_parsers/gw_eigenvalues_parser.py @@ -1,21 +1,22 @@ -""" Parse GW's EVALQP.DAT file into dicts. +"""Parse GW's EVALQP.DAT file into dicts. Also seemed like a logical place to add the Fermi-level parser. """ + import enum +import os +import re from itertools import count + import numpy as np -import re -import os from excitingtools.dataclasses.data_structs import NumberOfStates - # GW eigenvalue file name -_file_name = 'EVALQP.DAT' +_file_name = "EVALQP.DAT" def parse_efermi_gw(name: str) -> dict: - """ Parser for EFERMI_GW.OUT. + """Parser for EFERMI_GW.OUT. :param name: File name :return data: GW Fermi level. @@ -37,14 +38,11 @@ def k_points_from_evalqp(file_string: str) -> dict: :param file_string: File string. :return k_points: k-points and their weights. """ - k_points_raw: list = re.findall(r'\s*k-point .*$', file_string, flags=re.MULTILINE) + k_points_raw: list = re.findall(r"\s*k-point .*$", file_string, flags=re.MULTILINE) k_points = {} for ik, line in enumerate(k_points_raw): kx, ky, kz, w = line.split()[-4:] - k_points[ik + 1] = { - 'k_point': [float(k) for k in [kx, ky, kz]], - 'weight': float(w) - } + k_points[ik + 1] = {"k_point": [float(k) for k in [kx, ky, kz]], "weight": float(w)} return k_points @@ -68,10 +66,8 @@ def n_states_from_file(file_string: str, n_header: int) -> NumberOfStates: """ lines = file_string.splitlines() - # ibgw = first_state first_state = int(lines[n_header].split()[0]) - # nbgw = last_state last_state = None for line in reversed(lines): if len(line.strip()) > 0: @@ -126,9 +122,7 @@ def parse_evalqp_blocks(full_file_name: str, k_points: dict, n_states: int) -> d # Must iterate lowest to highest, else data won't match k-points for ik in range(1, len(k_points) + 1): - block_data = np.loadtxt(full_file_name, - skiprows=skip_lines, - max_rows=n_states) + block_data = np.loadtxt(full_file_name, skiprows=skip_lines, max_rows=n_states) # Ignore first column (state index) data[ik] = block_data[:, 1:] skip_lines += n_states + (header_size + blank_line) @@ -168,21 +162,19 @@ def parse_evalqp(full_file_name: str) -> dict: energies = parse_evalqp_blocks(full_file_name, k_points, eval_indexing.n_states) assert len(k_points) == len(energies), "Should be a set of energies for each k-point" - data = {'state_range': [eval_indexing.first_state, eval_indexing.last_state], - 'column_labels': parse_column_labels(file_string)} + data = { + "state_range": [eval_indexing.first_state, eval_indexing.last_state], + "column_labels": parse_column_labels(file_string), + } # Repackage energies with their respective k-points for ik in range(1, len(k_points) + 1): - data[ik] = { - 'k_point': k_points[ik]['k_point'], - 'weight': k_points[ik]['weight'], - 'energies': energies[ik] - } + data[ik] = {"k_point": k_points[ik]["k_point"], "weight": k_points[ik]["weight"], "energies": energies[ik]} return data def parse_column_labels(file_string: str) -> enum.Enum: - """ Parse the column labels of EVALQP.DAT, which vary between code versions. + """Parse the column labels of EVALQP.DAT, which vary between code versions. :param str file_string: Input string return An enum class with the column labels as attributes, with corresponding @@ -191,7 +183,7 @@ def parse_column_labels(file_string: str) -> enum.Enum: column_str = file_string.splitlines()[1:2][0] column_labels = column_str.split()[1:] # zip and count ensure enum indexing starts at 0 - return enum.Enum(value='EvalQPColumns', names=zip(column_labels, count())) + return enum.Enum(value="EvalQPColumns", names=zip(column_labels, count())) def parse_gw_dos(full_file_name: str) -> dict: @@ -200,10 +192,10 @@ def parse_gw_dos(full_file_name: str) -> dict: :param full_file_name: Path + file name :return dict data: Parsed energies and DOS from GW DOS files """ - valid_file_names = ['TDOS.OUT', 'TDOS-QP.OUT'] - path, file_name = os.path.split(full_file_name) + valid_file_names = ["TDOS.OUT", "TDOS-QP.OUT"] + _, file_name = os.path.split(full_file_name) if file_name not in valid_file_names: - raise ValueError(f'{file_name} not a valid DOS file name.') + raise ValueError(f"{file_name} not a valid DOS file name.") data = np.genfromtxt(full_file_name) - return {'energy': data[:, 0], 'dos': data[:, 1]} + return {"energy": data[:, 0], "dos": data[:, 1]} diff --git a/excitingtools/exciting_dict_parsers/gw_eps00_parser.py b/excitingtools/exciting_dict_parsers/gw_eps00_parser.py index 9bc50e1..1c00558 100644 --- a/excitingtools/exciting_dict_parsers/gw_eps00_parser.py +++ b/excitingtools/exciting_dict_parsers/gw_eps00_parser.py @@ -1,12 +1,12 @@ -"""Parsers for GW's EPS00_GW.OUT output. -""" +"""Parsers for GW's EPS00_GW.OUT output.""" + import numpy as np from excitingtools.parser_utils.parser_decorators import accept_file_name from excitingtools.utils.utils import get_new_line_indices # Output file name -_file_name = 'EPS00_GW.OUT' +_file_name = "EPS00_GW.OUT" def parse_eps00_frequencies(file_string: str) -> dict: @@ -25,7 +25,7 @@ def parse_eps00_frequencies(file_string: str) -> dict: frequencies = {} j = 0 - for i in range(0, n_freq): + for i in range(n_freq): j = i * block_size frequencies[i + 1] = float(file[j].split()[-1]) @@ -59,8 +59,7 @@ def parse_eps00_blocks(file_string: str, n_freq: int) -> dict: eps00, for each frequency point. Frequency indexing (keys) start at 1. """ line = get_new_line_indices(file_string) - assert file_string[line[0]:line[1]].isspace(), ( - "First line of EPS00_GW.OUT must be a whiteline") + assert file_string[line[0] : line[1]].isspace(), "First line of EPS00_GW.OUT must be a whiteline" initial_header = 3 offset = initial_header - 1 @@ -75,7 +74,7 @@ def extract_eps_i(line: str) -> tuple: eps_i_img = np.array(line.split()[3:6], dtype=float) return eps_i_re, eps_i_img - for i_freq in range(0, n_freq): + for i_freq in range(n_freq): i = offset + i_freq * repeat_block_size eps_x_re, eps_x_img = extract_eps_i(file_string[i]) @@ -83,8 +82,8 @@ def extract_eps_i(line: str) -> tuple: eps_z_re, eps_z_img = extract_eps_i(file_string[i + 2]) data[i_freq + 1] = { - 're': np.array([eps_x_re, eps_y_re, eps_z_re]), - 'img': np.array([eps_x_img, eps_y_img, eps_z_img]) + "re": np.array([eps_x_re, eps_y_re, eps_z_re]), + "img": np.array([eps_x_img, eps_y_img, eps_z_img]), } return data @@ -92,7 +91,7 @@ def extract_eps_i(line: str) -> tuple: @accept_file_name def parse_eps00_gw(file_string: str) -> dict: - """ Parser frequency grid and epsilon00 from EPS00_GW.OUT. + """Parser frequency grid and epsilon00 from EPS00_GW.OUT. :param str file_string: Input string :return dict data: Dict containing the frequency, and real and imaginary parts @@ -100,12 +99,11 @@ def parse_eps00_gw(file_string: str) -> dict: """ frequencies = parse_eps00_frequencies(file_string) eps00 = parse_eps00_blocks(file_string, len(frequencies)) - assert len(frequencies) == len(eps00), \ - "Expect eps00 to have n frequencies consistent with the frequency grid" + assert len(frequencies) == len(eps00), "Expect eps00 to have n frequencies consistent with the frequency grid" # Repackage frequency points and eps00 together data = {} for i_freq in range(1, len(frequencies) + 1): - data[i_freq] = {'frequency': frequencies[i_freq], 'eps00': eps00[i_freq]} + data[i_freq] = {"frequency": frequencies[i_freq], "eps00": eps00[i_freq]} return data diff --git a/excitingtools/exciting_dict_parsers/gw_info_parser.py b/excitingtools/exciting_dict_parsers/gw_info_parser.py index f661967..af26747 100644 --- a/excitingtools/exciting_dict_parsers/gw_info_parser.py +++ b/excitingtools/exciting_dict_parsers/gw_info_parser.py @@ -1,17 +1,20 @@ -""" GW_INFO.OUT Parser, and all sub-block parses that comprise it. +"""GW_INFO.OUT Parser, and all sub-block parses that comprise it. NOTE. This could be trivialised to one line if GW_INFO.OUT was refactored to write to YAML. """ -import numpy as np -from typing import List, Union -import sys + import copy import re +import sys +import warnings +from typing import List, Union + +import numpy as np from excitingtools.parser_utils.parser_decorators import accept_file_name from excitingtools.parser_utils.regex_parser import parse_value_regex, parse_values_regex -from excitingtools.parser_utils.simple_parser import match_current_return_line_n, match_current_extract_from_line_n +from excitingtools.parser_utils.simple_parser import match_current_extract_from_line_n, match_current_return_line_n from excitingtools.utils.utils import can_be_float # Info file name for GW @@ -29,11 +32,12 @@ def parse_correlation_self_energy_params(file_string: str) -> dict: :param str file_string: Input string :return dict data: Matched data """ - keys_extractions = {'Solution of the QP equation': lambda x: int(x.split()[0]), - 'Energy alignment': lambda x: int(x.split()[0]), - 'Analytic continuation method': lambda x: x.strip(), - 'Scheme to treat singularities': lambda x: x.strip() - } + keys_extractions = { + "Solution of the QP equation": lambda x: int(x.split()[0]), + "Energy alignment": lambda x: int(x.split()[0]), + "Analytic continuation method": lambda x: x.strip(), + "Scheme to treat singularities": lambda x: x.strip(), + } data = match_current_extract_from_line_n(file_string, keys_extractions) @@ -45,15 +49,15 @@ def ref_after_citation(x: str) -> str: """ try: i = x.index(":") - return x[i + 1:].strip() + return x[i + 1 :].strip() except ValueError: - return 'Not found' + return "Not found" # Extraction of citations after - for key in ['Analytic continuation method', 'Scheme to treat singularities']: + for key in ["Analytic continuation method", "Scheme to treat singularities"]: citation_extraction = {key: ref_after_citation} matched_dictionary = match_current_extract_from_line_n(file_string, citation_extraction, n_line=2) - data[key + ' citation'] = matched_dictionary[key] + data[key + " citation"] = matched_dictionary[key] return data @@ -65,20 +69,18 @@ def parse_mixed_product_params(file_string: str) -> dict: :return dict data: Matched data """ search_keys = [ - 'Angular momentum cutoff:', 'Linear dependence tolerance factor:', - 'Plane wave cutoff \\(in units of Gkmax\\):' + "Angular momentum cutoff:", + "Linear dependence tolerance factor:", + "Plane wave cutoff \\(in units of Gkmax\\):", ] data = parse_values_regex(file_string, search_keys) # Rename keys to give better context modified_data = { - 'MT Angular momentum cutoff': - data['Angular momentum cutoff'], - 'MT Linear dependence tolerance factor': - data['Linear dependence tolerance factor'], - 'Plane wave cutoff (in units of Gkmax)': - data['Plane wave cutoff (in units of Gkmax)'] + "MT Angular momentum cutoff": data["Angular momentum cutoff"], + "MT Linear dependence tolerance factor": data["Linear dependence tolerance factor"], + "Plane wave cutoff (in units of Gkmax)": data["Plane wave cutoff (in units of Gkmax)"], } return modified_data @@ -104,19 +106,19 @@ def parse_bare_coulomb_potential_params(file_string: str) -> dict: :return dict data: Matched data """ - pw_cutoff_matching_str = 'Plane wave cutoff \\(in units of Gkmax\\*input%gw%MixBasis%gmb\\):' + pw_cutoff_matching_str = "Plane wave cutoff \\(in units of Gkmax\\*input%gw%MixBasis%gmb\\):" pw_cutoff = list(parse_value_regex(file_string, pw_cutoff_matching_str).values()) assert len(pw_cutoff) == 1, "Matched plane wave cutoff is ambiguous - more than one match" # Defined with less-verbose key - data = {'Plane wave cutoff (in units of Gkmax*gmb)': float(pw_cutoff[0])} + data = {"Plane wave cutoff (in units of Gkmax*gmb)": float(pw_cutoff[0])} - data2 = parse_value_regex(file_string, 'Error tolerance for structure constants:') + data2 = parse_value_regex(file_string, "Error tolerance for structure constants:") - data3 = parse_value_regex(file_string, 'the eigenvectors of the bare Coulomb potential:') + data3 = parse_value_regex(file_string, "the eigenvectors of the bare Coulomb potential:") # Defined with less-verbose key (see docs above for full key, over 2 lines) - modified_data3 = {'MB tolerance factor': data3.pop('the eigenvectors of the bare Coulomb potential')} + modified_data3 = {"MB tolerance factor": data3.pop("the eigenvectors of the bare Coulomb potential")} return {**data, **data2, **modified_data3} @@ -128,10 +130,10 @@ def parse_mixed_product_wf_info(file_string: str) -> dict: :return dict data: Matched data """ wf_info_keys = [ - 'Maximal number of MT wavefunctions per atom:', - 'Total number of MT wavefunctions:', - 'Maximal number of PW wavefunctions:', - 'Total number of mixed-product wavefunctions:' + "Maximal number of MT wavefunctions per atom:", + "Total number of MT wavefunctions:", + "Maximal number of PW wavefunctions:", + "Total number of mixed-product wavefunctions:", ] return parse_values_regex(file_string, wf_info_keys) @@ -143,11 +145,12 @@ def parse_frequency_grid_info(file_string: str) -> dict: :param str file_string: Input string :return dict data: Matched data """ - fgrid_keys = ['Type: < fgrid >', - 'Frequency axis: < fconv >', - 'Number of frequencies: < nomeg >', - 'Cutoff frequency: < freqmax >' - ] + fgrid_keys = [ + "Type: < fgrid >", + "Frequency axis: < fconv >", + "Number of frequencies: < nomeg >", + "Cutoff frequency: < freqmax >", + ] return parse_values_regex(file_string, fgrid_keys) @@ -160,11 +163,11 @@ def parse_frequency_grid(file_string: str, n_points: int) -> np.ndarray: :return np.ndarray grid_and_weights: Frequency grid and weights in grid[0, :] and grid[1, :], respectively """ - index = file_string.find('frequency list: < # freqs weight >') - frequency_lines = file_string[index:].split('\n') + index = file_string.find("frequency list: < # freqs weight >") + frequency_lines = file_string[index:].split("\n") grid_and_weights = np.empty(shape=(2, n_points)) - for i in range(0, n_points): + for i in range(n_points): index, frequency, weight = frequency_lines[i + 1].split() grid_and_weights[0:2, i] = np.array([float(frequency), float(weight)]) @@ -180,17 +183,18 @@ def parse_ks_eigenstates(file_string: str) -> dict: """ # Trailing whitespace in some instances required for match - ks_eigenstates_keys = ['Maximum number of LAPW states:', - 'Minimal number of LAPW states:', - '- total KS', - '- occupied', - '- unoccupied ', - '- dielectric function', - '- self energy', - 'Energy of the highest unoccupied state: ', - 'Number of valence electrons:', - 'Number of valence electrons treated in GW: ' - ] + ks_eigenstates_keys = [ + "Maximum number of LAPW states:", + "Minimal number of LAPW states:", + "- total KS", + "- occupied", + "- unoccupied ", + "- dielectric function", + "- self energy", + "Energy of the highest unoccupied state: ", + "Number of valence electrons:", + "Number of valence electrons treated in GW: ", + ] data = parse_values_regex(file_string, ks_eigenstates_keys) @@ -199,10 +203,7 @@ def parse_ks_eigenstates(file_string: str) -> dict: prepend_str = "Number of states used in GW" for key, value in data.items(): - if key[0] == '-': - new_key = prepend_str + " " + key.rstrip().rstrip(':') - else: - new_key = key.rstrip().rstrip(':') + new_key = prepend_str + " " + key.rstrip().rstrip(":") if key[0] == "-" else key.rstrip().rstrip(":") modified_data[new_key] = value return modified_data @@ -214,9 +215,8 @@ def parse_n_q_point_cycles(file_string: str) -> int: :param str file_string: Input string :return int n_q_cycles: maximum nunber of q iterations performed """ - matches = re.findall('\\(task_gw\\): q-point cycle, iq =' + '(.+?)\n', - file_string) - n_q_cycles = max([int(string.strip()) for string in matches]) + matches = re.findall("\\(task_gw\\): q-point cycle, iq =" + "(.+?)\n", file_string) + n_q_cycles = max(int(string.strip()) for string in matches) return n_q_cycles @@ -239,19 +239,18 @@ def parse_k_match(file_string: str, key: str) -> dict: data = {} try: - match = re.search(key + '(.+?)\n', file_string) + match = re.search(key + "(.+?)\n", file_string) k_point_and_index = match.group(1).split() - data = {'k_point': [float(k) for k in k_point_and_index[:3]], - 'ik': int(k_point_and_index[-1])} + data = {"k_point": [float(k) for k in k_point_and_index[:3]], "ik": int(k_point_and_index[-1])} except AttributeError: raise AttributeError("extract_kpoint. Did not find the key", match) return data - k_data = parse_k_match(file_string, 'at k = ') + k_data = parse_k_match(file_string, "at k = ") - return {'VBM': k_data, 'CBm': k_data} + return {"VBM": k_data, "CBm": k_data} def extract_kpoints(file_string: str) -> dict: @@ -273,27 +272,23 @@ def extract_kpoints(file_string: str) -> dict: def parse_k_match(file_string: str, key: str) -> dict: data = {} - match_key_to_parser_key = { - 'at k\\(VBM\\) = ': 'VBM', - 'k\\(CBm\\) = ': 'CBm' - } + match_key_to_parser_key = {"at k\\(VBM\\) = ": "VBM", "k\\(CBm\\) = ": "CBm"} + match = re.search(key + "(.+?)\n", file_string) try: - match = re.search(key + '(.+?)\n', file_string) - parser_key = match_key_to_parser_key[key].replace('\\', "") + parser_key = match_key_to_parser_key[key].replace("\\", "") k_point_and_index = match.group(1).split() - data[parser_key] = {'k_point': [float(k) for k in k_point_and_index[:3]], - 'ik': int(k_point_and_index[-1])} + data[parser_key] = {"k_point": [float(k) for k in k_point_and_index[:3]], "ik": int(k_point_and_index[-1])} except AttributeError: - print("extract_kpoints. Did not find the key", match) + warnings.warn(f"extract_kpoints. Did not find the key {match}") return data - k_data = parse_k_match(file_string, 'at k\\(VBM\\) = ') - k_data2 = parse_k_match(file_string, 'k\\(CBm\\) = ') + k_data = parse_k_match(file_string, "at k\\(VBM\\) = ") + k_data2 = parse_k_match(file_string, "k\\(CBm\\) = ") - return {** k_data, **k_data2} + return {**k_data, **k_data2} def parse_band_structure_info(file_string: str, bs_type: str) -> dict: @@ -321,44 +316,36 @@ def parse_band_structure_info(file_string: str, bs_type: str) -> dict: :param str file_string: Input string :return dict k_data: Matched data """ - if bs_type == 'ks': + if bs_type == "ks": # Parse first instance of each key, exploiting that Kohn-Sham band structure # comes before G0W0 band structure. This ASSUMES fixed structure to the file pass - elif bs_type == 'gw': + elif bs_type == "gw": # Find G0W0 band structure in the file, then start parsing from there - gw_header = ' G0W0 band structure ' - index = file_string.find('G0W0 band structure ') + gw_header = "G0W0 band structure " + index = file_string.find(gw_header) file_string = file_string[index:] else: sys.exit("bs_type must be 'ks' or 'gw'") # Indirect BandGap may not be present - band_structure_keys = ['Fermi energy:', - 'Energy range:', - 'Band index of VBM:', - 'Band index of CBm:'] + band_structure_keys = ["Fermi energy:", "Energy range:", "Band index of VBM:", "Band index of CBm:"] data = parse_values_regex(file_string, band_structure_keys) # Only present if there's an indirect gap - indirect_gap = parse_value_regex(file_string, - 'Indirect BandGap \\(eV\\):', - silent_key_error=True) + indirect_gap = parse_value_regex(file_string, "Indirect BandGap \\(eV\\):", silent_key_error=True) if indirect_gap: - direct_gap_keys = [ - 'Direct Bandgap at k\\(VBM\\) \\(eV\\):', - 'Direct Bandgap at k\\(CBm\\) \\(eV\\):' - ] + direct_gap_keys = ["Direct Bandgap at k\\(VBM\\) \\(eV\\):", "Direct Bandgap at k\\(CBm\\) \\(eV\\):"] data.update(indirect_gap) data.update(parse_values_regex(file_string, direct_gap_keys)) k_point_data = extract_kpoints(file_string) else: - data.update(parse_value_regex(file_string, 'Direct BandGap \\(eV\\):')) + data.update(parse_value_regex(file_string, "Direct BandGap \\(eV\\):")) k_point_data = extract_kpoint(file_string) return {**data, **k_point_data} @@ -374,23 +361,24 @@ def parse_gw_info(file_string: str) -> dict: :return: dict data: dictionary of parsed data. """ data = {} - data['correlation_self_energy_parameters'] = parse_correlation_self_energy_params(file_string) - data['mixed_product_basis_parameters'] = parse_mixed_product_params(file_string) - data['bare_coulomb_potential_parameters'] = parse_bare_coulomb_potential_params(file_string) - data['screened_coulomb_potential'] = match_current_return_line_n(file_string, 'Screened Coulomb potential:').strip() - data['core_electrons_treatment'] = match_current_return_line_n(file_string, 'Core electrons treatment:').strip() - data['qp_interval'] = parse_value_regex(file_string, 'Interval of quasiparticle states \\(ibgw, nbgw\\):')\ - ['Interval of quasiparticle states (ibgw, nbgw)'] - data['n_empty'] = parse_value_regex(file_string, 'Number of empty states \\(GW\\):')['Number of empty states (GW)'] - data['q_grid'] = parse_value_regex(file_string, 'k/q-points grid:')['k/q-points grid'] - data['mixed_product_wf_info'] = parse_mixed_product_wf_info(file_string) - data['frequency_grid'] = parse_frequency_grid_info(file_string) - n_freq_points = data['frequency_grid']['Number of frequencies: < nomeg >'] - data['frequency_grid']['frequencies_weights'] = parse_frequency_grid(file_string, n_freq_points) - data['ks_eigenstates_summary'] = parse_ks_eigenstates(file_string) - data['ks_band_structure_summary'] = parse_band_structure_info(file_string, 'ks') - data['n_q_cycles'] = parse_n_q_point_cycles(file_string) - data['g0w0_band_structure_summary'] = parse_band_structure_info(file_string, 'gw') + data["correlation_self_energy_parameters"] = parse_correlation_self_energy_params(file_string) + data["mixed_product_basis_parameters"] = parse_mixed_product_params(file_string) + data["bare_coulomb_potential_parameters"] = parse_bare_coulomb_potential_params(file_string) + data["screened_coulomb_potential"] = match_current_return_line_n(file_string, "Screened Coulomb potential:").strip() + data["core_electrons_treatment"] = match_current_return_line_n(file_string, "Core electrons treatment:").strip() + data["qp_interval"] = parse_value_regex(file_string, "Interval of quasiparticle states \\(ibgw, nbgw\\):")[ + "Interval of quasiparticle states (ibgw, nbgw)" + ] + data["n_empty"] = parse_value_regex(file_string, "Number of empty states \\(GW\\):")["Number of empty states (GW)"] + data["q_grid"] = parse_value_regex(file_string, "k/q-points grid:")["k/q-points grid"] + data["mixed_product_wf_info"] = parse_mixed_product_wf_info(file_string) + data["frequency_grid"] = parse_frequency_grid_info(file_string) + n_freq_points = data["frequency_grid"]["Number of frequencies: < nomeg >"] + data["frequency_grid"]["frequencies_weights"] = parse_frequency_grid(file_string, n_freq_points) + data["ks_eigenstates_summary"] = parse_ks_eigenstates(file_string) + data["ks_band_structure_summary"] = parse_band_structure_info(file_string, "ks") + data["n_q_cycles"] = parse_n_q_point_cycles(file_string) + data["g0w0_band_structure_summary"] = parse_band_structure_info(file_string, "gw") return data @@ -403,9 +391,9 @@ def extract_gw_timings_as_list(file_string: str) -> Union[List[str], None]: :param list timings: GW timings, with each element storing a line of timings as a string. """ - file_list = file_string.split('\n') + file_list = file_string.split("\n") for i, line in enumerate(reversed(file_list)): - if 'GW timing info (seconds)' in line: + if "GW timing info (seconds)" in line: index = i - 2 return file_list[-index:] @@ -442,29 +430,28 @@ def parse_gw_timings(file_string: str) -> dict: # Parse and store nested timings data = {} component_time = {} - root_key = 'Initialization' + root_key = "Initialization" initial_key = root_key for line in timings: - - key = line.strip().split(':')[0].rstrip() - if len(key) == 0: continue - is_root_key = key[0] != '-' + key = line.strip().split(":")[0].rstrip() + if len(key) == 0: + continue + is_root_key = key[0] != "-" if is_root_key and (key != initial_key): data[root_key] = copy.deepcopy(component_time) component_time.clear() root_key = key - time_str = line.strip().split(':')[-1] - component_time[key.strip('-').strip()] = float( - time_str) if can_be_float(time_str) else None + time_str = line.strip().split(":")[-1] + component_time[key.strip("-").strip()] = float(time_str) if can_be_float(time_str) else None # Store last value, Total data[root_key] = {root_key: float(time_str)} # Remove superfluous key from table formatting - arb_str = '_________________________________________________________' + arb_str = "_________________________________________________________" del data[arb_str] return data diff --git a/excitingtools/exciting_dict_parsers/gw_taskgroup_parser.py b/excitingtools/exciting_dict_parsers/gw_taskgroup_parser.py new file mode 100644 index 0000000..c4e2032 --- /dev/null +++ b/excitingtools/exciting_dict_parsers/gw_taskgroup_parser.py @@ -0,0 +1,121 @@ +"""Parser for output files generated by taskGroup of GW.""" + +from ast import literal_eval +from typing import Dict + +import numpy as np +from numpy.typing import NDArray + + +def __parse_file_with_matrix(file_name: str) -> NDArray[np.complex128]: + """Parser for files containing matrices. + + This file looks like: + 1st line: 2 (rank=2) + 2nd line: fours integers i,j,m,n with the ranges: matrix[i:m,j:n] + next lines: matrix elements (complex numbers) + + :param file_name: name of the file + :return: matrix read from file + """ + with open(file_name) as file: + dim = int(file.readline().split()[0]) + m_ini, n_ini, m_end, n_end = (int(x) for x in file.readline().split()) + assert dim == 2 and m_ini == 1 and n_ini == 1, "file should contain a full matrix" + matrix = np.zeros((m_end, n_end), dtype=complex) + + counter = 0 + for line in file: + for data in line.split(): + matrix[np.unravel_index(counter, (m_end, n_end), order="F")] = complex(*literal_eval(data)) + counter += 1 + return matrix + + +def __parse_file_with_array_of_rank_3(file_name: str) -> NDArray[np.complex128]: + """Parser for files containing arrays of rank 3. + + This file looks like: + 1st line: 3 (rank=3) + 2nd line: six integers i,j,k,m,n,p with the ranges: matrix[i:m,j:n,k:p] + next lines: array elements (complex numbers) + + :param file_name: name of the file + :return: array read from file + """ + with open(file_name) as file: + dim = int(file.readline().split()[0]) + m_ini, n_ini, p_ini, m_end, n_end, p_end = (int(x) for x in file.readline().split()) + assert dim == 3 and m_ini == 1 and n_ini == 1 and p_ini == 1, "file should contain a full array" + array = np.zeros((m_end, n_end, p_end), dtype=complex) + + counter = 0 + for line in file: + for data in line.split(): + array[np.unravel_index(counter, (m_end, n_end, p_end), order="F")] = complex(*literal_eval(data)) + counter += 1 + return array + + +def __square_matrix(a: NDArray) -> NDArray: + """Square a matrix. + + :param a: input matrix + :return: the result product + """ + return np.matmul(a.T.conj(), a) + + +def parse_barc(file_name: str) -> Dict[str, NDArray[np.complex128]]: + """Parser for BARC_*.OUT, where * is an integer. + + The file contains the product: M*(v^1/2), where M is a matrix with + eigenvectors, and v is a diagonal matrix with the eigenvalues. + Since the definition of M is not unique, we must return A^H*A, + where A is the matrix read. + + :param file_name: name of the file + :return: parsed data as dictionary + """ + return {"CoulombMatrix": __square_matrix(__parse_file_with_matrix(file_name))} + + +def parse_sgi(file_name: str) -> Dict[str, NDArray[np.complex128]]: + """Parser for SGI_*.OUT, where * is an integer. + + This file contains the vectors $\tilde{S_{Gi}}$, defined in Eq. (41) of + Computer Phys. Comm. 184, 348 (2013). + By reading the matrix A as stored, and performing A^H*A, one gets the overlap + matrix between basis elements defined for the interstitial region. + + :param file_name: name of the file + :return: parsed data as dictionary + """ + return {"OverlapMatrix": __square_matrix(__parse_file_with_matrix(file_name))} + + +def parse_epsilon(file_name: str) -> Dict[str, NDArray[np.complex128]]: + """Parser for the gw-epsilon files, which contain the dielectric function, its head or wings.: + - EPSH.OUT, + - EPSW1.OUT, + - EPSW2.OUT, + - EPSILON-GW_Q*.OUT, where * is an integer + + :param file_name: name of the file + :return: parsed data as dictionary + """ + return {"epsilon_tensor": __parse_file_with_array_of_rank_3(file_name)} + + +def parse_inverse_epsilon(file_name: str) -> Dict[str, NDArray[np.complex128]]: + """Parser for the gw-inverse-epsilon files, which contain the inverse of the + dielectric function, its head or wings: + - INVERSE-EPSH.OUT, + - INVERSE-EPSW1.OUT, + - INVERSE-EPSW2.OUT, + - INVERSE-EPSILON_Q*.OUT, where * is an integer + + :param file_name: name of the file + :return: parsed data as dictionary + """ + return {"inverse_epsilon_tensor": __parse_file_with_array_of_rank_3(file_name)} diff --git a/excitingtools/exciting_dict_parsers/gw_vxc_parser.py b/excitingtools/exciting_dict_parsers/gw_vxc_parser.py index e9ecfdb..ec705c3 100644 --- a/excitingtools/exciting_dict_parsers/gw_vxc_parser.py +++ b/excitingtools/exciting_dict_parsers/gw_vxc_parser.py @@ -1,9 +1,10 @@ -""" VXCNN.DAT Parser. -""" -import numpy as np +"""VXCNN.DAT Parser.""" + +import pathlib import re from typing import Union -import pathlib + +import numpy as np from excitingtools.dataclasses.data_structs import NumberOfStates from excitingtools.exciting_dict_parsers.gw_eigenvalues_parser import n_states_from_file @@ -21,7 +22,7 @@ def vkl_from_vxc(file_string: str) -> dict: :param str file_string: File string. :return dict vkl: k-points in fractional coordinates. """ - raw_data: list = re.findall(r'\s*ik= .*$', file_string, flags=re.MULTILINE) + raw_data: list = re.findall(r"\s*ik= .*$", file_string, flags=re.MULTILINE) vkl = {} for ik, line in enumerate(raw_data): vkl[ik + 1] = [float(k) for k in line.split()[-3:]] @@ -63,9 +64,7 @@ def parse_vxnn_vectors(full_file_name: Union[str, pathlib.Path], vkl: dict, n_st # Must iterate lowest to highest, else data won't match k-points for ik in range(1, len(vkl) + 1): - vxc_vector = np.loadtxt(full_file_name, - skiprows=skip_lines, - max_rows=n_states) + vxc_vector = np.loadtxt(full_file_name, skiprows=skip_lines, max_rows=n_states) # Ignore first column (state index) data[ik] = vxc_vector[:, 1:] skip_lines += n_states + (header_size + blank_line) @@ -93,12 +92,11 @@ def parse_vxcnn(full_file_name: Union[str, pathlib.Path]) -> dict: states = n_states_from_vxcnn(file_string) vkl = vkl_from_vxc(file_string) v_xc = parse_vxnn_vectors(full_file_name, vkl, states.n_states) - assert len(vkl) == len(v_xc), ( - "Should be a vector of Vxc_NN for each k-point") + assert len(vkl) == len(v_xc), "Should be a vector of Vxc_NN for each k-point" # Repackage Vxc vectors with their respective k-points data = {} for ik in range(1, len(vkl) + 1): - data[ik] = {'vkl': vkl[ik], 'v_xc_nn': v_xc[ik]} + data[ik] = {"vkl": vkl[ik], "v_xc_nn": v_xc[ik]} return data diff --git a/excitingtools/exciting_dict_parsers/hdf5_parser.py b/excitingtools/exciting_dict_parsers/hdf5_parser.py new file mode 100644 index 0000000..2788b2f --- /dev/null +++ b/excitingtools/exciting_dict_parsers/hdf5_parser.py @@ -0,0 +1,52 @@ +"""Parsers for HDF5 files.""" + +import numpy as np + + +def parse_hdf5_file_as_dict(fname: str) -> dict: + """Parse the content of an hdf5 file as dictionary. + Use this function only for small files. + + :param str fname: path to the file. + :return dict h5_file_content: Content of the hdf5 file. + """ + + try: + import h5py + except ImportError: + raise ImportError("h5py module not installed, but is required") + + def recursive_unpack(hdfobject, datadict): + """Unpack an HDF5 data object to a dictionary recursively. + + :param h5py.Group hdfobject: HDF5 object to unpack + :param dict datadict: Dictionary to unpack to. + """ + for key, value in hdfobject.items(): + if isinstance(value, h5py.Group): + datadict[key] = {} + datadict[key] = recursive_unpack(value, datadict[key]) + + elif isinstance(value, h5py.Dataset): + datadict[key] = value[()] + + return datadict + + def convert_one_element_arrays(obj): + """Replace np.array([a]) with a.""" + + for key, value in obj.items(): + if isinstance(value, np.ndarray) and len(value) == 1: + obj.update({key: value[0]}) + + elif isinstance(value, dict): + convert_one_element_arrays(value) + + file = h5py.File(fname) + h5_file_content = recursive_unpack(file, {}) + if file: + file.close() + + convert_one_element_arrays(h5_file_content) + + return h5_file_content diff --git a/excitingtools/exciting_dict_parsers/input_parser.py b/excitingtools/exciting_dict_parsers/input_parser.py index 95d1208..2d9d792 100644 --- a/excitingtools/exciting_dict_parsers/input_parser.py +++ b/excitingtools/exciting_dict_parsers/input_parser.py @@ -1,125 +1,145 @@ -""" Parsers for input.xml. - -TODO(Fabian): Issues 117 & 121: -As more sub-elements are implemented in the input files, also add parsers here -""" -import pathlib -import warnings -from typing import Dict, Union +"""Parsers for input.xml.""" + +import copy +from typing import Optional, Tuple from xml.etree import ElementTree from excitingtools.parser_utils.parser_decorators import xml_root +from excitingtools.parser_utils.parser_utils import convert_string_dict, find_element +from excitingtools.utils import valid_attributes as all_valid_attributes +from excitingtools.utils.valid_attributes import input_valid_attributes + + +@xml_root +def parse_input_xml(root): + """Parse an input.xml file into dictionary.""" + assert root.tag == "input" + return parse_element_xml(root) + -# Valid input formats for all parsers -root_type = Union[str, ElementTree.Element, pathlib.Path] +def get_root_from_tag(root: ElementTree.Element, tag: Optional[str] = None) -> Tuple[ElementTree.Element, str]: + """Get the root from a tag. + + :param tag: tag of interest + :param root: xml root containing the tag (or having the specified tag as tag) + :returns: the tag and the found root, if tag was None returns the tag of the given root + """ + if tag is None: + return root, root.tag + + root = find_element(root, tag) + if root is None: + raise ValueError(f"Your specified input has no tag {tag}.") + + return root, root.tag @xml_root -def parse_groundstate(root: root_type) -> dict: +def parse_element_xml(root, tag: Optional[str] = None) -> dict: + """Parse a xml element into dictionary. Can be input.xml root or a subelement of it. + Put the attributes simply in dict and add recursively the subtrees and nested dicts. + + :param tag: the tag to parse + :param root: the xml root containing the tag + :returns: the parsed dictionary, data converted to actual data types """ - Parse exciting input.xml groundstate element into python dictionary. - :param root: Input for the parser. - :returns: Dictionary containing the groundstate input element attributes. + root, tag = get_root_from_tag(root, tag) + + if tag in special_tags_to_parse_map: + return special_tags_to_parse_map[tag](root) + + element_dict = convert_string_dict(copy.deepcopy(root.attrib)) + + multiple_children = set(getattr(all_valid_attributes, f"{tag}_multiple_children", set())) + subelements = list(root) + multiple_tags = {subelement.tag for subelement in subelements} & multiple_children + element_dict.update({tag: [] for tag in multiple_tags}) + + # for loop over all subelements to retain the order + for subelement in subelements: + if subelement.tag in multiple_children: + element_dict[subelement.tag].append(parse_element_xml(subelement)) + else: + element_dict[subelement.tag] = parse_element_xml(subelement) + + return element_dict + + +def _parse_input_tag(root) -> dict: + """Parse special input tag. Necessary because exciting/xml allows arbitrary attributes at this level. + Only parses the explicitly named attributes in the schema. + + :param root: the xml root containing the input tag + :returns: the parsed dictionary, data converted to actual data types """ - ground_state = root.find('groundstate') - return ground_state.attrib + valid_attribs = {key: value for key, value in root.attrib.items() if key in input_valid_attributes} + element_dict = convert_string_dict(valid_attribs) + + subelements = list(root) + for subelement in subelements: + element_dict[subelement.tag] = parse_element_xml(subelement) + + return element_dict @xml_root -def parse_structure(root: root_type) -> dict: - """ - Parse exciting input.xml structure element into python dictionary. +def parse_structure(root) -> dict: + """Parse exciting input.xml structure element into python dictionary. + :param root: Input for the parser. :returns: Dictionary containing the structure input element attributes and subelements. Looks like: {'atoms': List of atoms with atom positions in fractional coordinates, 'lattice': List of 3 lattice vectors, 'species_path': species_path as string, - 'structure_properties': dictionary with the structure_properties, 'crystal_properties': dictionary with the crystal_properties, - 'species_properties': dictionary with the species_properties} + 'species_properties': dictionary with the species_properties, + all additional keys are structure attributes} """ - structure = root.find('structure') - structure_properties = structure.attrib - species_path = structure_properties.pop('speciespath') - crystal = structure.find('crystal') - crystal_properties = crystal.attrib - lattice = [] - for base_vect in crystal.findall('basevect'): - lattice.append([float(x) for x in base_vect.text.split()]) + structure = find_element(root, "structure") + structure_properties = convert_string_dict(copy.deepcopy(structure.attrib)) + species_path = structure_properties.pop("speciespath") + + crystal = structure.find("crystal") + crystal_properties = convert_string_dict(copy.deepcopy(crystal.attrib)) + lattice = [[float(x) for x in base_vect.text.split()] for base_vect in crystal] atoms = [] species_properties = {} - for species in structure.findall('species'): - species_attributes = species.attrib - species_file = species_attributes.pop('speciesfile') + for species in structure.findall("species"): + species_attributes = convert_string_dict(copy.deepcopy(species.attrib)) + species_file = species_attributes.pop("speciesfile") species_symbol = species_file[:-4] - species_properties[species_symbol] = species_attributes - for atom in species: - atom_attributes = atom.attrib - coord = [float(x) for x in atom_attributes.pop('coord').split()] - atom_dict = {'species': species_symbol, 'position': coord} + + species_subelements = list(species) + atom_xml_trees = [x for x in species_subelements if x.tag == "atom"] + for atom in atom_xml_trees: + atom_attributes = convert_string_dict(copy.deepcopy(atom.attrib)) + atom_dict = {"species": species_symbol, "position": atom_attributes.pop("coord")} atom_dict.update(atom_attributes) atoms.append(atom_dict) + other_xml_trees = set(species_subelements) - set(atom_xml_trees) + for tree in other_xml_trees: + species_attributes[tree.tag] = parse_element_xml(tree) + species_properties[species_symbol] = species_attributes + return { - 'atoms': atoms, - 'lattice': lattice, - 'species_path': species_path, - 'structure_properties': structure_properties, - 'crystal_properties': crystal_properties, - 'species_properties': species_properties + "atoms": atoms, + "lattice": lattice, + "species_path": species_path, + "crystal_properties": crystal_properties, + "species_properties": species_properties, + **structure_properties, } -@xml_root -def parse_xs(root: root_type) -> dict: - """ - Parse exciting input.xml xs element into python dictionary. - :param root: Input for the parser. - :returns: Dictionary containing the xs input element attributes and subelements. Could look like: - {'xstype': xstype as string, 'xs_properties': dictionary with the xs_properties, - 'energywindow': dictionary with the energywindow_properties, - 'screening': dictionary with the screening_properties, 'BSE': dictionary with bse_properties, - 'qpointset': List of qpoints, 'plan': List of tasks} - """ - xs = root.find('xs') - if xs is None: - return {} - - xs_properties = xs.attrib - xs_type = xs_properties.pop('xstype') - valid_xml_elements = ['BSE', 'energywindow', 'screening'] - optional_subelements = {} - for subelement in xs: - tag = subelement.tag - if tag == 'qpointset': - qpointset = [] - for qpoint in subelement: - qpointset.append([float(x) for x in qpoint.text.split()]) - optional_subelements['qpointset'] = qpointset - elif tag == 'plan': - plan = [] - for doonly in subelement: - plan.append(doonly.attrib.pop('task')) - optional_subelements['plan'] = plan - elif tag in valid_xml_elements: - optional_subelements[tag] = subelement.attrib - else: - warnings.warn(f'Subelement {tag} not yet supported. Its ignored...') - - xs_dict = {'xstype': xs_type, 'xs_properties': xs_properties} - xs_dict.update(optional_subelements) - return xs_dict - - -@xml_root -def parse_input_xml(root: root_type) -> Dict[str, dict]: - """ - Parse exciting input.xml into python dictionaries. - :param root: Input for the parser. - :returns: Dictionary which looks like: {'structure': structure_dict, - 'ground_state': groundstate_dict, 'xs': xs_dict}. - """ - structure = parse_structure(root) - ground_state = parse_groundstate(root) - xs = parse_xs(root) - return {'structure': structure, 'groundstate': ground_state, 'xs': xs} +# special tag to parse function map or lambda if one-liner +# necessary for tags which doesn't contain simply xml attributes and subtrees +special_tags_to_parse_map = { + "input": _parse_input_tag, + "title": lambda root: root.text, + "keywords": lambda root: root.text, + "structure": parse_structure, + "qpointset": lambda root: [[float(x) for x in qpoint.text.split()] for qpoint in root], + "plan": lambda root: [doonly.attrib["task"] for doonly in root], + "kstlist": lambda root: [[int(x) for x in pointstatepair.text.split()] for pointstatepair in root], +} diff --git a/excitingtools/exciting_dict_parsers/parser_factory.py b/excitingtools/exciting_dict_parsers/parser_factory.py index 38ced4c..896ad20 100644 --- a/excitingtools/exciting_dict_parsers/parser_factory.py +++ b/excitingtools/exciting_dict_parsers/parser_factory.py @@ -6,169 +6,145 @@ a) accept a file name (not a contents string) b) return a dictionary. """ -import os - -from excitingtools.utils.dict_utils import container_converter - -from excitingtools.exciting_dict_parsers import \ - bse_parser, groundstate_parser, gw_eigenvalues_parser, gw_eps00_parser, gw_info_parser, gw_vxc_parser, \ - input_parser, properties_parser, RT_TDDFT_parser, species_parser +import warnings +from fnmatch import fnmatch +from pathlib import Path +from typing import Callable, Union + +from excitingtools.exciting_dict_parsers import ( + RT_TDDFT_parser, + bse_parser, + groundstate_parser, + gw_eigenvalues_parser, + gw_eps00_parser, + gw_info_parser, + gw_taskgroup_parser, + gw_vxc_parser, + hdf5_parser, + input_parser, + properties_parser, + species_parser, + state_parser, +) # Map file name to parser function +# Note: more specific names should be higher, as the search will go through this map top-down _file_to_parser = { - 'INFO.OUT': groundstate_parser.parse_info_out, - 'info.xml': groundstate_parser.parse_info_xml, - 'input.xml': input_parser.parse_input_xml, - 'species.xml': species_parser.parse_species_xml, - 'atoms.xml': groundstate_parser.parse_atoms, - 'evalcore.xml': groundstate_parser.parse_evalcore, - 'eigval.xml': groundstate_parser.parse_eigval, - 'geometry.xml': groundstate_parser.parse_geometry, - 'RHO3D.xml': properties_parser.parse_plot_3d, - 'VCL3D.xml': properties_parser.parse_plot_3d, - 'VXC3D.xml': properties_parser.parse_plot_3d, - 'WF3D.xml': properties_parser.parse_plot_3d, - 'ELF3D.xml': properties_parser.parse_plot_3d, - 'EF3D.xml': properties_parser.parse_plot_3d, - 'LSJ.xml': properties_parser.parse_lsj, - 'EFG.xml': properties_parser.parse_efg, - 'mossbauer.xml': properties_parser.parse_mossbauer, - 'expiqr.xml': properties_parser.parse_expiqr, - 'effmass.xml': properties_parser.parse_effmass, - 'bandstructure.xml': properties_parser.parse_bandstructure_depreciated, - 'dos.xml': properties_parser.parse_dos, - 'KERR.OUT': properties_parser.parse_kerr, - 'EPSILON_11.OUT': properties_parser.parse_epsilon, - 'EPSILON_12.OUT': properties_parser.parse_epsilon, - 'EPSILON_33.OUT': properties_parser.parse_epsilon, - 'CHI_111.OUT': properties_parser.parse_chi, - 'ELNES.OUT': properties_parser.parse_elnes, - 'SEEBECK_11.OUT': properties_parser.parse_seebeck, - 'ELECTCOND_11.OUT': properties_parser.parse_seebeck, - 'THERMALCOND_11.OUT': properties_parser.parse_seebeck, - 'Z_11.OUT': properties_parser.parse_seebeck, - 'ldos.out': properties_parser.parse_ldos, - 'band_edges.out': properties_parser.parse_band_edges, - 'spintext.xml': properties_parser.parse_spintext, - 'POLARIZATION.OUT': properties_parser.parse_polarization, - 'TDOS_WANNIER.OUT': properties_parser.parse_tdos_wannier, - 'WANNIER_INFO.OUT': properties_parser.parse_wannier_info, - 'coreoverlap.xml': properties_parser.parse_core_overlap, - 'INFOXS.OUT': bse_parser.parse_infoxs_out, - 'EPSILON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_FXCMB1_OC11_QMT001.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_FXCMB1_OC22_QMT001.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_FXCMB1_OC33_QMT001.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_NLF_FXCMB1_OC11_QMT001.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_NLF_FXCMB1_OC22_QMT001.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_NAR_NLF_FXCMB1_OC33_QMT001.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_BSE-singlet-TDA-BAR_SCR-full_OC12.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_BSE-singlet-TDA-BAR_SCR-full_OC12.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC12.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EPSILON_NAR, - 'DICHROIC_K_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC12.OUT': bse_parser.parse_EPSILON_NAR, - 'OSCI_K_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_K_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_K_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_K_BSE-singlet-TDA-BAR_SCR-full_OC12.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'OSCI_KBAR_BSE-singlet-TDA-BAR_SCR-full_OC12.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EPSILON_BSE-IP_SCR-full_OC11.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_BSE-IP_SCR-full_OC22.OUT': bse_parser.parse_EPSILON_NAR, - 'EPSILON_BSE-IP_SCR-full_OC33.OUT': bse_parser.parse_EPSILON_NAR, - 'LOSS_NAR_FXCMB1_OC11_QMT001.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_NAR_FXCMB1_OC22_QMT001.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_NAR_FXCMB1_OC33_QMT001.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_NAR_NLF_FXCMB1_OC11_QMT001.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_NAR_NLF_FXCMB1_OC22_QMT001.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_NAR_NLF_FXCMB1_OC33_QMT001.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_BSE-IP_SCR-full_OC11.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_BSE-IP_SCR-full_OC22.OUT': bse_parser.parse_LOSS_NAR, - 'LOSS_BSE-IP_SCR-full_OC33.OUT': bse_parser.parse_LOSS_NAR, - 'EXCITON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_NAR_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_BSE-singlet-TDA-BAR_SCR-full_OC11.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_BSE-singlet-TDA-BAR_SCR-full_OC22.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_BSE-singlet-TDA-BAR_SCR-full_OC33.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_BSE-IP_SCR-full_OC11.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_BSE-IP_SCR-full_OC22.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'EXCITON_BSE-IP_SCR-full_OC33.OUT': bse_parser.parse_EXCITON_NAR_BSE, - 'GW_INFO.OUT': gw_info_parser.parse_gw_info, - 'EFERMI_GW.OUT': gw_eigenvalues_parser.parse_efermi_gw, - 'EVALQP.DAT': gw_eigenvalues_parser.parse_evalqp, - 'VXCNN.DAT': gw_vxc_parser.parse_vxcnn, - 'EPS00_GW.OUT': gw_eps00_parser.parse_eps00_gw, - 'JIND.OUT': RT_TDDFT_parser.parse_jind, - 'NEXC.OUT': RT_TDDFT_parser.parse_nexc, - 'ETOT_RTTDDFT.OUT': RT_TDDFT_parser.parse_etot, - 'EIGVAL_': RT_TDDFT_parser.parse_eigval_screenshots, - 'PROJ_': RT_TDDFT_parser.parse_proj_screenshots + "INFO.OUT": groundstate_parser.parse_info_out, + "info.xml": groundstate_parser.parse_info_xml, + "input.xml": input_parser.parse_input_xml, + "species.xml": species_parser.parse_species_xml, + "atoms.xml": groundstate_parser.parse_atoms, + "evalcore.xml": groundstate_parser.parse_evalcore, + "eigval.xml": groundstate_parser.parse_eigval, + "geometry.xml": groundstate_parser.parse_geometry, + "LINENGY.OUT": groundstate_parser.parse_linengy, + "LO_RECOMMENDATION.OUT": groundstate_parser.parse_lo_recommendation, + "*3D.xml": properties_parser.parse_plot_3d, + "LSJ.xml": properties_parser.parse_lsj, + "EFG.xml": properties_parser.parse_efg, + "mossbauer.xml": properties_parser.parse_mossbauer, + "expiqr.xml": properties_parser.parse_expiqr, + "effmass.xml": properties_parser.parse_effmass, + "bandstructure.xml": properties_parser.parse_bandstructure_depreciated, + "dos.xml": properties_parser.parse_dos, + "KERR.OUT": properties_parser.parse_kerr, + "EPSILON_??.OUT": properties_parser.parse_epsilon, + "CHI_111.OUT": properties_parser.parse_chi, + "ELNES.OUT": properties_parser.parse_elnes, + "SEEBECK_11.OUT": properties_parser.parse_seebeck, + "ELECTCOND_11.OUT": properties_parser.parse_seebeck, + "THERMALCOND_11.OUT": properties_parser.parse_seebeck, + "Z_11.OUT": properties_parser.parse_seebeck, + "ldos.out": properties_parser.parse_ldos, + "band_edges.out": properties_parser.parse_band_edges, + "spintext.xml": properties_parser.parse_spintext, + "POLARIZATION.OUT": properties_parser.parse_polarization, + "TDOS_WANNIER.OUT": properties_parser.parse_tdos_wannier, + "WANNIER_INFO.OUT": properties_parser.parse_wannier_info, + "coreoverlap.xml": properties_parser.parse_core_overlap, + "wf1d-*.dat": properties_parser.parse_wf1d, + "wf2d-*.xsf": properties_parser.parse_wf2d, + "wf3d-*.xsf": properties_parser.parse_wf3d, + "wf3d-*.cube": properties_parser.parse_cube, + "INFOXS.OUT": bse_parser.parse_infoxs_out, + "EPSILON_BSE*.OUT": bse_parser.parse_EPSILON_NAR, + "EPSILON_NAR*.OUT": bse_parser.parse_EPSILON_NAR, + "DICHROIC_*.OUT": bse_parser.parse_EPSILON_NAR, + "OSCI_*.OUT": bse_parser.parse_EXCITON_NAR_BSE, + "EXCITON_*.OUT": bse_parser.parse_EXCITON_NAR_BSE, + "LOSS_*.OUT": bse_parser.parse_LOSS_NAR, + "GW_INFO.OUT": gw_info_parser.parse_gw_info, + "EFERMI_GW.OUT": gw_eigenvalues_parser.parse_efermi_gw, + "EVALQP.DAT": gw_eigenvalues_parser.parse_evalqp, + "VXCNN.DAT": gw_vxc_parser.parse_vxcnn, + "EPS00_GW.OUT": gw_eps00_parser.parse_eps00_gw, + "BARC_*": gw_taskgroup_parser.parse_barc, + "SGI_*": gw_taskgroup_parser.parse_sgi, + "EPSILON-GW_Q*": gw_taskgroup_parser.parse_epsilon, + "EPSH.OUT": gw_taskgroup_parser.parse_epsilon, + "EPSW1.OUT": gw_taskgroup_parser.parse_epsilon, + "EPSW2.OUT": gw_taskgroup_parser.parse_epsilon, + "INVERSE-EPS*": gw_taskgroup_parser.parse_inverse_epsilon, + "JIND.OUT": RT_TDDFT_parser.parse_jind, + "NEXC.OUT": RT_TDDFT_parser.parse_nexc, + "ETOT_RTTDDFT.OUT": RT_TDDFT_parser.parse_etot, + "EIGVAL_*": RT_TDDFT_parser.parse_eigval_screenshots, + "PROJ_*": RT_TDDFT_parser.parse_proj_screenshots, + "ATOM_*": RT_TDDFT_parser.parse_atom_position_velocity_force, + "FCR_*": RT_TDDFT_parser.parse_force, + "FEXT_*": RT_TDDFT_parser.parse_force, + "FHF_*": RT_TDDFT_parser.parse_force, + "FVAL_*": RT_TDDFT_parser.parse_force, + "STATE.OUT": state_parser.parse_state_out, + "bse_output.h5": hdf5_parser.parse_hdf5_file_as_dict, + "fastBSE_output.h5": hdf5_parser.parse_hdf5_file_as_dict, + "fastBSE_absorption_spectrum.out": bse_parser.parse_fastBSE_absorption_spectrum_out, + "fastBSE_exciton_energies.out": bse_parser.parse_fastBSE_exciton_energies_out, + "fastBSE_oscillator_strengths.out": bse_parser.parse_fastBSE_oscillator_strength_out, } -def truncate_fnames_with_exts(file_name: str) -> str: - """ Truncate file names that have open-ended extensions. - - For example: - EIGVAL_00.dat -> EIGVAL_ - EIGVAL_01.dat -> EIGVAL_ - - :param file_name: File name containing fixed prefix and - an extension beginning with '_'. - :return file_name: File name prefix, else input file name. - """ - if ('EIGVAL_' in file_name) or ('PROJ_' in file_name): - file_name_prefix = file_name.split('_')[0] + '_' - return file_name_prefix - - return file_name - - -def parser_chooser(full_file_name: str) -> dict: - """ Selects parser according to the name of the input file then returns the result of the parser. +def parse(full_file_name: str) -> dict: + """Selects parser according to the name of the input file then returns the result of the parser. REQUIREMENTS. Parser function must: a) accept a file name (not a contents string) b) return a dictionary. - param: str, full_file_name: file name prepended by full path - return: parsed data + :param full_file_name: file name prepended by full path + :return: parsed data """ - full_file_name = full_file_name.rstrip() - if not os.path.exists(full_file_name): - raise FileNotFoundError(f'File not found: {full_file_name}') - file_name = os.path.split(full_file_name)[1] - file_name = truncate_fnames_with_exts(file_name) + full_file_path = Path(full_file_name.rstrip()) + if not full_file_path.exists(): + raise FileNotFoundError(f"File not found: {full_file_path}") + + file_name = full_file_path.name - files_with_parsers = [name for name in _file_to_parser.keys()] - if file_name not in files_with_parsers: + parser: Union[Callable[[str], dict], None] = None + for pattern in _file_to_parser: + if fnmatch(file_name, pattern): + parser = _file_to_parser[pattern] + break + + if not parser: raise KeyError(f"File does not have a parser: {file_name}") - parser = _file_to_parser[file_name] - data = parser(full_file_name) + return parser(full_file_path.as_posix()) + - # TODO(Alex) Issue 135 Ensure all parsers return appropriate values, not strings. - # container_converter should therefore be used as a decorator on parsers with values that are strings. - # That will massively speed up parsing - return container_converter(data) +def parser_chooser(full_file_name: str) -> dict: + """Old API. Selects parser according to the name of the input file then returns the result of the parser. + + :param full_file_name: file name prepended by full path + :return: parsed data + """ + warnings.warn( + "Deprecated API. Use 'excitingtools.parse' instead. " + "Support for this API will be removed in excitingtools 1.8.0", + DeprecationWarning, + stacklevel=2, + ) + return parse(full_file_name) diff --git a/excitingtools/exciting_dict_parsers/properties_parser.py b/excitingtools/exciting_dict_parsers/properties_parser.py index 5cd28d4..faa3360 100644 --- a/excitingtools/exciting_dict_parsers/properties_parser.py +++ b/excitingtools/exciting_dict_parsers/properties_parser.py @@ -1,14 +1,16 @@ -"""Parsers for exciting properties. -""" -import xml.etree.cElementTree as ET -from xml.etree.ElementTree import ParseError -import numpy as np +"""Parsers for exciting properties.""" + import os +import xml.etree.ElementTree as ET from typing import Dict +from xml.etree.ElementTree import ParseError -from excitingtools.parser_utils.parser_decorators import xml_root +import numpy as np + +from excitingtools.parser_utils.parser_decorators import set_return_values, xml_root +@set_return_values def parse_plot_3d(name: str) -> dict: """ Parser for RHO3D.xml, VCL3D.xml, VXC3D.xml, WF3D.xml, ELF3D.xml, EF3D.xmlit @@ -19,9 +21,7 @@ def parse_plot_3d(name: str) -> dict: root = ET.parse(name) plot_3d = {"title": root.find("title").text} grid = root.find("grid").attrib - axis = [] - for ax in root.find("grid").findall("axis"): - axis.append(ax.attrib) + axis = [ax.attrib for ax in root.find("grid").findall("axis")] grid["axis"] = {} for item in axis: name = item["name"] @@ -53,6 +53,7 @@ def parse_plot_3d(name: str) -> dict: return plot_3d +@set_return_values def parse_lsj(name: str) -> dict: """ Parser for LSJ.xml @@ -79,8 +80,8 @@ def parse_lsj(name: str) -> dict: species.append(spec) LSJ["species"] = {} for item in species: - name = item['n'] - LSJ['species'][name] = item + name = item["n"] + LSJ["species"][name] = item return LSJ @@ -106,14 +107,12 @@ def parse_efg(name: str) -> dict: root = ET.parse(name).getroot() data = {} - for species in root.findall('species'): - species_key = species.tag + str(species.attrib['n']) - data[species_key] = { - 'chemicalSymbol': species.attrib['chemicalSymbol'] - } + for species in root.findall("species"): + species_key = species.tag + str(species.attrib["n"]) + data[species_key] = {"chemicalSymbol": species.attrib["chemicalSymbol"]} - for atom in species.findall('atom'): - atom_key = atom.tag + atom.attrib['n'] + for atom in species.findall("atom"): + atom_key = atom.tag + atom.attrib["n"] for efg_tensor in atom.findall("EFG-tensor"): efg = np.empty(shape=(3, 3)) @@ -124,14 +123,11 @@ def parse_efg(name: str) -> dict: for eigenvalues in efg_tensor.findall("eigenvalues"): eig = [float(e) for e in eigenvalues.text.split()] - data[species_key][atom_key] = { - 'trace': float(efg_tensor.attrib['trace']), - 'efg': efg, - 'eigenvalues': eig - } + data[species_key][atom_key] = {"trace": float(efg_tensor.attrib["trace"]), "efg": efg, "eigenvalues": eig} return data +@set_return_values def parse_mossbauer(name: str) -> dict: """ Parser for mossbauer.xml @@ -144,9 +140,7 @@ def parse_mossbauer(name: str) -> dict: species = [] for node in root.findall("species"): spec = node.attrib - atom = [] - for nod in node: - atom.append(nod.attrib) + atom = [nod.attrib for nod in node] spec["atom"] = {} for item in atom: name = item["n"] @@ -160,6 +154,7 @@ def parse_mossbauer(name: str) -> dict: return mossbauer +@set_return_values def parse_expiqr(name: str) -> dict: """ Parser for expiqr.xml @@ -193,6 +188,7 @@ def parse_expiqr(name: str) -> dict: return expiqr +@set_return_values def parse_effmass(name: str) -> dict: """ Parser for effmass.xml @@ -208,15 +204,11 @@ def parse_effmass(name: str) -> dict: st = node.attrib evdk = node.find("evdk").attrib - matrix1 = [] - for line in node.find("evdk").findall("line"): - matrix1.append(line.text.split()) + matrix1 = [line.text.split() for line in node.find("evdk").findall("line")] evdk["evdk_matrix"] = matrix1 emt = node.find("emt").attrib - matrix2 = [] - for line in node.find("emt").findall("line"): - matrix2.append(line.text.split()) + matrix2 = [line.text.split() for line in node.find("emt").findall("line")] emt["emt_matrix"] = matrix2 st["evdk"] = evdk @@ -233,6 +225,7 @@ def parse_effmass(name: str) -> dict: # TODO(Hannah). Issue 138. Ensure test cases work with `parse_bandstructure` and remove `parse_bandstructure_depreciated` # This parser is depreciated. Please do not use. +@set_return_values def parse_bandstructure_depreciated(name: str) -> dict: """ Parser for bandstructure.xml. @@ -270,42 +263,51 @@ def parse_bandstructure_depreciated(name: str) -> dict: @xml_root def parse_band_structure_xml(root) -> dict: - """ Parse KS band structure from bandstructure.xml. + """Parse KS band structure from bandstructure.xml. :param root: Band structure XML file name, XML string or ElementTree.Element as input. :return: Band data """ # Split band structure file contents: title, bands and vertices - bs_xml: Dict[str, list] = {'title': [], 'band': [], 'vertex': []} + bs_xml: Dict[str, list] = {"title": [], "band": [], "vertex": []} - for item in list(root): - try: + item = root[0] + try: + for item in list(root): bs_xml[item.tag].append(item) - except KeyError: - raise KeyError(f'Element tag {item.tag} requires implementing in band structure parser') + except KeyError: + raise KeyError(f"Element tag {item.tag} requires implementing in band structure parser") - n_bands = len(bs_xml['band']) - first_band = bs_xml['band'][0] + n_bands = len(bs_xml["band"]) + first_band = bs_xml["band"][0] n_kpts = len(list(first_band)) # Same set of flattened k-points, per band - so parse once - k_points_along_band = np.array([point.get('distance') for point in list(first_band)], dtype=float) + k_points_along_band = np.array([point.get("distance") for point in list(first_band)], dtype=float) # Read E(k), per band band_energies = np.empty(shape=(n_kpts, n_bands)) - for ib, band in enumerate(bs_xml['band']): + for ib, band in enumerate(bs_xml["band"]): for ik, point in enumerate(list(band)): - band_energies[ik, ib] = point.get('eval') + band_energies[ik, ib] = point.get("eval") - vertices = [] - for element in bs_xml['vertex']: - vertices.append({'distance': float(element.get('distance')), - 'label': element.get('label'), - 'coord': [float(x) for x in element.get('coord').split()]}) + vertices = [ + { + "distance": float(element.get("distance")), + "label": element.get("label"), + "coord": [float(x) for x in element.get("coord").split()], + } + for element in bs_xml["vertex"] + ] - return {'title': bs_xml['title'], 'n_kpts': n_kpts, 'n_bands': n_bands, - 'k_points_along_band': k_points_along_band, - 'band_energies': band_energies, 'vertices': vertices} + return { + "title": bs_xml["title"], + "n_kpts": n_kpts, + "n_bands": n_bands, + "k_points_along_band": k_points_along_band, + "band_energies": band_energies, + "vertices": vertices, + } def parse_band_structure_dat(name: str) -> dict: @@ -325,20 +327,21 @@ def parse_band_structure_dat(name: str) -> dict: k_points = np.empty(shape=(n_kpts, dimensions)) flattened_k_points = np.empty(n_kpts) for i in range(n_kpts): - k_points[i] = np.array([k for k in bs_dat[i, 2:5]]) + k_points[i] = np.array(list(bs_dat[i, 2:5])) flattened_k_points[i] = bs_dat[i, 5] - band_energies = np.reshape(bs_dat[:, 6], (n_kpts, n_bands), order='F') + band_energies = np.reshape(bs_dat[:, 6], (n_kpts, n_bands), order="F") return { - 'n_kpts': n_kpts, - 'n_bands': n_bands, - 'k_points': k_points, - 'flattened_k_points': flattened_k_points, - 'band_energies': band_energies - } + "n_kpts": n_kpts, + "n_bands": n_bands, + "k_points": k_points, + "flattened_k_points": flattened_k_points, + "band_energies": band_energies, + } +@set_return_values def parse_dos(name: str) -> dict: """ Parser for dos.xml @@ -368,17 +371,17 @@ def parse_dos(name: str) -> dict: @xml_root def parse_charge_density(root) -> np.ndarray: - """ Parse charge density from RHO1D.xml file. + """Parse charge density from RHO1D.xml file. `axis` and `vertex` sub-trees ignored in the parsing. :param root: XML file name, XML string or ElementTree.Element as input. :return: Numpy array containing rho[:, 1] = distance and rho[:, 2] = density. """ - function_points = root.find('grid').find('function') + function_points = root.find("grid").find("function") rho = np.empty(shape=(len(function_points), 2)) for i, point in enumerate(function_points): - rho[i, :] = [point.attrib['distance'], float(point.attrib['value'])] + rho[i, :] = [point.attrib["distance"], float(point.attrib["value"])] return rho @@ -391,10 +394,10 @@ def parse_kerr(name: str) -> dict: """ try: data = np.genfromtxt(name, skip_header=1) - except: + except Exception: raise ParseError - out = {'energy': data[:, 0], 're': data[:, 1], 'im': data[:, 2]} + out = {"energy": data[:, 0], "re": data[:, 1], "im": data[:, 2]} return out @@ -408,9 +411,9 @@ def parse_epsilon(name: str) -> dict: """ try: data = np.genfromtxt(name, skip_header=1) - except: + except Exception: raise ParseError - out = {'energy': data[:, 0], 're': data[:, 1], 'im': data[:, 2]} + out = {"energy": data[:, 0], "re": data[:, 1], "im": data[:, 2]} return out @@ -423,14 +426,9 @@ def parse_chi(name: str) -> dict: """ try: data = np.genfromtxt(name, skip_header=1) - except: + except Exception: raise ParseError - out = { - 'energy': data[:, 0], - 're': data[:, 1], - 'im': data[:, 2], - 'modulus': data[:, 3] - } + out = {"energy": data[:, 0], "re": data[:, 1], "im": data[:, 2], "modulus": data[:, 3]} return out @@ -443,9 +441,9 @@ def parse_elnes(name: str) -> dict: """ try: data = np.genfromtxt(name) - except: + except Exception: raise ParseError - out = {'energy': data[:, 0], 'elnes': data[:, 1]} + out = {"energy": data[:, 0], "elnes": data[:, 1]} return out @@ -458,14 +456,9 @@ def parse_seebeck(name: str) -> dict: """ try: data = np.genfromtxt(name) - except: + except Exception: raise ParseError - out = { - 'temperature': data[:, 0], - 'mu': data[:, 1], - 're': data[:, 2], - 'im': data[:, 3] - } + out = {"temperature": data[:, 0], "mu": data[:, 1], "re": data[:, 2], "im": data[:, 3]} return out @@ -479,9 +472,9 @@ def parse_ldos(name: str) -> dict: """ try: data = np.genfromtxt(name) - except: + except Exception: raise ParseError - out = {'energy': data[:, 0], 'ldos': data[:, 1]} + out = {"energy": data[:, 0], "ldos": data[:, 1]} return out @@ -501,9 +494,9 @@ def parse_band_edges(name: str) -> dict: """ try: data = np.genfromtxt(name) - except: + except Exception: raise ParseError - out = {'c_axis': data[:, 0], 'VBM': data[:, 1], 'CBm': data[:, 2]} + out = {"c_axis": data[:, 0], "VBM": data[:, 1], "CBm": data[:, 2]} return out @@ -521,16 +514,15 @@ def parse_spintext(name: str) -> dict: :return dict spintext: List that holds the parsed spintexture.xml """ # parse file - file_name = 'spintext.xml' - if name.split('/')[-1] != file_name: + file_name = "spintext.xml" + if name.split("/")[-1] != file_name: name = os.path.join(name, file_name) tree_spin = ET.parse(name) root_spin = tree_spin.getroot() spintext = {} - i = 0 - for band in root_spin.findall("band"): + for i, band in enumerate(root_spin.findall("band")): k_point = [] spin = [] energy = [] @@ -540,17 +532,12 @@ def parse_spintext(name: str) -> dict: spin.append([float(s) for s in val.attrib["spin"].split()]) energy.append(float(val.attrib["energy"])) - spintext[str(i)] = { - "ist": int(band.attrib["ist"]), - "k-point": k_point, - "spin": spin, - "energy": energy - } - i += 1 + spintext[str(i)] = {"ist": int(band.attrib["ist"]), "k-point": k_point, "spin": spin, "energy": energy} return spintext +@set_return_values def parse_polarization(name: str) -> dict: """ Parser for POLARIZATION.OUT @@ -560,15 +547,11 @@ def parse_polarization(name: str) -> dict: """ file = open(name) lines = [] - for i in range(len(open(name).readlines())): + for _ in range(len(open(name).readlines())): line = next(file) - if '#' not in line: + if "#" not in line: lines.append(line.split()) - polarization = { - 'total': lines[0], - 'electronic': lines[1], - 'ionic': lines[2] - } + polarization = {"total": lines[0], "electronic": lines[1], "ionic": lines[2]} return polarization @@ -581,9 +564,9 @@ def parse_tdos_wannier(name: str) -> dict: """ try: data = np.genfromtxt(name) - except: + except Exception: raise ParseError - out = {'energy': data[:, 0], 'dos': data[:, 1]} + out = {"energy": data[:, 0], "dos": data[:, 1]} return out @@ -607,45 +590,35 @@ def parse_wannier_info(name: str) -> dict: start = True if start: lines.append(line) - for i in range(len(lines)): - if lines[i].strip().startswith('1'): - for j in range(4): - data.append(lines[i + j].split()) - if lines[i].strip().startswith('5'): - for j in range(4): - data.append(lines[i + j].split()) - if lines[i].strip().startswith('total'): - total.append(lines[i].split()) + for i, line in enumerate(lines): + if line.strip().startswith("1") or line.strip().startswith("5"): + data.extend(lines[i + j].split() for j in range(4)) + elif line.strip().startswith("total"): + total.append(line.split()) file.close() # Package data into dictionary n_wannier = len(data) localisation_center = np.empty(shape=(n_wannier, 3)) - wannier = { - 'n_wannier': n_wannier, - 'Omega': [], - 'Omega_I': [], - 'Omega_D': [], - 'Omega_OD': [] - } + wannier = {"n_wannier": n_wannier, "Omega": [], "Omega_I": [], "Omega_D": [], "Omega_OD": []} for i, item in enumerate(data): localisation_center[i, :] = [float(x) for x in item[1:4]] - wannier['Omega'].append(float(item[4])) - wannier['Omega_I'].append(float(item[5])) - wannier['Omega_D'].append(float(item[6])) - wannier['Omega_OD'].append(float(item[7])) + wannier["Omega"].append(float(item[4])) + wannier["Omega_I"].append(float(item[5])) + wannier["Omega_D"].append(float(item[6])) + wannier["Omega_OD"].append(float(item[7])) - wannier['localisation_center'] = localisation_center + wannier["localisation_center"] = localisation_center - totals = {'Omega': [], 'Omega_I': [], 'Omega_D': [], 'Omega_OD': []} + totals = {"Omega": [], "Omega_I": [], "Omega_D": [], "Omega_OD": []} for j, item in enumerate(total): - totals['Omega'].append(float(item[1])) - totals['Omega_I'].append(float(item[2])) - totals['Omega_D'].append(float(item[3])) - totals['Omega_OD'].append(float(item[4])) + totals["Omega"].append(float(item[1])) + totals["Omega_I"].append(float(item[2])) + totals["Omega_D"].append(float(item[3])) + totals["Omega_OD"].append(float(item[4])) - wannier['total'] = totals + wannier["total"] = totals return wannier @@ -675,27 +648,26 @@ def parse_core_overlap(name: str) -> dict: """ try: tree = ET.parse(name) - except: + except Exception: raise ParseError root = tree.getroot() core_overlap = { - 'nkpt': int(root.attrib['nkpt']), - 'nstfv': int(root.attrib['nstfv']), - 'ncg': int(root.attrib['ncg']) + "nkpt": int(root.attrib["nkpt"]), + "nstfv": int(root.attrib["nstfv"]), + "ncg": int(root.attrib["ncg"]), } k_points = [] for k_point in root: - kpt = {"index": int(k_point.attrib['index'])} + kpt = {"index": int(k_point.attrib["index"])} pairs = [] for pair_xml in k_point: pair = pair_xml.attrib - pair['ist1'] = int(pair['ist1']) - pair['ist2'] = int(pair['ist2']) + pair["ist1"] = int(pair["ist1"]) + pair["ist2"] = int(pair["ist2"]) pair["de"] = float(pair["de"]) - pair["overlap"] = float(pair["overlap"].split()[0]) ** 2 + float( - pair["overlap"].split()[1]) ** 2 + pair["overlap"] = float(pair["overlap"].split()[0]) ** 2 + float(pair["overlap"].split()[1]) ** 2 pairs.append(pair) kpt["pairs"] = pairs k_points.append(kpt) @@ -713,9 +685,9 @@ def parse_lossfunction(fname: str) -> tuple: """ xdata = [] ydata = [] - file = open(fname, 'r') + file = open(fname) for lines in file: - if 'Frequency' in lines: + if "Frequency" in lines: break for lines in file: data = lines.split() @@ -723,3 +695,177 @@ def parse_lossfunction(fname: str) -> tuple: ydata.append(float(data[1])) file.close() return xdata, ydata + + +def parse_wf1d(fname: str) -> dict: + """ + Parses files containing one dimensional wave function plot as saved in + the files, _e.g._, wf1d-0001-0001.dat, where the first 0001 indicates the k-point + and the second the state index. + + :param str fname: name of the file + """ + + data = np.genfromtxt(fname) + + output = {} + output["path"] = data[:, 0] + output["|psi|^2"] = data[:, 1] + output["Re(psi)"] = data[:, 2] + output["Im(psi)"] = data[:, 3] + + return output + + +def parse_wf2d(fname: str) -> dict: + """ + Parses files containing the two dimensional wave function plot as saved in the files, + _e.g._, wf2d-0001-0001.xsf, where the first 0001 indicates the k-point + and the second the state index. + + Does not parse PRIMVEC and PRIMCOORD. These can be parsed with ase.io.read(fname). + + :param str fname: name of the file + """ + + file = open(fname) + lines = file.readlines() + output = {} + + i = 0 + while i < len(lines): + if "BEGIN_BLOCK_DATAGRID_2D" in lines[i]: + i = i + 1 + data_name = lines[i].replace("\n", "").lstrip() + + i = i + 2 + grid = np.fromstring(lines[i], dtype=int, sep=" ") + + i = i + 1 + origin = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + point1 = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + point2 = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + data = np.fromstring(lines[i], dtype=np.double, sep=" ") + while "END_DATAGRID_2D" not in lines[i]: + i = i + 1 + new_data = np.fromstring(lines[i], dtype=np.double, sep=" ") + data = np.concatenate((data, new_data), axis=None) + + output["grid"] = grid + output["origin"] = origin + output["point1"] = point1 + output["point2"] = point2 + output[data_name] = data # data_name: "module squared", "real", "imaginary" + + i = i + 1 + + return output + + +def parse_wf3d(fname: str) -> dict: + """ + Parses files containing the two dimensional wave function plot as saved in the files, + _e.g._, wf3d-0001-0001.xsf, where the first 0001 indicates the k-point + and the second the state index. + + Does not parse PRIMVEC and PRIMCOORD. These can be parsed with ase.io.read(fname). + + :param str fname: name of the file + """ + + file = open(fname) + lines = file.readlines() + output = {} + + i = 0 + while i < len(lines): + if "BEGIN_BLOCK_DATAGRID_3D" in lines[i]: + i = i + 1 + data_name = lines[i].replace("\n", "").lstrip() + + i = i + 2 + grid = np.fromstring(lines[i], dtype=int, sep=" ") + + i = i + 1 + origin = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + point1 = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + point2 = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + point3 = np.fromstring(lines[i], dtype=np.double, sep=" ") + + i = i + 1 + data = np.fromstring(lines[i], dtype=np.double, sep=" ") + while "END_DATAGRID_3D" not in lines[i]: + i = i + 1 + new_data = np.fromstring(lines[i], dtype=np.double, sep=" ") + data = np.concatenate((data, new_data), axis=None) + + output["grid"] = grid + output["origin"] = origin + output["point1"] = point1 + output["point2"] = point2 + output["point3"] = point3 + output[data_name] = data # data_name: "module squared", "real", "imaginary" + + i = i + 1 + + return output + + +def parse_cube(fname: str) -> dict: + """ + Parses .cube files. These files contain data calculated on a regular grid in a box. + All vectors are given in cartesian coordinates. `output['cube_data']` contains the data. + It is described by `output['description']`. + + :param str fname: name of the file + """ + + file = open(fname) + lines = file.readlines() + output = {} + + output["title"] = str(lines[0]) + output["description"] = str(lines[1]) + + n_atoms = int(lines[2].split()[0]) + output["n_atoms"] = n_atoms + output["origin"] = np.array(lines[2].split()[1:], dtype=np.double) + + output["n_1"] = int(lines[3].split()[0]) + output["v_increment_1"] = np.array(lines[3].split()[1:], dtype=np.double) + + output["n_2"] = int(lines[4].split()[0]) + output["v_increment_2"] = np.array(lines[4].split()[1:], dtype=np.double) + + output["n_3"] = int(lines[5].split()[0]) + output["v_increment_3"] = np.array(lines[5].split()[1:], dtype=np.double) + + line_offset = 6 + output["atoms"] = [] + for line in lines[line_offset : line_offset + n_atoms]: + atom_number = int(line.split()[0]) + charge = np.double(line.split()[1]) + coordinate = np.array(line.split()[2:], dtype=np.double) + output["atoms"].append(dict(atom_number=atom_number, charge=charge, coordinate=coordinate)) + + line_offset = line_offset + n_atoms + + cube_data = [] + for line in lines[line_offset:]: + cube_data = cube_data + line.split() + + output["cube_data"] = np.array(cube_data, dtype=np.double) + + return output diff --git a/excitingtools/exciting_dict_parsers/species_parser.py b/excitingtools/exciting_dict_parsers/species_parser.py index 1fd1274..8b2061f 100644 --- a/excitingtools/exciting_dict_parsers/species_parser.py +++ b/excitingtools/exciting_dict_parsers/species_parser.py @@ -1,15 +1,14 @@ -""" Parse exciting species files into dictionary. -""" +"""Parse exciting species files into dictionary.""" + from typing import Dict from excitingtools.parser_utils.parser_decorators import xml_root -from excitingtools.utils.dict_utils import string_value_to_type -from excitingtools.utils.utils import string_to_bool +from excitingtools.parser_utils.parser_utils import convert_string_dict @xml_root def parse_species_xml(root) -> dict: - """ Parses exciting species files as a dict. + """Parses exciting species files as a dict. TODO(Alex) Issue 124. See how easy it is to replace with a generic XML parser, with keys defined according to the associated schema. @@ -40,61 +39,28 @@ def parse_species_xml(root) -> dict: :return : Dictionary of species file data (described above). """ species_tree = root[0] - species = {key: value for key, value in species_tree.attrib.items()} - - for key in ['z', 'mass']: - species[key] = float(species[key]) + species = convert_string_dict(species_tree.attrib) - children: Dict[str, list] = {'atomicState': [], 'basis': [], 'muffinTin': []} + children: Dict[str, list] = {"atomicState": [], "basis": [], "muffinTin": []} for child in list(species_tree): children[child.tag].append(child) - assert len(children['muffinTin']) == 1, "More than one muffinTin sub-tree in the species file" - assert len(children['basis']) == 1, "More than one basis sub-tree in the species file" + assert len(children["muffinTin"]) == 1, "More than one muffinTin sub-tree in the species file" + assert len(children["basis"]) == 1, "More than one basis sub-tree in the species file" - muffin_tin_tree = children['muffinTin'][0].attrib - muffin_tin = {key: float(value) for key, value in muffin_tin_tree.items()} + muffin_tin = convert_string_dict(children["muffinTin"][0].attrib) - atomic_states = [] - for atomic_state_tree in children['atomicState']: - assert atomic_state_tree.tag == 'atomicState', "Expect tag to be atomicState" - atomic_states.append(string_value_to_type(atomic_state_tree.attrib)) + atomic_states = [convert_string_dict(atomic_state_tree.attrib) for atomic_state_tree in children["atomicState"]] - basis_tree = children['basis'][0] - basis: Dict[str, list] = {'default': [], 'custom': [], 'lo': []} + basis_tree = children["basis"][0] + basis: Dict[str, list] = {"default": [], "custom": [], "lo": []} for func in basis_tree: - function: dict = func.attrib - processed_function = string_value_to_type(function) - - if func.tag == 'lo': - processed_function.update(_parse_lo_from_species(func)) - - basis[func.tag].append(processed_function) + parsed_attributes = convert_string_dict(func.attrib) - return { - 'species': species, - 'muffin_tin': muffin_tin, - 'atomic_states': atomic_states, - 'basis': basis - } + if func.tag == "lo": + parsed_attributes["wf"] = [convert_string_dict(wf.attrib) for wf in func] + basis[func.tag].append(parsed_attributes) -def _parse_lo_from_species(lo_function) -> dict: - """ - Given some lo_function with: - wf {'matchingOrder': '0', 'trialEnergy': '-2.0', 'searchE': 'true'} - wf {'matchingOrder': '1', 'trialEnergy': '-2.0', 'searchE': 'true'} - - return - {'matchingOrder': [0, 1], 'trialEnergy': [-2.0, -2.0], 'searchE': [True, True]} - """ - # Use lists to GUARANTEE consistent ordering - matching_order = [] - trial_energy = [] - search = [] - for radial in lo_function: - matching_order.append(int(radial.attrib.get('matchingOrder'))) - trial_energy.append(float(radial.attrib.get('trialEnergy'))) - search.append(string_to_bool(radial.attrib.get('searchE'))) - return {'matchingOrder': matching_order, 'trialEnergy': trial_energy, 'searchE': search} + return {"species": species, "muffin_tin": muffin_tin, "atomic_states": atomic_states, "basis": basis} diff --git a/excitingtools/exciting_dict_parsers/state_parser.py b/excitingtools/exciting_dict_parsers/state_parser.py new file mode 100644 index 0000000..41ab5ba --- /dev/null +++ b/excitingtools/exciting_dict_parsers/state_parser.py @@ -0,0 +1,171 @@ +"""Parser for STATE.OUT binary file.""" + +import struct +import sys + +import numpy as np + + +def get_byteorder_format_char(byteorder) -> str: + """Converts the byteorder string to formatting symbol for struct.unpack function + + :param byteorder: endianness of the byteorder ("little" or "big") + :return: the character representing the endianness ('<' or '>') + """ + chars = {"little": "<", "big": ">"} + return chars[byteorder] + + +def read_array(file, shape, byteorder) -> np.ndarray: + """Read in a sequence of double values and reshape them into the wanted shape. + + :param file: binary file object, which contains the number sequence + :param shape: the desired shape of the number sequence (with column-major order) + :param byteorder: the endianness of the bytes representation + :return: a numpy array of double values with the desired shape + """ + # get the correct format character for the byteorder + byteorder = get_byteorder_format_char(byteorder) + # get the number of bytes for the array + num_bytes = np.prod(shape) * 8 + # get all the double numbers + values = struct.unpack(f"{byteorder}{np.prod(shape)}d", file.read(num_bytes)) + # return the values as correctly shaped numpy array + return np.reshape(values, shape, order="F") + + +def read_complex_array(file, shape, byteorder) -> np.ndarray: + """Read in a sequence of complex values and reshape them into the wanted shape. + + :param file: binary file object, which contains the number sequence + :param shape: the desired shape of the number sequence (with column-major order) + :param byteorder: the endianness of the bytes representation + :return: a numpy array of complex values with the desired shape + """ + byteorder = get_byteorder_format_char(byteorder) + array = np.reshape( + [complex(*struct.unpack(f"{byteorder}2d", file.read(16))) for _ in range(np.prod(shape))], shape, order="F" + ) + return array + + +def read_int(file, byteorder, num_bytes=4, signed=False) -> int: + """Read in a single integer. + + :param file: binary file object, which contains the number + :param byteorder: the endianness of the bytes representation + :param num_bytes: the number of bytes, which constitute the integer + :param signed: weather the bytes should be interpreted as a signed or unsigned integer + :return: the read in integer + """ + return int.from_bytes(file.read(num_bytes), byteorder, signed=signed) + + +def read_integers(file, integers, dest, byteorder): + """Read in a sequence of named integers and store them in a dictionary. + + :param file: binary file object, which contains the integers + :param integers: the keys of the integers for the dictionary + :param dest: the dictionary, which stores the integers and gets modified + :param byteorder: the endianness of the bytes representation + """ + for integer in integers: + num_bytes = read_int(file, byteorder) + dest[integer] = read_int(file, byteorder, num_bytes) + assert num_bytes == read_int(file, byteorder) + + +def parse_state_out(path, byteorder=sys.byteorder) -> dict: + """Parser for: STATE.OUT + + STATE.OUT is a binary file. For every 'Write' (in Fortran) there are 4 leading bytes, which are an integer + equal to the number of bytes of the Fortran objects written. After this the leading 4 bytes are + repeated. + This file contains information about the version of exciting, which was used and all the information about + the density and potentials. + The functions for the muffin-tin region are stored as an expansion of real spherical harmonics. + + :param path: the path to binary file + :param byteorder: the endianness of the file (defaults to the endianness of the system) + :return: a dictionary containing all the state information + """ + state = {} + radial_meshes = [] + number_of_atoms = [] + with open(path, "rb") as file: + # first read the version tuple (3 * 4 bytes) and the versionhash (40 bytes) + assert read_int(file, byteorder) == 52 + state["version"] = tuple(read_int(file, byteorder) for _ in range(3)) + state["versionhash"] = file.read(40).decode("utf-8") + assert read_int(file, byteorder) == 52 + # next read whether the calculation was spin polarized or not (4 bytes) + assert read_int(file, byteorder) == 4 + state["spinpol"] = read_int(file, byteorder) == 1 + assert read_int(file, byteorder) == 4 + # next we read different integers + integers = ("nspecies", "lmmaxvr", "nrmtmax") + read_integers(file, integers, state, byteorder) + # next for every species there are the number of atoms for this species, the number of points + # of the radial mesh and finally the radial mesh + for _ in range(state["nspecies"]): + num_bytes = read_int(file, byteorder) + number_of_atoms.append(read_int(file, byteorder, num_bytes)) + assert num_bytes == read_int(file, byteorder) + num_bytes = read_int(file, byteorder) + nrmt = read_int(file, byteorder, num_bytes) + assert num_bytes == read_int(file, byteorder) + num_bytes = read_int(file, byteorder) + assert num_bytes // 8 == nrmt + radial_meshes.append(read_array(file, nrmt, byteorder)) + assert num_bytes == read_int(file, byteorder) + # save the number of atoms per species + state["number of atoms"] = number_of_atoms + # save the radial meshes + state["muffintin radial meshes"] = radial_meshes + + # next read the g vector grid size (3 * 4 bytes) + num_bytes = read_int(file, byteorder) + state["g vector grid"] = tuple(read_int(file, byteorder) for _ in range(3)) + assert num_bytes == read_int(file, byteorder) + + # next again read a few integers + integers = ("ngvec", "ndmag", "nspinor", "ldapu", "lmmaxlu") + read_integers(file, integers, state, byteorder) + + # next read a few pairs of arrays + arrays = ( + ("muffintin density", "interstitial density"), + ("muffintin coulomb potential", "interstitial coulomb potential"), + ("muffintin exchange-correlation potential", "interstitial exchange-correlation potential"), + ) + muffintin_shape = (state["lmmaxvr"], state["nrmtmax"], sum(number_of_atoms)) + interstitial_shape = np.prod(state["g vector grid"]) + for muffintin_array, interstitial_array in arrays: + num_bytes = read_int(file, byteorder) + assert num_bytes // 8 == (np.prod(muffintin_shape) + interstitial_shape) + state[muffintin_array] = read_array(file, muffintin_shape, byteorder) + state[interstitial_array] = read_array(file, interstitial_shape, byteorder) + assert num_bytes == read_int(file, byteorder) + # the next entry is an array triple + num_bytes = read_int(file, byteorder) + assert num_bytes // 8 == (np.prod(muffintin_shape) + interstitial_shape + 2 * state["ngvec"]) + state["muffintin effective potential"] = read_array(file, muffintin_shape, byteorder) + state["interstitial effective potential"] = read_array(file, interstitial_shape, byteorder) + state["reciprocal interstitial effective potential"] = read_complex_array(file, state["ngvec"], byteorder) + assert num_bytes == read_int(file, byteorder) + + # the next two arrays are only present if the calculation was spin polarized + if state["spinpol"]: + num_bytes = read_int(file, byteorder) + state["muffintin magnetization"] = read_array(file, muffintin_shape, byteorder) + state["interstitial magnetization"] = read_array(file, interstitial_shape, byteorder) + assert num_bytes == read_int(file, byteorder) + + if state["ldapu"] != 0: + shape = (state["lmmaxlu"], state["lmmaxlu"], state["nspinor"], state["nspinor"], sum(number_of_atoms)) + num_bytes = read_int(file, byteorder) + assert num_bytes // 8 == 2 * np.prod(shape) + state["vmatlu"] = read_complex_array(file, shape, byteorder) + assert num_bytes == read_int(file, byteorder) + + return state diff --git a/excitingtools/exciting_obj_parsers/__init__.py b/excitingtools/exciting_obj_parsers/__init__.py index 8fdba87..6b7ed21 100644 --- a/excitingtools/exciting_obj_parsers/__init__.py +++ b/excitingtools/exciting_obj_parsers/__init__.py @@ -1,3 +1,3 @@ -# from excitingtools.exciting_obj_parsers.gw_eigenvalues import gw_eigenvalue_parser, OxygenEvalQPColumns, \ -# NitrogenEvalQPColumns from excitingtools.exciting_obj_parsers.ks_band_structure import parse_band_structure + +__all__ = ["parse_band_structure"] diff --git a/excitingtools/exciting_obj_parsers/eigenvalue_parser.py b/excitingtools/exciting_obj_parsers/eigenvalue_parser.py new file mode 100644 index 0000000..e0286bd --- /dev/null +++ b/excitingtools/exciting_obj_parsers/eigenvalue_parser.py @@ -0,0 +1,40 @@ +"""Eigenvalue parser, processing dict values and returning to an object.""" + +import numpy as np + +from excitingtools.dataclasses.data_structs import NumberOfStates +from excitingtools.dataclasses.eigenvalues import EigenValues +from excitingtools.exciting_dict_parsers.groundstate_parser import parse_eigval + + +def parse_eigenvalues(root) -> EigenValues: + """High-level parser for eigenvalues. Calls dictionary parser to parse information from the "eigval.xml" file. + + :param root: XML file name, XML string or ElementTree.Element as input. + :return: EigenValues object. + """ + + eigval_dict = parse_eigval(root) + data = eigval_dict["kpt"] + + k_points = [[float(x) for x in point["vkl"].split()] for point in data.values()] + + k_indices = list(map(int, data.keys())) + + n_k = len(k_points) + n_e = len(data["1"]["state"]) + eigenvalues = np.empty((n_k, n_e)) + occupations = np.empty((n_k, n_e)) + + for ik in range(n_k): + for ie in range(n_e): + eigenvalues[ik, ie] = data[str(ik + 1)]["state"][str(ie + 1)]["eigenvalue"] + occupations[ik, ie] = data[str(ik + 1)]["state"][str(ie + 1)]["occupancy"] + + return EigenValues( + state_range=NumberOfStates(1, occupations.shape[1]), + k_points=k_points, + k_indices=k_indices, + all_eigenvalues=eigenvalues, + occupations=occupations, + ) diff --git a/excitingtools/exciting_obj_parsers/gw_eigenvalues.py b/excitingtools/exciting_obj_parsers/gw_eigenvalues.py index be04528..30ce910 100644 --- a/excitingtools/exciting_obj_parsers/gw_eigenvalues.py +++ b/excitingtools/exciting_obj_parsers/gw_eigenvalues.py @@ -1,14 +1,15 @@ -""" GW eigenvalue parser, processing dict values and returning to an object. -""" -import os -from typing import Optional, List, Union, Dict +"""GW eigenvalue parser, processing dict values and returning to an object.""" + import enum +import os +from typing import Dict, List, Optional, Union + import numpy as np -from excitingtools.exciting_dict_parsers.gw_eigenvalues_parser import parse_evalqp, _file_name, parse_gw_dos from excitingtools.dataclasses.data_structs import NumberOfStates -from excitingtools.dataclasses.eigenvalues import EigenValues from excitingtools.dataclasses.density_of_states import DOS +from excitingtools.dataclasses.eigenvalues import EigenValues +from excitingtools.exciting_dict_parsers.gw_eigenvalues_parser import _file_name, parse_evalqp, parse_gw_dos class NitrogenEvalQPColumns(enum.Enum): @@ -26,6 +27,7 @@ class NitrogenEvalQPColumns(enum.Enum): class OxygenEvalQPColumns(enum.Enum): """Columns of `_file_name`, for exciting oxygen excluding the state index.""" + E_KS = 0 E_HF = 1 E_GW = 2 @@ -46,9 +48,10 @@ class OxygenEvalQPColumns(enum.Enum): return_type = Union[EigenValues, Dict[enum.Enum, EigenValues]] -def gw_eigenvalue_parser(input_file_path: str, columns: Optional[columns_type] = OxygenEvalQPColumns.E_GW) -> \ - return_type: - """ High-level Parser for GW eigenvalues file. +def gw_eigenvalue_parser( + input_file_path: str, columns: Optional[columns_type] = OxygenEvalQPColumns.E_GW +) -> return_type: + """High-level Parser for GW eigenvalues file. Unpacks the result of dict into a sensible form and returns the data to return_type. @@ -58,10 +61,7 @@ def gw_eigenvalue_parser(input_file_path: str, columns: Optional[columns_type] = :return An instance of EigenValues, or a dict of (key, value) = [EvalQPColumns, EigenValues]. """ path, file_name = os.path.split(input_file_path) - if file_name == _file_name: - file_path = path - else: - file_path = input_file_path + file_path = path if file_name == _file_name else input_file_path abs_file_name = os.path.join(file_path, _file_name) if not isinstance(columns, list): @@ -69,8 +69,8 @@ def gw_eigenvalue_parser(input_file_path: str, columns: Optional[columns_type] = # Parse data data: dict = parse_evalqp(abs_file_name) - state_range_list: List[int] = data.pop('state_range') - column_enums = data.pop('column_labels') + state_range_list: List[int] = data.pop("state_range") + column_enums = data.pop("column_labels") n_k = len(data.keys()) state_range = NumberOfStates(state_range_list[0], state_range_list[1]) @@ -83,9 +83,11 @@ def gw_eigenvalue_parser(input_file_path: str, columns: Optional[columns_type] = if (requested_column_names - parsed_column_names) != set(): enum_class_name = type(columns[0]).__name__ - raise ValueError(f'The requested data column is indexed according to exciting version {enum_class_name},' - f'which is not consistent with the columns of the parsed data.' - f' Check that your data was produced with the same code version.') + raise ValueError( + f"The requested data column is indexed according to exciting version {enum_class_name}," + f"which is not consistent with the columns of the parsed data." + f" Check that your data was produced with the same code version." + ) # Repackage data k_indices = [] @@ -96,9 +98,9 @@ def gw_eigenvalue_parser(input_file_path: str, columns: Optional[columns_type] = for ik, k_block in data.items(): k_indices.append(ik) - k_points.append(k_block['k_point']) - weights.append(k_block['weight']) - all_eigenvalues[ik - 1, :, :] = k_block['energies'][:, :] + k_points.append(k_block["k_point"]) + weights.append(k_block["weight"]) + all_eigenvalues[ik - 1, :, :] = k_block["energies"][:, :] # Return data if len(columns) == 1: @@ -121,4 +123,4 @@ def parse_obj_gw_dos(full_file_name: str) -> DOS: :return: DOS object """ gw_dos_data = parse_gw_dos(full_file_name) - return DOS(gw_dos_data['energy'], gw_dos_data['dos']) + return DOS(gw_dos_data["energy"], gw_dos_data["dos"]) diff --git a/excitingtools/exciting_obj_parsers/input_xml.py b/excitingtools/exciting_obj_parsers/input_xml.py index 7bd6a7b..12a95e2 100644 --- a/excitingtools/exciting_obj_parsers/input_xml.py +++ b/excitingtools/exciting_obj_parsers/input_xml.py @@ -1,74 +1,15 @@ -""" Parse input XML data directly into corresponding python classes. -""" -from typing import Dict, Union +"""Parse input XML data directly into corresponding python classes.""" -from excitingtools.input.base_class import ExcitingInput -from excitingtools.input.ground_state import ExcitingGroundStateInput -from excitingtools.input.structure import ExcitingStructure -from excitingtools.input.xs import ExcitingXSInput +from excitingtools.exciting_dict_parsers.input_parser import parse_input_xml as parse_input_xml_to_dict +from excitingtools.input.input_xml import ExcitingInputXML from excitingtools.parser_utils.parser_decorators import xml_root -from excitingtools.exciting_dict_parsers.input_parser import root_type, parse_groundstate as parse_groundstate_to_dict, \ - parse_structure as parse_structure_to_dict, parse_xs as parse_xs_to_dict @xml_root -def parse_groundstate(root: root_type) -> ExcitingGroundStateInput: - """ - Parse exciting input.xml groundstate element into python ExcitingGroundStateInput. - :param root: Input for the parser. - :returns: ExcitingGroundStateInput containing the groundstate input element attributes. - """ - groundstate: dict = parse_groundstate_to_dict(root) - return ExcitingGroundStateInput(**groundstate) - - -@xml_root -def parse_structure(root: root_type) -> ExcitingStructure: - """ - Parse exciting input.xml structure element into python ExcitingStructure object. - :param root: Input for the parser. - :returns: ExcitingStructure containing the structure input element attributes and subelements. - """ - structure: dict = parse_structure_to_dict(root) - return ExcitingStructure( - structure['atoms'], - structure['lattice'], - structure['species_path'], - structure['structure_properties'], - structure['crystal_properties'], - structure['species_properties'] - ) - - -@xml_root -def parse_xs(root: root_type) -> Union[ExcitingXSInput, None]: - """ - Parse exciting input.xml xs element into python ExcitingXSInput object. - :param root: Input for the parser. - :returns: ExcitingXSInput containing the xs input element attributes and subelements. Returns None if no xs element - was found. - """ - xs: dict = parse_xs_to_dict(root) - if xs == {}: - return None - xs_type = xs.pop('xstype') - xs_properties = xs.pop('xs_properties') - return ExcitingXSInput(xs_type, xs_properties, **xs) - - -exciting_input_type = Union[ExcitingInput, None] - - -@xml_root -def parse_input_xml(root: root_type) -> Dict[str, exciting_input_type]: - """ - Parse exciting input.xml into the different python ExcitingInput Objects. +def parse_input_xml(root) -> ExcitingInputXML: + """Parse exciting input.xml into the corresponding python ExcitingInput Objects. :param root: Input for the parser. - :returns: Dictionary which looks like: {'structure': ExcitingStructure, - 'ground_state': ExcitingGroundstateInput, 'xs': ExcitingXSInput} - If no xs element was found, the value of 'xs' is None. + :returns: the exciting input object """ - structure = parse_structure(root) - ground_state = parse_groundstate(root) - xs = parse_xs(root) - return {'structure': structure, 'groundstate': ground_state, 'xs': xs} + element_dict: dict = parse_input_xml_to_dict(root) + return ExcitingInputXML(**element_dict) diff --git a/excitingtools/exciting_obj_parsers/ks_band_structure.py b/excitingtools/exciting_obj_parsers/ks_band_structure.py index 2208eb7..d4b1c23 100644 --- a/excitingtools/exciting_obj_parsers/ks_band_structure.py +++ b/excitingtools/exciting_obj_parsers/ks_band_structure.py @@ -1,15 +1,16 @@ -""" KS (ground state) Band structure Parser, returning to an object. -""" +"""KS (ground state) Band structure Parser, returning to an object.""" import os +from pathlib import Path + import numpy as np + from excitingtools.dataclasses.band_structure import BandData -from excitingtools.exciting_dict_parsers.properties_parser import parse_band_structure_xml, parse_band_structure_dat -from pathlib import Path +from excitingtools.exciting_dict_parsers.properties_parser import parse_band_structure_dat, parse_band_structure_xml def parse_band_structure(file_name: str) -> BandData: - """ High-level parser for KS band structure. Calls dictionary parsers to parse information from both + """High-level parser for KS band structure. Calls dictionary parsers to parse information from both "bandstructure.xml" and "bandstructure.dat" files. :param str file_name: File name for band structure such as "bandstructure.dat", "bandstructure.xml" or @@ -17,13 +18,15 @@ def parse_band_structure(file_name: str) -> BandData: :return: BandData object. """ name = os.path.splitext(file_name)[0] - data_from_xml = parse_band_structure_xml(name + '.xml') - data_from_dat = parse_band_structure_dat(name + '.dat') + data_from_xml = parse_band_structure_xml(name + ".xml") + data_from_dat = parse_band_structure_dat(name + ".dat") e_fermi = float(np.loadtxt(Path(file_name).parent / "EFERMI.OUT")) - return BandData(bands=data_from_xml['band_energies'], - k_points=data_from_dat['k_points'], - e_fermi=e_fermi, - flattened_k_points=data_from_xml['k_points_along_band'], - vertices=data_from_xml['vertices']) + return BandData( + bands=data_from_xml["band_energies"], + k_points=data_from_dat["k_points"], + e_fermi=e_fermi, + flattened_k_points=data_from_xml["k_points_along_band"], + vertices=data_from_xml["vertices"], + ) diff --git a/excitingtools/input/__init__.py b/excitingtools/input/__init__.py index e69de29..5e9e3e1 100644 --- a/excitingtools/input/__init__.py +++ b/excitingtools/input/__init__.py @@ -0,0 +1,27 @@ +"""Main exciting input classes.""" + +from excitingtools.input.input_classes import ( + ExcitingEPHInput, + ExcitingGroundStateInput, + ExcitingGWInput, + ExcitingMDInput, + ExcitingPhononsInput, + ExcitingPropertiesInput, + ExcitingRelaxInput, + ExcitingXSInput, +) +from excitingtools.input.input_xml import ExcitingInputXML +from excitingtools.input.structure import ExcitingStructure + +__all__ = [ + "ExcitingGroundStateInput", + "ExcitingXSInput", + "ExcitingPropertiesInput", + "ExcitingRelaxInput", + "ExcitingPhononsInput", + "ExcitingGWInput", + "ExcitingMDInput", + "ExcitingEPHInput", + "ExcitingInputXML", + "ExcitingStructure", +] diff --git a/excitingtools/input/bandstructure.py b/excitingtools/input/bandstructure.py new file mode 100644 index 0000000..bf1b2be --- /dev/null +++ b/excitingtools/input/bandstructure.py @@ -0,0 +1,74 @@ +"""Generate bandstructure input from atoms.""" + +from __future__ import annotations + +import re +from typing import List + +import ase.dft +import numpy as np +from ase import Atoms +from ase.cell import Cell + +from excitingtools import ExcitingStructure +from excitingtools.input.input_classes import ExcitingBandStructureInput + +_default_steps = 100 + + +def band_structure_input_from_cell_or_bandpath( + cell_or_bandpath: Cell | ase.dft.kpoints.BandPath, steps: int = _default_steps +) -> ExcitingBandStructureInput: + """Get band path from ASE lattice cell or ASE bandpath object. + + :param cell_or_bandpath: ase.cell.Cell object or ase.dft.kpoints.BandPath + :param steps: number of steps for bandstructure calculation + :return: the exciting band structure input + """ + bandpath = cell_or_bandpath.bandpath() if isinstance(cell_or_bandpath, Cell) else cell_or_bandpath + points = [] + pattern = r"[A-Z,][a-z]*[0-9]*" + for point in re.findall(pattern, bandpath.path): + if point == ",": + points[-1]["breakafter"] = True + else: + points.append({"coord": list(bandpath.special_points[point]), "label": point}) + + return ExcitingBandStructureInput(plot1d={"path": {"steps": steps, "point": points}}) + + +def band_structure_input_from_lattice( + lattice_vectors: List[List[float]] | np.ndarray, steps: int = _default_steps +) -> ExcitingBandStructureInput: + """Get band path from lattice vectors as array or list of lists using ASE. Lattice vectors + in array needs to be stored row-wise. + + :param lattice_vectors: lattice + :param steps: number of steps for bandstructure calculation + :return: the exciting band structure input + """ + return band_structure_input_from_cell_or_bandpath(Cell(lattice_vectors), steps=steps) + + +def band_structure_input_from_ase_atoms_obj( + atoms_obj: Atoms, steps: int = _default_steps +) -> ExcitingBandStructureInput: + """Get band path from ase object. + + :param atoms_obj: ase atoms object + :param steps: number of steps for bandstructure calculation + :return: the exciting band structure input + """ + return band_structure_input_from_cell_or_bandpath(atoms_obj.cell, steps) + + +def get_bandstructure_input_from_exciting_structure( + structure: ExcitingStructure, steps: int = _default_steps +) -> ExcitingBandStructureInput: + """Use ase bandpath to get bandstructure input for exciting using ASE. + + :param structure: exciting structure input object + :param steps: number of steps for bandstructure calculation + :return: bandstructure input for exciting + """ + return band_structure_input_from_lattice(structure.get_lattice(), steps=steps) diff --git a/excitingtools/input/base_class.py b/excitingtools/input/base_class.py index caf51f3..6896fe9 100644 --- a/excitingtools/input/base_class.py +++ b/excitingtools/input/base_class.py @@ -1,54 +1,168 @@ -"""Base class for exciting input classes. -""" +"""Base class for exciting input classes.""" + +import importlib +import re +import warnings from abc import ABC, abstractmethod -from typing import Union, Set -from xml.etree import ElementTree from pathlib import Path +from typing import Iterator, List, Type, Union +from xml.etree import ElementTree + import numpy as np +from excitingtools.exciting_dict_parsers.input_parser import parse_element_xml +from excitingtools.utils import valid_attributes as all_valid_attributes from excitingtools.utils.dict_utils import check_valid_keys +from excitingtools.utils.jobflow_utils import special_serialization_attrs +from excitingtools.utils.utils import flatten_list, list_to_str + +path_type = Union[str, Path] -class ExcitingInput(ABC): - """Base class for exciting inputs.""" +class AbstractExcitingInput(ABC): + """Base class for exciting inputs. + + name: Used as tag for the xml subelement + """ + + name: str = "ABSTRACT" # not directly used, need a value here because of the dynamic class list + + @abstractmethod + def __init__(self, **kwargs): ... @abstractmethod def to_xml(self) -> ElementTree: - """ Convert class attributes to XML ElementTree.""" - ... + """Convert class attributes to XML ElementTree.""" + def to_xml_str(self) -> str: + """Convert attributes to XML tree string.""" + return ElementTree.tostring(self.to_xml(), encoding="unicode", method="xml") -class ExcitingXMLInput(ExcitingInput): - """Base class for exciting inputs that only consist of many attributes.""" + def as_dict(self) -> dict: + """Convert attributes to dictionary.""" + serialise_attrs = special_serialization_attrs(self) + return {**serialise_attrs, "xml_string": self.to_xml_str()} - # Convert python data to string, formatted specifically for - _attributes_to_input_str = {int: lambda x: str(x), - np.int64: lambda x: str(x), - np.float64: lambda x: str(x), - float: lambda x: str(x), - bool: lambda x: str(x).lower(), - str: lambda x: x, - list: lambda mylist: " ".join(str(x).lower() for x in mylist).strip(), - tuple: lambda mylist: " ".join(str(x).lower() for x in mylist).strip() - } + @classmethod + def from_xml(cls, xml_string: path_type): + """Initialise class instance from XML-formatted string. - def __init__(self, name: str, valid_attributes: Set[str] = None, **kwargs): + Example Usage + -------------- + xs_input = ExcitingXSInput.from_xml(xml_string) + """ + return cls(**parse_element_xml(xml_string, tag=cls.name)) + + @classmethod + def from_dict(cls, d): + """Recreates class instance from dictionary.""" + return cls.from_xml(d["xml_string"]) + + +class ExcitingXMLInput(AbstractExcitingInput, ABC): + """Base class for exciting inputs, with exceptions being title, plan, qpointset and kstlist, + because they are not passed as a dictionary.""" + + # Convert python data to string, formatted specifically for exciting + _attributes_to_input_str = { + int: lambda x: str(x), + np.int64: lambda x: str(x), + np.float64: lambda x: str(x), + float: lambda x: str(x), + bool: lambda x: str(x).lower(), + str: lambda x: x, + list: list_to_str, + tuple: list_to_str, + np.ndarray: list_to_str, + } + + def __init__(self, **kwargs): """Initialise class attributes with kwargs. Rather than define all options for a given method, pass as kwargs and directly insert as class attributes. - :param name: Method name. + Valid attributes, subtrees and mandatory attributes are taken automatically from + the parsed schema, see [valid_attributes.py](excitingtools/utils/valid_attributes.py). """ - self.name = name - if valid_attributes is not None: - check_valid_keys(kwargs.keys(), valid_attributes, self.name) + valid_attributes, valid_subtrees, mandatory_keys, multiple_children = self.get_valid_attributes() + + # check the keys + missing_mandatory_keys = mandatory_keys - set(kwargs.keys()) + if missing_mandatory_keys: + raise ValueError(f"Missing mandatory arguments: {missing_mandatory_keys}") + check_valid_keys(kwargs.keys(), valid_attributes | set(valid_subtrees), self.name) + + # initialise the subtrees + class_list = self._class_list_excitingtools() + subtree_class_map = {cls.name: cls for cls in class_list} + subtrees = set(kwargs.keys()) - valid_attributes + single_subtrees = subtrees - multiple_children + multiple_subtrees = subtrees - single_subtrees + for subtree in single_subtrees: + kwargs[subtree] = self._initialise_subelement_attribute(subtree_class_map[subtree], kwargs[subtree]) + for subtree in multiple_subtrees: + kwargs[subtree] = [ + self._initialise_subelement_attribute(subtree_class_map[subtree], x) for x in kwargs[subtree] + ] + + # Set attributes from kwargs self.__dict__.update(kwargs) + def __setattr__(self, name: str, value): + """Overload the attribute setting in python with instance.attr = value to check for validity in the schema. + + :param name: name of the attribute + :param value: new value, can be anything + """ + valid_attributes, valid_subtrees, _, _ = self.get_valid_attributes() + check_valid_keys({name}, valid_attributes | set(valid_subtrees), self.name) + super().__setattr__(name, value) + + def __delattr__(self, name: str): + mandatory_keys = list(self.get_valid_attributes())[2] + if name in mandatory_keys: + warnings.warn(f"Attempt to delete mandatory attribute '{name}' was prevented.") + else: + super().__delattr__(name) + + def get_valid_attributes(self) -> Iterator: + """Extract the valid attributes, valid subtrees, mandatory attributes and multiple children + from the parsed schema. + + :return: valid attributes, valid subtrees, mandatory attributes and multiple children + """ + yield set(getattr(all_valid_attributes, f"{self.name}_valid_attributes", set())) + yield getattr(all_valid_attributes, f"{self.name}_valid_subtrees", []) + yield set(getattr(all_valid_attributes, f"{self.name}_mandatory_attributes", set())) + yield set(getattr(all_valid_attributes, f"{self.name}_multiple_children", set())) + + @staticmethod + def _class_list_excitingtools() -> List[Type[AbstractExcitingInput]]: + """Find all exciting input classes in own module and excitingtools.""" + excitingtools_namespace_content = importlib.import_module("excitingtools").__dict__ + input_class_namespace_content = importlib.import_module("excitingtools.input.input_classes").__dict__ + all_contents = {**excitingtools_namespace_content, **input_class_namespace_content}.values() + return [cls for cls in all_contents if isinstance(cls, type) and issubclass(cls, AbstractExcitingInput)] + + @staticmethod + def _initialise_subelement_attribute(xml_class, element): + """Initialize given elements to the ExcitingXSInput constructor. If element is already ExcitingXMLInput class + object, nothing happens. Else the class constructor of the given XMLClass is called. For a passed + dictionary the dictionary is passed as kwargs. + """ + if isinstance(element, xml_class): + return element + if isinstance(element, dict): + # assume kwargs + return xml_class(**element) + # Assume the element type is valid for the class constructor + return xml_class(element) + def to_xml(self) -> ElementTree: """Put class attributes into an XML tree, with the element given by self.name. - Example ground state XML sub-tree: + Example ground state XML subtree: Note, kwargs preserve the order of the arguments, however the order does not appear to be @@ -56,25 +170,29 @@ def to_xml(self) -> ElementTree: :return ElementTree.Element sub_tree: sub_tree element tree, with class attributes inserted. """ - inputs = {} - attributes = {key: value for key, value in self.__dict__.items() if key != 'name'} - for key, value in attributes.items(): - inputs[key] = self._attributes_to_input_str[type(value)](value) + valid_attributes, valid_subtrees, _, _ = self.get_valid_attributes() - sub_tree = ElementTree.Element(self.name, **inputs) + attributes = { + key: self._attributes_to_input_str[type(value)](value) + for key, value in vars(self).items() + if key in valid_attributes + } + xml_tree = ElementTree.Element(self.name, **attributes) + + subtrees = {key: self.__dict__[key] for key in set(vars(self).keys()) - set(attributes.keys())} + ordered_subtrees = flatten_list([subtrees[x] for x in valid_subtrees if x in subtrees]) + for subtree in ordered_subtrees: + xml_tree.append(subtree.to_xml()) # Seems to want this operation on a separate line - sub_tree.text = ' ' + xml_tree.text = " " - return sub_tree + return xml_tree - def to_xml_str(self) -> str: - """ Convert attributes to XML tree string. """ - return ElementTree.tostring(self.to_xml(), encoding='unicode', method='xml') +def query_exciting_version(exciting_root: path_type) -> dict: + """Query the exciting version. -def query_exciting_version(exciting_root: Union[Path, str]) -> dict: - """Query the exciting version Inspect version.inc, which is constructed at compile-time. Assumes version.inc has this structure: @@ -83,23 +201,16 @@ def query_exciting_version(exciting_root: Union[Path, str]) -> dict: #define COMPILERVERSION "GNU Fortran (MacPorts gcc9 9.3.0_4) 9.3.0" #define VERSIONFROMDATE /21,12,01/ - TODO(Fab) Issue 117. Parse major version. - Would need to parse src/mod_misc.F90 and regex for "character(40) :: versionname = " - Refactor whole routine to use regex. + Also checks the src/mod_misc.F90 file for the major exciting version. :param exciting_root: exciting root directory. :return version: Build and version details """ - if isinstance(exciting_root, str): - exciting_root = Path(exciting_root) - - version_inc = exciting_root / 'src/version.inc' + exciting_root = Path(exciting_root) + version_inc = exciting_root / "src/version.inc" + assert version_inc.exists(), f"{version_inc} cannot be found. This file generated when the code is built" - if not version_inc.exists(): - raise FileNotFoundError(f'{version_inc} cannot be found. ' - f'This file generated when the code is built') - - with open(version_inc, 'r') as fid: + with open(version_inc) as fid: all_lines = fid.readlines() git_hash_part1 = all_lines[0].split()[-1][1:-1] @@ -107,5 +218,7 @@ def query_exciting_version(exciting_root: Union[Path, str]) -> dict: compiler_parts = all_lines[2].split()[2:] compiler = " ".join(s for s in compiler_parts).strip() - version = {'compiler': compiler[1:-1], 'git_hash': git_hash_part1 + git_hash_part2} - return version + mod_misc = exciting_root / "src/mod_misc.F90" + major_version = re.search(r"character\(40\) :: versionname = '(NEON)'", mod_misc.read_text())[1] + + return {"compiler": compiler[1:-1], "git_hash": git_hash_part1 + git_hash_part2, "major": major_version} diff --git a/excitingtools/input/dynamic_class.py b/excitingtools/input/dynamic_class.py new file mode 100644 index 0000000..9ce6542 --- /dev/null +++ b/excitingtools/input/dynamic_class.py @@ -0,0 +1,117 @@ +"""Dynamic Class Construction + +class_definitions defined as {'class name': + {'bases': '(ParentClass1, ParentClass2, ...)', + 'attributes': + {'attribute1', 'attribute1', + 'method1': 'method1' + } + } + } + +where: + * 'class name' defines the name of the class, when calling in Python. + * 'bases' define any parent classes for "class name" to inherit from, given in a tuple. + * 'attributes' defines any attributes (data or methods) to include in "class name". + +ALL data should be defined as a string, as it will get rendered as a Python-valid string, +which subsequently gets interpreted at run-time. +""" + +from typing import Dict, Iterator, List, Optional, Union + +from excitingtools.exciting_dict_parsers.input_parser import special_tags_to_parse_map +from excitingtools.utils import valid_attributes +from excitingtools.utils.valid_attributes import input_valid_subtrees + + +def class_constructor_string(class_definitions: Dict[str, Union[dict, str]]) -> str: + """Given a dictionary, return a Python-interpretable string of one or more + class definitions. + + Example function argument: + class_definitions = {'class name': + {'bases': '(ParentClass1, ParentClass2)', + 'attributes': + {'attribute1', 'attribute1', + 'method1': 'method1' + } + } + } + + :param class_definitions: A dict containing the class name, attributes and parent classes, + all in string form. + :return: class_definition_str: A Python-interpretable string of class definitions using + the type() constructor. + """ + # first import the base class + class_definition_str = "from excitingtools.input.base_class import ExcitingXMLInput \n" + for class_name, bases_and_attributes in class_definitions.items(): + # Parent class (or classes) + bases = bases_and_attributes["bases"] + + # Attributes and methods (including private attributes) + attributes = bases_and_attributes["attributes"] + + class_definition_str += ( + f"Exciting{class_name}Input = type('Exciting{class_name}Input', {bases}, {attributes}) \n" + ) + + return class_definition_str + + +def give_class_dictionary(name: str) -> dict: + """Gives class dictionary with inheritance and further properties. + + :param name: name of class + :return: dict with definition + """ + return { + "bases": "(ExcitingXMLInput, )", + "attributes": { + "__doc__": f"Class for exciting {name} input.", + "__module__": "excitingtools.input.input_classes", + "name": name, + }, + } + + +def class_name_uppercase(name: str) -> str: + """Converts the name in a string which better fits as class name, capitalizing the first letter and + leaving the rest unchanged. + Exceptions are groundstate -> GroundState, xs -> XS, gw -> GW, bandstructure -> BandStructure, eph -> EPH + + :param name: name of the class/tag in original notation + :return: name usually capitilized, see exceptions + """ + exceptions = {"groundstate": "GroundState", "xs": "XS", "gw": "GW", "bandstructure": "BandStructure", "eph": "EPH"} + if name in exceptions: + return exceptions[name] + return name[0].upper() + name[1:] + + +def get_all_valid_subtrees(valid_xml_tags: Optional[List[str]]) -> Optional[Iterator[str]]: + """Get recursively all valid xml (sub-)trees (tags) from exciting. + + :param valid_xml_tags: valid xml tags + :return: list of all valid xml tags of all trees and subtrees + """ + if not valid_xml_tags: + return + for valid_tag in valid_xml_tags: + yield valid_tag + tag_valid_subtrees = valid_attributes.__dict__.get(f"{valid_tag}_valid_subtrees") + yield from get_all_valid_subtrees(tag_valid_subtrees) + + +def generate_classes_str() -> str: + """Generate string to execute by python. For all standard input classes. + + :return: python string + """ + non_standard_input_classes = set(special_tags_to_parse_map) + all_valid_subtrees = set(get_all_valid_subtrees(input_valid_subtrees)) + standard_input_classes = all_valid_subtrees - non_standard_input_classes + + class_definitions = {class_name_uppercase(cls): give_class_dictionary(cls) for cls in standard_input_classes} + return class_constructor_string(class_definitions) diff --git a/excitingtools/input/ground_state.py b/excitingtools/input/ground_state.py deleted file mode 100644 index 9303fc2..0000000 --- a/excitingtools/input/ground_state.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Module for class of exciting ground state. - -Ideally the input keywords (class attributes) should be parsed from the schema BUT -because excitingtools will also be available as a standalone package, one would need -to have a copy of the schema XML in excitingtools, which is kept synchronised with -the /xml/. -""" -from excitingtools.input.base_class import ExcitingXMLInput - - -class ExcitingGroundStateInput(ExcitingXMLInput): - - # Reference: http://exciting.wikidot.com/ref:groundstate - _valid_attributes = {'CoreRelativity', 'ExplicitKineticEnergy', 'PrelimLinSteps', 'ValenceRelativity', 'autokpt', - 'beta0', 'betadec', 'betainc', 'cfdamp', 'chgexs', 'deband', 'dipolecorrection', - 'dipoleposition', 'dlinengyfermi', 'do', 'energyref', 'epsband', 'epschg', 'epsengy', - 'epsforcescf', 'epsocc', 'epspot', 'fermilinengy', 'findlinentype', 'fracinr', 'frozencore', - 'gmaxvr', 'isgkmax', 'ldapu', 'lmaxapw', 'lmaxinr', 'lmaxmat', 'lmaxvr', 'lorecommendation', - 'lradstep', 'maxscl', 'mixer', 'mixerswitch', 'modifiedsv', 'msecStoredSteps', 'nempty', - 'ngridk', 'niterconvcheck', 'nktot', 'nosource', 'nosym', 'nprad', 'npsden', 'nwrite', - 'outputlevel', 'ptnucl', 'radialgridtype', 'radkpt', 'reducek', 'rgkmax', 'scfconv', 'stype', - 'swidth', 'symmorph', 'tevecsv', 'tfibs', 'tforce', 'tpartcharges', 'useDensityMatrix', - 'vdWcorrection', 'vkloff', 'xctype'} - - def __init__(self, **kwargs): - """Generate an object of ExcitingXMLInput for the groundstate attributes.""" - super().__init__('groundstate', self._valid_attributes, **kwargs) diff --git a/excitingtools/input/input_classes.py b/excitingtools/input/input_classes.py new file mode 100644 index 0000000..6c72ac7 --- /dev/null +++ b/excitingtools/input/input_classes.py @@ -0,0 +1,136 @@ +"""Automatic generation of all standard input classes plus definiton of exceptions.""" + +from typing import Any, List, Union +from xml.etree import ElementTree + +import numpy as np + +from excitingtools.input.base_class import AbstractExcitingInput +from excitingtools.input.dynamic_class import generate_classes_str +from excitingtools.utils.dict_utils import check_valid_keys +from excitingtools.utils.utils import list_to_str +from excitingtools.utils.valid_attributes import valid_plan_entries + +# define names of classes which are meant to be available for a user or used directly elsewhere in excitingtools +# type hint as Any to not conflict with static type checkers as the input classes are generated dynamically +ExcitingCrystalInput: Any +ExcitingSpeciesInput: Any +ExcitingAtomInput: Any +ExcitingGroundStateInput: Any +ExcitingXSInput: Any +ExcitingBSEInput: Any +ExcitingPropertiesInput: Any +ExcitingPointInput: Any +ExcitingBandStructureInput: Any +ExcitingRelaxInput: Any +ExcitingPhononsInput: Any +ExcitingGWInput: Any +ExcitingMDInput: Any +ExcitingEPHInput: Any + +# execute dynamically generated string with all standard class defintions +exec(generate_classes_str()) + + +class ExcitingTitleInput(AbstractExcitingInput): + """Holds only the title but for consistency reasons as class.""" + + name = "title" + + def __init__(self, title: str): + self.title = title + + def to_xml(self) -> ElementTree: + """Puts title to xml, only the text is title.""" + title_tree = ElementTree.Element(self.name) + title_tree.text = self.title + return title_tree + + +class ExcitingKeywordsInput(AbstractExcitingInput): + """Input class for keywords. Can set any info via a text string, it's not used by exciting.""" + + name = "keywords" + + def __init__(self, info: str): + self.info = info + + def to_xml(self) -> ElementTree: + """Puts keywords to xml.""" + keywords_tree = ElementTree.Element(self.name) + keywords_tree.text = self.info + return keywords_tree + + +class ExcitingQpointsetInput(AbstractExcitingInput): + """ + Class for exciting Qpointset Input + """ + + name = "qpointset" + + def __init__(self, qpointset: Union[np.ndarray, List[List[float]]] = np.array([0.0, 0.0, 0.0])): + """ + Qpointset should be passed either as numpy array or as a list of lists, so either + np.array([[0., 0., 0.], [0.0, 0.0, 0.01], ...]) + or + [[0., 0., 0.], [0.0, 0.0, 0.01], ...] + """ + self.qpointset = qpointset + + def to_xml(self) -> ElementTree.Element: + """Special implementation of to_xml for the qpointset element.""" + qpointset = ElementTree.Element(self.name) + for qpoint in self.qpointset: + ElementTree.SubElement(qpointset, "qpoint").text = list_to_str(qpoint) + + return qpointset + + +class ExcitingPlanInput(AbstractExcitingInput): + """ + Class for exciting Plan Input + """ + + name = "plan" + + def __init__(self, plan: List[str]): + """ + Plan doonly elements are passed as a List of strings in the order exciting shall execute them: + ['bse', 'xseigval', ...] + """ + check_valid_keys(plan, valid_plan_entries, self.name) + self.plan = plan + + def to_xml(self) -> ElementTree.Element: + """Special implementation of to_xml for the plan element.""" + plan = ElementTree.Element(self.name) + for task in self.plan: + ElementTree.SubElement(plan, "doonly", task=task) + + return plan + + +class ExcitingKstlistInput(AbstractExcitingInput): + """ + Class for exciting Kstlist Input + """ + + name = "kstlist" + + def __init__(self, kstlist: Union[np.ndarray, List[List[int]]]): + """ + Kstlist should be passed either as numpy array or as a list of lists, so either + np.array([[1, 2], [3, 4], ...]) + or + [[1, 2], [3, 4], ...] + """ + self.kstlist = kstlist + + def to_xml(self) -> ElementTree.Element: + """Special implementation of to_xml for the kstlist element.""" + kstlist = ElementTree.Element(self.name) + for pointstatepair in self.kstlist: + ElementTree.SubElement(kstlist, "pointstatepair").text = list_to_str(pointstatepair) + + return kstlist diff --git a/excitingtools/input/input_xml.py b/excitingtools/input/input_xml.py index 1b12dc6..5cd1369 100644 --- a/excitingtools/input/input_xml.py +++ b/excitingtools/input/input_xml.py @@ -1,70 +1,40 @@ -"""Generate an exciting XML input tree. -""" -from typing import Optional -from collections import OrderedDict -from xml.etree import ElementTree - -from excitingtools.input.structure import ExcitingStructure -from excitingtools.input.ground_state import ExcitingGroundStateInput -from excitingtools.input.xs import ExcitingXSInput -from excitingtools.input.xml_utils import xml_tree_to_pretty_str, prettify_tag_attributes +"""Input xml class.""" +from pathlib import Path +from typing import Union -def initialise_input_xml(title: str) -> ElementTree.Element: - """Initialise input.xml element tree for exciting. +from excitingtools.input.base_class import ExcitingXMLInput +from excitingtools.input.input_classes import ExcitingGroundStateInput, ExcitingTitleInput +from excitingtools.input.structure import ExcitingStructure +from excitingtools.input.xml_utils import prettify_tag_attributes, xml_tree_to_pretty_str - Information on the schema reference ignored, but could be reintroduced for validation purposes. - root.set( - '{http://www.w3.org/2001/XMLSchema-instance}noNamespaceSchemaLocation', - 'http://xml.exciting-code.org/excitinginput.xsd') - :param str title: Title for calculation. - :return ElementTree.Elementroot: Element tree root. +class ExcitingInputXML(ExcitingXMLInput): """ - root = ElementTree.Element('input') - ElementTree.SubElement(root, 'title').text = title - return root - - -def exciting_input_xml(structure: ExcitingStructure, - groundstate: ExcitingGroundStateInput, - title: Optional[str] = '', - xs: Optional[ExcitingXSInput] = None) -> ElementTree.Element: - """Compose XML ElementTrees from exciting input classes to create an input XML tree. - - Expected usage: input_xml = exciting_input_xml(structure, groundstate, title=title) - - :param ExcitingStructure structure: Structure containing lattice vectors and atomic positions. - :param groundstate: exciting ground state input object. - :param Optional[str] title: Optional title for input file. - :param xs: exciting xs input object. - :return ElementTree.Element root: Input XML tree, with sub-elements inserted. + Container for a complete input xml file. """ - root = initialise_input_xml(title) - structure_tree = structure.to_xml() - root.append(structure_tree) + name = "input" + _default_filename = "input.xml" + structure: ExcitingStructure + groundstate: ExcitingGroundStateInput + title: ExcitingTitleInput - exciting_elements = OrderedDict([('groundstate', groundstate), ('xs', xs)]) + def set_title(self, title: str): + """Set a new title.""" + self.__dict__["title"].title = title - for element in exciting_elements.values(): - if element is not None: - root.append(element.to_xml()) + def to_xml_str(self) -> str: + """Compose XML ElementTrees from exciting input classes to create an input xml string. - return root + :return: Input XML tree as a string, with pretty formatting. + """ + return prettify_tag_attributes(xml_tree_to_pretty_str(self.to_xml())) + def write(self, filename: Union[str, Path] = _default_filename): + """Writes the xml string to file. -def exciting_input_xml_str(structure: ExcitingStructure, - groundstate: ExcitingGroundStateInput, - **kwargs) -> str: - """Compose XML ElementTrees from exciting input classes to create an input xml string. - - :param ExcitingStructure structure: Structure containing lattice vectors and atomic positions. - :param groundstate: exciting ground state input object. - :return input_xml_str: Input XML tree as a string, with pretty formatting. - """ - xml_tree = exciting_input_xml(structure, groundstate, **kwargs) - tags_to_prettify = ["\t tuple: - """ Initialise lattice, species and positions from an ASE Atoms Object. + def _init_lattice_species_positions_from_ase_atoms( + self, atoms + ) -> Tuple[NDArray[float], List[str], List[NDArray[float]]]: + """Initialise lattice, species and positions from an ASE Atoms Object. Duck typing for atoms, such that ASE is not a hard dependency. @@ -134,18 +117,23 @@ def _init_lattice_species_positions_from_ase_atoms(atoms) -> tuple: """ try: cell = atoms.get_cell() - # Convert to consistent form, [a, b, c], where a = [ax, ay, az] - lattice = [list(cell[i, :]) for i in range(0, 3)] + # ASE works in Angstrom, whereas exciting expects atomic units + lattice = np.asarray(cell, dtype=np.float64) * angstrom_to_bohr species = [x.capitalize() for x in atoms.get_chemical_symbols()] - positions = atoms.get_positions() - return lattice, species, positions + if self.structure_attributes.get("cartesian"): + positions = angstrom_to_bohr * atoms.get_positions() + else: + positions = atoms.get_scaled_positions() + return lattice, species, list(positions) except AttributeError: - message = "atoms must either be an ase.atoms.Atoms object or List[dict], of the form" \ - "[{'species': 'X', 'position': [x, y, z]}, ...]." + message = ( + "atoms must either be an ase.atoms.Atoms object or List[dict], of the form" + "[{'species': 'X', 'position': [x, y, z]}, ...]." + ) raise AttributeError(message) - def _init_atom_properties(self, atoms: List[dict]) -> List[dict]: - """ Initialise atom_properties. + def _init_atom_properties(self, atoms: List[dict]) -> Iterator[dict]: + """Initialise atom_properties. For atoms that contain optional atomic properties, store them as dicts in a list of len(n_atoms). Atoms with none of these properties @@ -153,74 +141,88 @@ def _init_atom_properties(self, atoms: List[dict]) -> List[dict]: For each element of atoms, one must have {'species': 'X', 'position': [x, y, z]} and may have the additional attributes: {'bfcmt': [bx, by, bz], 'lockxyz': [lx, ly, lz], 'mommtfix': [mx, my, mz]}. - Extract the optional attributes and return in `atom_properties`, with string values. + Extract the optional attributes and return in `atom_properties`. :param atoms: List container. :return atom_properties: List of atom properties. Each element is a dict. and the dict value has been converted to string - ready for XML usage. """ - atom_properties: List[dict] = [] - for atom in atoms: - optional_property_keys = set(atom.keys()) & self._valid_atom_attributes - optional_atom = {key: atom[key] for key in optional_property_keys} - optional_properties = {} - for key, value in optional_atom.items(): - optional_properties[key] = self._attributes_to_input_str[type(value)](value) - atom_properties.append(optional_properties) + atom_properties = {key: value for key, value in atom.items() if key not in {"species", "position"}} + check_valid_keys(atom_properties.keys(), self._valid_atom_attributes, "Atom properties") + yield atom_properties - return atom_properties + def _init_species_properties(self, species_properties: Union[dict, None]) -> Iterator[Tuple[str, ExcitingXMLInput]]: + """Initialise species_properties. - def _init_structure_properties(self, structure_properties: dict) -> dict: - """ Initialise structure_properties. + For species without properties, return empty_properties: {'S': {}, 'Al': {}}. - :param structure_properties: Dict of optional structure properties. - :return Dict of structure_properties, with string values. + :param species_properties: Species properties + :return Dict of ExitingXMLInput-species_properties. """ - if structure_properties is None: - return {} - - check_valid_keys(structure_properties.keys(), self._valid_structure_attributes, "structure_properties") + if species_properties is None: + species_properties = {} - return {key: str(value).lower() for key, value in structure_properties.items()} + for species in self.unique_species: + props = species_properties.get(species) or {} + props["speciesfile"] = species + ".xml" + yield species, ExcitingSpeciesInput(**props) - def _init_crystal_properties(self, crystal_properties: dict) -> dict: - """ Initialise crystal_properties. + def get_lattice(self, convert_to_angstrom: bool = False) -> np.ndarray: + """Get the full lattice, meaning after the application of scale and stretch values to the stored + lattice vectors. - :param crystal_properties: Dict of optional structure properties. - :return Dict of crystal_properties, with string values. + :param convert_to_angstrom: if True returns lattice in angstrom, else in bohr + :return: full lattice vectors, stored row-wise in a matrix """ - if crystal_properties is None: - return {} - - check_valid_keys(crystal_properties.keys(), self._valid_crystal_attributes, "crystal_properties") + lattice = copy.deepcopy(self.lattice) + unit_conversion = bohr_to_angstrom if convert_to_angstrom else 1 + scale = getattr(self.crystal_properties, "scale", 1) + lattice *= scale * unit_conversion + + stretch = np.array(getattr(self.crystal_properties, "stretch", [1, 1, 1])) + lattice *= stretch[:, None] + return lattice + + def add_atom( + self, + species: str, + position: Union[List[float], NDArray[float]], + properties: Union[dict, None] = None, + species_properties: Union[dict | None] = None, + ): + """Add a new atom to the structure. + + :param species: of the new atom + :param position: of the new atom + :param properties: optional atom properties + :param species_properties: if it is a new species, optional species properties, else not used + """ + species_properties = species_properties or {} - return {key: str(value) for key, value in crystal_properties.items()} + self.species.append(species) + if species not in self.unique_species: + self.unique_species = sorted(set(self.species)) + species_properties["speciesfile"] = species + ".xml" + self.species_properties[species] = ExcitingSpeciesInput(**species_properties) - def _init_species_properties(self, species_properties: dict) -> dict: - """ Initialise species_properties. + self.positions.append(position) + properties = properties or {} + check_valid_keys(properties.keys(), self._valid_atom_attributes, "Atom properties") + self.atom_properties.append(properties) - For species without properties, return empty_properties: {'S': {}, 'Al': {}}. + def remove_atom(self, index: int): + """Remove atom from the structure. - :param species_properties: Species properties - :return Dict of species_properties, with string values. + :param index: Number specifying the atom to remove, start by 0, -1 means the last atom """ - if species_properties is None: - empty_properties = {s: {} for s in self.unique_species} - return empty_properties - - new_species_properties = {} - for species in self.unique_species: - try: - properties = species_properties[species] - check_valid_keys(properties.keys(), - self._valid_species_attributes, - f"{species} element's species_properties") - new_species_properties[species] = {key: str(value) for key, value in properties.items()} - except KeyError: - new_species_properties[species] = {} + removed_species = self.species.pop(index) + self.positions.pop(index) + self.atom_properties.pop(index) - return new_species_properties + if removed_species not in self.species: + self.unique_species = sorted(set(self.species)) + del self.species_properties[removed_species] def _group_atoms_by_species(self) -> dict: """Get the atomic indices for atoms of each species. @@ -237,20 +239,18 @@ def _group_atoms_by_species(self) -> dict: indices[x] = [i for i, element in enumerate(self.species) if element == x] return indices - def _xml_atomic_subtree(self, x: str, species, atomic_indices): - """ Add the required atomic positions and any optional attributes, per species. + def _xml_atomic_subtree(self, species: str, species_tree: ElementTree.Element, atomic_indices: dict): + """Add the required atomic positions and any optional attributes, per species. - :param x: Species - :param species: Empty SubElement for species x - :return species: species SubElement with all atomic positions included + :param species: Species + :param species_tree: Empty SubElement for species x, which gets filled """ - for iatom in atomic_indices[x]: - coord_str = list_to_str(self.positions[iatom]) - ET.SubElement(species, "atom", coord=coord_str, **self.atom_properties[iatom]).text = ' ' - return species + for index in atomic_indices[species]: + species_tree.append(ExcitingAtomInput(coord=self.positions[index], **self.atom_properties[index]).to_xml()) - def to_xml(self) -> ET.Element: + def to_xml(self) -> ElementTree.Element: """Convert structure attributes to XML ElementTree + Makes use of the to_xml() function of the ExitingXMLInput class to convert values to string. Expect to return an XML structure which looks like: @@ -269,17 +269,23 @@ def to_xml(self) -> ET.Element: :return ET structure: Element tree containing structure attributes. """ - structure = ET.Element("structure", speciespath=self.species_path, **self.structure_properties) + structure_attributes = { + key: self._attributes_to_input_str[type(value)](value) for key, value in self.structure_attributes.items() + } + structure = ElementTree.Element(self.name, **structure_attributes) + structure.text = " " # Lattice vectors - crystal = ET.SubElement(structure, "crystal", **self.crystal_properties) + crystal = self.crystal_properties.to_xml() + structure.append(crystal) for vector in self.lattice: - ET.SubElement(crystal, "basevect").text = list_to_str(vector) + ElementTree.SubElement(crystal, "basevect").text = list_to_str(vector) # Species tags atomic_indices = self._group_atoms_by_species() for x in self.unique_species: - species = ET.SubElement(structure, "species", speciesfile=x + '.xml', **self.species_properties[x]) - species = self._xml_atomic_subtree(x, species, atomic_indices) + species = self.species_properties[x].to_xml() + structure.append(species) + self._xml_atomic_subtree(x, species, atomic_indices) return structure diff --git a/excitingtools/input/xml_utils.py b/excitingtools/input/xml_utils.py index e983bba..534d8b1 100644 --- a/excitingtools/input/xml_utils.py +++ b/excitingtools/input/xml_utils.py @@ -1,9 +1,8 @@ -"""Utilities to aid in writing and formatting XML -""" -from typing import Union, List +"""Utilities to aid in writing and formatting XML""" + +import re from xml.dom import minidom from xml.etree import ElementTree -import re def xml_tree_to_pretty_str(elem: ElementTree.Element) -> str: @@ -12,12 +11,12 @@ def xml_tree_to_pretty_str(elem: ElementTree.Element) -> str: :param ElementTree.Element elem: Element/ element tree :return str : XML tree string, with pretty formatting. """ - rough_string = ElementTree.tostring(elem, 'utf-8') + rough_string = ElementTree.tostring(elem, "utf-8") reparsed = minidom.parseString(rough_string) return reparsed.toprettyxml(indent="\t") -def line_reformatter(input_str: str, tag: str) -> str: +def line_reformatter(input_str: str) -> str: """Identify attributes of an XML string element, and reformat them such that they are on new lines. NOTE: This could be split into two routines. One that finds the (start, end) of @@ -27,13 +26,12 @@ def line_reformatter(input_str: str, tag: str) -> str: Example ---------- Takes: - input_str = - tag = 'groundstate' + input_str = '\t ' Returns: reformatted_str = - str: tforce="true" vkloff="0 0 0" xctype="GGA_PBE_SOL"> - + ' There are 3 possibilities: the xml element has further subelements, than it ends only with a single '>', if the xml element has only attributes, than there are two options to close the element: either long with a '> ' or short with a '/>'. :param str input_str: Input string, opened and closed with an XML element. - :param str tag: XML element name. :return str reformatted_str: Reformatted form of input_str """ - # Get rid of format characters, like \n, \t etc - input_str = input_str.strip() - number_of_tag_indents = len(tag) - len(tag.strip()) - tag = tag.strip() + full_tag = input_str.split(" ")[0] + tag = full_tag.strip() + number_of_tag_indents = len(full_tag) - len(tag) - # This should not occur if the routine is only used with `prettify_tag_attributes` - if tag != input_str[0:len(tag)]: - raise ValueError(f'tag, "{tag}", is inconsistent the element name, ' - f'"{input_str[0:len(tag)]}"') + tag_indent = "\t" * number_of_tag_indents + attr_indent = tag_indent + " " * 3 - tag_indent = '\t' * number_of_tag_indents - attr_indent = tag_indent + ' ' * 3 + # Get rid of format characters, like \n, \t etc + input_str = input_str.strip() # Isolate attributes according to position of quotation marks in string # (cannot use whitespaces to split) - quote_indices = [x.start() for x in re.finditer('\"', input_str)] - # if there are only few attributes present: - if len(quote_indices) < 5: - return tag_indent + input_str + quote_indices = [x.start() for x in re.finditer('"', input_str)] closing_quote_indices = quote_indices[1::2] - attribute_start_indices = [len(tag) + 1] + [i + 1 for i in - closing_quote_indices[:-1]] + attribute_start_indices = [len(tag) + 1] + [i + 1 for i in closing_quote_indices[:-1]] - reformatted_str = tag_indent + tag + '\n' + reformatted_str = tag_indent + tag + "\n" - for i in range(0, len(attribute_start_indices)): - i1 = attribute_start_indices[i] + for i, start_index in enumerate(attribute_start_indices): i2 = closing_quote_indices[i] - attribute_str = input_str[i1:i2 + 1].strip() - reformatted_str += attr_indent + attribute_str + '\n' + attribute_str = input_str[start_index : i2 + 1].strip() + reformatted_str += attr_indent + attribute_str + "\n" # short ending option: - if input_str[-2:] == '/>': - return reformatted_str[:-1] + '/>\n' - reformatted_str = reformatted_str[:-1] + '>\n' + if input_str[-2:] == "/>": + return reformatted_str[:-1] + "/>" + reformatted_str = reformatted_str[:-1] + ">" # no ending option: - if not input_str[-(len(tag) + 2):-len(tag)] == ' str: - """Prettify XML string formatting of attributes, for a given XML element. +def prettify_tag_attributes(xml_string: str) -> str: + """Prettify XML string formatting of attributes. - The routine finds the line containing the XML element which matches - pretty_string = prettify_tag_attributes(string, ' str: ``` :param str xml_string: Already-prettified XML string (assumes tags are on their own lines) - :param str tag: XML element of the form " ET.Element: - """Put class attributes into an XML tree, 'xs'. - """ - xs_tree = self.xs.to_xml() - - attributes = list(filter(lambda x: isinstance(x, ExcitingXMLInput), vars(self).values())) - attributes = list(filter(lambda x: x.name != 'xs', attributes)) - for attribute in attributes: - xs_tree.append(attribute.to_xml()) - - return xs_tree - - -class ExcitingXSBSEInput(ExcitingXMLInput): - """ - Class for exciting BSE Input - """ - _valid_BSE_attributes = {'aresbse', 'blocks', 'bsedirsing', 'bsetype', 'checkposdef', 'chibar0', 'chibar0comp', - 'chibarq', 'coupling', 'cuttype', 'distribute', 'econv', 'eecs', 'efind', 'fbzq', - 'iqmtrange', 'lmaxdielt', 'measure', 'nexc', 'ngridksub', 'nleblaik', 'nosym', 'nstlbse', - 'nstlxas', 'outputlevel', 'reducek', 'rgkmax', 'sciavbd', 'sciavqbd', 'sciavqhd', - 'sciavqwg', 'sciavtype', 'scrherm', 'vkloff', 'writehamhdf5', 'writepotential', 'xas', - 'xasatom', 'xasedge', 'xasspecies', 'xes'} - - def __init__(self, BSE: dict): - super().__init__('BSE', self._valid_BSE_attributes, **BSE) - - -class ExcitingXSScreeningInput(ExcitingXMLInput): - """ - Class for exciting Screening Input - """ - _valid_screening_attributes = {'do', 'intraband', 'nempty', 'ngridk', 'nosym', 'reducek', 'rgkmax', 'screentype', - 'tr', 'vkloff'} - - def __init__(self, screening: dict): - super().__init__('screening', self._valid_screening_attributes, **screening) - - -class ExcitingXSEnergywindowInput(ExcitingXMLInput): - """ - Class for exciting Energywindow Input - """ - _valid_energywindow_attributes = {'intv', 'points'} - - def __init__(self, energywindow: dict): - super().__init__('energywindow', **energywindow) - - -class ExcitingXSQpointsetInput(ExcitingXMLInput): - """ - Class for exciting Qpointset Input - """ - - def __init__(self, qpointset: Optional[Union[np.ndarray, List[List[float]]]] = np.array([0.0, 0.0, 0.0])): - """ - Qpointset should be passed either as numpy array or as a list of lists, so either - np.array([[0., 0., 0.], [0.0, 0.0, 0.01], ...]) - or - [[0., 0., 0.], [0.0, 0.0, 0.01], ...] - """ - super().__init__('qpointset') - self.qpointset = qpointset - - def to_xml(self) -> ET.Element: - qpointset = ET.Element('qpointset') - for qpoint in self.qpointset: - ET.SubElement(qpointset, 'qpoint').text = list_to_str(qpoint) - - return qpointset - - -class ExcitingXSPlanInput(ExcitingXMLInput): - """ - Class for exciting Plan Input - """ - _valid_plan_elements = {'xsgeneigvec', 'tetcalccw', 'writepmatxs', 'writeemat', 'df', 'df2', 'idf', 'scrgeneigvec', - 'scrtetcalccw', 'scrwritepmat', 'screen', 'scrcoulint', 'exccoulint', 'bse', 'bsegenspec', - 'writebevec', 'writekpathweights', 'bsesurvey', 'kernxc_bse', 'writebandgapgrid', - 'write_wfplot', 'write_screen', 'writepmat', 'dielectric', 'writepmatasc', 'pmatxs2orig', - 'writeoverlapxs', 'writeematasc', 'writepwmat', 'emattest', 'x0toasc', 'x0tobin', - 'fxc_alda_check', 'kernxc_bse3', 'testxs', 'xsestimate', 'testmain', 'excitonWavefunction', - 'portstate(1)', 'portstate(2)', 'portstate(-1)', 'portstate(-2)'} - - def __init__(self, plan: List[str]): - """ - Plan doonly elements are passed as a List of strings in the order exciting shall execute them: - ['bse', 'xseigval', ...] - """ - super().__init__('plan') - check_valid_keys(plan, self._valid_plan_elements, 'Plan') - self.plan = plan - - def to_xml(self) -> ET.Element: - plan = ET.Element('plan') - for task in self.plan: - ET.SubElement(plan, 'doonly', task=task) - - return plan diff --git a/excitingtools/math/math_utils.py b/excitingtools/math/math_utils.py index 54022e4..9ae7f68 100644 --- a/excitingtools/math/math_utils.py +++ b/excitingtools/math/math_utils.py @@ -1,14 +1,14 @@ -""" Math functions. -""" +"""Math functions.""" + import numpy as np def triple_product(a, b, c) -> np.ndarray: r""" - Vector triple product, defined as + Vector triple product, defined as \mathbf{a} \cdot (\mathbf{b} \wedge \mathbf{c}) - :param a: Vector a + :param a: Vector a :param b: Vector b :param c: Vector c :return triple product @@ -18,7 +18,7 @@ def triple_product(a, b, c) -> np.ndarray: def unit_vector(x: np.ndarray) -> np.ndarray: r""" - Unit vector of a vector 'x' + Unit vector of a vector 'x' \mathbf{\hat{x}} = \frac{\mathbf{x}}{|\mathbf{x}|} :param x: Vector x diff --git a/excitingtools/parser_utils/__init__.py b/excitingtools/parser_utils/__init__.py index 3cb80c2..0b14a7d 100644 --- a/excitingtools/parser_utils/__init__.py +++ b/excitingtools/parser_utils/__init__.py @@ -1 +1,3 @@ -from excitingtools.parser_utils.erroneous_file_error import ErroneousFileError \ No newline at end of file +from excitingtools.parser_utils.erroneous_file_error import ErroneousFileError + +__all__ = ["ErroneousFileError"] diff --git a/excitingtools/parser_utils/grep_parser.py b/excitingtools/parser_utils/grep_parser.py index 80932f8..2b0c489 100644 --- a/excitingtools/parser_utils/grep_parser.py +++ b/excitingtools/parser_utils/grep_parser.py @@ -1,6 +1,7 @@ -"""Wrapper for command-line grep. -""" +"""Wrapper for command-line grep.""" + import subprocess +import warnings from typing import Optional, Union @@ -15,18 +16,17 @@ def grep(string: str, fname: str, options: Optional[dict] = None) -> Union[str, :param Optional[dict] options: Grep options. :return output: String if matched, None if failed. """ - opts = '' + opts = "" if options is not None: for key, value in options.items(): - opts += '-' + key + ' ' + str(value) + ' ' + opts += "-" + key + " " + str(value) + " " grep_str = "grep " + opts + " '" + string + "' " + fname try: output = subprocess.check_output(grep_str, shell=True).decode("utf-8") except subprocess.CalledProcessError as grepexc: - print("subprocess error:", grepexc.returncode, "grep found:", - grepexc.output) + warnings.warn(f"subprocess error: {grepexc.returncode}, grep found: {grepexc.output}") output = None return output diff --git a/excitingtools/parser_utils/parser_decorators.py b/excitingtools/parser_utils/parser_decorators.py index 537fa2a..922acf9 100644 --- a/excitingtools/parser_utils/parser_decorators.py +++ b/excitingtools/parser_utils/parser_decorators.py @@ -1,13 +1,14 @@ -"""Decorators and wrappers for parser functions. -""" -from typing import Callable, Union -import os -import xml.etree.ElementTree as ET +"""Decorators and wrappers for parser functions.""" + import pathlib +import xml.etree.ElementTree as ET +from typing import Callable, Optional, Union + +from excitingtools.utils.dict_utils import __container_converter def return_file_string(file_name: Union[str, pathlib.Path]) -> str: - """ Given a file name, return the file contents as a string. + """Given a file name, return the file contents as a string. :param file_name: File name. :return file_string: File contents string. @@ -18,7 +19,7 @@ def return_file_string(file_name: Union[str, pathlib.Path]) -> str: file_name_ = pathlib.Path(file_name_) if not file_name_.exists(): - raise FileNotFoundError(f'{file_name_} not found') + raise FileNotFoundError(f"{file_name_} not found") return file_name_.read_text() @@ -36,42 +37,59 @@ def file_handler(file_name: Union[str, pathlib.Path], parser_func: Callable[[str def accept_file_name(parser: Callable): - """ Decorate parsers that accept string contents, such that they take file names instead. - """ + """Decorate parsers that accept string contents, such that they take file names instead.""" def modified_func(file_name: Union[str, pathlib.Path]): - """ Wrapper. + """Wrapper. param: file_name: File name. """ file_string = return_file_string(file_name) return parser(file_string) + return modified_func +def set_return_values(parser: Callable[[str], dict]) -> Callable[[str], dict]: + """Mutate the values of a parsed dictionary to return + appropriate types, rather than strings. + """ + + def modified_exciting_parser(full_file_name: str) -> dict: + """Wrapper. + :param full_file_name: File name. + :return: converted data + """ + data = parser(full_file_name) + return __container_converter(data) + + return modified_exciting_parser + + def xml_root(func: Callable): - """ Decorate XML parsers, enabling the developer to pass + """Decorate XML parsers, enabling the developer to pass an XML file name, XML string or ElementTree.Element as input and return the XML root. """ - def modified_func(input: str): + function_selection = {type(None): lambda x, _: func(x), str: lambda x, y: func(x, y)} + + def modified_func(input: str, tag: Optional[str] = None): # Element if isinstance(input, ET.Element): - return func(input) + return function_selection[type(tag)](input, tag) # File name try: tree = ET.parse(input) root = tree.getroot() - return func(root) + return function_selection[type(tag)](root, tag) except (FileNotFoundError, OSError): pass # XML string try: root = ET.fromstring(input) - return func(root) - except ET.ParseError: - raise ValueError(f'Input string neither an XML file, ' - f'nor valid XML: {input}') + return function_selection[type(tag)](root, tag) + except (ET.ParseError, TypeError): + raise ValueError(f"Input string neither an XML file, nor valid XML: {input}") return modified_func diff --git a/excitingtools/parser_utils/parser_utils.py b/excitingtools/parser_utils/parser_utils.py new file mode 100644 index 0000000..9370fbc --- /dev/null +++ b/excitingtools/parser_utils/parser_utils.py @@ -0,0 +1,87 @@ +"""General parser utility functions.""" + +import re +from json import JSONDecodeError, loads +from typing import Any, Dict +from xml.etree import ElementTree + + +def find_element(root: ElementTree.Element, tag: str) -> ElementTree.Element: + """Finds a given tag in an Element, either return the full ElementTree + if the tag is correct or find the tag in that ElementTree. + :param root: Element to find the tag in + :param tag: tag to search for + :return: the (sub)element with the correct tag + """ + if root.tag == tag: + return root + return root.find(tag) + + +def convert_string_dict(inputs: dict) -> Dict[str, Any]: + """Parses and converts a dictionary with string values to their actual data types. + + :param inputs: input dictionary + :return: the converted dictionary + """ + for key, value in inputs.items(): + inputs[key] = convert_single_entry(value) + return inputs + + +def convert_single_entry(input_str: str): + """Converts a single string. Accepts also lists separated by whitespaces. + + :param input_str: input string + :return: the converted data + """ + result = [json_convert(standardise_fortran_exponent(i)) for i in input_str.split()] + + if len(result) == 1: + return result[0] + if len(list(filter(lambda x: isinstance(x, str), result))) == len(result): + return " ".join(result) + return result + + +def json_convert(input_str: str): + """Tries to convert a single string with no whitespaces to its actual data type + using the json decoder (detects int, float and bool). Else returns the string as string. + :param input_str: input string + :return: the converted value + """ + try: + return loads(input_str) + except JSONDecodeError: + pass + + # the json standard (https://www.json.org/json-en.html) does not allow for numbers to have leading or trailing + # decimal points, therefore we need another try to parse floats. + try: + return float(input_str) + except ValueError: + return input_str + + +def standardise_fortran_exponent(input_str: str, return_as_str: bool = True): + """Tries to convert a single string representing a float value in scientific notation + to the actual number. + d(D) and q(Q) correspond to higher floating point precision than e(E). Replace + them since they cannot be parsed by JSON + + :param input_str: input string + :param return_as_str: return the number as a string in python format with e as exponential + :return: If string can be converted to a float return it, else + return the input string. + """ + subbed_str = re.sub("[dDqQ]", "E", input_str, count=1) + if subbed_str == input_str: + return input_str + + try: + number = float(subbed_str) + except ValueError: + return input_str + if return_as_str: + return str(number) + return number diff --git a/excitingtools/parser_utils/regex_parser.py b/excitingtools/parser_utils/regex_parser.py index eb0e16c..f0e279c 100644 --- a/excitingtools/parser_utils/regex_parser.py +++ b/excitingtools/parser_utils/regex_parser.py @@ -1,15 +1,12 @@ -"""Wrappers for parsing with regex -""" +"""Wrappers for parsing with regex""" + import re from typing import List, Union from excitingtools.utils.utils import convert_to_literal -def parse_value_regex(file_string: str, - key: str, - no_colon=True, - silent_key_error=False) -> dict: +def parse_value_regex(file_string: str, key: str, no_colon=True, silent_key_error=False) -> dict: """ Match the first instance of a string (key) if present in file_string, and return the result in a dictionary. @@ -27,27 +24,24 @@ def process_value(x: str) -> Union[int, float, str]: numerical_value = convert_to_literal(x) if numerical_value is None: return x.strip() - else: - return numerical_value + return numerical_value try: - match = re.search(key + '(.+)\n', file_string) + match = re.search(key + "(.+)\n", file_string) values = match.group(1).split() # Remove escape characters - parser_key = key.replace('\\', "") + parser_key = key.replace("\\", "") processed_values = [process_value(raw_value) for raw_value in values] - data[parser_key] = processed_values[0] if len( - processed_values) == 1 else processed_values + data[parser_key] = processed_values[0] if len(processed_values) == 1 else processed_values except AttributeError: if not silent_key_error: - print("parse_value_regex. Did not find the key:", key) + print("parse_value_regex. Did not find the key:", key) # noqa: T201 return {} if no_colon: - return {key.rstrip(':'): value for key, value in data.items()} - else: - return data + return {key.rstrip(":"): value for key, value in data.items()} + return data def parse_values_regex(file_string: str, keys: List[str]) -> dict: @@ -61,7 +55,7 @@ def parse_values_regex(file_string: str, keys: List[str]) -> dict: :return dict parsed_data: Matched data """ - assert type(keys) == list, "2nd argument of parse_values_regex must be List[str]" + assert isinstance(keys, list), "2nd argument of parse_values_regex must be List[str]" matches_dict = {} for key in keys: matches_dict.update(**parse_value_regex(file_string, key)) diff --git a/excitingtools/parser_utils/simple_parser.py b/excitingtools/parser_utils/simple_parser.py index b4f6d4e..fd9de0e 100644 --- a/excitingtools/parser_utils/simple_parser.py +++ b/excitingtools/parser_utils/simple_parser.py @@ -1,5 +1,5 @@ -""" Simple text parsers. -""" +"""Simple text parsers.""" + from typing import Union @@ -13,16 +13,14 @@ def match_current_return_line_n(file_string: str, match: str, n_line=1) -> Union :return Union[str, None] matched line string, or None """ - file = file_string.split('\n') + file = file_string.split("\n") for i, line in enumerate(file): if match in line: return file[i + n_line] return None -def match_current_extract_from_line_n(input_string: str, - keys_extractions: dict, - n_line=1) -> dict: +def match_current_extract_from_line_n(input_string: str, keys_extractions: dict, n_line=1) -> dict: """ Given an input_string, match a substring (defined by the key of keys_extractions), return the a substring from the i+n_line line below the match, and extract a value diff --git a/excitingtools/py.typed b/excitingtools/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/excitingtools/runner/runner.py b/excitingtools/runner/runner.py index c1c3c2c..df5f081 100644 --- a/excitingtools/runner/runner.py +++ b/excitingtools/runner/runner.py @@ -1,90 +1,82 @@ -""" Binary runner and results classes. -""" -from typing import List, Optional, Union -from pathlib import Path +"""Binary runner and results classes.""" + +from __future__ import annotations + +import copy +import enum import os -import subprocess import shutil +import subprocess import time -import enum +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Union + +from excitingtools.utils.jobflow_utils import special_serialization_attrs class RunnerCode(enum.Enum): - """ Runner codes. - By default, the initial value starts at 1. + """Runner codes. + By default, the initial value starts at 1. """ - time_out = enum.auto + + time_out = enum.auto() +@dataclass class SubprocessRunResults: - """ Results returned from subprocess.run() - """ + """Results returned from subprocess.run()""" + + stdout: str + stderr: str + return_code: int | RunnerCode + process_time: Optional[float] = None - def __init__(self, - stdout, - stderr, - return_code: Union[int, RunnerCode], - process_time: Optional[float] = None): - self.stdout = stdout - self.stderr = stderr - self.return_code = return_code - self.success = return_code == 0 - self.process_time = process_time + @property + def success(self) -> bool: + """Determine the run success by evaluating the return code.""" + return self.return_code == 0 class BinaryRunner: - """ Class to execute a subprocess. - """ + """Class to execute a subprocess.""" + path_type = Union[str, Path] - def __init__(self, - binary: str, - run_cmd: Union[List[str], str], - omp_num_threads: int, - time_out: int, - directory: Optional[path_type] = './', - args=None) -> None: - """ Initialise class. + def __init__( + self, + binary: path_type, + run_cmd: List[str] | str = "", + omp_num_threads: int = 1, + time_out: int = 60, + directory: path_type = "./", + args: Optional[List[str]] = None, + ): + """Initialise class. :param str binary: Binary name prepended by full path, or just binary name (if present in $PATH). + No check for existence here as it could live on a remote worker (see run() doc) :param Union[List[str], str] run_cmd: Run commands sequentially as a list. For example: - * For serial: ['./'] or [''] + * For serial: [] * For MPI: ['mpirun', '-np', '2'] or as a string. For example" - * For serial: "./" + * For serial: "" * For MPI: "mpirun -np 2" - :param int omp_num_threads: Number of OMP threads. - :param int time_out: Number of seconds before a job is defined to have timed out. - :param List[str] args: Optional arguments for the binary. + :param omp_num_threads: Number of OMP threads. + :param time_out: Number of seconds before a job is defined to have timed out. + :param args: Optional arguments for the binary. """ - if args is None: - args = [] - self.binary = binary - self.directory = directory + self.binary = Path(binary).as_posix() + self.directory = Path(directory).as_posix() self.run_cmd = run_cmd self.omp_num_threads = omp_num_threads self.time_out = time_out - self.args = args - - try: - os.path.isfile(self.binary) - except FileNotFoundError: - # If just the binary name, try checking the $PATH - self.binary = shutil.which(self.binary) - if self.binary is None: - raise FileNotFoundError( - f"{binary} does not exist and cannot be found in the $PATH" - ) - - if not Path(directory).is_dir(): - raise OSError(f"Run directory does not exist: {directory}") + self.args = args or [] if isinstance(run_cmd, str): self.run_cmd = run_cmd.split() elif not isinstance(run_cmd, list): - raise ValueError( - "Run commands expected in a str or list. For example ['mpirun', '-np', '2']" - ) + raise ValueError("Run commands expected in a str or list. For example ['mpirun', '-np', '2']") self._check_mpi_processes() @@ -94,58 +86,80 @@ def __init__(self, if time_out <= 0: raise ValueError("time_out must be a positive integer") - def _check_mpi_processes(self): - """ Check that the number of MPI processes specified is valid. + def as_dict(self) -> dict: + """Returns a dictionary representing the current object for later recreation. + The serialise attributes are required for recognition by monty and jobflow. """ - # MPI + serialise_attrs = special_serialization_attrs(self) + return {**serialise_attrs, **self.__dict__} + + @classmethod + def from_dict(cls, d: dict): + my_dict = copy.deepcopy(d) + # Remove key value pairs needed for workflow programs + # call function on class to get only the keys (values not needed) + serialise_keys = special_serialization_attrs(cls) + for key in serialise_keys: + my_dict.pop(key, None) + return cls(**my_dict) + + def _check_mpi_processes(self): + """Check whether mpi is specified and if yes that the number of MPI processes specified is valid.""" + # Search if MPI is specified: try: - i = self.run_cmd.index('-np') - mpi_processes = eval(self.run_cmd[i + 1]) - if type(mpi_processes) != int: - raise ValueError("Number of MPI processes should be an int") - if mpi_processes <= 0: - raise ValueError("Number of MPI processes must be > 0") - # Serial and OMP-only + i = self.run_cmd.index("-np") except ValueError: # .index will return ValueError if 'np' not found. This corresponds to serial and omp calculations. - pass + return + try: + mpi_processes = int(self.run_cmd[i + 1]) + except IndexError: + raise ValueError("Number of MPI processes must be specified after the '-np'") + except ValueError: + raise ValueError("Number of MPI processes should be an int") + if mpi_processes <= 0: + raise ValueError("Number of MPI processes must be > 0") - def _compose_execution_list(self) -> list: - """Generate a complete list of strings to pass to subprocess.run(), to execute the calculation. + def run(self) -> SubprocessRunResults: + """Run a binary. - For example, given: - ['mpirun', '-np, '2'] + ['binary.exe'] + ['>', 'std.out'] + First check for the binary and the run directory. Binary can be relative or absolute path to the + binary file. Alternatively, the binary name could exist at a different location, therefore check $PATH. - return ['mpirun', '-np, '2', 'binary.exe', '>', 'std.out'] - """ - run_cmd = self.run_cmd + Then executes the binary with given run command and args. + Special handling is performed if the execution reached the time limit. - if self.run_cmd[0] == './': - run_cmd = [] + :return: the run results with output and error message, runner code and run time + """ + binary = Path(self.binary) + if not binary.is_file(): + binary = shutil.which(self.binary) + if not binary: + raise FileNotFoundError(f"{self.binary} binary is not present in the current directory nor in $PATH") - return run_cmd + [self.binary] + self.args + if not Path(self.directory).is_dir(): + raise OSError(f"Run directory does not exist: {self.directory}") - def run(self) -> SubprocessRunResults: - """Run a binary. - """ - execution_list = self._compose_execution_list() + execution_list = self.run_cmd + [Path(binary).as_posix()] + self.args my_env = {**os.environ, "OMP_NUM_THREADS": str(self.omp_num_threads)} time_start: float = time.time() try: - result = subprocess.run(execution_list, - env=my_env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - timeout=self.time_out, - cwd=self.directory) + result = subprocess.run( + execution_list, + cwd=self.directory, + env=my_env, + capture_output=True, + encoding="utf-8", + timeout=self.time_out, + check=False, + ) total_time = time.time() - time_start - return SubprocessRunResults(result.stdout, result.stderr, - result.returncode, total_time) + return SubprocessRunResults(result.stdout, result.stderr, result.returncode, total_time) except subprocess.TimeoutExpired as timed_out: - error = 'BinaryRunner: Job timed out. \n\n' + output = timed_out.output.decode("utf-8") if timed_out.output else "" + error = "BinaryRunner: Job timed out. \n\n" if timed_out.stderr: error += timed_out.stderr.decode("utf-8") - return SubprocessRunResults(timed_out.output, error, - RunnerCode.time_out, self.time_out) + return SubprocessRunResults(output, error, RunnerCode.time_out, self.time_out) diff --git a/excitingtools/species/species_file.py b/excitingtools/species/species_file.py new file mode 100644 index 0000000..b20f787 --- /dev/null +++ b/excitingtools/species/species_file.py @@ -0,0 +1,353 @@ +"""SpeciesFile class. +Provides functionality to read in a species file, add high energy local orbitals (HELOs), and +write the updated XML structures back to file. +""" + +import copy +import warnings +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, Set, Tuple, Union +from xml.etree import ElementTree + +from excitingtools.exciting_dict_parsers.species_parser import parse_species_xml +from excitingtools.input.xml_utils import xml_tree_to_pretty_str +from excitingtools.utils.jobflow_utils import special_serialization_attrs + + +@dataclass +class SpeciesFile: + """Holds a species file.""" + + species: dict + muffin_tin: dict + atomic_states: list + basis: dict + + def __post_init__(self): + """Ensures that the 'basis' dictionary includes 'custom' and 'lo' keys. + If these keys are absent, they are initialized as empty lists. + """ + self.basis.setdefault("custom", []) + self.basis.setdefault("lo", []) + + @classmethod + def from_file(cls, file: Union[str, Path]): + """Reads in an exciting species XML file, parses the file as a dict and + initializes an instance of the class with the parsed data. + + :param file: XML species file. + :return: An instance of the class initialized with the data from the species file. + """ + return cls(**parse_species_xml(file)) + + def as_dict(self) -> dict: + """Returns a dictionary representing the current object for later recreation. + The serialise attributes are required for recognition by monty and jobflow. + """ + serialise_attrs = special_serialization_attrs(self) + return {**serialise_attrs, **self.__dict__} + + @classmethod + def from_dict(cls, d: dict): + my_dict = copy.deepcopy(d) + # Remove key value pairs needed for workflow programs + # call function on class to get only the keys (values not needed) + serialise_keys = special_serialization_attrs(cls) + for key in serialise_keys: + my_dict.pop(key, None) + return cls(**my_dict) + + def get_first_helo_n(self, l: int) -> int: + """Returns the first principle quantum number 'n' for which additional High Energy Local + Orbitals (HELOs) can be added, for a given angular momentum quantum number 'l'. + The 'n' value determination is based on: + + 1. For a specific 'l' channel the highest 'n' specified in the atomic states is 'max_n'. + 2. If there exist local orbitals with a 'n' greater than the maximum found in atomic states for + the same 'l', 'max_n' is set to this 'n'. + 3. The first added HELO then starts at 'max_n + 1'. + 4. For an 'l' channel not represented in atomic states or as a local orbital, the first added HELO + has principle quantum number 'n = l + 1' . + + :param l: angular momentum quantum number 'l' + :return: first possible principle quantum number 'n' for a HELO for the given 'l'-channel + """ + atomicstate_ns_per_l, lo_ns_per_l = self.get_atomicstates_ns_per_l(), self.get_lo_ns_per_l() + max_n_for_specified_l = max(atomicstate_ns_per_l.get(l, {l}) | lo_ns_per_l.get(l, {l})) + + # If matchingOrder > 1 for the same l and highest n, skip n+1 HELO. + ns_per_l_with_mO_over_1 = self.check_matching_orders() + if ns_per_l_with_mO_over_1.get(l) and max_n_for_specified_l in ns_per_l_with_mO_over_1[l]: + warnings.warn(f"HELO skipped for l: {l} and n: {max_n_for_specified_l + 1}") + return max_n_for_specified_l + 2 + + return max_n_for_specified_l + 1 + + def get_atomicstates_ns_per_l(self, cond: Callable[[dict], bool] = lambda _: True) -> Dict[int, set]: + """Generates a dictionary mapping each 'l' channel present in the atomic states their corresponding 'n' values. + + :param cond: condition for choosing specific atomic states + :return: Dictionary with 'l' channels as keys and sets of 'n' values from the atomic states. + """ + atomicstate_ns_per_l = defaultdict(set) + cond_atomic_states = filter(cond, self.atomic_states) + for state in cond_atomic_states: + atomicstate_ns_per_l[state["l"]].add(state["n"]) + + return atomicstate_ns_per_l + + def get_valence_and_semicore_atomicstate_ns_per_l(self) -> Dict[int, Dict[str, Set[int]]]: + """ + Classifies and returns all non-core states into valence and semicore states for each 'l' channel and + corresponding 'n' value. + + This function classifies all states that do not belong to the core based on the maximum 'n' value for each 'l': + - States with the maximum 'n' value for their 'l' channel are classified as valence states. + - All other non-core states are classified as semicore states. + + :return: A dictionary mapping each 'l' channel to another dictionary containing two keys: 'semicore' and + 'valence'. Each of these keys maps to a set of 'n' values corresponding to the classified atomic states. + + Example: + { + 0: {'semicore': {1, 2}, 'valence': {3}}, + 1: {'semicore': {2}, 'valence': {3, 4}}, + ... + } + """ + noncore_atomicstate_ns_per_l = self.get_atomicstates_ns_per_l(lambda x: not x["core"]) + valence_and_semicore_states = {} + + for l, ns in noncore_atomicstate_ns_per_l.items(): + max_n = max(ns) + ns.remove(max_n) + valence_and_semicore_states[l] = {"semicore": ns, "valence": {max_n}} + + return valence_and_semicore_states + + def get_lo_ns_per_l(self) -> Dict[int, set]: + """Generates a dictionary mapping each 'l' channel present in the local orbitals to their corresponding 'n' + values. Only includes local orbitals with a defined 'n' value. Local orbitals only defined through the + attribute 'trialEnergy' are ignored. + + :return: Dictionary with 'l' channels as keys and corresponding sets of 'n' values from local orbitals. + """ + lo_ns_per_l = defaultdict(set) + for lo in self.basis["lo"]: + for wf in lo["wf"]: + if "n" in wf: + lo_ns_per_l[lo["l"]].add(wf["n"]) + + return lo_ns_per_l + + def get_helos_from_species(self) -> Dict[int, set]: + """Generates a dictionary mapping each 'l' channel to a list of principle quantum numbers 'n' unique to high + energy/local orbitals (HELOs). This is achieved by comparing 'n' values in local orbitals against those + in atomic states for each 'l' channel, extracting those exclusive to HELOs. + + :return: Dictionary with 'l' channels as keys and exclusive HELO 'n' values as sets. + """ + atomicstate_ns_per_l, lo_ns_per_l = self.get_atomicstates_ns_per_l(), self.get_lo_ns_per_l() + + helo_ns_per_l = {} + for l, ns in lo_ns_per_l.items(): + unique_helos = ns - atomicstate_ns_per_l.get(l, set()) + helo_ns_per_l[l] = unique_helos + + return helo_ns_per_l + + def check_matching_orders(self) -> Dict[int, list]: + """Creates a dictionary that lists principle quantum numbers 'n' for each angular momentum quantum number 'l', + where the 'n' values are associated with a matchingOrder greater than 1 in the local orbitals. + + :return: Dictionary with 'l' channels as keys and lists of 'n' values that have matchingOrder > 1 as values. + """ + ns_per_l_with_mO_over_1 = defaultdict(list) + for lo in self.basis["lo"]: + for wf in lo["wf"]: + if wf.get("n") and wf["matchingOrder"] > 1: + ns_per_l_with_mO_over_1[lo["l"]].append(wf["n"]) + + return ns_per_l_with_mO_over_1 + + def add_number_los_for_all_valence_semicore_states(self, number_lo: int): + """Adds a certain number `number_lo` of local orbitals with increasing matching order for every valence and + semicore state to the basis `self.basis[lo]`. Here the local orbital always consists of 2 wave function + elements. + + :param number_lo: number of local orbitals added for every valence and semicore state + """ + valence_semicore_states = self.get_atomicstates_ns_per_l(lambda x: not x["core"]) + + for l, ns in valence_semicore_states.items(): + for n in ns: + for _ in range(number_lo): + self.add_lo_higher_matching_order(l, n) + + def add_basic_lo_all_semicore_states(self): + """Adds one local orbital for every semicore state present in the atomic states. + + Moving a state from core to valence requires the addition of at least one local orbital to accurately + describe the semicore state. + The local orbital consists of two radial functions at matchingOrder 0: + - one at the same principal quantum number 'n' of the (L)APW for the given angular momentum ('l') channel. + - the other at the principal quantum number 'n' of the semi core state. + """ + valence_and_semicore_states = self.get_valence_and_semicore_atomicstate_ns_per_l() + semicore_states = {l: data["semicore"] for l, data in valence_and_semicore_states.items()} + + for l, ns in semicore_states.items(): + for n in ns: + self.add_lo(l, (n + 1, n), (0, 0)) + + def add_custom_for_all_valence_states(self, custom_type: str): + """Adds a custom element to the basis for every valence state present in the atomic states. + + :param custom_type: Type of the custom basis function. It can be either `apw`, `lapw`, or `apw+lo`. + """ + valence_and_semicore_states = self.get_valence_and_semicore_atomicstate_ns_per_l() + valence_states = {l: data["valence"] for l, data in valence_and_semicore_states.items()} + + for l, ns in valence_states.items(): + for n in ns: + self.basis["custom"].append({"l": l, "type": custom_type, "n": n, "searchE": False}) + + def add_helos(self, l: int, number: int): + """Adds a specific 'number' of High Energy Local Orbitals (HELOs) to the basis + for a given angular momentum channel 'l'. + + :param l: angular momentum number l + :param number: the number of HELOs to be added to the l-channel + """ + first_helo_n_for_l = self.get_first_helo_n(l) + + for nr_lo in range(number): + self.add_lo(l, (first_helo_n_for_l + nr_lo,) * 2, [0, 1]) + + def find_highest_matching_order_for_state(self, l: int, n: int) -> int: + """Returns the highest matching order for a specific state, defined by principal quantum numner 'n' and + anguar momentum 'l', for which a local orbital exists in the species file. + + :param l: angular momentum number l + :param n: principal quantum number n + :return: highest matchingOrder mO + """ + + l_los = filter(lambda x: x["l"] == l, self.basis["lo"]) + mOs = [wf["matchingOrder"] for lo in l_los for wf in lo["wf"] if wf.get("n") == n] + + return max(mOs, default=0) + + def add_lo_higher_matching_order(self, l: int, n: int): + """Adds a Local Orbital with the next highest matching order for a state defined by angular momentum 'l' + and principal quantum number 'n'. + + :param l: angular momentum number + :param n: principal quantum number + """ + mO = self.find_highest_matching_order_for_state(l, n) + self.add_lo(l, (n, n), (mO, mO + 1)) + + def add_lo(self, l: int, ns: Tuple[int, int], matching_orders: Tuple[int, int]): + """Adds a single local orbital to the basis for a given angular momentum channel 'l', + with tuples of principal quantum number 'n' and corresponding 'matching_orders'. + + :param l: angular momentum number l + :param ns: tuple of principal quantum number n + :param matching_orders: tuple of matching orders + """ + assert len(ns) == len( + matching_orders + ), "Number of principal quantum numbers n must equal the number of given matching orders." + + if matching_orders[0] >= 3: + warnings.warn("Maximum matchingOrder reached; cannot add new local orbital.") + return + + wf = [{"matchingOrder": mO, "searchE": False, "n": n} for mO, n in zip(matching_orders, ns)] + self.basis["lo"].append({"l": l, "wf": wf}) + + def remove_lo(self, l: int, ns: Tuple[int, int], matching_orders: Tuple[int, int]) -> bool: + """Removes a single local orbital from the basis for a given angular momentum channel 'l', + based on a list of tuples, each containing a principal quantum number 'n' and its corresponding matching order. + If a local orbital with the specified 'n' and matching orders is present in the basis, it is removed. + + :param l: Angular momentum number l. + :param ns: tuple of principal quantum number n + :param matching_orders: tuple of matching orders + :return removed: Boolean indicating if the local orbital was present and removed. + """ + + removed = False + + for lo in self.basis["lo"]: + ns_mOs_lo = sorted([(wf["n"], wf["matchingOrder"]) for wf in lo["wf"] if wf.get("n")]) + if lo["l"] == l and sorted(zip(ns, matching_orders)) == ns_mOs_lo: + removed = True + self.basis["lo"].remove(lo) + break + + return removed + + def remove_lo_highest_matching_order(self, l: int, n: int) -> (bool, int): + """Removes the Local Orbital from the basis for a given angular momentum channel 'l' and + principal quantum number 'n' that contains the wave function with the highest matching order. + If the local orbital to be removed is present in the basis, the returned boolean is set to True. + + :param l: angular momentum number l + :param n: principal quantum number n + :return: tuple of states if the given local orbital was present in the basis and highest matching order + """ + mO = self.find_highest_matching_order_for_state(l, n) + + return self.remove_lo(l, (n, n), (mO - 1, mO)), mO + + def add_default(self, trial_energy: float, default_type: str): + """Adds the default element with a given trial energy and type to the basis. + + :param trial_energy: trial energy used in the default element + :param default_type: type of the default basis functions. Can be either `apw`, `lapw`, or `apw+lo`. + """ + self.basis["default"].append({"type": default_type, "trialEnergy": trial_energy, "searchE": False}) + + def to_xml(self) -> ElementTree.Element: + """Converts the class attributes into an XML structure using ElementTree. + + :return: An ElementTree.Element representing the root of the XML structure. + """ + spdb = ElementTree.Element("spdb") + sp = ElementTree.SubElement(spdb, "sp", {k: str(v) for k, v in self.species.items()}) + ElementTree.SubElement(sp, "muffinTin", {k: str(v) for k, v in self.muffin_tin.items()}) + for state in self.atomic_states: + ElementTree.SubElement(sp, "atomicState", {k: str(v).lower() for k, v in state.items()}) + + basis = ElementTree.SubElement(sp, "basis") + for default in self.basis["default"]: + ElementTree.SubElement(basis, "default", {k: str(v).lower() for k, v in default.items()}) + + for custom in self.basis["custom"]: + ElementTree.SubElement(basis, "custom", {k: str(v).lower() for k, v in custom.items()}) + + for lo in self.basis["lo"]: + lo_element = ElementTree.SubElement(basis, "lo", l=str(lo["l"])) + for wf in lo["wf"]: + ElementTree.SubElement(lo_element, "wf", {k: str(v).lower() for k, v in wf.items()}) + + return spdb + + def to_xml_str(self) -> str: + """Compose XML ElementTrees from exciting input classes to create an input xml string. + + :return: Input XML tree as a string, with pretty formatting. + """ + return xml_tree_to_pretty_str(self.to_xml()) + + def write(self, filename: Union[str, Path]): + """Writes the xml string to file. + + :param filename: name of the file. + """ + with open(filename, "w") as fid: + fid.write(self.to_xml_str()) diff --git a/excitingtools/structure/ase_utilities.py b/excitingtools/structure/ase_utilities.py new file mode 100644 index 0000000..677aa09 --- /dev/null +++ b/excitingtools/structure/ase_utilities.py @@ -0,0 +1,20 @@ +"""Utilities for interaction with the ASE library.""" + +from ase import Atoms + +from excitingtools import ExcitingStructure +from excitingtools.constants.units import bohr_to_angstrom + + +def exciting_structure_to_ase(structure: ExcitingStructure) -> Atoms: + """Function to extract the physical structure from an exciting structure object + and transforms it into an ase.atoms.Atoms object. + + :param structure: input exciting structure object + :returns: ASE Atoms object + """ + lattice = structure.get_lattice(convert_to_angstrom=True) + if structure.structure_attributes.get("cartesian"): + positions = structure.positions * bohr_to_angstrom + return Atoms(symbols=structure.species, positions=positions, cell=lattice, pbc=True) + return Atoms(symbols=structure.species, scaled_positions=structure.positions, cell=lattice, pbc=True) diff --git a/excitingtools/structure/lattice.py b/excitingtools/structure/lattice.py index 3c1bbc7..ef2639f 100644 --- a/excitingtools/structure/lattice.py +++ b/excitingtools/structure/lattice.py @@ -1,5 +1,5 @@ -"""Functions that operate on a crystal lattice. -""" +"""Functions that operate on a crystal lattice.""" + import numpy as np from excitingtools.math.math_utils import triple_product @@ -14,34 +14,31 @@ def check_lattice(lattice: list): :param list lattice: Lattice vectors """ if len(lattice) != 3: - raise ValueError('lattice argument expected to have 3 elements') + raise ValueError("lattice argument expected to have 3 elements") - for i in range(0, 3): + for i in range(3): if len(lattice[i]) != 3: - raise ValueError( - f'lattice vector {i} expected to have 3 components. Instead has {len(lattice[i])}' - ) + raise ValueError(f"lattice vector {i} expected to have 3 components. Instead has {len(lattice[i])}") for i, j in [(0, 1), (1, 2), (0, 2)]: - if np.allclose(lattice[i], lattice[j], atol=1.e-6): - raise ValueError( - f'lattice vectors {i} and {j} are numerically equivalent') + if np.allclose(lattice[i], lattice[j], atol=1.0e-6): + raise ValueError(f"lattice vectors {i} and {j} are numerically equivalent") -def check_lattice_vector_norms(lattice: list, tol=1.e-6): - """ Check the norm of each lattice vector. +def check_lattice_vector_norms(lattice: list, tol=1.0e-6): + """Check the norm of each lattice vector. :param list lattice: Lattice vectors :param tol: Optional tolerance for what is considered numerically zero. """ norms = np.empty(shape=3) - for i in range(0, 3): + for i in range(3): norms[i] = np.linalg.norm(lattice[i]) zero_norms = np.where(norms < tol)[0] for i in zero_norms: - raise ValueError(f'lattice vector {i} has a norm of zero') + raise ValueError(f"lattice vector {i} has a norm of zero") def parallelepiped_volume(lattice_vectors: np.ndarray) -> float: @@ -50,9 +47,7 @@ def parallelepiped_volume(lattice_vectors: np.ndarray) -> float: :param np.ndarray lattice_vectors: Lattice vectors, stored column-wise :return: float: Cell volume """ - return np.abs( - triple_product(lattice_vectors[:, 0], lattice_vectors[:, 1], - lattice_vectors[:, 2])) + return np.abs(triple_product(lattice_vectors[:, 0], lattice_vectors[:, 1], lattice_vectors[:, 2])) def reciprocal_lattice_vectors(a: np.ndarray) -> np.ndarray: @@ -75,8 +70,7 @@ def reciprocal_lattice_vectors(a: np.ndarray) -> np.ndarray: # TODO(Bene) This needs cleaning up. Missing any documenting maths. Not at all clear what's happening -def plane_transformation(rec_lat_vec: np.array, - plot_vec: np.array) -> np.array: +def plane_transformation(rec_lat_vec: np.array, plot_vec: np.array) -> np.array: """ Take reciprocal lattice vectors and ONS of a plane in rec. lat. coordinates where the first two vectors span the plane and the third is normal to them and calculate a matrix that transforms points in the plane to the xy plane in cartesian coordinates. @@ -89,13 +83,15 @@ def plane_transformation(rec_lat_vec: np.array, # transform plot vec in cartesian coordinates plot_vec = (rec_lat_vec.dot(plot_vec)).transpose() # extend plot vec to an orthogonal system - plot_vec = np.array([(plot_vec[1] - plot_vec[0]) / norm(plot_vec[1] - plot_vec[0]), - (plot_vec[2] - plot_vec[0]) / norm(plot_vec[2] - plot_vec[0]), - np.cross(plot_vec[1] - plot_vec[0], plot_vec[2] - plot_vec[0]) \ - / norm(np.cross(plot_vec[1] - plot_vec[0], plot_vec[2] - plot_vec[0]))]) + plot_vec = np.array( + [ + (plot_vec[1] - plot_vec[0]) / norm(plot_vec[1] - plot_vec[0]), + (plot_vec[2] - plot_vec[0]) / norm(plot_vec[2] - plot_vec[0]), + np.cross(plot_vec[1] - plot_vec[0], plot_vec[2] - plot_vec[0]) + / norm(np.cross(plot_vec[1] - plot_vec[0], plot_vec[2] - plot_vec[0])), + ] + ) transformation_matrix = np.linalg.inv(plot_vec) - for v in transformation_matrix: - v = v / norm(v) transformation_matrix = np.transpose(transformation_matrix) return transformation_matrix diff --git a/excitingtools/structure/pymatgen_utilities.py b/excitingtools/structure/pymatgen_utilities.py new file mode 100644 index 0000000..583c5f1 --- /dev/null +++ b/excitingtools/structure/pymatgen_utilities.py @@ -0,0 +1,33 @@ +"""Utilities to interact with the pymatgen library.""" + +from pymatgen.core import Structure + +from excitingtools import ExcitingStructure +from excitingtools.constants.units import angstrom_to_bohr, bohr_to_angstrom + + +def exciting_structure_to_pymatgen(structure: ExcitingStructure) -> Structure: + """Function to extract the physical structure from an exciting structure object + and transforms it into a pymatgen.core.structure.Structure object. + + :param structure: input exciting structure object + :returns: pymatgen Structure object + """ + lattice = structure.get_lattice(convert_to_angstrom=True) + cartesian = structure.structure_attributes.get("cartesian", False) + positions = structure.positions * bohr_to_angstrom if cartesian else structure.positions + return Structure(lattice=lattice, species=structure.species, coords=positions, coords_are_cartesian=cartesian) + + +def pymatgen_to_exciting_structure(structure: Structure) -> ExcitingStructure: + """Initialise lattice, species and positions from a pymatgen Structure Object. + Note: pymatgen works in Angstrom, whereas exciting expects atomic units + + :param structure: pymatgen Structure object. + :return exciting structure object + """ + lattice = structure.lattice.matrix * angstrom_to_bohr + species = [x.symbol.capitalize() for x in structure.species] + positions = structure.frac_coords + atoms = [{"species": atom, "position": positions[i]} for i, atom in enumerate(species)] + return ExcitingStructure(atoms, lattice) diff --git a/excitingtools/utils/dict_utils.py b/excitingtools/utils/dict_utils.py index d2f2474..78fe938 100644 --- a/excitingtools/utils/dict_utils.py +++ b/excitingtools/utils/dict_utils.py @@ -1,11 +1,13 @@ -""" Dictionary Utilities. -""" -from typing import Optional, Iterator, Union -import json -import numpy as np +"""Dictionary Utilities.""" + +import contextlib import copy -from collections.abc import Mapping, Hashable, KeysView +import json import sys +from collections.abc import Hashable, KeysView, Mapping +from typing import Iterator, Union + +import numpy as np def common_iterable(obj: Union[dict, list]): @@ -19,8 +21,7 @@ def common_iterable(obj: Union[dict, list]): """ if isinstance(obj, dict): return obj - else: - return (index for index, value in enumerate(obj)) + return (index for index, value in enumerate(obj)) def __container_converter(data: Union[list, dict]) -> dict: @@ -28,12 +29,12 @@ def __container_converter(data: Union[list, dict]) -> dict: Converts string versions of numerical data from input dict into numerical data. :param dict data: Dictionary with string values. - :return:dict new_data: Dictionary with all string literal values converted to numerical values. + :return:dict data: Input dictionary with all string literal values converted to numerical values. """ np_convert = {np.float64: float, np.int32: int} for element in common_iterable(data): - if isinstance(data[element], dict) or isinstance(data[element], list): + if isinstance(data[element], (dict, list)): data[element] = __container_converter(data[element]) elif isinstance(data[element], np.ndarray): data[element] = data[element].tolist() @@ -41,13 +42,10 @@ def __container_converter(data: Union[list, dict]) -> dict: value = data[element] data[element] = np_convert[type(value)](value) elif isinstance(data[element], Hashable): - try: + with contextlib.suppress(Exception): data[element] = json.loads(data[element]) - except: - pass else: - message = 'Type not converted by dict converter: ' + str( - type(data[element])) + message = "Type not converted by dict converter: " + str(type(data[element])) sys.exit(message) return data @@ -90,7 +88,7 @@ def serialise_dict_values(d): d[index] = list(d[index]) # Class instances with the __dict__ attribute - if '__dict__' in dir(d[index]): + if "__dict__" in dir(d[index]): d[index] = vars(d[index]) # Recursively apply routine to other containers @@ -137,14 +135,13 @@ def delete_nested_key(dictionary: dict, key_chain: list): if len(key_chain) == 1: del dictionary[key_chain[0]] return - else: - delete_nested_key(dictionary[key_chain[0]], key_chain[1:]) + delete_nested_key(dictionary[key_chain[0]], key_chain[1:]) -def check_valid_keys(input_keys: Union[list, set, tuple, KeysView], - valid_keys: Union[list, set, tuple, KeysView], - name: Optional[str] = ''): - """ Check that a given set of input keys are valid. +def check_valid_keys( + input_keys: Union[list, set, tuple, KeysView], valid_keys: Union[list, set, tuple, KeysView], name: str = "" +): + """Check that a given set of input keys are valid. :param input_keys: Input keys :param valid_keys: Valid keys @@ -152,20 +149,4 @@ def check_valid_keys(input_keys: Union[list, set, tuple, KeysView], """ erroneous_inputs = set(input_keys) - set(valid_keys) if erroneous_inputs: - raise ValueError(f'{name} keys are not valid: {erroneous_inputs}') - - -def string_value_to_type(input: dict) -> dict: - """ Convert dictionary string values to appropriate types. - - :param input: Dictionary with string values. - :return: Dictionary with type-converted values. - """ - output = {} - for key, value in input.items(): - try: - output[key] = json.loads(value) - except json.decoder.JSONDecodeError: - # Most likely value that should not be converted, like 'some_string' - output[key] = value - return output + raise ValueError(f"{name} keys are not valid: {erroneous_inputs}") diff --git a/excitingtools/utils/jobflow_utils.py b/excitingtools/utils/jobflow_utils.py new file mode 100644 index 0000000..e63e322 --- /dev/null +++ b/excitingtools/utils/jobflow_utils.py @@ -0,0 +1,15 @@ +"""Utils for serialization for jobflow.""" + +import os + + +def special_serialization_attrs(instance) -> dict: + """Gives special keys + values for serialization. + Currently only supports jobflow via the env_var USE_JOBFLOW. + + :param instance: object you want to serialise + :returns: dictionary with the special keys + values + """ + if os.getenv("USE_JOBFLOW") is not None: + return {"@module": instance.__class__.__module__, "@class": instance.__class__.__name__} + return {} diff --git a/excitingtools/utils/schema_parsing.py b/excitingtools/utils/schema_parsing.py new file mode 100644 index 0000000..76c7c58 --- /dev/null +++ b/excitingtools/utils/schema_parsing.py @@ -0,0 +1,235 @@ +"""Parse the schema and generate a python file which can be read by the input classes. +Should only be run if changes to the schema are made. +""" + +import re +from pathlib import Path +from typing import List + +import xmlschema +from xmlschema.validators import XsdAnyElement + +from excitingtools.utils.utils import get_excitingtools_root + + +def copy_schema_files_for_parsing(schema_files: List[str]) -> List[Path]: + """Copies the schema files to the current directory. + + Also generates a file (input.xsd) with schema extensions, with modified include paths. + This is a work-around because the exciting documentation fails to compile + when a schema with the modified include is used. + + :param schema_files: the name of the schema files and tags of the xml element + :return file_list: A list of all generated or copied files. + """ + inputschemaextentions_name = "inputschemaextentions" + inputschemaextentions = f""" + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + inputschemaextentions_path = Path(f"{inputschemaextentions_name}.xsd") + inputschemaextentions_path.write_text(inputschemaextentions) + root = get_excitingtools_root() + + # handling of the input.xsd file + inputschema_file = root / "../../xml/schema/input.xsd" + content = inputschema_file.read_text().split("\n") + content[1] = ( + '' + ) + new_inputschema_file = Path("input.xsd") + new_inputschema_file.write_text("\n".join(content)) + + file_list = [inputschemaextentions_path, new_inputschema_file] + + # handling of all other included schema files + for schema_name in schema_files: + schema_root = root / "../../xml/schema" + schema_reference = schema_root / f"{schema_name}.xsd" + content = schema_reference.read_text().split("\n") + + content[1] = f' xmlns:ex="{inputschemaextentions_name}.xsd"' + content[3] = f' xsi:schemaLocation="{inputschemaextentions_name}.xsd {inputschemaextentions_name}.xsd">' + content = "\n".join(content) + + new_schema_file = Path(f"{schema_name}.xsd") + new_schema_file.write_text(content) + file_list.append(new_schema_file) + + return file_list + + +def read_schema_to_dict(name: str) -> dict: + """Read schema and transform to sensible dictionary. + + Note: This could be done with an external library, such as `xmltodict` or `xmljson` + but as this module should be run infrequently, a custom implementation reduces + dependencies. + + :param name: name of the schema file and tag of the xml element + :return: a dictionary with the need information about children/parents and valid attributes. + """ + schema = xmlschema.XMLSchema(f"{name}.xsd") + + tag_info = {} + xsd_elements = filter(lambda x: isinstance(x, xmlschema.XsdElement) and x.ref is None, schema.iter_components()) + + for xsd_element in xsd_elements: + attributes = xsd_element.attributes + mandatory_attributes = set([k for k, v in attributes.items() if v.use == "required"]) + children = [x.name for x in xsd_element.iterchildren() if not isinstance(x, XsdAnyElement)] + mandatory_children = set([x.name for x in xsd_element.iterchildren() if x.min_occurs > 0]) + multiple_childs = set([x.name for x in xsd_element.iterchildren() if x.max_occurs is None or x.max_occurs > 1]) + + tag_info[xsd_element.name] = { + "attribs": filter(lambda x: x is not None, attributes), + "children": children, + "mandatory_attribs": mandatory_attributes | mandatory_children, + "multiple_children": multiple_childs, + } + + # special handling for the plan + if xsd_element.name == "doonly": + tag_info["doonly"]["plan"] = attributes["task"].type.validators[0].enumeration + + # exclude special structure attributes (already explicitly specified in the __init__ of ExcitingStructure class) + if name == "structure": + tag_info["structure"]["mandatory_attribs"].remove("crystal") + tag_info["crystal"]["mandatory_attribs"].remove("basevect") + tag_info["species"]["mandatory_attribs"].remove("atom") + + return tag_info + + +def write_schema_info(super_tag: str, schema_dict: dict) -> str: + """Converts dict representation of the schema to string. + + :param super_tag: name of the top-level element + :param schema_dict: contains all the information read from the schema + :return info_string: string of python-formatted code + """ + info_string = f"\n# {super_tag} information \n" + for tag in schema_dict: + valid_attributes = sorted(schema_dict[tag]["attribs"]) + valid_subtrees = schema_dict[tag]["children"] + mandatory_attributes = sorted(schema_dict[tag]["mandatory_attribs"]) + multiple_childs = sorted(schema_dict[tag]["multiple_children"]) + + if not (valid_attributes or valid_subtrees or mandatory_attributes): + continue + + if valid_attributes: + info_string += list_string_line_limit(f"{tag}_valid_attributes", valid_attributes) + " \n" + if valid_subtrees: + info_string += list_string_line_limit(f"{tag}_valid_subtrees", valid_subtrees) + " \n" + if mandatory_attributes: + info_string += list_string_line_limit(f"{tag}_mandatory_attributes", mandatory_attributes) + " \n" + if multiple_childs: + info_string += list_string_line_limit(f"{tag}_multiple_children", multiple_childs) + " \n" + info_string += "\n" + return info_string + + +def list_string_line_limit(name: str, content: list, max_length: int = 120) -> str: + """Given a list of unique items, produces a python formatted string containing the definition of the list + given by the name: + name = ['entry1', 'entry2', ...] + Inserts a line break every time the string gets longer then the limit. + + :param name: the name of the set in the final string representing the definition + :param content: the list to write to string as a set (list because of consistent ordering) + :param max_length: the maximum line length + :return: the formatted string with fixed line length + """ + formatted_string = "" + current_line_string = name + " = [" + start_len = len(current_line_string) + entry_break_string = '", ' + + for entry in content: + if len(str(entry) + current_line_string + entry_break_string) > max_length: + formatted_string += current_line_string + "\n" + current_line_string = start_len * " " + current_line_string += f'"{entry}{entry_break_string}' + + formatted_string += current_line_string[:-2] + "]" + return formatted_string + + +def get_all_include_files() -> list: + """Gets a list of all included files in the input.xsd file.""" + input_schema_file = (get_excitingtools_root() / "../../xml/schema/input.xsd").resolve() + if not input_schema_file.exists(): + raise ValueError( + "Couldn't find exciting schema. Most likely you are using excitingtools outside of exciting." + "To fix this, try installing excitingtools from source in editable (-e) mode." + ) + return re.findall(r'', input_schema_file.read_text()) + + +def main(): + """Main function to read the schema and write it to python readable file.""" + filename = Path(__file__).parent / "valid_attributes.py" + info = ( + '""" Automatically generated file with the valid attributes from the schema. \n' + 'Do not manually change. Instead, run "utils/schema_parsing.py" to regenerate. """ \n' + ) + + schemas = get_all_include_files() + tmp_files = copy_schema_files_for_parsing(schemas) + + input_schema_dict = {"input": read_schema_to_dict("input")["input"]} + info += write_schema_info("input", input_schema_dict) + + for schema in schemas: + schema_dict = read_schema_to_dict(schema) + info += write_schema_info(schema, schema_dict) + + # Handle special case for 'xs' to handle the valid plan entries + xs_schema_dict = read_schema_to_dict("xs") + info += "\n# valid entries for the xs subtree 'plan'\n" + info += list_string_line_limit("valid_plan_entries", sorted(xs_schema_dict["doonly"]["plan"])) + " \n" + + with open(filename, "w") as fid: + fid.write(info) + + for file in tmp_files: + file.unlink() + + +if __name__ == "__main__": + main() diff --git a/excitingtools/utils/test_utils.py b/excitingtools/utils/test_utils.py index 819c808..d9d230c 100644 --- a/excitingtools/utils/test_utils.py +++ b/excitingtools/utils/test_utils.py @@ -1,10 +1,10 @@ -""" Classes and functions to aid unit testing. -""" +"""Classes and functions to aid unit testing.""" + import pathlib class MockFile: - """ Single class for testing parsers that require either: + """Single class for testing parsers that require either: * File. * String contents of file. @@ -15,6 +15,7 @@ def file_mock(tmp_path): file.write_text(string_contents) return MockFile(file, string_contents) """ + def __init__(self, file: pathlib.Path, string: str): # File object self.file = file diff --git a/excitingtools/utils/utils.py b/excitingtools/utils/utils.py index ed7c7bb..99d05b9 100644 --- a/excitingtools/utils/utils.py +++ b/excitingtools/utils/utils.py @@ -1,7 +1,13 @@ -"""General utility functions. Typically conversion/type-checking. -""" -from typing import Union, List, Optional, Callable +"""General utility functions. Typically, conversion/type-checking.""" + +import pathlib import re +from typing import Any, Callable, Iterable, Iterator, List, Optional, Union + + +def get_excitingtools_root() -> pathlib.Path: + """Get the root directory of excitingtools.""" + return pathlib.Path(__file__).parents[2] def can_be_float(value) -> bool: @@ -58,30 +64,30 @@ def get_new_line_indices(string: str) -> List[int]: new lines in string. """ indices = [0] - indices += [m.start() + 1 for m in re.finditer('\n', string)] + indices += [m.start() + 1 for m in re.finditer("\n", string)] return indices -def list_to_str(mylist: list, modifier: Optional[Callable] = None) -> str: - """ Convert a list to a string +def list_to_str(mylist: Iterable[Any], modifier: Optional[Callable] = None) -> str: + """Convert a list or iterable to a lower-case string. + + :param mylist: the input iterable + :param modifier: function which is additionally called on the stringified elements of the input iterable + :return: string representation in lower-case """ if modifier is None: - modifier = lambda x: x - return "".join(modifier(str(xyz)) + ' ' for xyz in mylist).strip() + return " ".join([str(x).lower() for x in mylist]) + return " ".join([modifier(str(x).lower()) for x in mylist]) -def string_to_bool(string: str) -> bool: - """ Convert string representation of true/false to True/False. +def flatten_list(input_list: list) -> Iterator: + """Flatten a list of lists and other elements. - :param string: String - :return bool + :param input_list: input list + :return: an iterator for the flattened list """ - if string.lower() == 'true': - return True - elif string.lower() == 'false': - return False - else: - raise ValueError() - - - + for x in input_list: + if isinstance(x, list): + yield from flatten_list(x) + else: + yield x diff --git a/excitingtools/utils/valid_attributes.py b/excitingtools/utils/valid_attributes.py new file mode 100644 index 0000000..166c0eb --- /dev/null +++ b/excitingtools/utils/valid_attributes.py @@ -0,0 +1,444 @@ +""" Automatically generated file with the valid attributes from the schema. +Do not manually change. Instead, run "utils/schema_parsing.py" to regenerate. """ + +# input information +input_valid_attributes = ["sharedfs", "xsltpath"] +input_valid_subtrees = ["title", "structure", "groundstate", "relax", "properties", "phonons", "xs", "gw", "MD", "eph", + "keywords"] +input_mandatory_attributes = ["groundstate", "structure", "title"] + + +# common information +origin_valid_attributes = ["coord"] + +point_valid_attributes = ["breakafter", "coord", "label"] +point_mandatory_attributes = ["coord"] + +plot1d_valid_subtrees = ["path"] +plot1d_mandatory_attributes = ["path"] + +path_valid_attributes = ["outfileprefix", "steps"] +path_valid_subtrees = ["point"] +path_mandatory_attributes = ["point", "steps"] +path_multiple_children = ["point"] + +plot2d_valid_subtrees = ["parallelogram"] +plot2d_mandatory_attributes = ["parallelogram"] + +parallelogram_valid_attributes = ["grid", "outfileprefix"] +parallelogram_valid_subtrees = ["origin", "point"] +parallelogram_mandatory_attributes = ["grid", "origin", "point"] +parallelogram_multiple_children = ["point"] + +plot3d_valid_attributes = ["usesym"] +plot3d_valid_subtrees = ["box"] +plot3d_mandatory_attributes = ["box"] + +box_valid_attributes = ["grid", "outfileprefix"] +box_valid_subtrees = ["origin", "point"] +box_mandatory_attributes = ["grid", "origin", "point"] +box_multiple_children = ["point"] + +kstlist_valid_subtrees = ["pointstatepair"] +kstlist_mandatory_attributes = ["pointstatepair"] +kstlist_multiple_children = ["pointstatepair"] + +energywindow_valid_attributes = ["intv", "points"] + +qpointset_valid_subtrees = ["qpoint"] +qpointset_mandatory_attributes = ["qpoint"] +qpointset_multiple_children = ["qpoint"] + +parts_valid_subtrees = ["dopart"] +parts_multiple_children = ["dopart"] + +dopart_valid_attributes = ["id"] +dopart_mandatory_attributes = ["id"] + +qpoints_valid_attributes = ["qf", "qi"] + + +# structure information +structure_valid_attributes = ["autormt", "autormtscaling", "cartesian", "epslat", "primcell", "speciespath", "tshift"] +structure_valid_subtrees = ["crystal", "species", "symmetries"] +structure_mandatory_attributes = ["speciespath"] +structure_multiple_children = ["species"] + +crystal_valid_attributes = ["scale", "stretch"] +crystal_valid_subtrees = ["basevect"] +crystal_multiple_children = ["basevect"] + +species_valid_attributes = ["atomicNumber", "chemicalSymbol", "fixrmt", "rmt", "speciesfile"] +species_valid_subtrees = ["atom", "LDAplusU", "dfthalfparam"] +species_mandatory_attributes = ["speciesfile"] +species_multiple_children = ["atom"] + +atom_valid_attributes = ["bfcmt", "coord", "lockxyz", "mommtfix", "velocity"] +atom_mandatory_attributes = ["coord"] + +LDAplusU_valid_attributes = ["J", "U", "l"] + +dfthalfparam_valid_attributes = ["ampl", "cut", "exponent"] +dfthalfparam_valid_subtrees = ["shell"] +dfthalfparam_mandatory_attributes = ["shell"] +dfthalfparam_multiple_children = ["shell"] + +shell_valid_attributes = ["ionization", "number"] + + +# groundstate information +groundstate_valid_attributes = ["APWprecision", "CoreRelativity", "ExplicitKineticEnergy", "PrelimLinSteps", + "ValenceRelativity", "autokpt", "beta0", "betadec", "betainc", "cfdamp", "chgexs", + "deband", "dipolecorrection", "dipoleposition", "dlinengyfermi", "do", "energyref", + "epsband", "epschg", "epsengy", "epsforcescf", "epsocc", "epspot", "fermilinengy", + "findlinentype", "fracinr", "frozencore", "gmaxvr", "isgkmax", "ldapu", "lmaxapw", + "lmaxinr", "lmaxmat", "lmaxvr", "lradstep", "maxscl", "mixer", "mixerswitch", + "modifiedsv", "msecStoredSteps", "nempty", "ngridk", "niterconvcheck", "nktot", + "nosource", "nosym", "nprad", "npsden", "nwrite", "outputlevel", "ptnucl", + "radialgridtype", "radkpt", "reducek", "rgkmax", "scfconv", "stype", "swidth", + "symmorph", "tevecsv", "tfibs", "tforce", "tpartcharges", "useAPWprecision", + "useDensityMatrix", "vdWcorrection", "vkloff", "xctype"] +groundstate_valid_subtrees = ["DFTD2parameters", "TSvdWparameters", "spin", "HartreeFock", "dfthalf", "Hybrid", + "sirius", "solver", "OEP", "RDMFT", "output", "libxc", "xsLO", "lorecommendation"] + +DFTD2parameters_valid_attributes = ["cutoff", "d", "s6", "sr6"] + +TSvdWparameters_valid_attributes = ["cutoff", "d", "nr", "nsph", "s6", "sr6"] + +spin_valid_attributes = ["bfieldc", "fixspin", "momfix", "nosv", "realspace", "reducebf", "spinorb", "spinsprl", "svlo", + "taufsm", "vqlss"] + +dfthalf_valid_attributes = ["printVSfile"] + +Hybrid_valid_attributes = ["HSEsingularity", "eccoeff", "epsmb", "exchangetype", "excoeff", "gmb", "lmaxmb", "maxscl", + "mblksiz", "omega", "updateRadial"] + +sirius_valid_attributes = ["cfun", "density", "densityinit", "eigenstates", "sfacg", "vha", "xc"] + +solver_valid_attributes = ["constructHS", "evaltol", "minenergy", "packedmatrixstorage", "type"] + +OEP_valid_attributes = ["convoep", "maxitoep", "tauoep"] + +RDMFT_valid_attributes = ["maxitc", "maxitn", "rdmalpha", "rdmmaxscl", "rdmtemp", "rdmxctype", "taurdmc", "taurdmn"] + +output_valid_attributes = ["state"] + +libxc_valid_attributes = ["correlation", "exchange", "xc"] + +xsLO_valid_attributes = ["emax", "lmax", "maxnodes"] + +lorecommendation_valid_attributes = ["lmaxlo", "nodesmaxlo"] + + +# relax information +relax_valid_attributes = ["addtohistory", "endbfgs", "epsforce", "history", "historyformat", "maxbfgs", "maxsteps", + "method", "outputlevel", "printtorque", "taubfgs", "taunewton"] + + +# phonons information +phonons_valid_attributes = ["canonical", "delete_eigensystem_response", "deltaph", "do", "drynumprocs", "gamma", + "maxprocsperpart", "method", "minprocsperpart", "ngridq", "polar", "reduceq", "sumrule", + "write_schedule"] +phonons_valid_subtrees = ["qpointset", "phonondos", "phonondispplot", "reformatdynmat", "interpolate", "parts"] + +phonondos_valid_attributes = ["inttype", "ngrdos", "ngridqint", "nsmdos", "ntemp", "nwdos"] + +phonondispplot_valid_subtrees = ["plot1d"] +phonondispplot_mandatory_attributes = ["plot1d"] + +interpolate_valid_attributes = ["ngridq", "vqloff", "writeeigenvectors"] +interpolate_mandatory_attributes = ["ngridq"] + + +# properties information +properties_valid_subtrees = ["spintext", "coreoverlap", "bandstructure", "stm", "wfplot", "dos", "LSJ", "masstensor", + "chargedensityplot", "TSvdW", "DFTD2", "exccplot", "elfplot", "mvecfield", "xcmvecfield", + "electricfield", "gradmvecfield", "fermisurfaceplot", "EFG", "mossbauer", "expiqr", + "elnes", "eliashberg", "momentummatrix", "dielmat", "boltzequ", "raman", "moke", "shg", + "wannier", "wannierplot", "wanniergap", "ldos", "polarization"] + +spintext_valid_attributes = ["bands"] +spintext_valid_subtrees = ["plot2d"] +spintext_mandatory_attributes = ["plot2d"] + +coreoverlap_valid_attributes = ["coreatom", "corespecies"] + +bandstructure_valid_attributes = ["character", "deriv", "scissor", "wannier"] +bandstructure_valid_subtrees = ["plot1d"] +bandstructure_mandatory_attributes = ["plot1d"] + +stm_valid_attributes = ["bias", "stmmode", "stmtype"] +stm_valid_subtrees = ["plot2d", "region"] + +region_valid_attributes = ["grid2d", "grid3d", "height", "zrange"] + +wfplot_valid_attributes = ["version"] +wfplot_valid_subtrees = ["kstlist", "plot1d", "plot2d", "plot3d"] +wfplot_mandatory_attributes = ["kstlist"] + +dos_valid_attributes = ["inttype", "jdos", "linkpt", "lmirep", "lonly", "ngrdos", "ngridkint", "nsmdos", "nwdos", + "scissor", "sqados", "wannier", "winddos"] + +LSJ_valid_subtrees = ["kstlist"] + +masstensor_valid_attributes = ["deltaem", "ndspem", "vklem"] + +chargedensityplot_valid_attributes = ["nocore"] +chargedensityplot_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +exccplot_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +elfplot_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +mvecfield_valid_subtrees = ["plot2d", "plot3d"] + +xcmvecfield_valid_subtrees = ["plot2d", "plot3d"] + +electricfield_valid_subtrees = ["plot2d", "plot3d"] + +gradmvecfield_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +fermisurfaceplot_valid_attributes = ["nstfsp"] +fermisurfaceplot_valid_subtrees = ["plot2d", "plot3d"] + +expiqr_valid_subtrees = ["kstlist"] + +elnes_valid_attributes = ["ngrid", "vecql", "wgrid", "wmax", "wmin"] + +eliashberg_valid_attributes = ["mustar"] + +momentummatrix_valid_attributes = ["fastpmat"] + +dielmat_valid_attributes = ["drude", "intraband", "scissor", "swidth", "tevout", "wgrid", "wmax"] +dielmat_valid_subtrees = ["epscomp"] +dielmat_multiple_children = ["epscomp"] + +boltzequ_valid_attributes = ["chemicalPotentialRange", "chemicalPotentialSpacing", "dopingConcentration", + "energyReference", "evOutputEnergies", "siOutputUnits", "temperatureRange", + "temperatureSpacing", "transportDfBroadening", "transportDfRange", "transportDfSpacing", + "useDopingConcentration", "useTransportDf"] +boltzequ_valid_subtrees = ["etCoeffComponents"] +boltzequ_multiple_children = ["etCoeffComponents"] + +raman_valid_attributes = ["broad", "degree", "displ", "doequilibrium", "elaser", "elaserunit", "getphonon", "mode", + "molecule", "ninter", "nstate", "nstep", "temp", "useforces", "usesym", "writefunc", "xmax", + "xmin"] +raman_valid_subtrees = ["eigvec", "energywindow"] +raman_mandatory_attributes = ["energywindow"] +raman_multiple_children = ["eigvec"] + +eigvec_valid_attributes = ["comp"] +eigvec_mandatory_attributes = ["comp"] + +moke_valid_attributes = ["drude", "intraband", "scissor", "swidth", "tevout", "wgrid", "wmax"] + +shg_valid_attributes = ["etol", "scissor", "swidth", "tevout", "wgrid", "wmax"] +shg_valid_subtrees = ["chicomp"] +shg_mandatory_attributes = ["chicomp"] +shg_multiple_children = ["chicomp"] + +wannier_valid_attributes = ["cutshell", "do", "fermizero", "input", "mindist", "minshell", "nbzshell", "printproj"] +wannier_valid_subtrees = ["projection", "group"] +wannier_multiple_children = ["group"] + +projection_valid_attributes = ["dordmax", "epsld", "nprojtot", "nunocc"] + +group_valid_attributes = ["epsdis", "epsmax", "epsopf", "epsproj", "fst", "innerwindow", "lst", "maxitdis", "maxitmax", + "maxitopf", "memlendis", "memlenmax", "memlenopf", "method", "minitdis", "minitmax", + "minitopf", "minstepdis", "minstepmax", "minstepopf", "neighcells", "nproj", "nwf", "nwrite", + "optim", "outerwindow", "writeconv"] +group_valid_subtrees = ["projector"] +group_multiple_children = ["projector"] + +projector_valid_attributes = ["nr"] +projector_mandatory_attributes = ["nr"] + +wannierplot_valid_attributes = ["cell", "fst", "lst"] +wannierplot_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +wanniergap_valid_attributes = ["auto", "ngridkint"] +wanniergap_valid_subtrees = ["pointband"] +wanniergap_multiple_children = ["pointband"] + +pointband_valid_attributes = ["band", "extremal", "vkl"] +pointband_mandatory_attributes = ["band", "vkl"] + +ldos_valid_attributes = ["delta", "grid", "newint", "ngrdos", "nsmdos", "nwdos", "scissor", "tol", "winddos"] + + +# xs information +xs_valid_attributes = ["bfieldc", "broad", "dbglev", "dfoffdiag", "dogroundstate", "emattype", "emaxdf", "epsdfde", + "fastpmat", "gqmax", "gqmaxtype", "h5fname", "h5gname", "lmaxapwwf", "lmaxemat", "maxscl", + "nempty", "ngridk", "ngridq", "nosym", "pwmat", "reducek", "reduceq", "rgkmax", "scissor", + "skipgnd", "swidth", "tappinfo", "tevout", "vkloff", "writexsgrids", "xstype"] +xs_valid_subtrees = ["storeexcitons", "pwelements", "writeexcitons", "writekpathweights", "excitonPlot", + "realTimeTDDFT", "tddft", "screening", "phonon_screening", "expand_eps", "BSE", "fastBSE", + "transitions", "qpointset", "tetra", "energywindow", "plan"] +xs_mandatory_attributes = ["xstype"] + +storeexcitons_valid_attributes = ["MaxEnergyExcitons", "MaxNumberExcitons", "MinEnergyExcitons", "MinNumberExcitons", + "selectenergy", "useev"] + +pwelements_valid_attributes = ["band_combinations"] +pwelements_mandatory_attributes = ["band_combinations"] + +writeexcitons_valid_attributes = ["MaxEnergyExcitons", "MaxNumberExcitons", "MinEnergyExcitons", "MinNumberExcitons", + "abscutares", "abscutres", "selectenergy", "useev"] + +writekpathweights_valid_attributes = ["MaxEnergyExcitons", "MaxNumberExcitons", "MinEnergyExcitons", + "MinNumberExcitons", "intorder", "printgridweights", "selectenergy", "useev"] + +excitonPlot_valid_attributes = ["epstol"] +excitonPlot_valid_subtrees = ["exciton", "hole", "electron"] +excitonPlot_mandatory_attributes = ["electron", "hole"] +excitonPlot_multiple_children = ["exciton"] + +exciton_valid_attributes = ["fix", "lambda"] + +hole_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +electron_valid_subtrees = ["plot1d", "plot2d", "plot3d"] + +realTimeTDDFT_valid_attributes = ["TaylorOrder", "calculateNExcitedElectrons", "calculateTotalEnergy", "endTime", + "normalizeWF", "printAfterIterations", "printTimingDetailed", "printTimingGeneral", + "propagator", "subtractJ0", "timeStep", "vectorPotentialSolver"] +realTimeTDDFT_valid_subtrees = ["predictorCorrector", "screenshots", "laser", "pmat"] +realTimeTDDFT_mandatory_attributes = ["pmat"] + +predictorCorrector_valid_attributes = ["maxIterations", "tol"] + +screenshots_valid_attributes = ["niter"] +screenshots_valid_subtrees = ["eigenvalues", "projectionCoefficients", "occupations"] + +eigenvalues_valid_attributes = ["nEigenvalues", "tolerance"] + +projectionCoefficients_valid_attributes = ["format", "printAbsoluteValue"] + +occupations_valid_attributes = ["format"] + +laser_valid_attributes = ["fieldType"] +laser_valid_subtrees = ["kick", "trapCos", "sinSq"] +laser_multiple_children = ["kick", "sinSq", "trapCos"] + +kick_valid_attributes = ["amplitude", "direction", "t0", "width"] + +trapCos_valid_attributes = ["amplitude", "direction", "omega", "phase", "riseTime", "t0", "width"] + +sinSq_valid_attributes = ["amplitude", "direction", "omega", "phase", "pulseLength", "t0"] + +pmat_valid_attributes = ["forceHermitian", "readFromFile", "writeToFile"] + +tddft_valid_attributes = ["acont", "ahc", "alphalrc", "alphalrcdyn", "aresdf", "aresfxc", "betalrcdyn", "do", "drude", + "fxcbsesplit", "fxctype", "intraband", "kerndiag", "lindhard", "lmaxalda", "mdfqtype", + "nwacont", "torddf", "tordfxc"] + +screening_valid_attributes = ["do", "intraband", "nempty", "ngridk", "ngridq_interpolation", "nosym", "nqpt_unique", + "qpointsgamma", "quasiparticle_correction", "reducek", "rgkmax", "screentype", "tr", + "vkloff"] + +phonon_screening_valid_attributes = ["alat_qe", "excitation_energy", "file_type", "phonon_file", "zstar_file"] +phonon_screening_mandatory_attributes = ["alat_qe", "excitation_energy", "phonon_file", "zstar_file"] + +expand_eps_valid_attributes = ["supercell_1", "supercell_2"] + +BSE_valid_attributes = ["aresbse", "blocks", "brixshdf5", "bsedirsing", "bsetype", "checkposdef", "chibar0", + "chibar0comp", "chibarq", "coupling", "cuttype", "dichroism", "distribute", "econv", "eecs", + "efind", "fbzq", "iqmtrange", "lmaxdielt", "measure", "nexc", "ngridksub", "nleblaik", "nosym", + "nosymspec", "nstlbse", "nstlxas", "outputlevel", "reducek", "rgkmax", "sciavbd", "sciavqbd", + "sciavqhd", "sciavqwg", "sciavtype", "scrherm", "solver", "vkloff", "xas", "xasatom", "xasedge", + "xasspecies", "xes"] + +fastBSE_valid_attributes = ["clanczos", "cvtsteplim", "cvttol", "ngridr", "nisdf", "nlanczos", "saveQ", "seed"] + +transitions_valid_subtrees = ["individual", "ranges", "lists"] + +individual_valid_subtrees = ["trans"] +individual_multiple_children = ["trans"] + +trans_valid_attributes = ["action", "final", "initial", "kpointnumber"] + +ranges_valid_subtrees = ["range"] +ranges_multiple_children = ["range"] + +range_valid_attributes = ["action", "kpointnumber", "start", "statestype", "stop"] +range_mandatory_attributes = ["statestype"] + +lists_valid_subtrees = ["istate"] +lists_multiple_children = ["istate"] + +istate_valid_attributes = ["action", "kpointnumber", "state", "statestype"] +istate_mandatory_attributes = ["statestype"] + +tetra_valid_attributes = ["cw1k", "kordexc", "qweights", "tetradf", "tetraocc"] + +plan_valid_subtrees = ["doonly"] +plan_multiple_children = ["doonly"] + +doonly_valid_attributes = ["task"] +doonly_mandatory_attributes = ["task"] + + +# gw information +gw_valid_attributes = ["at1", "at2", "coreflag", "debug", "eph", "ibgw", "ibmax", "ibmax2", "ibmin", "ibmin2", "igmax", + "igmin", "iik", "jjk", "mblksiz", "nbgw", "nempty", "ngridq", "printSelfC", + "printSpectralFunction", "qdepw", "reduceq", "rmax", "rpath", "rpmat", "skipgnd", "taskname", + "vqloff", "wlo", "wto"] +gw_valid_subtrees = ["plot1d", "freqgrid", "selfenergy", "mixbasis", "barecoul", "scrcoul", "taskGroup"] + +freqgrid_valid_attributes = ["eta", "fconv", "fgrid", "freqmax", "freqmin", "nomeg"] + +selfenergy_valid_attributes = ["actype", "eqpsolver", "eshift", "method", "nempty", "singularity", "swidth", "tol"] +selfenergy_valid_subtrees = ["wgrid"] + +wgrid_valid_attributes = ["size", "type", "wmax", "wmin"] + +mixbasis_valid_attributes = ["epsmb", "gmb", "lmaxmb"] + +barecoul_valid_attributes = ["barcevtol", "basis", "cutofftype", "pwm", "stctol"] + +scrcoul_valid_attributes = ["averaging", "omegap", "q0eps", "scrtype"] + +taskGroup_valid_attributes = ["outputFormat"] +taskGroup_valid_subtrees = ["Coulomb", "epsilon", "invertEpsilon"] + +Coulomb_valid_subtrees = ["qpoints"] +Coulomb_mandatory_attributes = ["qpoints"] +Coulomb_multiple_children = ["qpoints"] + +epsilon_valid_subtrees = ["qpoints"] +epsilon_mandatory_attributes = ["qpoints"] +epsilon_multiple_children = ["qpoints"] + +invertEpsilon_valid_subtrees = ["qpoints"] +invertEpsilon_mandatory_attributes = ["qpoints"] +invertEpsilon_multiple_children = ["qpoints"] + + +# MD information +MD_valid_attributes = ["basisDerivative", "coreCorrections", "integrationAlgorithm", "printAllForces", "timeStep", + "type", "updateOverlap", "updatePmat", "valenceCorrections"] + + +# eph information +eph_valid_attributes = ["debugeph", "ibeph", "ibsumeph", "nbeph", "nbsumeph", "nemptyeph", "ngridqeph", "tasknameeph", + "vqloffeph"] +eph_valid_subtrees = ["freqgrideph", "selfenergyeph"] + +freqgrideph_valid_attributes = ["freqmaxeph", "nomegeph"] + +selfenergyeph_valid_subtrees = ["SpectralFunctionPloteph"] + +SpectralFunctionPloteph_valid_attributes = ["axis", "eta", "nwgrid", "wmax", "wmin"] + + +# valid entries for the xs subtree 'plan' +valid_plan_entries = ["bse", "bsegenspec", "bsesurvey", "df", "df2", "dielectric", "emattest", "exccoulint", + "excitonWavefunction", "expand_add_eps", "fastBSE_groundstate_properties", + "fastBSE_human_readable_output", "fastBSE_isdf_cvt", "fastBSE_main", "fxc_alda_check", "idf", + "kernxc_bse", "kernxc_bse3", "phonon_screening", "planewave_elements", "pmatxs2orig", + "portstate(-1)", "portstate(-2)", "portstate(1)", "portstate(2)", "scrcoulint", "screen", + "scrgeneigvec", "scrtetcalccw", "scrwritepmat", "testmain", "testxs", "tetcalccw", + "write_dielectric_matrix", "write_pmat_hdf5_xs", "write_screen", "write_screened_coulomb", + "writebandgapgrid", "writebevec", "writeemat", "writeematasc", "writekpathweights", + "writeoverlapxs", "writepmat", "writepmatasc", "writepmatxs", "writepwmat", "x0toasc", "x0tobin", + "xsestimate", "xsgeneigvec"] diff --git a/pyproject.toml b/pyproject.toml index 4ff5470..c5e9d7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,29 @@ [project] name = "excitingtools" description = "Utilities for aiding in the construction of exciting inputs and the postprocessing exciting outputs." -version = "1.1.0" +version = "1.7.1" authors = [ - { name = "Alexander Buccheri", email = "alexander.buccheri@mpsd.mpg.de"}, - { name = "Fabian Peschel", email = "peschelf@physik.hu-berlin.de"} + {name = "Alexander Buccheri", email = "alexander.buccheri@mpsd.mpg.de"}, + {name = "Fabian Peschel", email = "peschelf@physik.hu-berlin.de"} ] -license = {file = "COPYING.md"} +license = {text = "GNU GENERAL PUBLIC LICENSE, see 'COPYING.md'"} readme = "README.md" -requires-python = ">=3.6" +requires-python = ">=3.7" dependencies = [ - 'wheel>=0.35.0', 'numpy>=1.14.5', - 'matplotlib>=2.2.0', + 'matplotlib>=2.2.0' ] [project.optional-dependencies] dev = [ "pytest", - "ase>=3.20.0" + "ase>=3.20.0", + "ruff", ] +schemaparsing = ["xmlschema"] +h5_parsing = ["h5py"] + [project.urls] repository = "https://github.com/exciting/excitingtools" @@ -28,12 +31,52 @@ repository = "https://github.com/exciting/excitingtools" [build-system] requires = [ "setuptools >= 35.0.2", - "setuptools_scm >= 3.5.0" + "setuptools_scm >= 2.0.0, <3" ] build-backend = "setuptools.build_meta" -[tool.distutils.bdist_wheel] -universal = true - [tool.setuptools] packages = ["excitingtools"] + +[tool.ruff] +line-length = 120 +extend-exclude = ["build/*", "valid_attributes.py"] + +[tool.ruff.format] +skip-magic-trailing-comma = true + +[tool.ruff.lint] +ignore = [ + "E741", # ambigous variable name like 'l' + "UP006", # py3.7 doesn't support type hinting with built-in 'list' + "UP007", # py3.7 doesn't support type hinting with 'X | Y' + "COM812", # problems with linter + "ISC001", # problems with linter, see https://github.com/astral-sh/ruff/issues/8272 + "RET504", # Unnecessary assignment + "SIM115", # context handler not used for opening files + "PLR2004", # magic values, maybe reintroduce at some point +] +extend-select = [ + "I", # sort imports + "UP", # pyupgrade + "COM", # trailing commas + "ISC", # implicit string concats + "PIE", # flake8-pie + "T20", # no print statements + "Q", # Quotes + "RET", # return statements + "SIM", # flake8 simplify + "ARG", # unused arguments + "ERA", # commented-out code + "NPY", # find deprecated numpy code + "PERF", # perflint + "PL", # pylint +] + +[tool.ruff.lint.isort] +split-on-trailing-comma = false + +[tool.ruff.lint.pylint] +max-args = 7 +max-branches = 14 +max-statements = 60 diff --git a/requirements.txt b/requirements.txt index 6cdf7cd..09bbcda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ numpy matplotlib -pytest \ No newline at end of file +pytest +xmlschema +h5py \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0872e88 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +""" +File containing pytest fixtures. They can be seen from all other files and subdirectories. +That's because of the special name of the file. +Needed for testing environment variables. +""" + +import pytest + + +@pytest.fixture +def mock_env_jobflow(monkeypatch): + monkeypatch.setenv("USE_JOBFLOW", "true") + + +@pytest.fixture +def mock_env_jobflow_missing(monkeypatch): + monkeypatch.delenv("USE_JOBFLOW", raising=False) diff --git a/tests/dataclasses/test_band_structure.py b/tests/dataclasses/test_band_structure.py index 5ccc411..239da47 100644 --- a/tests/dataclasses/test_band_structure.py +++ b/tests/dataclasses/test_band_structure.py @@ -1,47 +1,59 @@ -from excitingtools.dataclasses.band_structure import BandData -import pytest import numpy as np +import pytest + +from excitingtools.dataclasses.band_structure import BandData @pytest.fixture def band_data(): - """ Initialise an instance of BandData, and check attributes are set correctly. + """Initialise an instance of BandData, and check attributes are set correctly. Reference data taken from 'bandstructure.xml' and 'bandstructure.dat' files for silver, containing only two bands and only 6 k-sampling points per band. """ - ref_bands = np.array([[-3.37071333, 0.59519479], - [-3.37071074, 0.59537998], - [-3.37070519, 0.59575556], - [-3.3706986, 0.59616303], - [-3.3706822, 0.59664939], - [-3.37066123, 0.59639211]]) - - ref_k_points = np.array([[1.000000, 0.000000, 0.000000], - [0.988281, 0.011719, 0.000000], - [0.976562, 0.023438, 0.000000], - [0.964844, 0.035156, 0.000000], - [0.953125, 0.046875, 0.000000], - [0.941406, 0.058594, 0.000000]]) + ref_bands = np.array( + [ + [-3.37071333, 0.59519479], + [-3.37071074, 0.59537998], + [-3.37070519, 0.59575556], + [-3.3706986, 0.59616303], + [-3.3706822, 0.59664939], + [-3.37066123, 0.59639211], + ] + ) + + ref_k_points = np.array( + [ + [1.000000, 0.000000, 0.000000], + [0.988281, 0.011719, 0.000000], + [0.976562, 0.023438, 0.000000], + [0.964844, 0.035156, 0.000000], + [0.953125, 0.046875, 0.000000], + [0.941406, 0.058594, 0.000000], + ] + ) ref_e_fermi = 0.0 - ref_flattened_k_points = np.array([0., 0.02697635, 0.05395270, 0.08092905, 0.10790540, 0.13488176]) + ref_flattened_k_points = np.array([0.0, 0.02697635, 0.05395270, 0.08092905, 0.10790540, 0.13488176]) - ref_vertices = [{'distance': 0.0, 'label': 'Gamma', 'coord': [0.0, 0.0, 0.0]}, - {'distance': 0.8632432750, 'label': 'K', 'coord': [0.625, 0.375, 0.0]}, - {'distance': 1.150991033, 'label': 'X', 'coord': [0.5, 0.5, 0.0]}, - {'distance': 1.964864598, 'label': 'G', 'coord': [0.0, 0.0, 0.0]}, - {'distance': 2.669699781, 'label': 'L', 'coord': [0.5, 0.0, 0.0]}] + ref_vertices = [ + {"distance": 0.0, "label": "Gamma", "coord": [0.0, 0.0, 0.0]}, + {"distance": 0.8632432750, "label": "K", "coord": [0.625, 0.375, 0.0]}, + {"distance": 1.150991033, "label": "X", "coord": [0.5, 0.5, 0.0]}, + {"distance": 1.964864598, "label": "G", "coord": [0.0, 0.0, 0.0]}, + {"distance": 2.669699781, "label": "L", "coord": [0.5, 0.0, 0.0]}, + ] band_data = BandData(ref_bands, ref_k_points, ref_e_fermi, ref_flattened_k_points, ref_vertices) - assert band_data.n_k_points == band_data.bands.shape[0], ( - "First dim of bands array equals the number of k-sampling points in the band structure") + assert ( + band_data.n_k_points == band_data.bands.shape[0] + ), "First dim of bands array equals the number of k-sampling points in the band structure" assert band_data.n_k_points == 6, "sampling points per band" assert band_data.n_bands == 2, "band_structure_xml contains two bands" - assert np.allclose(band_data.k_points, ref_k_points, atol=1.e-8) - assert np.allclose(band_data.bands, ref_bands, atol=1.e-8) + assert np.allclose(band_data.k_points, ref_k_points, atol=1.0e-8) + assert np.allclose(band_data.bands, ref_bands, atol=1.0e-8) assert band_data.vertices == ref_vertices return band_data @@ -49,7 +61,7 @@ def band_data(): def test_xticks_and_labels(band_data): flattened_high_sym_points_ref = [0.000000000, 0.86324327, 1.15099103, 1.9648646, 2.66969978] - unicode_gamma = '\u0393' + unicode_gamma = "\u0393" labels_ref = [unicode_gamma, "K", "X", unicode_gamma, "L"] flattened_high_sym_points, labels = band_data.band_path() diff --git a/tests/dataclasses/test_eigenvalues.py b/tests/dataclasses/test_eigenvalues.py index fac178b..3259dc5 100644 --- a/tests/dataclasses/test_eigenvalues.py +++ b/tests/dataclasses/test_eigenvalues.py @@ -1,14 +1,15 @@ +from typing import List + import numpy as np import pytest -from typing import List -from excitingtools.dataclasses.data_structs import NumberOfStates, PointIndex, BandIndices +from excitingtools.dataclasses.data_structs import BandIndices, NumberOfStates, PointIndex from excitingtools.dataclasses.eigenvalues import EigenValues @pytest.fixture def eigenvalues_instance(): - """ Initialise an instance of EigenValues, and check attributes are set correctly. + """Initialise an instance of EigenValues, and check attributes are set correctly. Reference data taken from `evalqp_oxygen` in test_gw_eigenvalues.py G0W0 band structure @@ -22,17 +23,25 @@ def eigenvalues_instance(): Direct Bandgap at k(VBM) (eV): 5.5451 Direct Bandgap at k(CBm) (eV): 5.9468 """ - ref_gw_eigenvalues = np.array([[-0.12905, -0.12891, -0.12896, 0.08958, 0.08957, 0.18396], - [-0.19801, -0.17047, -0.17035, 0.11429, 0.11430, 0.19514], - [-0.15828, -0.15818, -0.10809, 0.09569, 0.14613, 0.18404]]) - - ref_k_points = np.array([[0.000000, 0.000000, 0.000000], - [0.000000, 0.000000, 0.500000], - [0.000000, 0.500000, 0.500000]]) + ref_gw_eigenvalues = np.array( + [ + [-0.12905, -0.12891, -0.12896, 0.08958, 0.08957, 0.18396], + [-0.19801, -0.17047, -0.17035, 0.11429, 0.11430, 0.19514], + [-0.15828, -0.15818, -0.10809, 0.09569, 0.14613, 0.18404], + ] + ) + + ref_k_points = np.array( + [[0.000000, 0.000000, 0.000000], [0.000000, 0.000000, 0.500000], [0.000000, 0.500000, 0.500000]] + ) ref_weights = [0.125, 0.5, 0.375000] - eigen_values = EigenValues(NumberOfStates(19, 24), ref_k_points, [1, 2, 3], ref_gw_eigenvalues, ref_weights) + ref_occupations = np.array([[2, 2, 2, 0, 0, 0], [2, 2, 2, 0, 0, 0], [2, 2, 2, 0, 0, 0]]) + + eigen_values = EigenValues( + NumberOfStates(19, 24), ref_k_points, [1, 2, 3], ref_gw_eigenvalues, ref_weights, ref_occupations + ) assert ref_gw_eigenvalues.shape == (len(eigen_values.k_points), eigen_values.state_range.n_states) assert eigen_values.state_range.first_state == 19 @@ -41,6 +50,7 @@ def eigenvalues_instance(): assert eigen_values.k_indices == [1, 2, 3] assert np.allclose(eigen_values.all_eigenvalues, ref_gw_eigenvalues), "GW column eigenvalues, for all k-points" assert eigen_values.weights == ref_weights + assert np.allclose(eigen_values.occupations, ref_occupations) return eigen_values @@ -75,11 +85,9 @@ def test_class_eigenvalues_get_k_points(eigenvalues_instance): def test_class_eigenvalues_get_eigenvalues(eigenvalues_instance): eigenvalues = eigenvalues_instance.get_eigenvalues(k_point=[0.0, 0.5, 0.5]) - assert np.allclose(eigenvalues, - [-0.15828, -0.15818, -0.10809, 0.09569, 0.14613, 0.18404]) + assert np.allclose(eigenvalues, [-0.15828, -0.15818, -0.10809, 0.09569, 0.14613, 0.18404]) - assert eigenvalues_instance.get_eigenvalues(k_point=[0.5, 0.5, 0.5]).size == 0, \ - "No k-point, hence eigenvalues" + assert eigenvalues_instance.get_eigenvalues(k_point=[0.5, 0.5, 0.5]).size == 0, "No k-point, hence eigenvalues" def test_class_eigenvalues_band_gap(eigenvalues_instance): @@ -89,5 +97,22 @@ def test_class_eigenvalues_band_gap(eigenvalues_instance): indirect_band_gap = eigenvalues_instance.band_gap(BandIndices(21, 22), k_points=[k_valence, k_conduction]) direct_band_gap_at_Gamma = eigenvalues_instance.band_gap(BandIndices(21, 22), k_points=[k_conduction, k_conduction]) - assert np.isclose(indirect_band_gap, 0.19767), 'Indirect band gap in Ha' - assert np.isclose(direct_band_gap_at_Gamma, 0.218540887), 'Direct band gap at Gamma, in Ha' + assert np.isclose(indirect_band_gap, 0.19767), "Indirect band gap in Ha" + assert np.isclose(direct_band_gap_at_Gamma, 0.218540887), "Direct band gap at Gamma, in Ha" + + +def test_class_eigenvalues_get_transition_energy(eigenvalues_instance): + k_valence = [0.000, 0.500, 0.500] + k_conduction = [0.000, 0.000, 0.000] + + indirect_gap = eigenvalues_instance.get_transition_energy(k_valence, k_conduction) + direct_gap_at_Gamma = eigenvalues_instance.get_transition_energy(k_conduction, k_conduction) + + assert np.isclose(indirect_gap, 0.19767), "Transition energy for X→Γ, in Ha" + assert np.isclose(direct_gap_at_Gamma, 0.21854), "Transition energy for Γ→Γ, in Ha" + + with pytest.raises(ValueError, match="Requested conduction k-point \\[1, 1, 1\\] not present."): + ( + eigenvalues_instance.get_transition_energy(k_valence, [1, 1, 1]), + "ValueError is returned if k-point is not matched.", + ) diff --git a/tests/dict_parsers/test_RT_TDDFT_parser.py b/tests/dict_parsers/test_RT_TDDFT_parser.py new file mode 100644 index 0000000..ba9b864 --- /dev/null +++ b/tests/dict_parsers/test_RT_TDDFT_parser.py @@ -0,0 +1,51 @@ +""" +Test for the RT_TDDFT_parser +""" + +import numpy as np +import pytest + +from excitingtools.exciting_dict_parsers.RT_TDDFT_parser import parse_proj_screenshots + +proj_file_str_square_matrices = """ ik: 1 + 1.00000 0.00000 + 0.00000 1.00000 + ik: 2 + 1.00000 0.00000 + 0.00000 1.00000 +""" + +reference_parsed_proj_square_matrices = { + "ik": [1, 2], + "projection": [np.array([[1.0, 0.0], [0.0, 1.0]]), np.array([[1.0, 0.0], [0.0, 1.0]])], +} + +proj_file_str_rectangular_matrices = """ ik: 1 + 1.00000 0.00000 0.00000 + 0.00000 1.00000 0.00000 + ik: 2 + 0.60000 0.80000 0.00000 + 0.00000 0.00000 1.00000 +""" + +reference_parsed_proj_rectangular_matrices = { + "ik": [1, 2], + "projection": [np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]), np.array([[0.6, 0.8, 0.0], [0.0, 0.0, 1.0]])], +} + + +@pytest.mark.parametrize( + ["proj_file_str", "reference_parsed_dict"], + [ + (proj_file_str_square_matrices, reference_parsed_proj_square_matrices), + (proj_file_str_rectangular_matrices, reference_parsed_proj_rectangular_matrices), + ], +) +def test_parse_proj_screenshots(proj_file_str, reference_parsed_dict, tmp_path): + proj_file_path = tmp_path / "PROJ_0.OUT" + proj_file_path.write_text(proj_file_str) + proj_out = parse_proj_screenshots(proj_file_path.as_posix()) + is_equal = proj_out["ik"] == reference_parsed_dict["ik"] + key = "projection" + is_equal = is_equal and all([np.allclose(x, y) for (x, y) in zip(proj_out[key], reference_parsed_dict[key])]) + assert is_equal diff --git a/tests/dict_parsers/test_bse_parser.py b/tests/dict_parsers/test_bse_parser.py index fd3e254..ed6c291 100644 --- a/tests/dict_parsers/test_bse_parser.py +++ b/tests/dict_parsers/test_bse_parser.py @@ -4,8 +4,16 @@ Execute tests from exciting_tools directory: pytest --capture=tee-sys """ + +import numpy as np import pytest -from excitingtools.exciting_dict_parsers.bse_parser import parse_infoxs_out + +from excitingtools.exciting_dict_parsers.bse_parser import ( + parse_fastBSE_absorption_spectrum_out, + parse_fastBSE_exciton_energies_out, + parse_infoxs_out, +) +from excitingtools.utils.test_utils import MockFile infoxs_file_str_success = """================================================================================ | EXCITING NITROGEN-14 started for task xsgeneigvec (301) = @@ -13,18 +21,7 @@ | = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -================================================================================ -One-shot GS runs for BSE calculations -================================================================================ -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -One-shot GS runs for k+qmt/2 grids -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -Info(xsgeneigvec): Generating eigenvectors for Q-point 1 --------------------------------------------------------------------------------- -Info(xsgeneigvec): Generation of eigenvectors finished - -Info(xsfinit): task Nr. 301 stopped gracefully - + Timings: Date (DD-MM-YYYY) : 10-12-2020 Time (hh:mm:ss) : 20:05:23 @@ -44,18 +41,15 @@ | EXCITING NITROGEN-14 started for task writepmatxs (320) = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -Info(writepmatxs): Momentum matrix elements finished -Info(xsfinit): task Nr. 320 stopped gracefully - - Timings: + Timings: Date (DD-MM-YYYY) : 10-12-2020 - Time (hh:mm:ss) : 20:05:24 - CPU time : 4.46 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 04 s ) - wall time : 0.84 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 01 s ) - CPU load : 533.19 % - CPU time (cumulative) : 116.04 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 56 s ) - wall time (cumulative) : 2.96 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 03 s ) - CPU load (cumulative) : 533.19 % + Time (hh:mm:ss) : 20:05:23 + CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) + wall time : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load : 684.78 % + CPU time (cumulative) : 111.58 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 52 s ) + wall time (cumulative) : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load (cumulative) : 684.78 % ================================================================================ = EXCITING NITROGEN-14 stopped for task 320 = @@ -66,21 +60,36 @@ | EXCITING NITROGEN-14 started for task scrgeneigvec (401) = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ - -Info(xsinit): mapping screening-specific parameters + + Timings: + Date (DD-MM-YYYY) : 10-12-2020 + Time (hh:mm:ss) : 20:05:23 + CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) + wall time : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load : 684.78 % + CPU time (cumulative) : 111.58 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 52 s ) + wall time (cumulative) : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load (cumulative) : 684.78 % ================================================================================ = EXCITING NITROGEN-14 stopped for task 401 = ================================================================================ - ================================================================================ | EXCITING NITROGEN-14 started for task scrwritepmat (420) = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -Info(xsinit): mapping screening-specific parameters - + Timings: + Date (DD-MM-YYYY) : 10-12-2020 + Time (hh:mm:ss) : 20:05:23 + CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) + wall time : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load : 684.78 % + CPU time (cumulative) : 111.58 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 52 s ) + wall time (cumulative) : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load (cumulative) : 684.78 % + ================================================================================ = EXCITING NITROGEN-14 stopped for task 420 = ================================================================================ @@ -89,8 +98,16 @@ | EXCITING NITROGEN-14 started for task bse (445) = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ - -Info(xsinit): mapping BSE-specific parameters + + Timings: + Date (DD-MM-YYYY) : 10-12-2020 + Time (hh:mm:ss) : 20:05:23 + CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) + wall time : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load : 684.78 % + CPU time (cumulative) : 111.58 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 52 s ) + wall time (cumulative) : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load (cumulative) : 684.78 % ================================================================================ = EXCITING NITROGEN-14 stopped for task 445 = @@ -98,24 +115,13 @@ """ infoxs_file_str_fail = """================================================================================ -| EXCITING NITROGEN-14 started for task xsgeneigvec (301) = +| EXCITING NITROGEN-14 started for task xsgeneigvec ( 301) = | version hash id: 1775bff4453c84689fb848894a9224f155377cfc = | = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -================================================================================ -One-shot GS runs for BSE calculations -================================================================================ -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -One-shot GS runs for k+qmt/2 grids -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -Info(xsgeneigvec): Generating eigenvectors for Q-point 1 --------------------------------------------------------------------------------- -Info(xsgeneigvec): Generation of eigenvectors finished -Info(xsfinit): task Nr. 301 stopped gracefully - - Timings: + Timings: Date (DD-MM-YYYY) : 10-12-2020 Time (hh:mm:ss) : 20:05:23 CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) @@ -130,11 +136,9 @@ ================================================================================ ================================================================================ -| EXCITING NITROGEN-14 started for task writepmatxs (320) = +| EXCITING NITROGEN-14 started for task writepmatxs ( 320) = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -Info(writepmatxs): Momentum matrix elements finished -Info(xsfinit): task Nr. 320 stopped gracefully ================================================================================ | EXCITING NITROGEN-14 started for task xsgeneigvec (301) = @@ -142,19 +146,8 @@ | = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -================================================================================ -One-shot GS runs for BSE calculations -================================================================================ -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -One-shot GS runs for k+qmt/2 grids -++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ -Info(xsgeneigvec): Generating eigenvectors for Q-point 1 --------------------------------------------------------------------------------- -Info(xsgeneigvec): Generation of eigenvectors finished - -Info(xsfinit): task Nr. 301 stopped gracefully - Timings: + Timings: Date (DD-MM-YYYY) : 10-12-2020 Time (hh:mm:ss) : 20:05:23 CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) @@ -173,18 +166,16 @@ | EXCITING NITROGEN-14 started for task writepmatxs (320) = | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -Info(writepmatxs): Momentum matrix elements finished -Info(xsfinit): task Nr. 320 stopped gracefully - Timings: + Timings: Date (DD-MM-YYYY) : 10-12-2020 - Time (hh:mm:ss) : 20:05:24 - CPU time : 4.46 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 04 s ) - wall time : 0.84 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 01 s ) - CPU load : 533.19 % - CPU time (cumulative) : 116.04 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 56 s ) - wall time (cumulative) : 2.96 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 03 s ) - CPU load (cumulative) : 533.19 % + Time (hh:mm:ss) : 20:05:23 + CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) + wall time : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load : 684.78 % + CPU time (cumulative) : 111.58 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 52 s ) + wall time (cumulative) : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load (cumulative) : 684.78 % ================================================================================ = EXCITING NITROGEN-14 stopped for task 320 = @@ -196,8 +187,16 @@ | Date (DD-MM-YYYY) : 10-12-2020 = ================================================================================ -Info(xsinit): mapping screening-specific parameters - + Timings: + Date (DD-MM-YYYY) : 10-12-2020 + Time (hh:mm:ss) : 20:05:23 + CPU time : 14.57 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 15 s ) + wall time : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load : 684.78 % + CPU time (cumulative) : 111.58 sec; 0.03 hrs; ( 0 d, 00 h, 01 m, 52 s ) + wall time (cumulative) : 2.13 sec; 0.00 hrs; ( 0 d, 00 h, 00 m, 02 s ) + CPU load (cumulative) : 684.78 % + ================================================================================ = EXCITING NITROGEN-14 stopped for task 401 = ================================================================================ @@ -209,40 +208,240 @@ ================================================================================ """ + reference_parsed_infoxs_file_success = { - 'tasks': [{'name': 'xsgeneigvec', 'number': 301, - 'finished': True}, {'name': 'writepmatxs', 'number': 320, - 'finished': True}, - {'name': 'scrgeneigvec', 'number': 401, - 'finished': True}, {'name': 'scrwritepmat', 'number': 420, - 'finished': True}, - {'name': 'bse', 'number': 445, 'finished': True}], - 'success': True, - 'last_finished_task': 'bse' - } + "tasks": [ + {"name": "xsgeneigvec", "number": 301, "finished": True}, + {"name": "writepmatxs", "number": 320, "finished": True}, + {"name": "scrgeneigvec", "number": 401, "finished": True}, + {"name": "scrwritepmat", "number": 420, "finished": True}, + {"name": "bse", "number": 445, "finished": True}, + ], + "success": True, + "last_finished_task": "bse", +} reference_parsed_infoxs_file_fail = { - 'tasks': [{'name': 'xsgeneigvec', 'number': 301, - 'finished': True}, {'name': 'writepmatxs', 'number': 320, - 'finished': False}, - {'name': 'xsgeneigvec', 'number': 301, - 'finished': True}, {'name': 'writepmatxs', 'number': 320, - 'finished': True}, - {'name': 'scrgeneigvec', 'number': 401, - 'finished': True}, {'name': 'scrwritepmat', 'number': 420, - 'finished': False}], - 'success': False, - 'last_finished_task': 'scrgeneigvec' - } - - -@pytest.mark.parametrize(["infoxs_file_str", "reference_parsed_dict"], - [(infoxs_file_str_success, - reference_parsed_infoxs_file_success), - (infoxs_file_str_fail, - reference_parsed_infoxs_file_fail)]) + "tasks": [ + {"name": "xsgeneigvec", "number": 301, "finished": True}, + {"name": "writepmatxs", "number": 320, "finished": False}, + {"name": "xsgeneigvec", "number": 301, "finished": True}, + {"name": "writepmatxs", "number": 320, "finished": True}, + {"name": "scrgeneigvec", "number": 401, "finished": True}, + {"name": "scrwritepmat", "number": 420, "finished": False}, + ], + "success": False, + "last_finished_task": "scrgeneigvec", +} + + +@pytest.mark.parametrize( + ["infoxs_file_str", "reference_parsed_dict"], + [ + (infoxs_file_str_success, reference_parsed_infoxs_file_success), + (infoxs_file_str_fail, reference_parsed_infoxs_file_fail), + ], +) def test_parse_info_xs_out(infoxs_file_str, reference_parsed_dict, tmp_path): infoxs_file_path = tmp_path / "INFOXS.OUT" infoxs_file_path.write_text(infoxs_file_str) info_xs_out = parse_infoxs_out(infoxs_file_path.as_posix()) assert info_xs_out == reference_parsed_dict + + +reference_parsed_infoxs_file_times_success = { + "tasks": [ + { + "name": "xsgeneigvec", + "number": 301, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + { + "name": "writepmatxs", + "number": 320, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + { + "name": "scrgeneigvec", + "number": 401, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + { + "name": "scrwritepmat", + "number": 420, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + { + "name": "bse", + "number": 445, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + ], + "success": True, + "last_finished_task": "bse", +} + +reference_parsed_infoxs_file_times_fail = { + "tasks": [ + { + "name": "xsgeneigvec", + "number": 301, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + {"name": "writepmatxs", "number": 320, "finished": False}, + { + "name": "xsgeneigvec", + "number": 301, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + { + "name": "writepmatxs", + "number": 320, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + { + "name": "scrgeneigvec", + "number": 401, + "finished": True, + "cpu_time": 14.57, + "wall_time": 2.13, + "cpu_time_cum": 111.58, + "wall_time_cum": 2.13, + }, + {"name": "scrwritepmat", "number": 420, "finished": False}, + ], + "success": False, + "last_finished_task": "scrgeneigvec", +} + + +@pytest.mark.parametrize( + ["infoxs_file_str", "reference_parsed_dict"], + [ + (infoxs_file_str_success, reference_parsed_infoxs_file_times_success), + (infoxs_file_str_fail, reference_parsed_infoxs_file_times_fail), + ], +) +def test_parse_info_xs_out_timing(infoxs_file_str, reference_parsed_dict, tmp_path): + infoxs_file_path = tmp_path / "INFOXS.OUT" + infoxs_file_path.write_text(infoxs_file_str) + info_xs_out = parse_infoxs_out(infoxs_file_path.as_posix(), parse_timing=True) + assert info_xs_out == reference_parsed_dict + + +@pytest.fixture +def fastBSE_absorption_spectrum_out_mock(tmp_path): + string_contents = """# fastBSE imaginary macroscopic dielectric function +# +# Energy unit: 0.3674932539796232E-01 Hartree +# Broadening: 0.1360569193000000E+00 energy unit +# +# omega oc11 oc22 oc33 ++0.0000000000000000E+00 +0.0000000000000000E+00 +0.0000000000000000E+00 +0.0000000000000000E+00 ++0.6046974191111111E+01 +0.2462935121365850E+00 +0.2462935121365850E+00 +0.2462935121365850E+00 ++0.1209394838222222E+02 +0.2028366299352899E+02 +0.2028366299352899E+02 +0.2028366299352899E+02""" + + file = tmp_path / "fastBSE_absorption_spectrum.out" + file.write_text(string_contents) + return MockFile(file, string_contents) + + +reference_fastBSE_absorption_spectrum = { + "energy_unit": 0.3674932539796232e-01, + "broadening": 0.1360569193000000e00, + "frequency": np.array([+0.0000000000000000e00, +0.6046974191111111e01, +0.1209394838222222e02]), + "imag_epsilon": np.array( + [ + [+0.0000000000000000e00, +0.0000000000000000e00, +0.0000000000000000e00], + [+0.2462935121365850e00, +0.2462935121365850e00, +0.2462935121365850e00], + [+0.2028366299352899e02, +0.2028366299352899e02, +0.2028366299352899e02], + ] + ), +} + + +def test_parse_fastBSE_absorption_spectrum_out_parser(fastBSE_absorption_spectrum_out_mock): + fastBSE_absorption_spectrum_out = parse_fastBSE_absorption_spectrum_out(fastBSE_absorption_spectrum_out_mock.file) + assert np.allclose( + fastBSE_absorption_spectrum_out["energy_unit"], reference_fastBSE_absorption_spectrum["energy_unit"] + ) + assert np.allclose( + fastBSE_absorption_spectrum_out["broadening"], reference_fastBSE_absorption_spectrum["broadening"] + ) + assert np.allclose(fastBSE_absorption_spectrum_out["frequency"], reference_fastBSE_absorption_spectrum["frequency"]) + assert np.allclose( + fastBSE_absorption_spectrum_out["imag_epsilon"], reference_fastBSE_absorption_spectrum["imag_epsilon"] + ) + + +@pytest.fixture +def fastBSE_exciton_energies_out_mock(tmp_path): + string_contents = """# fastBSE exciton eigen energies +# The three rows correspond to the results of the three Lanczos runs, each for one of the +# directions of

as starting point. +# +# Energy unit: 0.3674932539796232E-01 Hartree +# IP band gap: 0.8205751967708917E+01 energy unit +# +# E -> E -> E -> ++0.8166524450038512E+01 +0.8166363823856052E+01 +0.8166439398878977E+01 ++0.8168631668065551E+01 +0.8198150340644158E+01 +0.8588070401753557E+01 ++0.8587998131087492E+01 +0.8588200092979093E+01 +0.9161347631625411E+01""" + + file = tmp_path / "fastBSE_exciton_energies.out" + file.write_text(string_contents) + return MockFile(file, string_contents) + + +reference_fastBSE_exciton_energies = { + "energy_unit": 0.3674932539796232e-01, + "ip_band_gap": 0.8205751967708917e01, + "exciton_energies": np.array( + [ + [+0.8166524450038512e01, +0.8166363823856052e01, +0.8166439398878977e01], + [+0.8168631668065551e01, +0.8198150340644158e01, +0.8588070401753557e01], + [+0.8587998131087492e01, +0.8588200092979093e01, +0.9161347631625411e01], + ] + ), +} + + +def test_parse_fastBSE_exciton_energies_out_parser(fastBSE_exciton_energies_out_mock): + fastBSE_exciton_energies_out = parse_fastBSE_exciton_energies_out(fastBSE_exciton_energies_out_mock.file) + assert np.allclose(fastBSE_exciton_energies_out["energy_unit"], reference_fastBSE_exciton_energies["energy_unit"]) + assert np.allclose(fastBSE_exciton_energies_out["ip_band_gap"], reference_fastBSE_exciton_energies["ip_band_gap"]) + assert np.allclose( + fastBSE_exciton_energies_out["exciton_energies"], reference_fastBSE_exciton_energies["exciton_energies"] + ) diff --git a/tests/dict_parsers/test_groundstate_parser.py b/tests/dict_parsers/test_groundstate_parser.py index d704cfd..d88ef12 100644 --- a/tests/dict_parsers/test_groundstate_parser.py +++ b/tests/dict_parsers/test_groundstate_parser.py @@ -3,7 +3,12 @@ Execute tests from exciting_tools directory: pytest --capture=tee-sys """ -from excitingtools.exciting_dict_parsers.groundstate_parser import parse_info_out + +from excitingtools.exciting_dict_parsers.groundstate_parser import ( + parse_info_out, + parse_linengy, + parse_lo_recommendation, +) def test_parse_info_out(tmp_path): @@ -18,151 +23,137 @@ def test_parse_info_out(tmp_path): # Only retained first and last SCF keys info_ref = { "initialization": { - "APW functions": "8", - "Brillouin zone volume": "0.0734963595", - "Effective Wigner radius, r_s": "3.55062021", - "Exchange-correlation type": "100", + "APW functions": 8, + "Brillouin zone volume": 0.0734963595, + "Effective Wigner radius, r_s": 3.55062021, + "Exchange-correlation type": 100, "G-vector grid sizes": "36 36 36", - "Lattice vectors (cartesian)": [ - "15.0000000000", - "0.0000000000", - "0.0000000000", - "0.0000000000", - "15.0000000000", - "0.0000000000", - "0.0000000000", - "0.0000000000", - "15.0000000000" - ], - "Maximum Hamiltonian size": "263", - "Maximum number of plane-waves": "251", - "Maximum |G| for potential and density": "7.50000000", - "Number of Bravais lattice symmetries": "48", - "Number of crystal symmetries": "48", - "Number of empty states": "5", - "Polynomial order for pseudochg. density": "9", + "Lattice vectors (cartesian)": [15.0, 0.0, 0.0, 0.0, 15.0, 0.0, 0.0, 0.0, 15.0], + "Maximum Hamiltonian size": 263, + "Maximum number of plane-waves": 251, + "Maximum |G+k| for APW functions": 1.66666667, + "Maximum |G| for potential and density": 7.5, + "Number of Bravais lattice symmetries": 48, + "Number of crystal symmetries": 48, + "Number of empty states": 5, + "Polynomial order for pseudochg. density": 9, + "R^MT_min * |G+k|_max (rgkmax)": 10.0, "Reciprocal lattice vectors (cartesian)": [ - "0.4188790205", - "0.0000000000", - "0.0000000000", - "0.0000000000", - "0.4188790205", - "0.0000000000", - "0.0000000000", - "0.0000000000", - "0.4188790205" + 0.4188790205, + 0.0, + 0.0, + 0.0, + 0.4188790205, + 0.0, + 0.0, + 0.0, + 0.4188790205, ], "Smearing scheme": "Gaussian", - "Smearing width": "0.00100000", + "Smearing width": 0.001, "Species 1": { - "# of radial points in muffin-tin": "1000", - "Atomic positions": { - "Atom 1": "0.00000000 0.00000000 0.00000000" - }, + "# of radial points in muffin-tin": 1000, + "Atomic positions": {"Atom 1": "0.00000000 0.00000000 0.00000000"}, "Species": "1 (Ar)", "Species symbol": "Ar", - "atomic mass": "72820.74920000", - "electronic charge": "18.00000000", - "muffin-tin radius": "6.00000000", + "atomic mass": 72820.7492, + "electronic charge": 18.0, + "muffin-tin radius": 6.0, "name": "argon", - "nuclear charge": "-18.00000000", - "parameters loaded from": "Ar.xml" + "nuclear charge": -18.0, + "parameters loaded from": "Ar.xml", }, "Species with R^MT_min": "1 (Ar)", - "Maximum |G+k| for APW functions": "1.66666667", "Spin treatment": "spin-unpolarised", - "Total core charge": "10.00000000", - "Total electronic charge": "18.00000000", - "Total nuclear charge": "-18.00000000", - "Total number of G-vectors": "23871", - "Total number of atoms per unit cell": "1", - "Total number of k-points": "1", - "Total number of local-orbitals": "12", - "Total number of valence states": "10", - "Total valence charge": "8.00000000", - "Unit cell volume": "3375.0000000000", - "computing H and O matrix elements": "4", - "inner part of muffin-tin": "2", + "Total core charge": 10.0, + "Total electronic charge": 18.0, + "Total nuclear charge": -18.0, + "Total number of G-vectors": 23871, + "Total number of atoms per unit cell": 1, + "Total number of k-points": 1, + "Total number of local-orbitals": 12, + "Total number of valence states": 10, + "Total valence charge": 8.0, + "Unit cell volume": 3375.0, + "computing H and O matrix elements": 4, + "inner part of muffin-tin": 2, "k-point grid": "1 1 1", - "R^MT_min * |G+k|_max (rgkmax)": "10.00000000", "libxc; exchange": "Slater exchange; correlation", "mixing": "Using multisecant Broyden potential mixing", - "potential and density": "4", - "units": { - "positions": "lattice" - } + "potential and density": 4, + "units": {"positions": "lattice"}, }, "scl": { "1": { - "Core-electron kinetic energy": "0.00000000", - "Correlation energy": "-1.43085548", - "Coulomb energy": "-1029.02167746", - "Coulomb potential energy": "-796.81322609", - "DOS at Fermi energy (states/Ha/cell)": "0.00000000", - "Effective potential energy": "-835.64023227", + "Core-electron kinetic energy": 0.0, + "Correlation energy": -1.43085548, + "Coulomb energy": -1029.02167746, + "Coulomb potential energy": -796.81322609, + "DOS at Fermi energy (states/Ha/cell)": 0.0, + "Effective potential energy": -835.64023227, "Electron charges": "", - "Electron-nuclear energy": "-1208.12684923", - "Estimated fundamental gap": "0.36071248", - "Exchange energy": "-27.93377198", - "Fermi energy": "-0.20111449", - "Hartree energy": "205.65681157", - "Kinetic energy": "530.56137212", - "Madelung energy": "-630.61506441", - "Nuclear-nuclear energy": "-26.55163980", - "Sum of eigenvalues": "-305.07886015", - "Total energy": "-527.82493279", - "Wall time (seconds)": "1.05", - "atom 1 Ar": "17.99816103", + "Electron-nuclear energy": -1208.12684923, + "Estimated fundamental gap": 0.36071248, + "Exchange energy": -27.93377198, + "Fermi energy": -0.20111449, + "Hartree energy": 205.65681157, + "Kinetic energy": 530.56137212, + "Madelung energy": -630.61506441, + "Nuclear-nuclear energy": -26.5516398, + "Sum of eigenvalues": -305.07886015, + "Total energy": -527.82493279, + "Wall time (seconds)": 1.05, + "atom 1 Ar": 17.99816103, "charge in muffin-tin spheres": "", - "core": "10.00000000", - "core leakage": "0.00000000", - "interstitial": "0.00183897", - "total charge": "18.00000000", - "total charge in muffin-tins": "17.99816103", - "valence": "8.00000000", - "xc potential energy": "-38.82700618" + "core": 10.0, + "core leakage": 0.0, + "interstitial": 0.00183897, + "total charge": 18.0, + "total charge in muffin-tins": 17.99816103, + "valence": 8.0, + "xc potential energy": -38.82700618, }, - "11": { - "Core-electron kinetic energy": "0.00000000", - "Correlation energy": "-1.43084350", - "Coulomb energy": "-1029.02642037", - "Coulomb potential energy": "-796.82023455", - "DOS at Fermi energy (states/Ha/cell)": "0.00000000", - "Effective potential energy": "-835.64716936", + "12": { + "Core-electron kinetic energy": 0.0, + "Correlation energy": -1.4308435, + "Coulomb energy": -1029.02642037, + "Coulomb potential energy": -796.82023455, + "DOS at Fermi energy (states/Ha/cell)": 0.0, + "Effective potential energy": -835.64716936, "Electron charges": "", - "Electron-nuclear energy": "-1208.12932661", - "Estimated fundamental gap": "0.36095838", - "Exchange energy": "-27.93372809", - "Fermi energy": "-0.20044598", - "Hartree energy": "205.65454603", - "Kinetic energy": "530.57303096", - "Madelung energy": "-630.61630310", - "Nuclear-nuclear energy": "-26.55163980", - "Sum of eigenvalues": "-305.07413840", - "Total energy": "-527.81796101", - "Wall time (seconds)": "4.95", - "atom 1 Ar": "17.99815963", + "Electron-nuclear energy": -1208.12932661, + "Estimated fundamental gap": 0.36095838, + "Exchange energy": -27.93372809, + "Fermi energy": -0.20044598, + "Hartree energy": 205.65454603, + "Kinetic energy": 530.57303096, + "Madelung energy": -630.6163031, + "Nuclear-nuclear energy": -26.55163980, + "Sum of eigenvalues": -305.0741384, + "Total energy": -527.81796101, + "atom 1 Ar": 17.99815963, "charge in muffin-tin spheres": "", - "core": "10.00000000", - "core leakage": "0.00000000", - "interstitial": "0.00184037", - "total charge": "18.00000000", - "total charge in muffin-tins": "17.99815963", - "valence": "8.00000000", - "xc potential energy": "-38.82693481" - } - } + "core": 10.0, + "core leakage": 0.0, + "interstitial": 0.00184037, + "total charge": 18.0, + "total charge in muffin-tins": 17.99815963, + "valence": 8.0, + "xc potential energy": -38.82693481, + }, + }, } - file = tmp_path / 'INFO.OUT' + file = tmp_path / "INFO.OUT" file.write_text(LDA_VWN_Ar_INFO_OUT) assert file.exists(), "INFO.OUT not written to tmp_path" info_out = parse_info_out(file.as_posix()) - assert info_out['initialization'] == info_ref['initialization'], "Initialization data consistent" - assert info_out['scl']['1'] == info_ref['scl']['1'], "SCF first iteration data consistent" - assert info_out['scl']['11'] == info_ref['scl']['11'], "SCF last iteration data consistent" + assert info_out["initialization"] == info_ref["initialization"], "Initialization data consistent" + assert len(info_out["scl"]) == 12, "expected 12 SCF steps" + assert info_out["scl"]["1"] == info_ref["scl"]["1"], "SCF first iteration data consistent" + assert info_out["scl"]["12"] == info_ref["scl"]["12"], "SCF last iteration data consistent" LDA_VWN_Ar_INFO_OUT = """================================================================================ @@ -797,3 +788,560 @@ def test_parse_info_out(tmp_path): | EXCITING NITROGEN-14 stopped = ================================================================================ """ + + +def test_parse_linengy(tmp_path): + """ + Note, this test will break if: + * the parser keys change + * the structure of the output changes + """ + + # Reference of the dictionary parsed with parse_linengy + # Generated with print(json.dumps(info_out, sort_keys=True, indent=4)) + + linengy_ref = { + "0": { + "apw": [-2.1, -1.1, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15], + "lo": [ + 1.839014478, + 1.839014478, + -0.7435250385, + -0.7435250385, + 1.839014478, + 1.839014478, + 1.839014478, + -1.775228393, + -0.7435250385, + -0.7435250385, + ], + }, + "1": { + "apw": [-0.3, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15, 0.15], + "lo": [ + 0.7475318132, + 0.7475318132, + 1.385527458, + 1.385527458, + 1.974765418, + 1.974765418, + 0.7475318132, + 0.7475318132, + 0.7475318132, + -9.003002855, + -9.003002855, + -9.003002855, + -9.003002855, + -9.003002855, + 1.385527458, + 1.385527458, + 1.385527458, + -6.768218591, + -6.768218591, + -6.768218591, + -6.768218591, + -6.768218591, + 1.974765418, + 1.974765418, + ], + }, + } + + file = tmp_path / "LINENGY.OUT" + file.write_text(GGA_PBE_SOL_automatic_trial_energies_NaCl_LINENGY_OUT) + assert file.exists(), "LINENGY.OUT not written to tmp_path" + + linengy = parse_linengy(file.as_posix()) + + assert linengy["0"]["apw"] == linengy_ref["0"]["apw"], "First species apw data consistent" + assert linengy["0"]["lo"] == linengy_ref["0"]["lo"], "First species lo data consistent" + assert linengy["1"]["apw"] == linengy_ref["1"]["apw"], "Second species apw data consistent" + assert linengy["1"]["lo"] == linengy_ref["1"]["lo"], "Second species lo data consistent" + + +GGA_PBE_SOL_automatic_trial_energies_NaCl_LINENGY_OUT = """Species : 1 (Na), atom : 1 + APW functions : + l = 0, order = 1 : -2.100000000 + l = 1, order = 1 : -1.100000000 + l = 2, order = 1 : 0.1500000000 + l = 2, order = 2 : 0.1500000000 + l = 3, order = 1 : 0.1500000000 + l = 3, order = 2 : 0.1500000000 + l = 4, order = 1 : 0.1500000000 + l = 4, order = 2 : 0.1500000000 + l = 5, order = 1 : 0.1500000000 + l = 5, order = 2 : 0.1500000000 + l = 6, order = 1 : 0.1500000000 + l = 6, order = 2 : 0.1500000000 + l = 7, order = 1 : 0.1500000000 + l = 7, order = 2 : 0.1500000000 + l = 8, order = 1 : 0.1500000000 + l = 8, order = 2 : 0.1500000000 + local-orbital functions : + l.o. = 1, l = 0, order = 1 : 1.839014478 + l.o. = 1, l = 0, order = 2 : 1.839014478 + l.o. = 2, l = 1, order = 1 : -0.7435250385 + l.o. = 2, l = 1, order = 2 : -0.7435250385 + l.o. = 3, l = 0, order = 1 : 1.839014478 + l.o. = 3, l = 0, order = 2 : 1.839014478 + l.o. = 4, l = 0, order = 1 : 1.839014478 + l.o. = 4, l = 0, order = 2 : -1.775228393 + l.o. = 5, l = 1, order = 1 : -0.7435250385 + l.o. = 5, l = 1, order = 2 : -0.7435250385 + +Species : 2 (Cl), atom : 1 + APW functions : + l = 0, order = 1 : -0.3000000000 + l = 1, order = 1 : 0.1500000000 + l = 2, order = 1 : 0.1500000000 + l = 3, order = 1 : 0.1500000000 + l = 3, order = 2 : 0.1500000000 + l = 4, order = 1 : 0.1500000000 + l = 4, order = 2 : 0.1500000000 + l = 5, order = 1 : 0.1500000000 + l = 5, order = 2 : 0.1500000000 + l = 6, order = 1 : 0.1500000000 + l = 6, order = 2 : 0.1500000000 + l = 7, order = 1 : 0.1500000000 + l = 7, order = 2 : 0.1500000000 + l = 8, order = 1 : 0.1500000000 + l = 8, order = 2 : 0.1500000000 + local-orbital functions : + l.o. = 1, l = 0, order = 1 : 0.7475318132 + l.o. = 1, l = 0, order = 2 : 0.7475318132 + l.o. = 2, l = 1, order = 1 : 1.385527458 + l.o. = 2, l = 1, order = 2 : 1.385527458 + l.o. = 3, l = 2, order = 1 : 1.974765418 + l.o. = 3, l = 2, order = 2 : 1.974765418 + l.o. = 4, l = 0, order = 1 : 0.7475318132 + l.o. = 4, l = 0, order = 2 : 0.7475318132 + l.o. = 5, l = 0, order = 1 : 0.7475318132 + l.o. = 5, l = 0, order = 2 : -9.003002855 + l.o. = 6, l = 0, order = 1 : -9.003002855 + l.o. = 6, l = 0, order = 2 : -9.003002855 + l.o. = 7, l = 0, order = 1 : -9.003002855 + l.o. = 7, l = 0, order = 2 : -9.003002855 + l.o. = 8, l = 1, order = 1 : 1.385527458 + l.o. = 8, l = 1, order = 2 : 1.385527458 + l.o. = 9, l = 1, order = 1 : 1.385527458 + l.o. = 9, l = 1, order = 2 : -6.768218591 + l.o. = 10, l = 1, order = 1 : -6.768218591 + l.o. = 10, l = 1, order = 2 : -6.768218591 + l.o. = 11, l = 1, order = 1 : -6.768218591 + l.o. = 11, l = 1, order = 2 : -6.768218591 + l.o. = 12, l = 2, order = 1 : 1.974765418 + l.o. = 12, l = 2, order = 2 : 1.974765418 """ + + +def test_parse_lo_recommendation(tmp_path): + """ + Note, this test will break if: + * the parser keys change + * the structure of the output changes + """ + + # Reference of the dictionary parsed with parse_lo_recommendation + # Generated with print(json.dumps(info_out, sort_keys=True, indent=4)) + + lo_recommendation_ref = { + "n_species": 2, + "n_l_channels": 4, + "n_nodes": 21, + "Na": { + 0: [ + [0.0, 1.0, -37.65990706181], + [1.0, 2.0, -1.796282469826], + [2.0, 3.0, 1.821425619362], + [3.0, 4.0, 7.892575399809], + [4.0, 5.0, 16.986165613637], + [5.0, 6.0, 28.846080585981], + [6.0, 7.0, 43.369227333017], + [7.0, 8.0, 60.495491296112], + [8.0, 9.0, 80.187724549122], + [9.0, 10.0, 102.422176950914], + [10.0, 11.0, 127.182990712735], + [11.0, 12.0, 154.458866712915], + [12.0, 13.0, 184.241158329361], + [13.0, 14.0, 216.522955263927], + [14.0, 15.0, 251.298756937161], + [15.0, 16.0, 288.564346156074], + [16.0, 17.0, 328.316602395923], + [17.0, 18.0, 370.553203330272], + [18.0, 19.0, 415.272297641907], + [19.0, 20.0, 462.472263422762], + [20.0, 21.0, 512.15160534914], + ], + 1: [ + [0.0, 2.0, -0.764380938525], + [1.0, 3.0, 2.150459202106], + [2.0, 4.0, 7.68064315441], + [3.0, 5.0, 15.944823175319], + [4.0, 6.0, 26.826688781275], + [5.0, 7.0, 40.283086869083], + [6.0, 8.0, 56.289616891011], + [7.0, 9.0, 74.830023777453], + [8.0, 10.0, 95.891983053944], + [9.0, 11.0, 119.465424264199], + [10.0, 12.0, 145.542112894232], + [11.0, 13.0, 174.115704751485], + [12.0, 14.0, 205.181692970898], + [13.0, 15.0, 238.737011824859], + [14.0, 16.0, 274.779422707574], + [15.0, 17.0, 313.306969990248], + [16.0, 18.0, 354.317721540693], + [17.0, 19.0, 397.809787603557], + [18.0, 20.0, 443.781471675578], + [19.0, 21.0, 492.231385973938], + [20.0, 22.0, 543.158450851233], + ], + 2: [ + [0.0, 3.0, 2.044659668292], + [1.0, 4.0, 6.427853785834], + [2.0, 5.0, 13.431600603035], + [3.0, 6.0, 23.065707720667], + [4.0, 7.0, 35.286723443086], + [5.0, 8.0, 50.060639647089], + [6.0, 9.0, 67.365856793742], + [7.0, 10.0, 87.18903211822], + [8.0, 11.0, 109.521849753452], + [9.0, 12.0, 134.358761651081], + [10.0, 13.0, 161.695405680071], + [11.0, 14.0, 191.52770328581], + [12.0, 15.0, 223.85164087452], + [13.0, 16.0, 258.663517453378], + [14.0, 17.0, 295.960290921283], + [15.0, 18.0, 335.73972677672], + [16.0, 19.0, 378.000263814603], + [17.0, 20.0, 422.740724390955], + [18.0, 21.0, 469.960046963641], + [19.0, 22.0, 519.657164852307], + [20.0, 23.0, 571.831020081621], + ], + 3: [ + [0.0, 4.0, 3.879401688185], + [1.0, 5.0, 9.964477889618], + [2.0, 6.0, 18.47378344193], + [3.0, 7.0, 29.505302500625], + [4.0, 8.0, 43.071695950394], + [5.0, 9.0, 59.165686791534], + [6.0, 10.0, 77.776580159806], + [7.0, 11.0, 98.89485358398], + [8.0, 12.0, 122.513132271493], + [9.0, 13.0, 148.626189112094], + [10.0, 14.0, 177.230592656783], + [11.0, 15.0, 208.324116670413], + [12.0, 16.0, 241.905061644769], + [13.0, 17.0, 277.971722458938], + [14.0, 18.0, 316.522185899964], + [15.0, 19.0, 357.554436071786], + [16.0, 20.0, 401.066608041866], + [17.0, 21.0, 447.057187991954], + [18.0, 22.0, 495.525055433203], + [19.0, 23.0, 546.469383913223], + [20.0, 24.0, 599.889489594382], + ], + }, + "Cl": { + 0: [ + [0.0, 1.0, -100.974592263771], + [1.0, 2.0, -8.943406336517], + [2.0, 3.0, 0.803820215121], + [3.0, 4.0, 11.265670674793], + [4.0, 5.0, 27.995235804499], + [5.0, 6.0, 50.139499480779], + [6.0, 7.0, 77.425670325936], + [7.0, 8.0, 109.722811813338], + [8.0, 9.0, 146.943417860813], + [9.0, 10.0, 189.030459361669], + [10.0, 11.0, 235.945624307269], + [11.0, 12.0, 287.660637659017], + [12.0, 13.0, 344.155876726629], + [13.0, 14.0, 405.415989381911], + [14.0, 15.0, 471.42935525429], + [15.0, 16.0, 542.186581729314], + [16.0, 17.0, 617.680068606306], + [17.0, 18.0, 697.90359882698], + [18.0, 19.0, 782.852080659573], + [19.0, 20.0, 872.521357718829], + [20.0, 21.0, 966.908013775037], + ], + 1: [ + [0.0, 2.0, -6.707821362791], + [1.0, 3.0, 1.442002863349], + [2.0, 4.0, 11.086329356142], + [3.0, 5.0, 26.446761118127], + [4.0, 6.0, 46.908960145565], + [5.0, 7.0, 72.31449630133], + [6.0, 8.0, 102.593748915541], + [7.0, 9.0, 137.708872214526], + [8.0, 10.0, 177.628868217528], + [9.0, 11.0, 222.335815545055], + [10.0, 12.0, 271.812774967963], + [11.0, 13.0, 326.047911895729], + [12.0, 14.0, 385.030908226461], + [13.0, 15.0, 448.753672165588], + [14.0, 16.0, 517.209705744261], + [15.0, 17.0, 590.393819776316], + [16.0, 18.0, 668.301879780751], + [17.0, 19.0, 750.930453348811], + [18.0, 20.0, 838.276595190814], + [19.0, 21.0, 930.337672332757], + [20.0, 22.0, 1027.111295649087], + ], + 2: [ + [0.0, 3.0, 2.030626399034], + [1.0, 4.0, 9.540398069814], + [2.0, 5.0, 22.521806870793], + [3.0, 6.0, 40.580505794574], + [4.0, 7.0, 63.608967480673], + [5.0, 8.0, 91.521851721479], + [6.0, 9.0, 124.28110055295], + [7.0, 10.0, 161.847878698612], + [8.0, 11.0, 204.202486065259], + [9.0, 12.0, 251.327905737439], + [10.0, 13.0, 303.212514505293], + [11.0, 14.0, 359.847427715492], + [12.0, 15.0, 421.225227525549], + [13.0, 16.0, 487.339835071361], + [14.0, 17.0, 558.185801503932], + [15.0, 18.0, 633.758349847869], + [16.0, 19.0, 714.053292909356], + [17.0, 20.0, 799.067051302979], + [18.0, 21.0, 888.796639877965], + [19.0, 22.0, 983.239587157865], + [20.0, 23.0, 1082.393826570133], + ], + 3: [ + [0.0, 4.0, 5.877668530234], + [1.0, 5.0, 16.734445897029], + [2.0, 6.0, 32.521343858776], + [3.0, 7.0, 53.248636736501], + [4.0, 8.0, 78.839760672149], + [5.0, 9.0, 109.265751512702], + [6.0, 10.0, 144.502223464379], + [7.0, 11.0, 184.526132806013], + [8.0, 12.0, 229.322756357998], + [9.0, 13.0, 278.877755682806], + [10.0, 14.0, 333.181582371119], + [11.0, 15.0, 392.226089160902], + [12.0, 16.0, 456.00524371215], + [13.0, 17.0, 524.514261786364], + [14.0, 18.0, 597.749258861014], + [15.0, 19.0, 675.706956131225], + [16.0, 20.0, 758.384407267418], + [17.0, 21.0, 845.778892453188], + [18.0, 22.0, 937.887883257667], + [19.0, 23.0, 1034.709065214155], + [20.0, 24.0, 1136.240377687629], + ], + }, + } + + file = tmp_path / "LO_RECOMMENDATION.OUT" + file.write_text(GGA_PBE_SOL_automatic_trial_energies_NaCl_LO_RECOMMENDATION_OUT) + assert file.exists(), "LO_RECOMMENDATION.OUT not written to tmp_path" + + lo_recommendation = parse_lo_recommendation(file.as_posix()) + + assert lo_recommendation["Na"] == lo_recommendation_ref["Na"], "First species data consistent" + assert lo_recommendation["Cl"] == lo_recommendation_ref["Cl"], "Second species data consistent" + + +GGA_PBE_SOL_automatic_trial_energies_NaCl_LO_RECOMMENDATION_OUT = """# Recommended linearization energies computet with Wigner-Seitz rules. + -------------------------------------------------------------------- + # n_species: 2 + # n_l-channels: 4 + # n_nodes: 21 + + # species: Na, l : 0 + # nodes n trial energy + 0 1 -37.659907061810 + 1 2 -1.796282469826 + 2 3 1.821425619362 + 3 4 7.892575399809 + 4 5 16.986165613637 + 5 6 28.846080585981 + 6 7 43.369227333017 + 7 8 60.495491296112 + 8 9 80.187724549122 + 9 10 102.422176950914 + 10 11 127.182990712735 + 11 12 154.458866712915 + 12 13 184.241158329361 + 13 14 216.522955263927 + 14 15 251.298756937161 + 15 16 288.564346156074 + 16 17 328.316602395923 + 17 18 370.553203330272 + 18 19 415.272297641907 + 19 20 462.472263422762 + 20 21 512.151605349140 + + # species: Na, l : 1 + # nodes n trial energy + 0 2 -0.764380938525 + 1 3 2.150459202106 + 2 4 7.680643154410 + 3 5 15.944823175319 + 4 6 26.826688781275 + 5 7 40.283086869083 + 6 8 56.289616891011 + 7 9 74.830023777453 + 8 10 95.891983053944 + 9 11 119.465424264199 + 10 12 145.542112894232 + 11 13 174.115704751485 + 12 14 205.181692970898 + 13 15 238.737011824859 + 14 16 274.779422707574 + 15 17 313.306969990248 + 16 18 354.317721540693 + 17 19 397.809787603557 + 18 20 443.781471675578 + 19 21 492.231385973938 + 20 22 543.158450851233 + + # species: Na, l : 2 + # nodes n trial energy + 0 3 2.044659668292 + 1 4 6.427853785834 + 2 5 13.431600603035 + 3 6 23.065707720667 + 4 7 35.286723443086 + 5 8 50.060639647089 + 6 9 67.365856793742 + 7 10 87.189032118220 + 8 11 109.521849753452 + 9 12 134.358761651081 + 10 13 161.695405680071 + 11 14 191.527703285810 + 12 15 223.851640874520 + 13 16 258.663517453378 + 14 17 295.960290921283 + 15 18 335.739726776720 + 16 19 378.000263814603 + 17 20 422.740724390955 + 18 21 469.960046963641 + 19 22 519.657164852307 + 20 23 571.831020081621 + + # species: Na, l : 3 + # nodes n trial energy + 0 4 3.879401688185 + 1 5 9.964477889618 + 2 6 18.473783441930 + 3 7 29.505302500625 + 4 8 43.071695950394 + 5 9 59.165686791534 + 6 10 77.776580159806 + 7 11 98.894853583980 + 8 12 122.513132271493 + 9 13 148.626189112094 + 10 14 177.230592656783 + 11 15 208.324116670413 + 12 16 241.905061644769 + 13 17 277.971722458938 + 14 18 316.522185899964 + 15 19 357.554436071786 + 16 20 401.066608041866 + 17 21 447.057187991954 + 18 22 495.525055433203 + 19 23 546.469383913223 + 20 24 599.889489594382 + + # species: Cl, l : 0 + # nodes n trial energy + 0 1 -100.974592263771 + 1 2 -8.943406336517 + 2 3 0.803820215121 + 3 4 11.265670674793 + 4 5 27.995235804499 + 5 6 50.139499480779 + 6 7 77.425670325936 + 7 8 109.722811813338 + 8 9 146.943417860813 + 9 10 189.030459361669 + 10 11 235.945624307269 + 11 12 287.660637659017 + 12 13 344.155876726629 + 13 14 405.415989381911 + 14 15 471.429355254290 + 15 16 542.186581729314 + 16 17 617.680068606306 + 17 18 697.903598826980 + 18 19 782.852080659573 + 19 20 872.521357718829 + 20 21 966.908013775037 + + # species: Cl, l : 1 + # nodes n trial energy + 0 2 -6.707821362791 + 1 3 1.442002863349 + 2 4 11.086329356142 + 3 5 26.446761118127 + 4 6 46.908960145565 + 5 7 72.314496301330 + 6 8 102.593748915541 + 7 9 137.708872214526 + 8 10 177.628868217528 + 9 11 222.335815545055 + 10 12 271.812774967963 + 11 13 326.047911895729 + 12 14 385.030908226461 + 13 15 448.753672165588 + 14 16 517.209705744261 + 15 17 590.393819776316 + 16 18 668.301879780751 + 17 19 750.930453348811 + 18 20 838.276595190814 + 19 21 930.337672332757 + 20 22 1027.111295649087 + + # species: Cl, l : 2 + # nodes n trial energy + 0 3 2.030626399034 + 1 4 9.540398069814 + 2 5 22.521806870793 + 3 6 40.580505794574 + 4 7 63.608967480673 + 5 8 91.521851721479 + 6 9 124.281100552950 + 7 10 161.847878698612 + 8 11 204.202486065259 + 9 12 251.327905737439 + 10 13 303.212514505293 + 11 14 359.847427715492 + 12 15 421.225227525549 + 13 16 487.339835071361 + 14 17 558.185801503932 + 15 18 633.758349847869 + 16 19 714.053292909356 + 17 20 799.067051302979 + 18 21 888.796639877965 + 19 22 983.239587157865 + 20 23 1082.393826570133 + + # species: Cl, l : 3 + # nodes n trial energy + 0 4 5.877668530234 + 1 5 16.734445897029 + 2 6 32.521343858776 + 3 7 53.248636736501 + 4 8 78.839760672149 + 5 9 109.265751512702 + 6 10 144.502223464379 + 7 11 184.526132806013 + 8 12 229.322756357998 + 9 13 278.877755682806 + 10 14 333.181582371119 + 11 15 392.226089160902 + 12 16 456.005243712150 + 13 17 524.514261786364 + 14 18 597.749258861014 + 15 19 675.706956131225 + 16 20 758.384407267418 + 17 21 845.778892453188 + 18 22 937.887883257667 + 19 23 1034.709065214155 + 20 24 1136.240377687629 +""" diff --git a/tests/dict_parsers/test_gw/mock_gw_info_out.py b/tests/dict_parsers/test_gw/mock_gw_info_out.py index c1bb8c4..6c472c5 100644 --- a/tests/dict_parsers/test_gw/mock_gw_info_out.py +++ b/tests/dict_parsers/test_gw/mock_gw_info_out.py @@ -1,24 +1,4 @@ -"""Outputs from GW_INFO.OUT -""" -import pytest -from excitingtools.utils.test_utils import MockFile - -from excitingtools.exciting_dict_parsers.gw_info_parser import _file_name - - -@pytest.fixture -def zro2_gw_info_out_mock(tmp_path): - file = tmp_path / _file_name - file.write_text(zro2_gw_info_out) - return MockFile(file, zro2_gw_info_out) - - -@pytest.fixture -def si_2_gw_info_out_mock(tmp_path): - file = tmp_path / _file_name - file.write_text(si_2_gw_info_out) - return MockFile(file, si_2_gw_info_out) - +"""Outputs from GW_INFO.OUT""" # GW output for ZrO2. This reports an indirect gap, as well as its direct gap zro2_gw_info_out = """ @@ -418,4 +398,3 @@ def si_2_gw_info_out_mock(tmp_path): _________________________________________________________ Total : 4.25 """ - diff --git a/tests/dict_parsers/test_gw/test_gw_dos_parser.py b/tests/dict_parsers/test_gw/test_gw_dos_parser.py index 8badf91..6754168 100644 --- a/tests/dict_parsers/test_gw/test_gw_dos_parser.py +++ b/tests/dict_parsers/test_gw/test_gw_dos_parser.py @@ -4,15 +4,16 @@ pytest --capture=tee-sys """ +import numpy as np import pytest -from excitingtools.utils.test_utils import MockFile + from excitingtools.exciting_dict_parsers.gw_eigenvalues_parser import parse_gw_dos -import numpy as np +from excitingtools.utils.test_utils import MockFile + @pytest.fixture def gw_dos_mock(tmp_path) -> MockFile: - """ Mock TDOS.OUT data for 15 energy and DOS value pairs - """ + """Mock TDOS.OUT data for 15 energy and DOS value pairs""" dos_str = """-0.5000000000 0.000000000 -0.4949748744 0.000000000 -0.4899497487 0.000000000 @@ -35,17 +36,48 @@ def gw_dos_mock(tmp_path) -> MockFile: def test_parse_gw_dos(gw_dos_mock): - """ Test parsing of energy and DOS values from TDOS.OUT - """ + """Test parsing of energy and DOS values from TDOS.OUT""" data = parse_gw_dos(gw_dos_mock.file) - ref_energies = np.array([-0.5000000000, -0.4949748744, -0.4899497487, -0.4849246231, -0.4798994975, -0.4748743719, - -0.4698492462, -0.4648241206, -0.4597989950, -0.4547738693, -0.4497487437, -0.4447236181, - -0.4396984925, -0.4346733668, -0.4296482412]) - ref_dos = np.array([0.000000000, 0.000000000, 0.000000000, 0.000000000, 0.000000000, 0.000000000, - 0.000000000, 0.000000000, 0.000000000, 0.000000000, 0.1025457101E-01, 0.1050068072, 0.3502961458, - 0.6899275378, 1.164919267]) + ref_energies = np.array( + [ + -0.5000000000, + -0.4949748744, + -0.4899497487, + -0.4849246231, + -0.4798994975, + -0.4748743719, + -0.4698492462, + -0.4648241206, + -0.4597989950, + -0.4547738693, + -0.4497487437, + -0.4447236181, + -0.4396984925, + -0.4346733668, + -0.4296482412, + ] + ) + ref_dos = np.array( + [ + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.1025457101e-01, + 0.1050068072, + 0.3502961458, + 0.6899275378, + 1.164919267, + ] + ) - assert set(data) == {'energy', 'dos'} - assert np.allclose(data['energy'], ref_energies) - assert np.allclose(data['dos'], ref_dos) + assert set(data) == {"energy", "dos"} + assert np.allclose(data["energy"], ref_energies) + assert np.allclose(data["dos"], ref_dos) diff --git a/tests/dict_parsers/test_gw/test_gw_eignvalues_parser.py b/tests/dict_parsers/test_gw/test_gw_eignvalues_parser.py index fed338f..09e924c 100644 --- a/tests/dict_parsers/test_gw/test_gw_eignvalues_parser.py +++ b/tests/dict_parsers/test_gw/test_gw_eignvalues_parser.py @@ -1,17 +1,19 @@ -import pytest import numpy as np - -from excitingtools.utils.test_utils import MockFile +import pytest from excitingtools.dataclasses.data_structs import NumberOfStates -from excitingtools.exciting_dict_parsers.gw_eigenvalues_parser import k_points_from_evalqp, n_states_from_evalqp, \ - parse_column_labels, parse_evalqp +from excitingtools.exciting_dict_parsers.gw_eigenvalues_parser import ( + k_points_from_evalqp, + n_states_from_evalqp, + parse_column_labels, + parse_evalqp, +) +from excitingtools.utils.test_utils import MockFile @pytest.fixture def evalqp_mock(tmp_path): - """ Mock EVALQP.OUT data with energies for 3 k-points, and corrections starting from the first state - """ + """Mock EVALQP.OUT data with energies for 3 k-points, and corrections starting from the first state""" evalqp_str = """k-point # 1: 0.000000 0.000000 0.000000 0.125000 state E_KS E_HF E_GW Sx Re(Sc) Im(Sc) Vxc DE_HF DE_GW Znk 1 -14.68627 -16.50308 -16.31014 -4.72516 0.17351 0.00002 -2.90835 -1.81681 -1.62387 0.98817 @@ -62,7 +64,7 @@ def evalqp_mock(tmp_path): @pytest.fixture def evalqp_mock_partial_corrections(tmp_path): - """ Mock EVALQP.OUT data with energies for 3 k-points, BUT corrections starting from NOT the + """Mock EVALQP.OUT data with energies for 3 k-points, BUT corrections starting from NOT the first state. """ evalqp_str = """k-point # 1: 0.000000 0.000000 0.000000 0.015625 @@ -92,21 +94,19 @@ def evalqp_mock_partial_corrections(tmp_path): def test_k_points_from_evalqp(evalqp_mock): - """ Test parsing of EVALQP.DAT - """ + """Test parsing of EVALQP.DAT""" ref = { - 1: {'k_point': [0.000000, 0.000000, 0.000000], 'weight': 0.125000}, - 2: {'k_point': [0.000000, 0.000000, 0.500000], 'weight': 0.500000}, - 3: {'k_point': [0.000000, 0.500000, 0.500000], 'weight': 0.375000} + 1: {"k_point": [0.000000, 0.000000, 0.000000], "weight": 0.125000}, + 2: {"k_point": [0.000000, 0.000000, 0.500000], "weight": 0.500000}, + 3: {"k_point": [0.000000, 0.500000, 0.500000], "weight": 0.375000}, } k_points_and_weights = k_points_from_evalqp(evalqp_mock.string) assert k_points_and_weights == ref, "k_points_and_weights should match reference" -def test_n_states_from_evalqp_one_kpoint(evalqp_mock): - """ Test parsing of EVALQP.DAT - """ +def test_n_states_from_evalqp_one_kpoint(): + """Test parsing of EVALQP.DAT""" evalqp_str_with_one_kpoint = """k-point # 1: 0.000000 0.000000 0.000000 0.125000 state E_KS E_HF E_GW Sx Re(Sc) Im(Sc) Vxc DE_HF DE_GW Znk 1 -14.68627 -16.50308 -16.31014 -4.72516 0.17351 0.00002 -2.90835 -1.81681 -1.62387 0.98817 @@ -142,92 +142,122 @@ def test_n_states_from_evalqp_first_state_not_one(evalqp_mock_partial_correction def test_parse_column_labels_for_oxygen(evalqp_mock): column_labels = parse_column_labels(evalqp_mock.string) - assert column_labels._member_names_ == ['E_KS', 'E_HF', 'E_GW', 'Sx', - 'Re(Sc)', 'Im(Sc)', 'Vxc', 'DE_HF', 'DE_GW', 'Znk'] + assert column_labels._member_names_ == [ + "E_KS", + "E_HF", + "E_GW", + "Sx", + "Re(Sc)", + "Im(Sc)", + "Vxc", + "DE_HF", + "DE_GW", + "Znk", + ] assert column_labels.E_KS.value == 0, "Expect first label value to start at 0" def test_parse_column_labels_for_nitrogen(evalqp_mock): column_labels = parse_column_labels(evalqp_mock.string) - assert column_labels._member_names_ == ['E_KS', 'E_HF', 'E_GW', 'Sx', - 'Re(Sc)', 'Im(Sc)', 'Vxc', 'DE_HF', 'DE_GW', 'Znk'] + assert column_labels._member_names_ == [ + "E_KS", + "E_HF", + "E_GW", + "Sx", + "Re(Sc)", + "Im(Sc)", + "Vxc", + "DE_HF", + "DE_GW", + "Znk", + ] assert column_labels.E_KS.value == 0, "Expect first label value to start at 0" def test_parse_evalqp(evalqp_mock): - """ Test parsing eigenvalues and weights from EVALQP.DAT. - """ + """Test parsing eigenvalues and weights from EVALQP.DAT.""" # Energies for all states, per k-point energies_1 = np.array( - [[-14.68627, -16.50308, -16.31014, -4.72516, 0.17351, 0.00002, -2.90835, -1.81681, -1.62387, 0.98817], - [-11.48914, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], - [-11.48914, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], - [-11.48914, -12.99338, -12.79203, -4.36834, 0.18151, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], - [-6.24399, -7.20716, -7.01655, -3.72742, 0.17144, 0.00000, -2.76425, -0.96317, -0.77257, 0.97579], - [-6.24399, -7.20716, -7.01652, -3.72742, 0.17144, -0.00001, -2.76425, -0.96317, -0.77253, 0.97575], - [-6.24340, -7.20928, -7.01752, -3.73069, 0.17268, 0.00003, -2.76481, -0.96588, -0.77412, 0.97594], - [-6.24340, -7.20928, -7.01752, -3.73069, 0.17268, 0.00003, -2.76481, -0.96588, -0.77412, 0.97594], - [-6.24340, -7.20928, -7.01752, -3.73069, 0.17268, 0.00003, -2.76481, -0.96588, -0.77412, 0.97594], - [-1.82156, -2.31023, -2.07248, -1.52298, 0.20180, 0.00623, -1.03431, -0.48867, -0.25092, 0.87468], - [-1.00771, -1.29282, -1.07701, -1.21488, 0.19790, 0.02221, -0.92977, -0.28511, -0.06930, 0.79459]] - ) + [ + [-14.68627, -16.50308, -16.31014, -4.72516, 0.17351, 0.00002, -2.90835, -1.81681, -1.62387, 0.98817], + [-11.48914, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], + [-11.48914, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], + [-11.48914, -12.99338, -12.79203, -4.36834, 0.18151, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], + [-6.24399, -7.20716, -7.01655, -3.72742, 0.17144, 0.00000, -2.76425, -0.96317, -0.77257, 0.97579], + [-6.24399, -7.20716, -7.01652, -3.72742, 0.17144, -0.00001, -2.76425, -0.96317, -0.77253, 0.97575], + [-6.24340, -7.20928, -7.01752, -3.73069, 0.17268, 0.00003, -2.76481, -0.96588, -0.77412, 0.97594], + [-6.24340, -7.20928, -7.01752, -3.73069, 0.17268, 0.00003, -2.76481, -0.96588, -0.77412, 0.97594], + [-6.24340, -7.20928, -7.01752, -3.73069, 0.17268, 0.00003, -2.76481, -0.96588, -0.77412, 0.97594], + [-1.82156, -2.31023, -2.07248, -1.52298, 0.20180, 0.00623, -1.03431, -0.48867, -0.25092, 0.87468], + [-1.00771, -1.29282, -1.07701, -1.21488, 0.19790, 0.02221, -0.92977, -0.28511, -0.06930, 0.79459], + ] + ) energies_2 = np.array( - [[-14.68627, -16.50308, -16.31014, -4.72516, 0.17351, 0.00002, -2.90835, -1.81681, -1.62387, 0.98817], - [-11.48915, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], - [-11.48915, -12.99338, -12.79204, -4.36834, 0.18151, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], - [-11.48914, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], - [-6.24401, -7.20719, -7.01653, -3.72740, 0.17145, -0.00001, -2.76423, -0.96318, -0.77252, 0.97574], - [-6.24401, -7.20719, -7.01650, -3.72740, 0.17145, -0.00001, -2.76423, -0.96318, -0.77249, 0.97570], - [-6.24345, -7.20933, -7.01756, -3.73065, 0.17268, 0.00003, -2.76477, -0.96588, -0.77411, 0.97593], - [-6.24344, -7.20932, -7.01754, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77410, 0.97593], - [-6.24344, -7.20932, -7.01755, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77411, 0.97593], - [-1.81969, -2.30723, -2.07258, -1.52476, 0.20051, 0.00867, -1.03723, -0.48754, -0.25289, 0.88104], - [-1.03473, -1.34882, -1.09998, -1.20344, 0.22865, 0.02686, -0.88936, -0.31408, -0.06525, 0.76380]] + [ + [-14.68627, -16.50308, -16.31014, -4.72516, 0.17351, 0.00002, -2.90835, -1.81681, -1.62387, 0.98817], + [-11.48915, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], + [-11.48915, -12.99338, -12.79204, -4.36834, 0.18151, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], + [-11.48914, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], + [-6.24401, -7.20719, -7.01653, -3.72740, 0.17145, -0.00001, -2.76423, -0.96318, -0.77252, 0.97574], + [-6.24401, -7.20719, -7.01650, -3.72740, 0.17145, -0.00001, -2.76423, -0.96318, -0.77249, 0.97570], + [-6.24345, -7.20933, -7.01756, -3.73065, 0.17268, 0.00003, -2.76477, -0.96588, -0.77411, 0.97593], + [-6.24344, -7.20932, -7.01754, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77410, 0.97593], + [-6.24344, -7.20932, -7.01755, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77411, 0.97593], + [-1.81969, -2.30723, -2.07258, -1.52476, 0.20051, 0.00867, -1.03723, -0.48754, -0.25289, 0.88104], + [-1.03473, -1.34882, -1.09998, -1.20344, 0.22865, 0.02686, -0.88936, -0.31408, -0.06525, 0.76380], + ] ) energies_3 = np.array( - [[-14.68627, -16.50308, -16.31014, -4.72516, 0.17351, 0.00002, -2.90835, -1.81681, -1.62387, 0.98818], - [-11.48915, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], - [-11.48915, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], - [-11.48915, -12.99338, -12.79204, -4.36834, 0.18151, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], - [-6.24405, -7.20722, -7.01657, -3.72737, 0.17144, -0.00001, -2.76420, -0.96317, -0.77252, 0.97574], - [-6.24403, -7.20721, -7.01657, -3.72738, 0.17144, -0.00000, -2.76421, -0.96317, -0.77253, 0.97575], - [-6.24346, -7.20934, -7.01757, -3.73064, 0.17268, 0.00004, -2.76476, -0.96588, -0.77411, 0.97593], - [-6.24344, -7.20932, -7.01755, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77412, 0.97594], - [-6.24344, -7.20932, -7.01755, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77411, 0.97593], - [-1.81908, -2.30620, -2.07323, -1.52531, 0.19899, 0.00760, -1.03818, -0.48712, -0.25415, 0.88205], - [-1.03685, -1.35370, -1.10126, -1.20433, 0.23184, 0.02427, -0.88748, -0.31684, -0.06441, 0.75775]] + [ + [-14.68627, -16.50308, -16.31014, -4.72516, 0.17351, 0.00002, -2.90835, -1.81681, -1.62387, 0.98818], + [-11.48915, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30290, 0.98500], + [-11.48915, -12.99338, -12.79204, -4.36834, 0.18150, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], + [-11.48915, -12.99338, -12.79204, -4.36834, 0.18151, 0.00003, -2.86410, -1.50424, -1.30289, 0.98500], + [-6.24405, -7.20722, -7.01657, -3.72737, 0.17144, -0.00001, -2.76420, -0.96317, -0.77252, 0.97574], + [-6.24403, -7.20721, -7.01657, -3.72738, 0.17144, -0.00000, -2.76421, -0.96317, -0.77253, 0.97575], + [-6.24346, -7.20934, -7.01757, -3.73064, 0.17268, 0.00004, -2.76476, -0.96588, -0.77411, 0.97593], + [-6.24344, -7.20932, -7.01755, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77412, 0.97594], + [-6.24344, -7.20932, -7.01755, -3.73066, 0.17268, 0.00003, -2.76478, -0.96588, -0.77411, 0.97593], + [-1.81908, -2.30620, -2.07323, -1.52531, 0.19899, 0.00760, -1.03818, -0.48712, -0.25415, 0.88205], + [-1.03685, -1.35370, -1.10126, -1.20433, 0.23184, 0.02427, -0.88748, -0.31684, -0.06441, 0.75775], + ] ) ref = { - 1: {'k_point': [0.000000, 0.000000, 0.000000], 'weight': 0.125000}, - 2: {'k_point': [0.000000, 0.000000, 0.500000], 'weight': 0.500000}, - 3: {'k_point': [0.000000, 0.500000, 0.500000], 'weight': 0.375000} - } + 1: {"k_point": [0.000000, 0.000000, 0.000000], "weight": 0.125000}, + 2: {"k_point": [0.000000, 0.000000, 0.500000], "weight": 0.500000}, + 3: {"k_point": [0.000000, 0.500000, 0.500000], "weight": 0.375000}, + } output = parse_evalqp(evalqp_mock.full_path) - assert set(output.keys()) == {'state_range', 'column_labels', 1, 2, 3}, \ - 'Key include state range, columns and k-indices' + assert set(output.keys()) == { + "state_range", + "column_labels", + 1, + 2, + 3, + }, "Key include state range, columns and k-indices" - del output['state_range'] - del output['column_labels'] + del output["state_range"] + del output["column_labels"] # k-points assert len(output) == 3, "Expect 3 k-points" - assert min([ik for ik in output.keys()]) == 1, 'k-point indexing starts at 1' - assert output[1]['k_point'] == ref[1]['k_point'], "Compare k-point 1 to reference" - assert output[2]['k_point'] == ref[2]['k_point'], "Compare k-point 2 to reference" - assert output[3]['k_point'] == ref[3]['k_point'], "Compare k-point 3 to reference" + assert min([ik for ik in output]) == 1, "k-point indexing starts at 1" + assert output[1]["k_point"] == ref[1]["k_point"], "Compare k-point 1 to reference" + assert output[2]["k_point"] == ref[2]["k_point"], "Compare k-point 2 to reference" + assert output[3]["k_point"] == ref[3]["k_point"], "Compare k-point 3 to reference" # Weights - assert output[1]['weight'] == ref[1]['weight'], "Compare weight 1 to reference" - assert output[2]['weight'] == ref[2]['weight'], "Compare weight 2 to reference" - assert output[3]['weight'] == ref[3]['weight'], "Compare weight 3 to reference" + assert output[1]["weight"] == ref[1]["weight"], "Compare weight 1 to reference" + assert output[2]["weight"] == ref[2]["weight"], "Compare weight 2 to reference" + assert output[3]["weight"] == ref[3]["weight"], "Compare weight 3 to reference" # Energies - assert output[1]['energies'].shape == (11, 10), 'rows = 11 states and cols = 10 energies' - assert np.allclose(output[1]['energies'], energies_1), "Compare energies 1 to reference" - assert np.allclose(output[2]['energies'], energies_2), "Compare energies 2 to reference" - assert np.allclose(output[3]['energies'], energies_3), "Compare energies 3 to reference" + assert output[1]["energies"].shape == (11, 10), "rows = 11 states and cols = 10 energies" + assert np.allclose(output[1]["energies"], energies_1), "Compare energies 1 to reference" + assert np.allclose(output[2]["energies"], energies_2), "Compare energies 2 to reference" + assert np.allclose(output[3]["energies"], energies_3), "Compare energies 3 to reference" diff --git a/tests/dict_parsers/test_gw/test_gw_eps00_parser.py b/tests/dict_parsers/test_gw/test_gw_eps00_parser.py index c255785..b99f4e4 100644 --- a/tests/dict_parsers/test_gw/test_gw_eps00_parser.py +++ b/tests/dict_parsers/test_gw/test_gw_eps00_parser.py @@ -1,14 +1,12 @@ -""" Test all GW output file parsers, except GW_INFO.OUT -""" -import pytest +"""Test all GW output file parsers, except GW_INFO.OUT""" + import numpy as np +import pytest +from excitingtools.exciting_dict_parsers.gw_eps00_parser import _file_name, parse_eps00_frequencies, parse_eps00_gw from excitingtools.utils.test_utils import MockFile from excitingtools.utils.utils import get_new_line_indices -from excitingtools.exciting_dict_parsers.gw_eps00_parser import parse_eps00_frequencies, parse_eps00_gw, \ - _file_name - @pytest.fixture def eps00_mock(tmp_path): @@ -41,62 +39,60 @@ def eps00_mock(tmp_path): def test_parse_eps00_frequencies(eps00_mock): - """ Test parsing frequencies from EPS00_GW.OUT - """ + """Test parsing frequencies from EPS00_GW.OUT""" line = get_new_line_indices(eps00_mock.string) ref = {1: 0.00529953, 2: 0.02771249, 3: 0.06718440} - assert eps00_mock.string[line[0]:line[1]].isspace(), "First line of eps_string must be a whiteline" + assert eps00_mock.string[line[0] : line[1]].isspace(), "First line of eps_string must be a whiteline" assert parse_eps00_frequencies(eps00_mock.string) == ref, "Frequency grid for eps00" def test_parse_eps00_gw(eps00_mock): - """ Test parsing EPS00_GW.OUT - """ + """Test parsing EPS00_GW.OUT""" line = get_new_line_indices(eps00_mock.string) - assert eps00_mock.string[line[0]:line[1]].isspace(), "First line of eps_string must be a whiteline" + assert eps00_mock.string[line[0] : line[1]].isspace(), "First line of eps_string must be a whiteline" ref = { 1: { - 'frequency': 0.00529953, - 'eps00': { - 're': np.array([[8.31881773, 0., 0.], [0., 8.31881773, 0.], [0., 0., 8.31881773]]), - 'img': np.array([[0., 0., 0.], [0., 0., 0.], [0., 0., 0.]]) - } + "frequency": 0.00529953, + "eps00": { + "re": np.array([[8.31881773, 0.0, 0.0], [0.0, 8.31881773, 0.0], [0.0, 0.0, 8.31881773]]), + "img": np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]), }, + }, 2: { - 'frequency': 0.02771249, - 'eps00': { - 're': np.array([[8.22189228, 0., 0.], [0., 8.22189228, 0.], [0., 0., 8.22189228]]), - 'img': np.array([[-0., 0., 0.], [0., -0., 0.], [0., 0., -0.]]) - } + "frequency": 0.02771249, + "eps00": { + "re": np.array([[8.22189228, 0.0, 0.0], [0.0, 8.22189228, 0.0], [0.0, 0.0, 8.22189228]]), + "img": np.array([[-0.0, 0.0, 0.0], [0.0, -0.0, 0.0], [0.0, 0.0, -0.0]]), }, + }, 3: { - 'frequency': 0.06718440, - 'eps00': { - 're': np.array([[7.78004308, 0.0, 0.0], [0., 7.78004308, 0.], [0., 0., 7.78004308]]), - 'img': np.array([[0., 0., 0.], [0., 0., 0.], [0., 0., 0.]]) - } - } - } + "frequency": 0.06718440, + "eps00": { + "re": np.array([[7.78004308, 0.0, 0.0], [0.0, 7.78004308, 0.0], [0.0, 0.0, 7.78004308]]), + "img": np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]), + }, + }, + } output = parse_eps00_gw(eps00_mock.file) assert len(output) == 3, "3 frequency points" - assert [k for k in output[1].keys()] == ['frequency', 'eps00'], "Frequency point 1 keys " - assert [k for k in output[2].keys()] == ['frequency', 'eps00'], "Frequency point 2 keys " - assert [k for k in output[3].keys()] == ['frequency', 'eps00'], "Frequency point 3 keys " + assert [k for k in output[1]] == ["frequency", "eps00"], "Frequency point 1 keys " + assert [k for k in output[2]] == ["frequency", "eps00"], "Frequency point 2 keys " + assert [k for k in output[3]] == ["frequency", "eps00"], "Frequency point 3 keys " - assert output[1]['frequency'] == 0.00529953, "Frequency point 1 value" - assert output[2]['frequency'] == 0.02771249, "Frequency point 2 value" - assert output[3]['frequency'] == 0.06718440, "Frequency point 3 value" + assert output[1]["frequency"] == 0.00529953, "Frequency point 1 value" + assert output[2]["frequency"] == 0.02771249, "Frequency point 2 value" + assert output[3]["frequency"] == 0.06718440, "Frequency point 3 value" - assert np.allclose(output[1]['eps00']['re'], ref[1]['eps00']['re']),"Re{eps00} at frequency point 1" - assert np.allclose(output[1]['eps00']['img'], ref[1]['eps00']['img']),"Im{eps00} at frequency point 1" + assert np.allclose(output[1]["eps00"]["re"], ref[1]["eps00"]["re"]), "Re{eps00} at frequency point 1" + assert np.allclose(output[1]["eps00"]["img"], ref[1]["eps00"]["img"]), "Im{eps00} at frequency point 1" - assert np.allclose(output[2]['eps00']['re'], ref[2]['eps00']['re']),"Re{eps00} at frequency point 2" - assert np.allclose(output[2]['eps00']['img'], ref[2]['eps00']['img']),"Im{eps00} at frequency point 2" + assert np.allclose(output[2]["eps00"]["re"], ref[2]["eps00"]["re"]), "Re{eps00} at frequency point 2" + assert np.allclose(output[2]["eps00"]["img"], ref[2]["eps00"]["img"]), "Im{eps00} at frequency point 2" - assert np.allclose(output[3]['eps00']['re'], ref[3]['eps00']['re']),"Re{eps00} at frequency point 3" - assert np.allclose(output[3]['eps00']['img'], ref[3]['eps00']['img']),"Im{eps00} at frequency point 3" + assert np.allclose(output[3]["eps00"]["re"], ref[3]["eps00"]["re"]), "Re{eps00} at frequency point 3" + assert np.allclose(output[3]["eps00"]["img"], ref[3]["eps00"]["img"]), "Im{eps00} at frequency point 3" diff --git a/tests/dict_parsers/test_gw/test_gw_info_parser.py b/tests/dict_parsers/test_gw/test_gw_info_parser.py index 75c39c1..6926d5d 100644 --- a/tests/dict_parsers/test_gw/test_gw_info_parser.py +++ b/tests/dict_parsers/test_gw/test_gw_info_parser.py @@ -6,39 +6,63 @@ """ import numpy as np - -from excitingtools.exciting_dict_parsers.gw_info_parser import parse_gw_info, parse_frequency_grid, \ - parse_correlation_self_energy_params, extract_kpoints, parse_ks_eigenstates, \ - parse_n_q_point_cycles, parse_band_structure_info, parse_mixed_product_params, \ - parse_bare_coulomb_potential_params, parse_gw_timings +import pytest + +from excitingtools.exciting_dict_parsers.gw_info_parser import ( + _file_name, + extract_kpoints, + parse_band_structure_info, + parse_bare_coulomb_potential_params, + parse_correlation_self_energy_params, + parse_frequency_grid, + parse_gw_info, + parse_gw_timings, + parse_ks_eigenstates, + parse_mixed_product_params, + parse_n_q_point_cycles, +) +from excitingtools.utils.test_utils import MockFile + +from .mock_gw_info_out import si_2_gw_info_out, zro2_gw_info_out # Text files are large, it's easier to store externally. -# Note, these fixtures are used, even if greyed out by the IDE -from . mock_gw_info_out import zro2_gw_info_out_mock, si_2_gw_info_out_mock + + +@pytest.fixture +def zro2_gw_info_out_mock(tmp_path): + file = tmp_path / _file_name + file.write_text(zro2_gw_info_out) + return MockFile(file, zro2_gw_info_out) + + +@pytest.fixture +def si_2_gw_info_out_mock(tmp_path): + file = tmp_path / _file_name + file.write_text(si_2_gw_info_out) + return MockFile(file, si_2_gw_info_out) def test_parse_correlation_self_energy_params(zro2_gw_info_out_mock): - """ Test `Correlation self-energy parameters` block of GW_INFO.OUT. - """ - reference = {'Solution of the QP equation': 0, - 'Energy alignment': 0, - 'Analytic continuation method': "PADE - Thiele's reciprocal difference method", - 'Analytic continuation method citation': "H. J. Vidberg and J. W. Serence, J. Low Temp. Phys. 29, 179 (1977)", - 'Scheme to treat singularities': 'Auxiliary function method "mpb"', - 'Scheme to treat singularities citation': 'S. Massidda, M. Posternak, and A. Baldereschi, PRB 48, 5058 (1993)' - } + """Test `Correlation self-energy parameters` block of GW_INFO.OUT.""" + reference = { + "Solution of the QP equation": 0, + "Energy alignment": 0, + "Analytic continuation method": "PADE - Thiele's reciprocal difference method", + "Analytic continuation method citation": "H. J. Vidberg and J. W. Serence, J. Low Temp. Phys. 29, 179 (1977)", + "Scheme to treat singularities": 'Auxiliary function method "mpb"', + "Scheme to treat singularities citation": "S. Massidda, M. Posternak, and A. Baldereschi, PRB 48, 5058 (1993)", + } output = parse_correlation_self_energy_params(zro2_gw_info_out_mock.string) assert reference == output, "parse_correlation_self_energy_params dictionary not consistent with reference" def test_parse_mixed_product_params(zro2_gw_info_out_mock): - """ Test `Mixed product basis parameters` block of GW_INFO.OUT. - """ + """Test `Mixed product basis parameters` block of GW_INFO.OUT.""" ref = { - 'MT Angular momentum cutoff': 4, - 'MT Linear dependence tolerance factor': 0.001, - 'Plane wave cutoff (in units of Gkmax)': 1.0 + "MT Angular momentum cutoff": 4, + "MT Linear dependence tolerance factor": 0.001, + "Plane wave cutoff (in units of Gkmax)": 1.0, } output = parse_mixed_product_params(zro2_gw_info_out_mock.string) @@ -47,12 +71,11 @@ def test_parse_mixed_product_params(zro2_gw_info_out_mock): def test_parse_bare_coulomb_potential_params(zro2_gw_info_out_mock): - """ Test `Bare Coulomb potential parameters` block of GW_INFO.OUT. - """ + """Test `Bare Coulomb potential parameters` block of GW_INFO.OUT.""" ref = { - 'Plane wave cutoff (in units of Gkmax*gmb)': 2.0, - 'Error tolerance for structure constants': 1e-16, - 'MB tolerance factor': 0.1 + "Plane wave cutoff (in units of Gkmax*gmb)": 2.0, + "Error tolerance for structure constants": 1e-16, + "MB tolerance factor": 0.1, } output = parse_bare_coulomb_potential_params(zro2_gw_info_out_mock.string) @@ -61,49 +84,106 @@ def test_parse_bare_coulomb_potential_params(zro2_gw_info_out_mock): def test_parse_frequency_grid(zro2_gw_info_out_mock): - """ Test parsing `frequency grid` block of GW_INFO.OUT. - """ + """Test parsing `frequency grid` block of GW_INFO.OUT.""" n_points = 32 f_grid = parse_frequency_grid(zro2_gw_info_out_mock.string, n_points) - ref_frequencies = np.array([5.2995325042E-03, 2.7712488463E-02, 6.7184398806E-02, 0.1222977958, 0.1910618778, - 0.2709916112, 0.3591982246, 0.4524937451, 0.5475062549, 0.6408017754, - 0.7290083888, 0.8089381222, 0.8777022042, 0.9328156012, 0.9722875115, - 0.9947004675, 1.005327767, 1.028502360, 1.072023237, 1.139338599, - 1.236188495, 1.371726328, 1.560544990, 1.826463152, 2.209975300, - 2.783978125, 3.690151129, 5.233906478, 8.176762249, 14.88440795, - 36.08481430, 188.6958895]) - - ref_weights = np.array([1.3576229706E-02, 3.1126761969E-02, 4.7579255841E-02, 6.2314485628E-02, 7.4797994408E-02, - 8.4578259698E-02, 9.1301707522E-02, 9.4725305228E-02, 9.4725305228E-02, 9.1301707522E-02, - 8.4578259698E-02, 7.4797994408E-02, 6.2314485628E-02, 4.7579255841E-02, 3.1126761969E-02, - 1.3576229706E-02, 1.3721277051E-02, 3.2926421206E-02, 5.4679689940E-02, 8.0889962951E-02, - 0.1143034524, 0.1591452545, 0.2223471090, 0.3160005534, 0.4626375217, - 0.7076370069, 1.151720377, 2.048999581, 4.166311667, 10.54097479, - 40.53058703, 483.3971183]) + ref_frequencies = np.array( + [ + 5.2995325042e-03, + 2.7712488463e-02, + 6.7184398806e-02, + 0.1222977958, + 0.1910618778, + 0.2709916112, + 0.3591982246, + 0.4524937451, + 0.5475062549, + 0.6408017754, + 0.7290083888, + 0.8089381222, + 0.8777022042, + 0.9328156012, + 0.9722875115, + 0.9947004675, + 1.005327767, + 1.028502360, + 1.072023237, + 1.139338599, + 1.236188495, + 1.371726328, + 1.560544990, + 1.826463152, + 2.209975300, + 2.783978125, + 3.690151129, + 5.233906478, + 8.176762249, + 14.88440795, + 36.08481430, + 188.6958895, + ] + ) + + ref_weights = np.array( + [ + 1.3576229706e-02, + 3.1126761969e-02, + 4.7579255841e-02, + 6.2314485628e-02, + 7.4797994408e-02, + 8.4578259698e-02, + 9.1301707522e-02, + 9.4725305228e-02, + 9.4725305228e-02, + 9.1301707522e-02, + 8.4578259698e-02, + 7.4797994408e-02, + 6.2314485628e-02, + 4.7579255841e-02, + 3.1126761969e-02, + 1.3576229706e-02, + 1.3721277051e-02, + 3.2926421206e-02, + 5.4679689940e-02, + 8.0889962951e-02, + 0.1143034524, + 0.1591452545, + 0.2223471090, + 0.3160005534, + 0.4626375217, + 0.7076370069, + 1.151720377, + 2.048999581, + 4.166311667, + 10.54097479, + 40.53058703, + 483.3971183, + ] + ) assert len(ref_frequencies) == 32, "Require 32 reference frequency points" assert len(ref_weights) == 32, "Require 32 reference weights" - assert np.allclose(f_grid[0, :], - ref_frequencies), "Frequency points parsed from gw_info_out disagree with reference" + assert np.allclose( + f_grid[0, :], ref_frequencies + ), "Frequency points parsed from gw_info_out disagree with reference" assert np.allclose(f_grid[1, :], ref_weights), "Weights parsed from gw_info_out disagree with reference" def test_parse_ks_eigenstates(zro2_gw_info_out_mock): - """ Test parsing ` Kohn-Sham eigenstates summary` block from GW_INFO.OUT. - """ + """Test parsing ` Kohn-Sham eigenstates summary` block from GW_INFO.OUT.""" ref = { - 'Maximum number of LAPW states': 847, - 'Minimal number of LAPW states': 838, - 'Number of states used in GW - total KS': 838, - 'Number of states used in GW - occupied': 21, - 'Number of states used in GW - unoccupied': 2000, - 'Number of states used in GW - dielectric function': 838, - 'Number of states used in GW - self energy': 838, - 'Energy of the highest unoccupied state': 1030.791933, - 'Number of valence electrons': 42, - 'Number of valence electrons treated in GW': 42 + "Maximum number of LAPW states": 847, + "Minimal number of LAPW states": 838, + "Number of states used in GW - total KS": 838, + "Number of states used in GW - occupied": 21, + "Number of states used in GW - unoccupied": 2000, + "Number of states used in GW - dielectric function": 838, + "Number of states used in GW - self energy": 838, + "Energy of the highest unoccupied state": 1030.791933, + "Number of valence electrons": 42, + "Number of valence electrons treated in GW": 42, } output = parse_ks_eigenstates(zro2_gw_info_out_mock.string) @@ -117,9 +197,7 @@ def test_parse_n_q_point_cycles(zro2_gw_info_out_mock): def test_extract_kpoint(zro2_gw_info_out_mock): - ref = {'VBM': {'k_point': [0.0, 0.5, 0.5], 'ik': 3}, - 'CBm': {'k_point': [0.0, 0.0, 0.0], 'ik': 1} - } + ref = {"VBM": {"k_point": [0.0, 0.5, 0.5], "ik": 3}, "CBm": {"k_point": [0.0, 0.0, 0.0], "ik": 1}} output = extract_kpoints(zro2_gw_info_out_mock.string) @@ -127,166 +205,172 @@ def test_extract_kpoint(zro2_gw_info_out_mock): def test_parse_band_structure_info(zro2_gw_info_out_mock): - """ Test parsing the `Kohn-Sham band structure` block of GW_INFO.OUT - """ + """Test parsing the `Kohn-Sham band structure` block of GW_INFO.OUT""" ks_ref = { - 'Fermi energy': 0.0, - 'Energy range': [-14.6863, 1030.7919], - 'Band index of VBM': 21, - 'Band index of CBm': 22, - 'Indirect BandGap (eV)': 3.3206, - 'Direct Bandgap at k(VBM) (eV)': 3.7482, - 'Direct Bandgap at k(CBm) (eV)': 3.8653, - 'VBM': {'k_point': [0.0, 0.5, 0.5], 'ik': 3}, - 'CBm': {'k_point': [0.0, 0.0, 0.0], 'ik': 1} + "Fermi energy": 0.0, + "Energy range": [-14.6863, 1030.7919], + "Band index of VBM": 21, + "Band index of CBm": 22, + "Indirect BandGap (eV)": 3.3206, + "Direct Bandgap at k(VBM) (eV)": 3.7482, + "Direct Bandgap at k(CBm) (eV)": 3.8653, + "VBM": {"k_point": [0.0, 0.5, 0.5], "ik": 3}, + "CBm": {"k_point": [0.0, 0.0, 0.0], "ik": 1}, } - ks_output = parse_band_structure_info(zro2_gw_info_out_mock.string, 'ks') + ks_output = parse_band_structure_info(zro2_gw_info_out_mock.string, "ks") assert ks_output == ks_ref, "Expect parsed KS band structure info to match the reference" def test_parse_g0w0_band_structure_info(si_2_gw_info_out_mock, zro2_gw_info_out_mock): - """ Test parsing the `G0W0 band structure` block of GW_INFO.OUT - """ + """Test parsing the `G0W0 band structure` block of GW_INFO.OUT""" # Direct gap g0w0_ref = { - 'Fermi energy': 0.0176, - 'Energy range': [-0.4799, 0.5045], - 'Band index of VBM': 4, - 'Band index of CBm': 5, - 'Direct BandGap (eV)': 3.2457, - 'VBM': {'k_point': [0.0, 0.0, 0.0], 'ik': 1}, - 'CBm': {'k_point': [0.0, 0.0, 0.0], 'ik': 1} + "Fermi energy": 0.0176, + "Energy range": [-0.4799, 0.5045], + "Band index of VBM": 4, + "Band index of CBm": 5, + "Direct BandGap (eV)": 3.2457, + "VBM": {"k_point": [0.0, 0.0, 0.0], "ik": 1}, + "CBm": {"k_point": [0.0, 0.0, 0.0], "ik": 1}, } - gw_output = parse_band_structure_info(si_2_gw_info_out_mock.string, 'gw') - assert gw_output == g0w0_ref, \ - "Expect parsed G0W0 band structure info to match the reference for direct gap " - + gw_output = parse_band_structure_info(si_2_gw_info_out_mock.string, "gw") + assert gw_output == g0w0_ref, "Expect parsed G0W0 band structure info to match the reference for direct gap " # Indirect gap g0w0_ref = { - 'Fermi energy': -0.0054, - 'Energy range': [-16.2632, 1031.409], - 'Band index of VBM': 21, - 'Band index of CBm': 22, - 'Indirect BandGap (eV)': 5.392, - 'Direct Bandgap at k(VBM) (eV)': 5.5472, - 'Direct Bandgap at k(CBm) (eV)': 5.9646, - 'VBM': {'k_point': [0.0, 0.5, 0.5], 'ik': 3}, - 'CBm': {'k_point': [0.0, 0.0, 0.0], 'ik': 1} + "Fermi energy": -0.0054, + "Energy range": [-16.2632, 1031.409], + "Band index of VBM": 21, + "Band index of CBm": 22, + "Indirect BandGap (eV)": 5.392, + "Direct Bandgap at k(VBM) (eV)": 5.5472, + "Direct Bandgap at k(CBm) (eV)": 5.9646, + "VBM": {"k_point": [0.0, 0.5, 0.5], "ik": 3}, + "CBm": {"k_point": [0.0, 0.0, 0.0], "ik": 1}, } - gw_output = parse_band_structure_info(zro2_gw_info_out_mock.string, 'gw') - assert gw_output == g0w0_ref, \ - "Expect parsed G0W0 band structure info to match the reference for indirect gap" + gw_output = parse_band_structure_info(zro2_gw_info_out_mock.string, "gw") + assert gw_output == g0w0_ref, "Expect parsed G0W0 band structure info to match the reference for indirect gap" def test_parse_gw_info(zro2_gw_info_out_mock): - """ Test parsing of the whole GW_INFO.OUT - """ + """Test parsing of the whole GW_INFO.OUT""" # Reference, without frequencies_weights - ref = {'correlation_self_energy_parameters': {'Solution of the QP equation': 0, - 'Energy alignment': 0, - 'Analytic continuation method': "PADE - Thiele's reciprocal difference method", - 'Analytic continuation method citation': - 'H. J. Vidberg and J. W. Serence, J. Low Temp. Phys. 29, 179 (1977)', - 'Scheme to treat singularities': 'Auxiliary function method "mpb"', - 'Scheme to treat singularities citation': - 'S. Massidda, M. Posternak, and A. Baldereschi, PRB 48, 5058 (1993)'}, - 'mixed_product_basis_parameters': {'MT Angular momentum cutoff': 4, - 'MT Linear dependence tolerance factor': 0.001, - 'Plane wave cutoff (in units of Gkmax)': 1.0}, - 'bare_coulomb_potential_parameters': {'Plane wave cutoff (in units of Gkmax*gmb)': 2.0, - 'Error tolerance for structure constants': 1e-16, - 'MB tolerance factor': 0.1}, - 'screened_coulomb_potential': 'Full-frequency Random-Phase Approximation', - 'core_electrons_treatment': 'all - Core states are included in all calculations', - 'qp_interval': [1, 2000], - 'n_empty': 2000, - 'q_grid': [2, 2, 2], - 'mixed_product_wf_info': {'Maximal number of MT wavefunctions per atom': 1069, - 'Total number of MT wavefunctions': 2733, - 'Maximal number of PW wavefunctions': 468, - 'Total number of mixed-product wavefunctions': 3201}, - 'frequency_grid': {'Type: < fgrid >': 'gauleg2', - 'Frequency axis: < fconv >': 'imfreq', - 'Number of frequencies: < nomeg >': 32, - 'Cutoff frequency: < freqmax >': 1.0}, - 'ks_eigenstates_summary': {'Maximum number of LAPW states': 847, - 'Minimal number of LAPW states': 838, - 'Number of states used in GW - total KS': 838, - 'Number of states used in GW - occupied': 21, - 'Number of states used in GW - unoccupied': 2000, - 'Number of states used in GW - dielectric function': 838, - 'Number of states used in GW - self energy': 838, - 'Energy of the highest unoccupied state': 1030.791933, - 'Number of valence electrons': 42, - 'Number of valence electrons treated in GW': 42}, - 'ks_band_structure_summary': {'Fermi energy': 0.0, - 'Energy range': [-14.6863, 1030.7919], - 'Band index of VBM': 21, 'Band index of CBm': 22, - 'Indirect BandGap (eV)': 3.3206, - 'Direct Bandgap at k(VBM) (eV)': 3.7482, - 'Direct Bandgap at k(CBm) (eV)': 3.8653, - 'VBM': {'k_point': [0.0, 0.5, 0.5], 'ik': 3}, - 'CBm': {'k_point': [0.0, 0.0, 0.0], 'ik': 1}}, - 'n_q_cycles': 2, - 'g0w0_band_structure_summary': {'Fermi energy': -0.0054, - 'Energy range': [-16.2632, 1031.409], - 'Band index of VBM': 21, - 'Band index of CBm': 22, - 'Indirect BandGap (eV)': 5.392, - 'Direct Bandgap at k(VBM) (eV)': 5.5472, - 'Direct Bandgap at k(CBm) (eV)': 5.9646, - 'VBM': {'k_point': [0.0, 0.5, 0.5], 'ik': 3}, - 'CBm': {'k_point': [0.0, 0.0, 0.0], 'ik': 1}} - } + ref = { + "correlation_self_energy_parameters": { + "Solution of the QP equation": 0, + "Energy alignment": 0, + "Analytic continuation method": "PADE - Thiele's reciprocal difference method", + "Analytic continuation method citation": "H. J. Vidberg and J. W. Serence, J. Low Temp. Phys. 29, 179 (1977)", + "Scheme to treat singularities": 'Auxiliary function method "mpb"', + "Scheme to treat singularities citation": "S. Massidda, M. Posternak, and A. Baldereschi, PRB 48, 5058 (1993)", + }, + "mixed_product_basis_parameters": { + "MT Angular momentum cutoff": 4, + "MT Linear dependence tolerance factor": 0.001, + "Plane wave cutoff (in units of Gkmax)": 1.0, + }, + "bare_coulomb_potential_parameters": { + "Plane wave cutoff (in units of Gkmax*gmb)": 2.0, + "Error tolerance for structure constants": 1e-16, + "MB tolerance factor": 0.1, + }, + "screened_coulomb_potential": "Full-frequency Random-Phase Approximation", + "core_electrons_treatment": "all - Core states are included in all calculations", + "qp_interval": [1, 2000], + "n_empty": 2000, + "q_grid": [2, 2, 2], + "mixed_product_wf_info": { + "Maximal number of MT wavefunctions per atom": 1069, + "Total number of MT wavefunctions": 2733, + "Maximal number of PW wavefunctions": 468, + "Total number of mixed-product wavefunctions": 3201, + }, + "frequency_grid": { + "Type: < fgrid >": "gauleg2", + "Frequency axis: < fconv >": "imfreq", + "Number of frequencies: < nomeg >": 32, + "Cutoff frequency: < freqmax >": 1.0, + }, + "ks_eigenstates_summary": { + "Maximum number of LAPW states": 847, + "Minimal number of LAPW states": 838, + "Number of states used in GW - total KS": 838, + "Number of states used in GW - occupied": 21, + "Number of states used in GW - unoccupied": 2000, + "Number of states used in GW - dielectric function": 838, + "Number of states used in GW - self energy": 838, + "Energy of the highest unoccupied state": 1030.791933, + "Number of valence electrons": 42, + "Number of valence electrons treated in GW": 42, + }, + "ks_band_structure_summary": { + "Fermi energy": 0.0, + "Energy range": [-14.6863, 1030.7919], + "Band index of VBM": 21, + "Band index of CBm": 22, + "Indirect BandGap (eV)": 3.3206, + "Direct Bandgap at k(VBM) (eV)": 3.7482, + "Direct Bandgap at k(CBm) (eV)": 3.8653, + "VBM": {"k_point": [0.0, 0.5, 0.5], "ik": 3}, + "CBm": {"k_point": [0.0, 0.0, 0.0], "ik": 1}, + }, + "n_q_cycles": 2, + "g0w0_band_structure_summary": { + "Fermi energy": -0.0054, + "Energy range": [-16.2632, 1031.409], + "Band index of VBM": 21, + "Band index of CBm": 22, + "Indirect BandGap (eV)": 5.392, + "Direct Bandgap at k(VBM) (eV)": 5.5472, + "Direct Bandgap at k(CBm) (eV)": 5.9646, + "VBM": {"k_point": [0.0, 0.5, 0.5], "ik": 3}, + "CBm": {"k_point": [0.0, 0.0, 0.0], "ik": 1}, + }, + } output = parse_gw_info(zro2_gw_info_out_mock.file) # frequencies and weights tested in separate unit test - f_w = output['frequency_grid'].pop('frequencies_weights') + output["frequency_grid"].pop("frequencies_weights") assert output == ref, "Output from parse_gw_info does not agree with reference dictionary" def test_parse_gw_timings(zro2_gw_info_out_mock): - """ Test parsing `GW timing info` block in GW_INFO.OUT - """ + """Test parsing `GW timing info` block in GW_INFO.OUT""" ref = { - 'Initialization': { - 'Initialization': 15.46, - 'init_scf': 8.38, - 'init_kpt': 0.04, - 'init_eval': 0.02, - 'init_freq': 0.0, - 'init_mb': 6.76 - }, - 'Subroutines': { - 'Subroutines': None, - 'calcpmat': 5.12, - 'calcbarcmb': 5.65, - 'BZ integration weights': 18.35 + "Initialization": { + "Initialization": 15.46, + "init_scf": 8.38, + "init_kpt": 0.04, + "init_eval": 0.02, + "init_freq": 0.0, + "init_mb": 6.76, }, - 'Dielectric function': { - 'Dielectric function': 422.09, - 'head': 0.3, - 'wings': 70.14, - 'body (not timed)': 0.0, - 'inversion': 1.72 + "Subroutines": {"Subroutines": None, "calcpmat": 5.12, "calcbarcmb": 5.65, "BZ integration weights": 18.35}, + "Dielectric function": { + "Dielectric function": 422.09, + "head": 0.3, + "wings": 70.14, + "body (not timed)": 0.0, + "inversion": 1.72, }, - 'WF products expansion': { - 'WF products expansion': 2525.59, - 'diagsgi': 0.24, - 'calcmpwipw': 0.04, - 'calcmicm': 2.63, - 'calcminc': 2.1, - 'calcminm': 2520.58 + "WF products expansion": { + "WF products expansion": 2525.59, + "diagsgi": 0.24, + "calcmpwipw": 0.04, + "calcmicm": 2.63, + "calcminc": 2.1, + "calcminm": 2520.58, }, - 'Self-energy': {'Self-energy': 7069.46, 'calcselfx': 43.33, 'calcselfc': 7026.14}, - 'calcvxcnn': {'calcvxcnn': 27.52}, - 'input/output': {'input/output': 0.0}, - 'Total': {'Total': 7555.78} + "Self-energy": {"Self-energy": 7069.46, "calcselfx": 43.33, "calcselfc": 7026.14}, + "calcvxcnn": {"calcvxcnn": 27.52}, + "input/output": {"input/output": 0.0}, + "Total": {"Total": 7555.78}, } - assert parse_gw_timings(zro2_gw_info_out_mock.string) == ref, "Parsed timings do not agree with reference dictionary" + assert ( + parse_gw_timings(zro2_gw_info_out_mock.string) == ref + ), "Parsed timings do not agree with reference dictionary" diff --git a/tests/dict_parsers/test_gw/test_gw_taskgroup_parser.py b/tests/dict_parsers/test_gw/test_gw_taskgroup_parser.py new file mode 100644 index 0000000..23ec129 --- /dev/null +++ b/tests/dict_parsers/test_gw/test_gw_taskgroup_parser.py @@ -0,0 +1,145 @@ +""" +Tests for the gw_taskgroup_parser +""" + +import numpy as np +import pytest + +from excitingtools.exciting_dict_parsers.gw_taskgroup_parser import ( + parse_barc, + parse_epsilon, + parse_inverse_epsilon, + parse_sgi, +) + +rectangular_matrix = """ 2 +1 1 2 3 +(1.01E-4,-5.5E-8) +(0.25,0.77) +(0.25,0.77) +(0.000000000000000E+000,0.000000000000000E+000) +(5.4E+005,-1.1) +(-5.4E+005,1.1) +""" + +reference_rectangular_matrix = { + "matrix": np.array( + [ + [complex(1.01e-4, -5.5e-8), complex(0.25, 0.77), complex(5.4e5, -1.1)], + [complex(0.25, 0.77), complex(0.0, 0.0), complex(-5.4e5, 1.1)], + ] + ) +} + +square_matrix = """ 2 +1 1 2 2 +(1.01E-4,-5.5E-8) (0.000000000000000E+000,0.000000000000000E+000) +(5.4E+005,-1.1) +(-5.4E+005,1.1) +""" + +reference_square_matrix = { + "matrix": np.array([[complex(1.01e-4, -5.5e-8), complex(5.4e5, -1.1)], [complex(0.0, 0.0), complex(-5.4e5, 1.1)]]) +} + + +@pytest.mark.parametrize( + ["barc_file_str", "reference_barc"], + [(rectangular_matrix, reference_rectangular_matrix), (square_matrix, reference_square_matrix)], +) +def test_parse_barc(barc_file_str, reference_barc, tmp_path): + barc_file_path = tmp_path / "BARC_1.OUT" + barc_file_path.write_text(barc_file_str) + barc = parse_barc(barc_file_path.as_posix()) + A = reference_barc["matrix"] + ref = {"CoulombMatrix": np.matmul(A.T.conj(), A)} + np.testing.assert_allclose(barc["CoulombMatrix"], ref["CoulombMatrix"]) + + +@pytest.mark.parametrize( + ["sgi_file_str", "reference_sgi"], + [(rectangular_matrix, reference_rectangular_matrix), (square_matrix, reference_square_matrix)], +) +def test_parse_sgi(sgi_file_str, reference_sgi, tmp_path): + sgi_file_path = tmp_path / "SGI_1.OUT" + sgi_file_path.write_text(sgi_file_str) + sgi = parse_sgi(sgi_file_path.as_posix()) + A = reference_sgi["matrix"] + ref = {"OverlapMatrix": np.matmul(A.T.conj(), A)} + np.testing.assert_allclose(sgi["OverlapMatrix"], ref["OverlapMatrix"]) + + +array_of_rank_3_example_1 = """ 3 +1 1 1 2 2 1 +(1.01E-4,-5.5E-8) (0.25,0.77) +(0.35,0.88) (0.000000000000000E+000,0.000000000000000E+000) +""" + +reference_array_of_rank_3_example_1 = { + "array": np.array( + [[[complex(1.01e-4, -5.5e-8)], [complex(0.35, 0.88)]], [[complex(0.25, 0.77)], [complex(0.0, 0.0)]]] + ) +} + +array_of_rank_3_example_2 = """ 3 +1 1 1 3 4 2 +(1.01E-4,-5.5E-8) (0.25,0.77) +(0.35,0.88) (0.000000000000000E+000,0.000000000000000E+000) +(-1.01E+004,-5.4E-8) (0.19,0.21) (1.5E-3,-9E6) +(0.07,0.00) (0.08,0.09) (1.1,2.2) (-4.5,-2.1) (-6.0E+000,8.0E-006) +(0.1,0.2) (1.5,2.5) (3.5,7.8) (7.0,8.7) (5.0,6.5) (8.0,9.1) +(0.01,0.00) (0.00,0.01) (0.02,0.00) (0.00,0.02) (0.3,0.0) (0.0,0.3) +""" + +reference_array_of_rank_3_example_2 = { + "array": np.array( + [ + [ + [complex(1.01e-4, -5.5e-8), complex(0.1, 0.2)], + [complex(0.0, 0.0), complex(7.0, 8.7)], + [complex(1.5e-3, -9e6), complex(0.01, 0.00)], + [complex(1.1, 2.2), complex(0.00, 0.02)], + ], + [ + [complex(0.25, 0.77), complex(1.5, 2.5)], + [complex(-1.01e4, -5.4e-8), complex(5.0, 6.5)], + [complex(0.07, 0.00), complex(0.00, 0.01)], + [complex(-4.5, -2.1), complex(0.3, 0.0)], + ], + [ + [complex(0.35, 0.88), complex(3.5, 7.8)], + [complex(0.19, 0.21), complex(8.0, 9.1)], + [complex(0.08, 0.09), complex(0.02, 0.00)], + [complex(-6, 8e-6), complex(0.0, 0.3)], + ], + ] + ) +} + + +@pytest.mark.parametrize( + ["file_epsilon_str", "reference_epsilon"], + [ + (array_of_rank_3_example_1, reference_array_of_rank_3_example_1), + (array_of_rank_3_example_2, reference_array_of_rank_3_example_2), + ], +) +def test_parse_epsilon(file_epsilon_str, reference_epsilon, tmp_path): + epsilon_file_path = tmp_path / "EPSILON-GW_Q1.OUT" + epsilon_file_path.write_text(file_epsilon_str) + epsilon = parse_epsilon(epsilon_file_path.as_posix()) + np.testing.assert_allclose(epsilon["epsilon_tensor"], reference_epsilon["array"]) + + +@pytest.mark.parametrize( + ["file_inverse_epsilon_str", "reference_inverse_epsilon"], + [ + (array_of_rank_3_example_1, reference_array_of_rank_3_example_1), + (array_of_rank_3_example_2, reference_array_of_rank_3_example_2), + ], +) +def test_parse_inverse_epsilon(file_inverse_epsilon_str, reference_inverse_epsilon, tmp_path): + inverse_epsilon_file_path = tmp_path / "INVERSE-EPSILON_Q1.OUT" + inverse_epsilon_file_path.write_text(file_inverse_epsilon_str) + inverse_epsilon = parse_inverse_epsilon(inverse_epsilon_file_path.as_posix()) + np.testing.assert_allclose(inverse_epsilon["inverse_epsilon_tensor"], reference_inverse_epsilon["array"]) diff --git a/tests/dict_parsers/test_gw/test_gw_vxc_parser.py b/tests/dict_parsers/test_gw/test_gw_vxc_parser.py index 7882cff..44b60b6 100644 --- a/tests/dict_parsers/test_gw/test_gw_vxc_parser.py +++ b/tests/dict_parsers/test_gw/test_gw_vxc_parser.py @@ -1,14 +1,13 @@ -import pytest import numpy as np +import pytest -from excitingtools.utils.test_utils import MockFile from excitingtools.exciting_dict_parsers.gw_vxc_parser import parse_vxcnn, vkl_from_vxc +from excitingtools.utils.test_utils import MockFile @pytest.fixture def vxc_mock(tmp_path): - """ Mock VXCNN.DAT data with energies for 3 k-points - """ + """Mock VXCNN.DAT data with energies for 3 k-points""" vxc_string = """ik= 1 vkl= 0.0000 0.0000 0.0000 1 -2.908349 -0.000000 2 -2.864103 0.000000 @@ -55,46 +54,76 @@ def vxc_mock(tmp_path): def test_vkl_from_vxc(vxc_mock): # k-points in units of the lattice vectors - vkl_ref = { - 1: [0.0000, 0.0000, 0.0000], 2: [0.0000, 0.0000, 0.5000], 3: [0.0000, 0.5000, 0.5000] - } + vkl_ref = {1: [0.0000, 0.0000, 0.0000], 2: [0.0000, 0.0000, 0.5000], 3: [0.0000, 0.5000, 0.5000]} output = vkl_from_vxc(vxc_mock.string) assert len(output) == 3, "Expect 3 k-points" assert output == vkl_ref, "vkl values equal to vkl_ref" def test_parse_vxcnn(vxc_mock): - # Reference V_xc extracted from vxc_string, defined above - v_xc_1 = np.array([[-2.908349, -0.000000], [-2.864103, 0.000000], [-2.864103, -0.000000], - [-2.864103, -0.000000], [-2.764246, -0.000000], [-2.764246, 0.000000], - [-2.764809, -0.000000], [-2.764809, -0.000000], [-2.764809, -0.000000], - [-1.034312, -0.000000], [-0.929773, -0.000000]]) - - v_xc_2 = np.array([[-2.908349, 0.000000], [-2.864100, -0.000000], [-2.864100, 0.000000], - [-2.864101, -0.000000], [-2.764227, -0.000000], [-2.764227, 0.000000], - [-2.764770, 0.000000], [-2.764777, 0.000000], [-2.764777, 0.000000], - [-1.037228, -0.000000], [-0.889360, 0.000000]]) - - v_xc_3 = np.array([[-2.908349, -0.000000], [-2.864099, -0.000000], [-2.864099, -0.000000], - [-2.864099, 0.000000], [-2.764195, 0.000000], [-2.764208, 0.000000], - [-2.764760, -0.000000], [-2.764780, -0.000000], [-2.764780, -0.000000], - [-1.038185, 0.000000], [-0.887485, -0.000000]]) + v_xc_1 = np.array( + [ + [-2.908349, -0.000000], + [-2.864103, 0.000000], + [-2.864103, -0.000000], + [-2.864103, -0.000000], + [-2.764246, -0.000000], + [-2.764246, 0.000000], + [-2.764809, -0.000000], + [-2.764809, -0.000000], + [-2.764809, -0.000000], + [-1.034312, -0.000000], + [-0.929773, -0.000000], + ] + ) + + v_xc_2 = np.array( + [ + [-2.908349, 0.000000], + [-2.864100, -0.000000], + [-2.864100, 0.000000], + [-2.864101, -0.000000], + [-2.764227, -0.000000], + [-2.764227, 0.000000], + [-2.764770, 0.000000], + [-2.764777, 0.000000], + [-2.764777, 0.000000], + [-1.037228, -0.000000], + [-0.889360, 0.000000], + ] + ) + + v_xc_3 = np.array( + [ + [-2.908349, -0.000000], + [-2.864099, -0.000000], + [-2.864099, -0.000000], + [-2.864099, 0.000000], + [-2.764195, 0.000000], + [-2.764208, 0.000000], + [-2.764760, -0.000000], + [-2.764780, -0.000000], + [-2.764780, -0.000000], + [-1.038185, 0.000000], + [-0.887485, -0.000000], + ] + ) output = parse_vxcnn(vxc_mock.file) - assert [key for key in output[1].keys()] == ['vkl', 'v_xc_nn'], "Key consistency for ik=1 of parsed vxcnn" - assert [key for key in output[2].keys()] == ['vkl', 'v_xc_nn'], "Key consistency for ik=2 of parsed vxcnn" - assert [key for key in output[3].keys()] == ['vkl', 'v_xc_nn'], "Key consistency for ik=3 of parsed vxcnn" + assert [key for key in output[1]] == ["vkl", "v_xc_nn"], "Key consistency for ik=1 of parsed vxcnn" + assert [key for key in output[2]] == ["vkl", "v_xc_nn"], "Key consistency for ik=2 of parsed vxcnn" + assert [key for key in output[3]] == ["vkl", "v_xc_nn"], "Key consistency for ik=3 of parsed vxcnn" - assert output[1]['vkl'] == [0.0000, 0.0000, 0.0000], "vkl (ik=1)" - assert output[2]['vkl'] == [0.0000, 0.0000, 0.5000], "vkl (ik=2)" - assert output[3]['vkl'] == [0.0000, 0.5000, 0.5000], "vkl (ik=3)" + assert output[1]["vkl"] == [0.0000, 0.0000, 0.0000], "vkl (ik=1)" + assert output[2]["vkl"] == [0.0000, 0.0000, 0.5000], "vkl (ik=2)" + assert output[3]["vkl"] == [0.0000, 0.5000, 0.5000], "vkl (ik=3)" - assert output[1]['v_xc_nn'].shape == (11, 2), "Expect V_xc to have 2 cols for 11 states" - assert output[2]['v_xc_nn'].shape == (11, 2), "Expect V_xc to have 2 cols for 11 states" - assert output[3]['v_xc_nn'].shape == (11, 2), "Expect V_xc to have 2 cols for 11 states" + assert output[1]["v_xc_nn"].shape == (11, 2), "Expect V_xc to have 2 cols for 11 states" + assert output[2]["v_xc_nn"].shape == (11, 2), "Expect V_xc to have 2 cols for 11 states" + assert output[3]["v_xc_nn"].shape == (11, 2), "Expect V_xc to have 2 cols for 11 states" - assert np.allclose(output[1]['v_xc_nn'], v_xc_1), "v_xc_nn for ik=1" - assert np.allclose(output[2]['v_xc_nn'], v_xc_2), "v_xc_nn for ik=2" - assert np.allclose(output[3]['v_xc_nn'], v_xc_3), "v_xc_nn for ik=3" + assert np.allclose(output[1]["v_xc_nn"], v_xc_1), "v_xc_nn for ik=1" + assert np.allclose(output[2]["v_xc_nn"], v_xc_2), "v_xc_nn for ik=2" + assert np.allclose(output[3]["v_xc_nn"], v_xc_3), "v_xc_nn for ik=3" diff --git a/tests/dict_parsers/test_input_parser.py b/tests/dict_parsers/test_input_parser.py index f870aaa..501db94 100644 --- a/tests/dict_parsers/test_input_parser.py +++ b/tests/dict_parsers/test_input_parser.py @@ -1,32 +1,63 @@ """ Test for the input.xml file parser """ -import pytest -from excitingtools.exciting_dict_parsers.input_parser import parse_groundstate, parse_structure, parse_xs, \ - parse_input_xml +import pytest +from excitingtools.exciting_dict_parsers.input_parser import parse_element_xml, parse_input_xml, parse_structure reference_input_str = """ - + Lithium Fluoride BSE - + 3.80402 3.80402 0.00000 3.80402 0.00000 3.80402 0.00000 3.80402 3.80402 + + + + - + + + + + + + + + + + + + + + + + + + + + keyword1 keyword2 + """ +def test_parse_title(): + assert parse_element_xml(reference_input_str, tag="title") == "Lithium Fluoride BSE" + + +def test_parse_keywords(): + assert parse_element_xml(reference_input_str, tag="keywords") == "keyword1 keyword2" + + def test_parse_groundstate(): - ground_state = parse_groundstate(reference_input_str) + ground_state = parse_element_xml(reference_input_str, tag="groundstate") assert ground_state == { - 'xctype': 'GGA_PBE', 'ngridk': '4 4 4', - 'epsengy': '1d-7', 'outputlevel': 'high' - } + "xctype": "GGA_PBE", + "ngridk": [4, 4, 4], + "epsengy": 1e-7, + "outputlevel": "high", + "spin": {"bfieldc": [0, 0, 0], "fixspin": "total FSM"}, + "OEP": {"maxitoep": 100}, + } + + +def test_parse_groundstate_from_gs_root(): + ground_state = parse_element_xml( + '', tag="groundstate" + ) + assert ground_state == {"xctype": "GGA_PBE", "ngridk": [4, 4, 4], "epsengy": 1e-7, "outputlevel": "high"} def test_parse_structure(): structure = parse_structure(reference_input_str) structure_ref = { - 'atoms': [{'species': 'Li', 'position': [0.0, 0.0, 0.0], - 'bfcmt': '0.0 0.0 0.0'}, - {'species': 'F', 'position': [0.5, 0.5, 0.5], - 'lockxyz': 'false true false'}], - 'lattice': [[3.80402, 3.80402, 0.0], - [3.80402, 0.0, 3.80402], - [0.0, 3.80402, 3.80402]], - 'species_path': '.', - 'structure_properties': {'autormt': 'false', 'epslat': '1.0d-6'}, - 'crystal_properties': {'scale': '1.0', 'stretch': '1.0'}, - 'species_properties': {'Li': {'rmt': '1.5'}, 'F': {}} - } + "atoms": [ + {"species": "Li", "position": [0.0, 0.0, 0.0], "bfcmt": [0.0, 0.0, 0.0]}, + {"species": "F", "position": [0.5, 0.5, 0.5], "lockxyz": [False, True, False]}, + ], + "lattice": [[3.80402, 3.80402, 0.0], [3.80402, 0.0, 3.80402], [0.0, 3.80402, 3.80402]], + "species_path": ".", + "crystal_properties": {"scale": 1.0, "stretch": [1.0, 1.0, 1.0]}, + "species_properties": { + "F": {"LDAplusU": {"J": 2.3, "U": 0.5, "l": 3}}, + "Li": { + "dfthalfparam": {"ampl": 1, "cut": 3.9, "exponent": 8, "shell": [{"ionization": 0.25, "number": 0}]}, + "rmt": 1.5, + }, + }, + "autormt": False, + "epslat": 1.0e-6, + } assert structure_ref == structure def test_parse_xs(): - xs = parse_xs(reference_input_str) + xs = parse_element_xml(reference_input_str, tag="xs") xs_ref = { - 'xstype': 'BSE', - 'xs_properties': { - 'ngridq': '3 3 3', - 'vkloff': '0.05 0.15 0.25', - 'nempty': '1', - 'broad': '0.0073499', - 'nosym': 'true' - }, - 'energywindow': {'intv': '0.0 1.0', 'points': '50'}, - 'screening': {'screentype': 'full', 'nempty': '115'}, - 'BSE': {'bsetype': 'singlet', 'nstlbse': '1 5 1 2', 'aresbse': 'false'}, - 'qpointset': [[0.0, 0.0, 0.0]], - 'plan': ['screen', 'bse'] - } + "xstype": "BSE", + "ngridq": [3, 3, 3], + "vkloff": [0.05, 0.15, 0.25], + "nempty": 1, + "broad": 0.0073499, + "nosym": True, + "energywindow": {"intv": [0.0, 1.0], "points": 50}, + "screening": {"screentype": "full", "nempty": 115}, + "BSE": {"bsetype": "singlet", "nstlbse": [1, 5, 1, 2], "aresbse": False}, + "qpointset": [[0.0, 0.0, 0.0]], + "plan": ["screen", "bse"], + } assert xs_ref == xs + assert isinstance(xs["ngridq"][0], int) -def test_parse_input_xml(): - parsed_data = parse_input_xml(reference_input_str) - assert set(parsed_data.keys()) == {'groundstate', 'structure', 'xs'} - parsed_objects = parse_input_xml(reference_input_str) - assert set(parsed_objects.keys()) == {'groundstate', 'structure', 'xs'} - +input_ref_parsed_keys = {"title", "groundstate", "structure", "xs", "sharedfs", "properties", "keywords"} -reference_input_str_without_xs = """ - - - Lithium Fluoride BSE - - - - 3.80402 3.80402 0.00000 - 3.80402 0.00000 3.80402 - 0.00000 3.80402 3.80402 - - - - - - - - - - - - -""" - - -def test_parse_xs_no_xs(): - xs = parse_xs(reference_input_str_without_xs) - assert xs == {} - - -reference_input_str_warning = """ - - - - - - - -""" +def test_parse_input_xml(): + parsed_data = parse_element_xml(reference_input_str) + assert set(parsed_data.keys()) == input_ref_parsed_keys + assert parsed_data["sharedfs"] -def test_parse_xs_warning(): - with pytest.warns(UserWarning, match='Subelement transitions not yet supported. Its ignored...'): - xs = parse_xs(reference_input_str_warning) - assert xs == {'xstype': 'BSE', - 'xs_properties': - {'ngridq': '3 3 3', 'vkloff': '0.05 0.15 0.25', - 'nempty': '1', 'broad': '0.0073499', 'nosym': 'true'}} +def test_parse_input_xml_directly(): + parsed_data = parse_input_xml(reference_input_str) + assert set(parsed_data.keys()) == input_ref_parsed_keys + + +def test_parse_missing_tag(): + with pytest.raises(ValueError, match="Your specified input has no tag missing_tag"): + parse_element_xml(reference_input_str, "missing_tag") + + +def test_parse_input_xml_with_tag(): + parsed_data = parse_element_xml(reference_input_str, tag="input") + assert set(parsed_data.keys()) == input_ref_parsed_keys + + +def test_parse_properties(): + properties = parse_element_xml(reference_input_str, tag="properties") + properties_ref = { + "dos": {"nsmdos": 2, "ngrdos": 300, "nwdos": 1000, "winddos": [-0.3, 0.3]}, + "bandstructure": { + "plot1d": { + "path": { + "steps": 100, + "point": [ + {"coord": [1, 0, 0], "label": "Gamma"}, + {"coord": [0.625, 0.375, 0], "label": "K"}, + {"coord": [0.5, 0.5, 0], "label": "X", "breakafter": True}, + {"coord": [0, 0, 0], "label": "Gamma"}, + {"coord": [0.5, 0, 0], "label": "L"}, + ], + } + } + }, + } + assert properties_ref == properties diff --git a/tests/dict_parsers/test_ks_band_structure.py b/tests/dict_parsers/test_ks_band_structure.py index 3fd495d..785d2f6 100644 --- a/tests/dict_parsers/test_ks_band_structure.py +++ b/tests/dict_parsers/test_ks_band_structure.py @@ -1,12 +1,13 @@ import numpy as np import pytest + +from excitingtools.exciting_dict_parsers.properties_parser import parse_band_structure_dat, parse_band_structure_xml from excitingtools.utils.test_utils import MockFile -from excitingtools.exciting_dict_parsers.properties_parser import parse_band_structure_xml, parse_band_structure_dat @pytest.fixture def band_structure_dat_mock(tmp_path) -> MockFile: - """ Mock 'bandstructure.dat' data, containing only two bands and + """Mock 'bandstructure.dat' data, containing only two bands and only 6 k-sampling points per band. """ bs_dat_str = """# 1 2 6 @@ -32,69 +33,86 @@ def band_structure_dat_mock(tmp_path) -> MockFile: def test_parse_band_structure_xml(): band_data = parse_band_structure_xml(band_structure_xml) - assert band_data['n_kpts'] == band_data['band_energies'].shape[0], ( - "First dim of bands array equals the number of k-sampling points in the band structure") - assert band_data['n_kpts'] == 6, "sampling points per band" - assert band_data['n_bands'] == 2, "band_structure_xml contains two bands" + assert ( + band_data["n_kpts"] == band_data["band_energies"].shape[0] + ), "First dim of bands array equals the number of k-sampling points in the band structure" + assert band_data["n_kpts"] == 6, "sampling points per band" + assert band_data["n_bands"] == 2, "band_structure_xml contains two bands" - ref_k_points = [0., 0.04082159, 0.08164318, 0.12246477, 0.16328636, 0.20410795] + ref_k_points = [0.0, 0.04082159, 0.08164318, 0.12246477, 0.16328636, 0.20410795] - ref_bands = np.array([[-0.45003454, -0.00937631], - [-0.44931675, -0.01419609], - [-0.44716535, -0.02681183], - [-0.44358550, -0.04401707], - [-0.43858583, -0.06361773], - [-0.43217908, -0.0844593]]) + ref_bands = np.array( + [ + [-0.45003454, -0.00937631], + [-0.44931675, -0.01419609], + [-0.44716535, -0.02681183], + [-0.44358550, -0.04401707], + [-0.43858583, -0.06361773], + [-0.43217908, -0.0844593], + ] + ) - assert np.allclose(band_data['k_points_along_band'], ref_k_points, atol=1.e-8) - assert np.allclose(band_data['band_energies'], ref_bands, atol=1.e-8) + assert np.allclose(band_data["k_points_along_band"], ref_k_points, atol=1.0e-8) + assert np.allclose(band_data["band_energies"], ref_bands, atol=1.0e-8) def test_parse_band_structure_xml_vertices(): - vertices_ref = [{'distance': 0.0, 'label': 'G', 'coord': [0.0, 0.0, 0.0]}, - {'distance': 0.6123238446, 'label': 'X', 'coord': [0.5, 0.0, 0.5]}, - {'distance': 0.918485767, 'label': 'W', 'coord': [0.5, 0.25, 0.75]}, - {'distance': 1.134974938, 'label': 'K', 'coord': [0.375, 0.375, 0.75]}, - {'distance': 1.784442453, 'label': 'G', 'coord': [0.0, 0.0, 0.0]}, - {'distance': 2.314730457, 'label': 'L', 'coord': [0.5, 0.5, 0.5]}, - {'distance': 2.689700702, 'label': 'U', 'coord': [0.625, 0.25, 0.625]}, - {'distance': 2.906189873, 'label': 'W', 'coord': [0.5, 0.25, 0.75]}, - {'distance': 3.339168216, 'label': 'L', 'coord': [0.5, 0.5, 0.5]}, - {'distance': 3.71413846, 'label': 'K', 'coord': [0.375, 0.375, 0.75]}, - {'distance': 3.71413846, 'label': 'U', 'coord': [0.625, 0.25, 0.625]}, - {'distance': 3.930627631, 'label': 'X', 'coord': [0.5, 0.0, 0.5]}] + vertices_ref = [ + {"distance": 0.0, "label": "G", "coord": [0.0, 0.0, 0.0]}, + {"distance": 0.6123238446, "label": "X", "coord": [0.5, 0.0, 0.5]}, + {"distance": 0.918485767, "label": "W", "coord": [0.5, 0.25, 0.75]}, + {"distance": 1.134974938, "label": "K", "coord": [0.375, 0.375, 0.75]}, + {"distance": 1.784442453, "label": "G", "coord": [0.0, 0.0, 0.0]}, + {"distance": 2.314730457, "label": "L", "coord": [0.5, 0.5, 0.5]}, + {"distance": 2.689700702, "label": "U", "coord": [0.625, 0.25, 0.625]}, + {"distance": 2.906189873, "label": "W", "coord": [0.5, 0.25, 0.75]}, + {"distance": 3.339168216, "label": "L", "coord": [0.5, 0.5, 0.5]}, + {"distance": 3.71413846, "label": "K", "coord": [0.375, 0.375, 0.75]}, + {"distance": 3.71413846, "label": "U", "coord": [0.625, 0.25, 0.625]}, + {"distance": 3.930627631, "label": "X", "coord": [0.5, 0.0, 0.5]}, + ] band_data = parse_band_structure_xml(band_structure_xml) - assert band_data['vertices'] == vertices_ref + assert band_data["vertices"] == vertices_ref def test_parse_band_structure_dat(band_structure_dat_mock): band_data = parse_band_structure_dat(band_structure_dat_mock.file) - assert band_data['n_kpts'] == band_data['band_energies'].shape[0], ( - "First dim of bands array equals the number of k-sampling points in the band structure") - assert band_data['n_kpts'] == 6, "sampling points per band" - assert band_data['n_bands'] == 2, "band_structure_xml contains two bands" - - ref_k_points = np.array([[1.000000, 0.000000, 0.000000], - [0.988281, 0.011719, 0.000000], - [0.976562, 0.023438, 0.000000], - [0.964844, 0.035156, 0.000000], - [0.953125, 0.046875, 0.000000], - [0.941406, 0.058594, 0.000000]]) - - ref_bands = np.array([[-3.370713328, -2.024168147], - [-3.370710744, -2.024186985], - [-3.370705193, -2.024297489], - [-3.370698602, -2.024460642], - [-3.370682200, -2.024597185], - [-3.370661229, -2.024765908]]) + assert ( + band_data["n_kpts"] == band_data["band_energies"].shape[0] + ), "First dim of bands array equals the number of k-sampling points in the band structure" + assert band_data["n_kpts"] == 6, "sampling points per band" + assert band_data["n_bands"] == 2, "band_structure_xml contains two bands" + + ref_k_points = np.array( + [ + [1.000000, 0.000000, 0.000000], + [0.988281, 0.011719, 0.000000], + [0.976562, 0.023438, 0.000000], + [0.964844, 0.035156, 0.000000], + [0.953125, 0.046875, 0.000000], + [0.941406, 0.058594, 0.000000], + ] + ) + + ref_bands = np.array( + [ + [-3.370713328, -2.024168147], + [-3.370710744, -2.024186985], + [-3.370705193, -2.024297489], + [-3.370698602, -2.024460642], + [-3.370682200, -2.024597185], + [-3.370661229, -2.024765908], + ] + ) + + ref_flattened_k_points = np.array([0.0, 0.02697635, 0.05395270, 0.08092905, 0.10790540, 0.13488176]) + + assert np.allclose(band_data["k_points"], ref_k_points, atol=1.0e-8) + assert np.allclose(band_data["flattened_k_points"], ref_flattened_k_points, atol=1.0e-8) + assert np.allclose(band_data["band_energies"], ref_bands, atol=1.0e-8) - ref_flattened_k_points = np.array([0., 0.02697635, 0.05395270, 0.08092905, 0.10790540, 0.13488176]) - - assert np.allclose(band_data['k_points'], ref_k_points, atol=1.e-8) - assert np.allclose(band_data['flattened_k_points'], ref_flattened_k_points, atol=1.e-8) - assert np.allclose(band_data['band_energies'], ref_bands, atol=1.e-8) # Band structure of silicon, containing two lowest bands and only 6 k-sampling points per band @@ -132,4 +150,3 @@ def test_parse_band_structure_dat(band_structure_dat_mock): """ - diff --git a/tests/dict_parsers/test_parser_factory.py b/tests/dict_parsers/test_parser_factory.py new file mode 100644 index 0000000..2615620 --- /dev/null +++ b/tests/dict_parsers/test_parser_factory.py @@ -0,0 +1,40 @@ +"""Test parser factory.""" + +from pathlib import Path + +from numpy.testing import assert_allclose + +from excitingtools.exciting_dict_parsers.parser_factory import parse + + +def test_parse(tmp_path: Path) -> None: + file = tmp_path / "EPSILON_11.OUT" + file.write_text("a\n0 1 0\n2 0 0") + parsed_data = parse(file.as_posix()) + assert set(parsed_data) == {"energy", "im", "re"} + assert_allclose(parsed_data["energy"], [0.0, 2.0]) + assert_allclose(parsed_data["im"], [0.0, 0.0]) + assert_allclose(parsed_data["re"], [1.0, 0.0]) + + file = tmp_path / "EPSILON_BSE-NAR_TDA-BAR_OC11.OUT" + file.write_text("a\n" * 14 + "0 0 0 1\n0 0 0 2") + parsed_data = parse(file.as_posix()) + assert set(parsed_data) == { + "frequency", + "imag_oscillator_strength", + "real_oscillator_strength", + "real_oscillator_strength_kkt", + } + assert_allclose(parsed_data["frequency"], [0.0, 0.0]) + assert_allclose(parsed_data["imag_oscillator_strength"], [0.0, 0.0]) + assert_allclose(parsed_data["real_oscillator_strength"], [0.0, 0.0]) + assert_allclose(parsed_data["real_oscillator_strength_kkt"], [1.0, 2.0]) + + file = tmp_path / "Z_11.OUT" + file.write_text("0 0 1 2\n0 0 3 4") + parsed_data = parse(file.as_posix()) + assert set(parsed_data) == {"im", "mu", "re", "temperature"} + assert_allclose(parsed_data["im"], [2.0, 4.0]) + assert_allclose(parsed_data["mu"], [0.0, 0.0]) + assert_allclose(parsed_data["re"], [1.0, 3.0]) + assert_allclose(parsed_data["temperature"], [0.0, 0.0]) diff --git a/tests/dict_parsers/test_properties_parser.py b/tests/dict_parsers/test_properties_parser.py index ab60c5b..4796191 100644 --- a/tests/dict_parsers/test_properties_parser.py +++ b/tests/dict_parsers/test_properties_parser.py @@ -2,7 +2,6 @@ from excitingtools.exciting_dict_parsers.properties_parser import parse_charge_density - # Most of the values have been removed to shorten the test RHO1_xml = """ @@ -24,12 +23,17 @@ """ + def test_parse_charge_density(): rho1 = parse_charge_density(RHO1_xml) - ref = np.array([[0.00000000e+00, 1.98518837e+03], - [4.48811667e-02, 5.08882988e+02], - [8.97623334e-02, 1.51827164e+02], - [1.34643500e-01, 5.22636138e+01], - [1.79524667e-01, 2.43321622e+01], - [2.24405834e-01, 1.59499145e+01]]) - assert np.allclose(rho1, ref, atol=1.e-8) + ref = np.array( + [ + [0.00000000e00, 1.98518837e03], + [4.48811667e-02, 5.08882988e02], + [8.97623334e-02, 1.51827164e02], + [1.34643500e-01, 5.22636138e01], + [1.79524667e-01, 2.43321622e01], + [2.24405834e-01, 1.59499145e01], + ] + ) + assert np.allclose(rho1, ref, atol=1.0e-8) diff --git a/tests/dict_parsers/test_species_parser.py b/tests/dict_parsers/test_species_parser.py index b7406ac..9980537 100644 --- a/tests/dict_parsers/test_species_parser.py +++ b/tests/dict_parsers/test_species_parser.py @@ -2,104 +2,188 @@ def test_parse_species_xml(): - """ Test parsing of species.xml files. - """ - assert isinstance(species_str, str), ( - "Expect species parser to handle strings of XML data, " - "due to use of decorator" - ) + """Test parsing of species.xml files.""" + assert isinstance(species_str, str), "Expect species parser to handle strings of XML data, due to use of decorator" species_dict = parse_species_xml(species_str) - assert set(species_dict) == {'species', 'muffin_tin', 'atomic_states', 'basis'}, 'Top level species file keys' - assert species_dict['species'] == { - 'chemicalSymbol': 'Zn', 'name': 'zinc', 'z': -30.0, 'mass': 119198.678 - } - assert species_dict['muffin_tin'] == { - 'rmin': 1e-06, 'radius': 2.0, 'rinf': 21.8982, 'radialmeshPoints': 600.0 - } - - assert isinstance(species_dict['atomic_states'], list), 'Atomic states stored as a list' - atomic_states = [{'n': 1, 'l': 0, 'kappa': 1, 'occ': 2.00000, - 'core': True}, {'n': 2, 'l': 0, 'kappa': 1, 'occ': 2.00000, 'core': True}, - {'n': 2, 'l': 1, 'kappa': 1, 'occ': 2.00000, - 'core': True}, {'n': 2, 'l': 1, 'kappa': 2, 'occ': 4.00000, 'core': True}, - {'n': 3, 'l': 0, 'kappa': 1, 'occ': 2.00000, - 'core': False}, {'n': 3, 'l': 1, 'kappa': 1, 'occ': 2.00000, 'core': False}, - {'n': 3, 'l': 1, 'kappa': 2, 'occ': 4.00000, - 'core': False}, {'n': 3, 'l': 2, 'kappa': 2, 'occ': 4.00000, 'core': False}, - {'n': 3, 'l': 2, 'kappa': 3, 'occ': 6.00000, - 'core': False}, {'n': 4, 'l': 0, 'kappa': 1, 'occ': 2.00000, 'core': False}] - assert species_dict['atomic_states'] == atomic_states - - basis = species_dict['basis'] - assert set(basis) == {'default', 'custom', 'lo'}, 'Keys for basis' - - assert basis['default'] == [{'type': 'lapw', 'trialEnergy': 0.1500, 'searchE': True}] + assert set(species_dict) == {"species", "muffin_tin", "atomic_states", "basis"}, "Top level species file keys" + assert species_dict["species"] == {"chemicalSymbol": "Zn", "name": "zinc", "z": -30.0, "mass": 119198.678} + assert species_dict["muffin_tin"] == {"rmin": 1e-06, "radius": 2.0, "rinf": 21.8982, "radialmeshPoints": 600.0} + + assert isinstance(species_dict["atomic_states"], list), "Atomic states stored as a list" + atomic_states = [ + {"n": 1, "l": 0, "kappa": 1, "occ": 2.00000, "core": True}, + {"n": 2, "l": 0, "kappa": 1, "occ": 2.00000, "core": True}, + {"n": 2, "l": 1, "kappa": 1, "occ": 2.00000, "core": True}, + {"n": 2, "l": 1, "kappa": 2, "occ": 4.00000, "core": True}, + {"n": 3, "l": 0, "kappa": 1, "occ": 2.00000, "core": False}, + {"n": 3, "l": 1, "kappa": 1, "occ": 2.00000, "core": False}, + {"n": 3, "l": 1, "kappa": 2, "occ": 4.00000, "core": False}, + {"n": 3, "l": 2, "kappa": 2, "occ": 4.00000, "core": False}, + {"n": 3, "l": 2, "kappa": 3, "occ": 6.00000, "core": False}, + {"n": 4, "l": 0, "kappa": 1, "occ": 2.00000, "core": False}, + ] + assert species_dict["atomic_states"] == atomic_states + + basis = species_dict["basis"] + assert set(basis) == {"default", "custom", "lo"}, "Keys for basis" + + assert basis["default"] == [{"type": "lapw", "trialEnergy": 0.1500, "searchE": True}] # Custom apw, lapw or apw+lo - assert basis['custom'] == [ - {'l': 0, 'type': 'lapw', 'trialEnergy': 1.35670550183736, 'searchE': False}, - {'l': 1, 'type': 'lapw', 'trialEnergy': -2.69952312512447, - 'searchE': False}, {'l': 2, 'type': 'lapw', 'trialEnergy': 0.00, 'searchE': False}, - {'l': 3, 'type': 'lapw', 'trialEnergy': 1.000, - 'searchE': False}, {'l': 4, 'type': 'lapw', 'trialEnergy': 1.000, 'searchE': False}, - {'l': 5, 'type': 'lapw', 'trialEnergy': 1.000, 'searchE': False} - ] + assert basis["custom"] == [ + {"l": 0, "type": "lapw", "trialEnergy": 1.35670550183736, "searchE": False}, + {"l": 1, "type": "lapw", "trialEnergy": -2.69952312512447, "searchE": False}, + {"l": 2, "type": "lapw", "trialEnergy": 0.00, "searchE": False}, + {"l": 3, "type": "lapw", "trialEnergy": 1.000, "searchE": False}, + {"l": 4, "type": "lapw", "trialEnergy": 1.000, "searchE": False}, + {"l": 5, "type": "lapw", "trialEnergy": 1.000, "searchE": False}, + ] # All explicitly specified LOs - los = [{'l': 0, 'matchingOrder': [0, 1], 'trialEnergy': [-4.37848525995355, -4.37848525995355], - 'searchE': [False, False]}, - {'l': 0, 'matchingOrder': [0, 1], 'trialEnergy': [1.35670550183736, 1.35670550183736], - 'searchE': [False, False]}, - {'l': 0, 'matchingOrder': [0, 0], 'trialEnergy': [1.35670550183736, -4.37848525995355], - 'searchE': [False, False]}, - {'l': 0, 'matchingOrder': [1, 2], 'trialEnergy': [1.35670550183736, 1.35670550183736], - 'searchE': [False, False]}, - {'l': 1, 'matchingOrder': [0, 1], 'trialEnergy': [-2.69952312512447, -2.69952312512447], - 'searchE': [False, False]}, - {'l': 1, 'matchingOrder': [1, 2], 'trialEnergy': [-2.69952312512447, -2.69952312512447], - 'searchE': [False, False]}, - {'l': 2, 'matchingOrder': [0, 1], 'trialEnergy': [0.0, 0.0], 'searchE': [False, False]}, - {'l': 2, 'matchingOrder': [1, 2], 'trialEnergy': [0.0, 0.0], 'searchE': [False, False]}, - {'l': 3, 'matchingOrder': [0, 1], 'trialEnergy': [1.0, 1.0], 'searchE': [False, False]}, - {'l': 3, 'matchingOrder': [1, 2], 'trialEnergy': [1.0, 1.0], 'searchE': [False, False]}, - {'l': 4, 'matchingOrder': [0, 1], 'trialEnergy': [1.0, 1.0], 'searchE': [False, False]}, - {'l': 4, 'matchingOrder': [1, 2], 'trialEnergy': [1.0, 1.0], 'searchE': [False, False]}, - {'l': 5, 'matchingOrder': [0, 1], 'trialEnergy': [1.0, 1.0], 'searchE': [False, False]}, - {'l': 5, 'matchingOrder': [1, 2], 'trialEnergy': [1.0, 1.0], 'searchE': [False, False]}] - - assert len(basis['lo']) == 14, "Number of explicitly-defined local orbitals" - assert set(basis['lo'][0]) == {'l', 'matchingOrder', 'trialEnergy', 'searchE'}, \ - "Attributes defining a local orbital" - assert basis['lo'] == los + los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": -4.37848525995355}, + {"matchingOrder": 1, "searchE": False, "trialEnergy": -4.37848525995355}, + ], + }, + { + "l": 0, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": 1.35670550183736}, + {"matchingOrder": 1, "searchE": False, "trialEnergy": 1.35670550183736}, + ], + }, + { + "l": 0, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": 1.35670550183736}, + {"matchingOrder": 0, "searchE": False, "trialEnergy": -4.37848525995355}, + ], + }, + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "searchE": False, "trialEnergy": 1.35670550183736}, + {"matchingOrder": 2, "searchE": False, "trialEnergy": 1.35670550183736}, + ], + }, + { + "l": 1, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": -2.69952312512447}, + {"matchingOrder": 1, "searchE": False, "trialEnergy": -2.69952312512447}, + ], + }, + { + "l": 1, + "wf": [ + {"matchingOrder": 1, "searchE": False, "trialEnergy": -2.69952312512447}, + {"matchingOrder": 2, "searchE": False, "trialEnergy": -2.69952312512447}, + ], + }, + { + "l": 2, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": 0.0}, + {"matchingOrder": 1, "searchE": False, "trialEnergy": 0.0}, + ], + }, + { + "l": 2, + "wf": [ + {"matchingOrder": 1, "searchE": False, "trialEnergy": 0.0}, + {"matchingOrder": 2, "searchE": False, "trialEnergy": 0.0}, + ], + }, + { + "l": 3, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": 1.0}, + {"matchingOrder": 1, "searchE": False, "trialEnergy": 1.0}, + ], + }, + { + "l": 3, + "wf": [ + {"matchingOrder": 1, "searchE": False, "trialEnergy": 1.0}, + {"matchingOrder": 2, "searchE": False, "trialEnergy": 1.0}, + ], + }, + { + "l": 4, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": 1.0}, + {"matchingOrder": 1, "searchE": False, "trialEnergy": 1.0}, + ], + }, + { + "l": 4, + "wf": [ + {"matchingOrder": 1, "searchE": False, "trialEnergy": 1.0}, + {"matchingOrder": 2, "searchE": False, "trialEnergy": 1.0}, + ], + }, + { + "l": 5, + "wf": [ + {"matchingOrder": 0, "searchE": False, "trialEnergy": 1.0}, + {"matchingOrder": 1, "searchE": False, "n": 5}, + ], + }, + { + "l": 5, + "wf": [{"matchingOrder": 1, "n": 5, "searchE": False}, {"matchingOrder": 2, "n": 6, "searchE": False}], + }, + ] + + assert len(basis["lo"]) == 14, "Number of explicitly-defined local orbitals" + assert set(basis["lo"][0]) == {"l", "wf"}, "Attributes defining a local orbital" + assert basis["lo"] == los def test_parse_species_xml_different_ordering(): species_dict = parse_species_xml(species_str_diff_order) - assert set(species_dict) == {'species', 'muffin_tin', 'atomic_states', 'basis'}, 'Top level species file keys' + assert set(species_dict) == {"species", "muffin_tin", "atomic_states", "basis"}, "Top level species file keys" # References - ref_species = {'chemicalSymbol': 'C', 'name': 'carbon', 'z': -6.0, 'mass': 21894.16673} - ref_muffin_tin = {'rmin': 1e-05, 'radius': 1.45, 'rinf': 21.0932, 'radialmeshPoints': 250.0} - ref_atomic_states = [{'n': 1, 'l': 0, 'kappa': 1, 'occ': 2.0, 'core': False}, - {'n': 2, 'l': 0, 'kappa': 1, 'occ': 2.0, 'core': False}, - {'n': 2, 'l': 1, 'kappa': 1, 'occ': 1.0, 'core': False}, - {'n': 2, 'l': 1, 'kappa': 2, 'occ': 1.0, 'core': False}] - ref_basis = {'default': [{'type': 'lapw', 'trialEnergy': 0.15, 'searchE': False}], - 'custom': [{'l': 0, 'type': 'apw+lo', 'trialEnergy': 0.15, 'searchE': True}, - {'l': 1, 'type': 'apw+lo', 'trialEnergy': 0.15, 'searchE': True}], - 'lo': [{'l': 0, 'wfproj': False, 'matchingOrder': [0, 1, 0], 'trialEnergy': [0.15, 0.15, -9.8], - 'searchE': [True, True, False]}] - } - - assert species_dict['species'] == ref_species, "species data disagrees" - assert species_dict['muffin_tin'] == ref_muffin_tin, "muffin tin data disagrees" - assert species_dict['atomic_states'] == ref_atomic_states, "atomic state data disagrees" - assert species_dict['basis']['default'] == ref_basis['default'], "basis default data disagrees" - assert species_dict['basis']['custom'] == ref_basis['custom'], "basis custom data disagrees" - assert species_dict['basis']['lo'] == ref_basis['lo'], "basis lo data disagrees" + ref_species = {"chemicalSymbol": "C", "name": "carbon", "z": -6.0, "mass": 21894.16673} + ref_muffin_tin = {"rmin": 1e-05, "radius": 1.45, "rinf": 21.0932, "radialmeshPoints": 250.0} + ref_atomic_states = [ + {"n": 1, "l": 0, "kappa": 1, "occ": 2.0, "core": False}, + {"n": 2, "l": 0, "kappa": 1, "occ": 2.0, "core": False}, + {"n": 2, "l": 1, "kappa": 1, "occ": 1.0, "core": False}, + {"n": 2, "l": 1, "kappa": 2, "occ": 1.0, "core": False}, + ] + ref_basis = { + "default": [{"type": "lapw", "trialEnergy": 0.15, "searchE": False}], + "custom": [ + {"l": 0, "type": "apw+lo", "trialEnergy": 0.15, "searchE": True}, + {"l": 1, "type": "apw+lo", "trialEnergy": 0.15, "searchE": True}, + ], + "lo": [ + { + "l": 0, + "wfproj": False, + "wf": [ + {"matchingOrder": 0, "trialEnergy": 0.15, "searchE": True}, + {"matchingOrder": 1, "trialEnergy": 0.15, "searchE": True}, + {"matchingOrder": 0, "trialEnergy": -9.8, "searchE": False}, + ], + } + ], + } + + assert species_dict["species"] == ref_species, "species data disagrees" + assert species_dict["muffin_tin"] == ref_muffin_tin, "muffin tin data disagrees" + assert species_dict["atomic_states"] == ref_atomic_states, "atomic state data disagrees" + assert species_dict["basis"]["default"] == ref_basis["default"], "basis default data disagrees" + assert species_dict["basis"]["custom"] == ref_basis["custom"], "basis custom data disagrees" + assert species_dict["basis"]["lo"] == ref_basis["lo"], "basis lo data disagrees" species_str = """ @@ -180,11 +264,11 @@ def test_parse_species_xml_different_ordering(): - + - - + + @@ -192,7 +276,6 @@ def test_parse_species_xml_different_ordering(): """ - species_str_diff_order = """ diff --git a/tests/dict_parsers/test_state_parser.py b/tests/dict_parsers/test_state_parser.py new file mode 100644 index 0000000..4c9e97f --- /dev/null +++ b/tests/dict_parsers/test_state_parser.py @@ -0,0 +1,157 @@ +import sys +from pathlib import Path + +import numpy as np + +from excitingtools.exciting_dict_parsers.state_parser import parse_state_out + + +def write_int(file, i: int, byteorder): + """Writes an integer as bytes to the file. + + :param file: file object to write the int + :param i: integer, which bytes should be written + :param byteorder: endianness of the byteorder ("little" or "big") + """ + file.write(int.to_bytes(4, 4, byteorder)) + file.write(i.to_bytes(4, byteorder)) + file.write(int.to_bytes(4, 4, byteorder)) + + +def write_array(file, a: np.ndarray, byteorder): + """Writes a numpy array to the file as bytes. + + :param file: file object to write the int + :param a: numpy array, which bytes should be written + :param byteorder: endianness of the byteorder ("little" or "big") + """ + num_bytes = a.size * 8 + file.write(num_bytes.to_bytes(4, byteorder)) + file.write(a.tobytes(order="F")) + file.write(num_bytes.to_bytes(4, byteorder)) + + +def write_state(path, state: dict): + """Creates a file at the specified path and writes the dictionary in the same structure as STATE.OUT + + :param path: path to the file to which should be written to + :param state: state dictionary, which should be converted to a binary STATE file + """ + byteorder = sys.byteorder + with open(path, "wb+") as file: + # write version information + file.write(int.to_bytes(52, 4, byteorder)) + for i in state["version"]: + i: int + file.write(i.to_bytes(4, byteorder)) + file.write(b"YomNFS0t4s8uhGxs4FCNGsAOp5DapSm7HAQ58aVd") + file.write(int.to_bytes(52, 4, byteorder)) + # write other general information + for key in ("spinpol", "nspecies", "lmmaxvr", "nrmtmax"): + write_int(file, state[key], byteorder) + # next write the species specific information + for i in range(state["nspecies"]): + write_int(file, state["number of atoms"][i], byteorder) + write_int(file, len(state["muffintin radial meshes"][i]), byteorder) + write_array(file, state["muffintin radial meshes"][i], byteorder) + # next write g vector grid + file.write(int.to_bytes(12, 4, byteorder)) + for i in range(3): + file.write(state["g vector grid"][i].to_bytes(4, byteorder)) + file.write(int.to_bytes(12, 4, byteorder)) + # next are again a few integers + for key in ("ngvec", "ndmag", "nspinor", "ldapu", "lmmaxlu"): + write_int(file, state[key], byteorder) + # next write the array pairs + arrays = ( + ("muffintin density", "interstitial density"), + ("muffintin coulomb potential", "interstitial coulomb potential"), + ("muffintin exchange-correlation potential", "interstitial exchange-correlation potential"), + ) + for muffintin_array, interstitial_array in arrays: + muffintin_array_state: np.ndarray = state[muffintin_array] + interstitial_array_state: np.ndarray = state[interstitial_array] + num_bytes = (muffintin_array_state.size + interstitial_array_state.size) * 8 + file.write(num_bytes.to_bytes(4, byteorder)) + file.write(muffintin_array_state.tobytes(order="F")) + file.write(interstitial_array_state.tobytes(order="F")) + file.write(num_bytes.to_bytes(4, byteorder)) + # Lastly write the potential arrays + num_bytes = 8 * ( + state["muffintin effective potential"].size + + state["interstitial effective potential"].size + + 2 * state["reciprocal interstitial effective potential"].size + ) + file.write(num_bytes.to_bytes(4, byteorder)) + file.write(state["muffintin effective potential"].tobytes(order="F")) + file.write(state["interstitial effective potential"].tobytes(order="F")) + file.write(state["reciprocal interstitial effective potential"].tobytes(order="F")) + file.write(num_bytes.to_bytes(4, byteorder)) + + +def test_parse_state_out(tmp_path: Path): + directory = tmp_path + + # setting seed to avoid randomness in tests + rng = np.random.default_rng(0) + + state_ref = { + "version": (1, 2, 3), + "versionhash": "YomNFS0t4s8uhGxs4FCNGsAOp5DapSm7HAQ58aVd", + "spinpol": False, + "nspecies": 2, + "lmmaxvr": 3, + "nrmtmax": 3, + "number of atoms": [1, 1], + "muffintin radial meshes": [np.array([1.0, 2.0]), np.array([0.5, 0.75, 1.5])], + "g vector grid": (2, 2, 2), + "ngvec": 5, + "ndmag": 0, + "nspinor": 1, + "ldapu": 0, + "lmmaxlu": 16, + "muffintin density": rng.random((3, 3, 2)), + "interstitial density": rng.random(8), + "muffintin coulomb potential": rng.random((3, 3, 2)), + "interstitial coulomb potential": rng.random(8), + "muffintin exchange-correlation potential": rng.random((3, 3, 2)), + "interstitial exchange-correlation potential": rng.random(8), + "muffintin effective potential": rng.random((3, 3, 2)), + "interstitial effective potential": rng.random(8), + "reciprocal interstitial effective potential": rng.random(5) + 1j * rng.random(5), + } + write_state(directory / "STATE.OUT", state_ref) + state_out = parse_state_out(directory / "STATE.OUT") + + assert state_out["version"] == state_ref["version"] + assert state_out["versionhash"] == state_ref["versionhash"] + assert state_out["spinpol"] == state_ref["spinpol"] + assert state_out["nspecies"] == state_ref["nspecies"] + assert state_out["lmmaxvr"] == state_ref["lmmaxvr"] + assert state_out["nrmtmax"] == state_ref["nrmtmax"] + assert state_out["number of atoms"] == state_ref["number of atoms"] + assert np.allclose(state_out["muffintin radial meshes"][0], state_ref["muffintin radial meshes"][0]) + assert np.allclose(state_out["muffintin radial meshes"][1], state_ref["muffintin radial meshes"][1]) + assert state_out["g vector grid"] == state_ref["g vector grid"] + assert state_out["ngvec"] == state_ref["ngvec"] + assert state_out["ndmag"] == state_ref["ndmag"] + assert state_out["nspinor"] == state_ref["nspinor"] + assert state_out["ldapu"] == state_ref["ldapu"] + assert state_out["lmmaxlu"] == state_ref["lmmaxlu"] + assert np.allclose(state_out["muffintin density"], state_ref["muffintin density"]) + assert np.allclose(state_out["interstitial density"], state_ref["interstitial density"]) + assert np.allclose(state_out["muffintin coulomb potential"], state_ref["muffintin coulomb potential"]) + assert np.allclose(state_out["interstitial coulomb potential"], state_ref["interstitial coulomb potential"]) + assert np.allclose( + state_out["muffintin exchange-correlation potential"], state_ref["muffintin exchange-correlation potential"] + ) + assert np.allclose( + state_out["interstitial exchange-correlation potential"], + state_ref["interstitial exchange-correlation potential"], + ) + assert np.allclose(state_out["muffintin effective potential"], state_ref["muffintin effective potential"]) + assert np.allclose(state_out["interstitial effective potential"], state_ref["interstitial effective potential"]) + assert np.allclose( + state_out["reciprocal interstitial effective potential"], + state_ref["reciprocal interstitial effective potential"], + ) diff --git a/tests/eigenstates/test_eigenstates.py b/tests/eigenstates/test_eigenstates.py index 1c6094f..af10f0c 100644 --- a/tests/eigenstates/test_eigenstates.py +++ b/tests/eigenstates/test_eigenstates.py @@ -1,18 +1,23 @@ -""" Test for get_index function in eigenstates.py -""" +"""Test for get_index function in eigenstates.py""" -from excitingtools.eigenstates.eigenstates import get_k_point_index -import pytest -import numpy as np import re +import numpy as np +import pytest + +from excitingtools.eigenstates.eigenstates import get_k_point_index + def test_get_k_point_index(): - k_points = np.array([[1.000000, 0.000000, 0.000000], - [0.988281, 0.011719, 0.000000], - [0.988281, 0.011719, 0.000000], - [0.976562, 0.023438, 0.000000], - [0.953125, 0.046875, 0.000000]]) + k_points = np.array( + [ + [1.000000, 0.000000, 0.000000], + [0.988281, 0.011719, 0.000000], + [0.988281, 0.011719, 0.000000], + [0.976562, 0.023438, 0.000000], + [0.953125, 0.046875, 0.000000], + ] + ) assert get_k_point_index([1.000000, 0.000000, 0.000000], k_points) == 0 assert get_k_point_index([0.953125, 0.046875, 0.000000], k_points) == 4 diff --git a/tests/input/test_base_class.py b/tests/input/test_base_class.py index 33e98b0..54a19ed 100644 --- a/tests/input/test_base_class.py +++ b/tests/input/test_base_class.py @@ -1,11 +1,17 @@ -"""Test exi class and its methods.""" +"""Test exiting input base class and its methods. -import pathlib +NOTE: +All attribute tests should assert on the XML tree content's as the attribute +order is not preserved by the ElementTree.tostring method. Elements appear to +be fine. +""" + +from pathlib import Path from excitingtools.input.base_class import query_exciting_version -def test_query_exciting_version(tmp_path): +def test_query_exciting_version(tmp_path: Path) -> None: """ Test querying the exciting version. """ @@ -14,21 +20,22 @@ def test_query_exciting_version(tmp_path): #define COMPILERVERSION "GNU Fortran (MacPorts gcc9 9.3.0_4) 9.3.0" #define VERSIONFROMDATE /21,12,01/ """ - # Mock the version.inc file, and prepended path - exciting_root = pathlib.Path(tmp_path) - src = exciting_root / "src" + src = tmp_path / "src" src.mkdir() - assert exciting_root.is_dir(), ("exciting_root tmp_path directory does not exist") - version_inc = exciting_root / "src" / "version.inc" + version_inc = tmp_path / "src" / "version.inc" version_inc.write_text(version_inc_contents) - assert version_inc.exists(), "version.inc does not exist" - versioning: dict = query_exciting_version(exciting_root) - assert set(versioning.keys()) == {'compiler', 'git_hash'}, ( + mod_misc = src / "mod_misc.F90" + mod_misc.write_text(" !> Code version\n character(40) :: versionname = 'NEON'\n") + + versioning: dict = query_exciting_version(tmp_path) + assert set(versioning.keys()) == {"compiler", "git_hash", "major"}, ( "Expect `query_exciting_version` to return compiler used " - "for last build, and exciting git hash") + "for last build, exciting git hash and major version name." + ) - assert versioning['compiler'] == 'GNU Fortran (MacPorts gcc9 9.3.0_4) 9.3.0' - assert versioning['git_hash'] == '1a2087b0775a87059d535d01a5475a10f00d0ad7' + assert versioning["compiler"] == "GNU Fortran (MacPorts gcc9 9.3.0_4) 9.3.0" + assert versioning["git_hash"] == "1a2087b0775a87059d535d01a5475a10f00d0ad7" + assert versioning["major"] == "NEON" diff --git a/tests/input/test_dynamic_class.py b/tests/input/test_dynamic_class.py new file mode 100644 index 0000000..6c31527 --- /dev/null +++ b/tests/input/test_dynamic_class.py @@ -0,0 +1,60 @@ +"""Test for dynamic class functions.""" + +from excitingtools.input.dynamic_class import ( + class_constructor_string, + class_name_uppercase, + get_all_valid_subtrees, + give_class_dictionary, +) + + +def test_get_all_valid_subtrees(): + ref_subtrees = { + "crystal", + "shell", + "dfthalfparam", + "structure", + "symmetries", + "basevect", + "species", + "atom", + "LDAplusU", + } + assert set(get_all_valid_subtrees(["structure"])) == ref_subtrees + + +def test_class_dir(): + ref_dir = { + "attributes": { + "__doc__": "Class for exciting structure input.", + "__module__": "excitingtools.input.input_classes", + "name": "structure", + }, + "bases": "(ExcitingXMLInput, )", + } + assert give_class_dictionary("structure") == ref_dir + + +def test_class_constructor_string(): + test_input = { + "Spin": { + "bases": "(ExcitingXMLInput, )", + "attributes": {"__doc__": "Class for exciting spin input.", "name": "spin"}, + } + } + ref_string = ( + "from excitingtools.input.base_class import ExcitingXMLInput \n" + "ExcitingSpinInput = type('ExcitingSpinInput', (ExcitingXMLInput, ), " + "{'__doc__': 'Class for exciting spin input.', 'name': 'spin'}) \n" + ) + assert class_constructor_string(test_input) == ref_string + + +def test_class_name_uppercase(): + assert class_name_uppercase("groundstate") == "GroundState" + assert class_name_uppercase("xs") == "XS" + assert class_name_uppercase("structure") == "Structure" + assert class_name_uppercase("LDAplusU") == "LDAplusU" + assert class_name_uppercase("HartreeFock") == "HartreeFock" + assert class_name_uppercase("EFG") == "EFG" + assert class_name_uppercase("etCoeffComponents") == "EtCoeffComponents" diff --git a/tests/input/test_ground_state.py b/tests/input/test_ground_state.py index 6685f29..0b51e29 100644 --- a/tests/input/test_ground_state.py +++ b/tests/input/test_ground_state.py @@ -17,73 +17,87 @@ ' ' """ + import pytest -from excitingtools.input.ground_state import ExcitingGroundStateInput +from excitingtools.input.input_classes import ExcitingGroundStateInput @pytest.mark.parametrize( "test_input,expected", - [({"rgkmax": 8.6}, [('rgkmax', '8.6')]), ({"ngridk": [8, 8, 8]}, [('ngridk', '8 8 8')]), - ({'vkloff': [0.1, 0.2, 0.3]}, [('vkloff', '0.1 0.2 0.3')]), - ({'CoreRelativity': 'dirac'}, [('CoreRelativity', 'dirac')]), - ({'ExplicitKineticEnergy': True}, [('ExplicitKineticEnergy', 'true')]), - ({'PrelimLinSteps': 2}, [('PrelimLinSteps', '2')]), - ({'ValenceRelativity': 'zora'}, [('ValenceRelativity', 'zora')]), - ({'autokpt': False}, [('autokpt', 'false')]), ({'beta0': 0.4}, [('beta0', '0.4')]), - ({'betadec': 0.6}, [('betadec', '0.6')]), ({'betainc': 1.1}, [('betainc', '1.1')]), - ({'cfdamp': 0.0}, [('cfdamp', '0.0')]), ({'chgexs': 0.0}, [('chgexs', '0.0')]), - ({'deband': 2.5e-3}, [('deband', '0.0025')]), ({'dipolecorrection': True}, [ - ('dipolecorrection', 'true') - ]), ({'dipoleposition': 1.0}, [('dipoleposition', '1.0')]), ({'dlinengyfermi': -0.1}, [ - ('dlinengyfermi', '-0.1') - ]), ({'do': "fromscratch"}, [('do', "fromscratch")]), - ({'energyref': 0.0}, [('energyref', '0.0')]), ({'epsband': 1.0e-6}, [('epsband', '1e-06')]), - ({'epschg': 1.0e-5}, [('epschg', '1e-05')]), ({'epsengy': 1e-6}, [('epsengy', '1e-06')]), - ({'epsforcescf': 5.0e-5}, [('epsforcescf', '5e-05')]), - ({'epsocc': 1e-8}, [('epsocc', '1e-08')]), ({'epspot': 1e-6}, [('epspot', '1e-06')]), - ({'fermilinengy': False}, [('fermilinengy', 'false')]), ({'findlinentype': "Wigner_Seitz"}, [ - ('findlinentype', "Wigner_Seitz") - ]), ({'fracinr': 0.02}, [('fracinr', '0.02')]), ({'frozencore': False}, [ - ('frozencore', 'false') - ]), ({'gmaxvr': 12}, [('gmaxvr', '12')]), ({'isgkmax': -1}, [('isgkmax', '-1')]), - ({'ldapu': "none"}, [('ldapu', "none")]), ({'lmaxapw': 8}, [('lmaxapw', '8')]), - ({'lmaxinr': 2}, [('lmaxinr', '2')]), ({'lmaxmat': 8}, [('lmaxmat', '8')]), ({'lmaxvr': 8}, [ - ('lmaxvr', '8') - ]), ({'lorecommendation': False}, [('lorecommendation', 'false')]), ({'lradstep': 1}, [ - ('lradstep', '1') - ]), ({'maxscl': 200}, [('maxscl', '200')]), ({'mixer': 'msec'}, [('mixer', 'msec')]), - ({'mixerswitch': 1}, [('mixerswitch', '1')]), ({'modifiedsv': False}, [ - ('modifiedsv', 'false') - ]), ({'msecStoredSteps': 8}, [('msecStoredSteps', '8')]), ({'nempty': 5}, [ - ('nempty', '5') - ]), ({'niterconvcheck': 2}, [('niterconvcheck', '2')]), ({'nktot': 0}, [ - ('nktot', '0') - ]), ({'nosource': False}, [('nosource', 'false')]), ({'nosym': False - }, [('nosym', 'false')]), - ({'nprad': 4}, [('nprad', '4')]), ({'npsden': 9}, [('npsden', '9')]), ({'nwrite': 0}, [ - ('nwrite', '0') - ]), ({'outputlevel': 'normal'}, [('outputlevel', 'normal')]), ({'ptnucl': True}, [ - ('ptnucl', 'true') - ]), ({'radialgridtype': 'cubic'}, [('radialgridtype', 'cubic')]), ({'radkpt': 40.0}, [ - ('radkpt', '40.0') - ]), ({'reducek': True}, [('reducek', 'true')]), ({'scfconv': 'multiple'}, [ - ('scfconv', 'multiple') - ]), ({'stype': 'Gaussian'}, [('stype', 'Gaussian')]), ({'swidth': 0.001}, [ - ('swidth', '0.001') - ]), ({'symmorph': False}, [('symmorph', 'false')]), ({'tevecsv': False}, [ - ('tevecsv', 'false') - ]), ({'tfibs': True}, [('tfibs', 'true')]), ({'tforce': False}, [ - ('tforce', 'false') - ]), ({'tpartcharges': False}, [('tpartcharges', 'false')]), - ({'useDensityMatrix': True}, [('useDensityMatrix', 'true')]), ({'vdWcorrection': 'none'}, [ - ('vdWcorrection', 'none') - ]), ({'xctype': 'GGA_PBE'}, [('xctype', 'GGA_PBE')])] - ) + [ + ({"rgkmax": 8.6}, [("rgkmax", "8.6")]), + ({"ngridk": [8, 8, 8]}, [("ngridk", "8 8 8")]), + ({"vkloff": [0.1, 0.2, 0.3]}, [("vkloff", "0.1 0.2 0.3")]), + ({"CoreRelativity": "dirac"}, [("CoreRelativity", "dirac")]), + ({"ExplicitKineticEnergy": True}, [("ExplicitKineticEnergy", "true")]), + ({"PrelimLinSteps": 2}, [("PrelimLinSteps", "2")]), + ({"ValenceRelativity": "zora"}, [("ValenceRelativity", "zora")]), + ({"autokpt": False}, [("autokpt", "false")]), + ({"beta0": 0.4}, [("beta0", "0.4")]), + ({"betadec": 0.6}, [("betadec", "0.6")]), + ({"betainc": 1.1}, [("betainc", "1.1")]), + ({"cfdamp": 0.0}, [("cfdamp", "0.0")]), + ({"chgexs": 0.0}, [("chgexs", "0.0")]), + ({"deband": 2.5e-3}, [("deband", "0.0025")]), + ({"dipolecorrection": True}, [("dipolecorrection", "true")]), + ({"dipoleposition": 1.0}, [("dipoleposition", "1.0")]), + ({"dlinengyfermi": -0.1}, [("dlinengyfermi", "-0.1")]), + ({"do": "fromscratch"}, [("do", "fromscratch")]), + ({"energyref": 0.0}, [("energyref", "0.0")]), + ({"epsband": 1.0e-6}, [("epsband", "1e-06")]), + ({"epschg": 1.0e-5}, [("epschg", "1e-05")]), + ({"epsengy": 1e-6}, [("epsengy", "1e-06")]), + ({"epsforcescf": 5.0e-5}, [("epsforcescf", "5e-05")]), + ({"epsocc": 1e-8}, [("epsocc", "1e-08")]), + ({"epspot": 1e-6}, [("epspot", "1e-06")]), + ({"fermilinengy": False}, [("fermilinengy", "false")]), + ({"findlinentype": "Wigner_Seitz"}, [("findlinentype", "Wigner_Seitz")]), + ({"fracinr": 0.02}, [("fracinr", "0.02")]), + ({"frozencore": False}, [("frozencore", "false")]), + ({"gmaxvr": 12}, [("gmaxvr", "12")]), + ({"isgkmax": -1}, [("isgkmax", "-1")]), + ({"ldapu": "none"}, [("ldapu", "none")]), + ({"lmaxapw": 8}, [("lmaxapw", "8")]), + ({"lmaxinr": 2}, [("lmaxinr", "2")]), + ({"lmaxmat": 8}, [("lmaxmat", "8")]), + ({"lmaxvr": 8}, [("lmaxvr", "8")]), + ({"lradstep": 1}, [("lradstep", "1")]), + ({"maxscl": 200}, [("maxscl", "200")]), + ({"mixer": "msec"}, [("mixer", "msec")]), + ({"mixerswitch": 1}, [("mixerswitch", "1")]), + ({"modifiedsv": False}, [("modifiedsv", "false")]), + ({"msecStoredSteps": 8}, [("msecStoredSteps", "8")]), + ({"nempty": 5}, [("nempty", "5")]), + ({"niterconvcheck": 2}, [("niterconvcheck", "2")]), + ({"nktot": 0}, [("nktot", "0")]), + ({"nosource": False}, [("nosource", "false")]), + ({"nosym": False}, [("nosym", "false")]), + ({"nprad": 4}, [("nprad", "4")]), + ({"npsden": 9}, [("npsden", "9")]), + ({"nwrite": 0}, [("nwrite", "0")]), + ({"outputlevel": "normal"}, [("outputlevel", "normal")]), + ({"ptnucl": True}, [("ptnucl", "true")]), + ({"radialgridtype": "cubic"}, [("radialgridtype", "cubic")]), + ({"radkpt": 40.0}, [("radkpt", "40.0")]), + ({"reducek": True}, [("reducek", "true")]), + ({"scfconv": "multiple"}, [("scfconv", "multiple")]), + ({"stype": "Gaussian"}, [("stype", "Gaussian")]), + ({"swidth": 0.001}, [("swidth", "0.001")]), + ({"symmorph": False}, [("symmorph", "false")]), + ({"tevecsv": False}, [("tevecsv", "false")]), + ({"tfibs": True}, [("tfibs", "true")]), + ({"tforce": False}, [("tforce", "false")]), + ({"tpartcharges": False}, [("tpartcharges", "false")]), + ({"useDensityMatrix": True}, [("useDensityMatrix", "true")]), + ({"vdWcorrection": "none"}, [("vdWcorrection", "none")]), + ({"xctype": "GGA_PBE"}, [("xctype", "GGA_PBE")]), + ], +) def test_class_exciting_ground_state_input_parametrized(test_input, expected): gs_input = ExcitingGroundStateInput(**test_input) gs_xml = gs_input.to_xml() - assert gs_xml.tag == 'groundstate' + assert gs_xml.tag == "groundstate" assert gs_xml.items() == expected @@ -92,8 +106,111 @@ def test_invalid_input(): Test error is raised when giving bogus attributes to class constructor. """ # Use an erroneous ground state attribute - with pytest.raises(ValueError) as error: + with pytest.raises(ValueError, match="groundstate keys are not valid: {'erroneous_attribute'}"): ExcitingGroundStateInput(erroneous_attribute=True) - assert error.value.args[ - 0] == "groundstate keys are not valid: {'erroneous_attribute'}" + +@pytest.mark.usefixtures("mock_env_jobflow_missing") +def test_as_dict(): + ref_rgkmax = 8.5 + gs_input = ExcitingGroundStateInput(rgkmax=ref_rgkmax) + ref_dict = {"xml_string": f' '} + assert gs_input.as_dict() == ref_dict, "expected different dict representation" + + +@pytest.mark.usefixtures("mock_env_jobflow") +def test_as_dict_jobflow(): + ref_rgkmax = 8.5 + gs_input = ExcitingGroundStateInput(rgkmax=ref_rgkmax) + ref_dict = { + "@class": "ExcitingGroundStateInput", + "@module": "excitingtools.input.input_classes", + "xml_string": f' ', + } + assert gs_input.as_dict() == ref_dict, "expected different dict representation" + + +def test_from_dict(): + ref_rgkmax = 8.5 + ref_dict = {"xml_string": f' '} + gs_input = ExcitingGroundStateInput.from_dict(ref_dict) + + assert gs_input.name == "groundstate" + # added comment for pylint to disable warning, because of dynamic attributes + assert gs_input.rgkmax == ref_rgkmax, f"Expect rgkmax to be equal {ref_rgkmax}" # pylint: disable=no-member + + +def test_spin_input(): + spin_attributes = {"bfieldc": [0, 0, 0], "fixspin": "total FSM"} + spin_keys = list(spin_attributes) + gs_input = ExcitingGroundStateInput(rgkmax=7.0, spin=spin_attributes) + + gs_xml = gs_input.to_xml() + assert gs_xml.tag == "groundstate" + assert set(gs_xml.keys()) == {"rgkmax"} + + elements = list(gs_xml) + assert len(elements) == 1 + + spin_xml = elements[0] + assert spin_xml.tag == "spin" + assert spin_xml.keys() == spin_keys, "Should contain all spin attributes" + assert spin_xml.get("bfieldc") == "0 0 0" + assert spin_xml.get("fixspin") == "total FSM" + + +def test_solver_input(): + solver_attributes = {"packedmatrixstorage": True, "type": "Lapack"} + solver_keys = list(solver_attributes) + gs_input = ExcitingGroundStateInput(solver=solver_attributes) + + gs_xml = gs_input.to_xml() + assert gs_xml.tag == "groundstate" + assert set(gs_xml.keys()) == set() + + elements = list(gs_xml) + assert len(elements) == 1 + + solver_xml = elements[0] + assert solver_xml.tag == "solver" + assert solver_xml.keys() == solver_keys, "Should contain all spin attributes" + assert solver_xml.get("packedmatrixstorage") == "true" + assert solver_xml.get("type") == "Lapack" + + +def test_dfthalf_input(): + dfthalf_attributes = {"printVSfile": True} + dfthalf_keys = list(dfthalf_attributes) + gs_input = ExcitingGroundStateInput(dfthalf=dfthalf_attributes) + + gs_xml = gs_input.to_xml() + assert gs_xml.tag == "groundstate" + assert set(gs_xml.keys()) == set() + + elements = list(gs_xml) + assert len(elements) == 1 + + dfthalf_xml = elements[0] + assert dfthalf_xml.tag == "dfthalf" + assert dfthalf_xml.keys() == dfthalf_keys, "Should contain all dfthalf attributes" + assert dfthalf_xml.get("printVSfile") == "true" + + +def test_OEP_input(): + oep_attributes = {"convoep": 1e-5, "maxitoep": 200, "tauoep": [1, 2, 3]} + oep_keys = list(oep_attributes) + gs_input = ExcitingGroundStateInput(OEP=oep_attributes) + + gs_xml = gs_input.to_xml() + assert gs_xml.tag == "groundstate" + assert set(gs_xml.keys()) == set() + + elements = list(gs_xml) + assert len(elements) == 1 + + oep_xml = elements[0] + assert oep_xml.tag == "OEP" + assert oep_xml.keys() == oep_keys, "Should contain all dfthalf attributes" + assert oep_xml.get("convoep") == "1e-05" + assert oep_xml.get("maxitoep") == "200" + assert oep_xml.get("tauoep") == "1 2 3" diff --git a/tests/input/test_input_xml.py b/tests/input/test_input_xml.py index 4e70830..f099cbb 100644 --- a/tests/input/test_input_xml.py +++ b/tests/input/test_input_xml.py @@ -3,25 +3,37 @@ TODO(Fab/Alex/Dan) Issue 117. Would be nice to assert that the output is valid XML * https://lxml.de/validation.html Also see: https://xmlschema.readthedocs.io/en/latest/usage.html#xsd-declarations + +NOTE: +All attribute tests should assert on the XML tree content's as the attribute +order is not preserved by the ElementTree.tostring method. Elements appear to +be fine. """ -from excitingtools.input.input_xml import exciting_input_xml + +import numpy as np +import pytest + +from excitingtools.input.input_classes import ExcitingGroundStateInput, ExcitingKeywordsInput, ExcitingXSInput +from excitingtools.input.input_xml import ExcitingInputXML from excitingtools.input.structure import ExcitingStructure -from excitingtools.input.ground_state import ExcitingGroundStateInput -from excitingtools.input.xs import ExcitingXSInput -def test_exciting_input_xml_structure_and_gs_and_xs(): - """Test the XML created for a ground state input is valid. - Test SubTree composition using only mandatory attributes for each XML subtree. - """ - # Structure +@pytest.fixture +def exciting_structure() -> ExcitingStructure: + """Initialise an exciting structure.""" cubic_lattice = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] - arbitrary_atoms = [{'species': 'Li', 'position': [0.0, 0.0, 0.0]}, - {'species': 'Li', 'position': [1.0, 0.0, 0.0]}, - {'species': 'F', 'position': [2.0, 0.0, 0.0]}] + arbitrary_atoms = [ + {"species": "Li", "position": [0.0, 0.0, 0.0]}, + {"species": "Li", "position": [1.0, 0.0, 0.0]}, + {"species": "F", "position": [2.0, 0.0, 0.0]}, + ] + + return ExcitingStructure(arbitrary_atoms, cubic_lattice, ".") - structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, '.') +@pytest.fixture +def exciting_input_xml(exciting_structure: ExcitingStructure) -> ExcitingInputXML: + """Initialises a complete input file.""" ground_state = ExcitingGroundStateInput( rgkmax=8.0, do="fromscratch", @@ -29,103 +41,238 @@ def test_exciting_input_xml_structure_and_gs_and_xs(): xctype="GGA_PBE_SOL", vkloff=[0, 0, 0], tforce=True, - nosource=False - ) - - xs_attributes = {'broad': 0.32, 'ngridk': [8, 8, 8]} - bse_attributes = {'bsetype': 'singlet', 'xas': True} - energywindow_attributes = {'intv': [5.8, 8.3], 'points': 5000} - screening_attributes = {'screentype': 'full', 'nempty': 15} - plan_input = ['screen', 'bse'] + nosource=False, + ) + + bse_attributes = {"bsetype": "singlet", "xas": True} + energywindow_attributes = {"intv": [5.8, 8.3], "points": 5000} + screening_attributes = {"screentype": "full", "nempty": 15} + plan_input = ["screen", "bse"] qpointset_input = [[0, 0, 0], [0.5, 0.5, 0.5]] - xs = ExcitingXSInput("BSE", xs=xs_attributes, - BSE=bse_attributes, - energywindow=energywindow_attributes, - screening=screening_attributes, - qpointset=qpointset_input, - plan=plan_input) + xs = ExcitingXSInput( + xstype="BSE", + broad=0.32, + ngridk=[8, 8, 8], + BSE=bse_attributes, + energywindow=energywindow_attributes, + screening=screening_attributes, + qpointset=qpointset_input, + plan=plan_input, + ) + keywords = ExcitingKeywordsInput("keyword1 keyword2 keyword3") + + return ExcitingInputXML( + sharedfs=True, + structure=exciting_structure, + title="Test Case", + groundstate=ground_state, + xs=xs, + keywords=keywords, + ) + - input_xml_tree = exciting_input_xml( - structure, title='Test Case', groundstate=ground_state, xs=xs) +def test_exciting_input_xml_structure_and_gs_and_xs(exciting_input_xml: ExcitingInputXML): # noqa: PLR0915 + """Test the XML created for a ground state input is valid. + Test SubTree composition using only mandatory attributes for each XML subtree. + """ + input_xml_tree = exciting_input_xml.to_xml() - assert input_xml_tree.tag == 'input' - assert input_xml_tree.keys() == [] + assert input_xml_tree.tag == "input" + assert input_xml_tree.keys() == ["sharedfs"] subelements = list(input_xml_tree) - assert len(subelements) == 4 + assert len(subelements) == 5 title_xml = subelements[0] - assert title_xml.tag == 'title' + assert title_xml.tag == "title" assert title_xml.keys() == [] - assert title_xml.text == 'Test Case' + assert title_xml.text == "Test Case" structure_xml = subelements[1] - assert structure_xml.tag == 'structure' - assert structure_xml.keys() == ['speciespath'] + assert structure_xml.tag == "structure" + assert structure_xml.keys() == ["speciespath"] assert len(list(structure_xml)) == 3 groundstate_xml = subelements[2] - assert groundstate_xml.tag == 'groundstate' - assert groundstate_xml.text == ' ' - assert groundstate_xml.keys() == \ - ['rgkmax', 'do', 'ngridk', 'xctype', 'vkloff', 'tforce', 'nosource'] - assert groundstate_xml.get('rgkmax') == "8.0" - assert groundstate_xml.get('do') == "fromscratch" - assert groundstate_xml.get('ngridk') == "6 6 6" - assert groundstate_xml.get('xctype') == "GGA_PBE_SOL" - assert groundstate_xml.get('vkloff') == "0 0 0" - assert groundstate_xml.get('tforce') == "true" - assert groundstate_xml.get('nosource') == "false" + assert groundstate_xml.tag == "groundstate" + assert groundstate_xml.text == " " + assert groundstate_xml.keys() == ["rgkmax", "do", "ngridk", "xctype", "vkloff", "tforce", "nosource"] + assert groundstate_xml.get("rgkmax") == "8.0" + assert groundstate_xml.get("do") == "fromscratch" + assert groundstate_xml.get("ngridk") == "6 6 6" + assert groundstate_xml.get("xctype") == "GGA_PBE_SOL" + assert groundstate_xml.get("vkloff") == "0 0 0" + assert groundstate_xml.get("tforce") == "true" + assert groundstate_xml.get("nosource") == "false" xs_xml = subelements[3] - assert xs_xml.tag == 'xs' - try: - assert xs_xml.keys() == ['broad', 'ngridk', 'xstype'] - except AssertionError: - assert xs_xml.keys() == ['xstype', 'broad', 'ngridk'] - assert xs_xml.get('broad') == '0.32' - assert xs_xml.get('ngridk') == '8 8 8' - assert xs_xml.get('xstype') == 'BSE' + assert xs_xml.tag == "xs" + assert set(xs_xml.keys()) == {"broad", "ngridk", "xstype"} + assert xs_xml.get("broad") == "0.32" + assert xs_xml.get("ngridk") == "8 8 8" + assert xs_xml.get("xstype") == "BSE" xs_subelements = list(xs_xml) assert len(xs_subelements) == 5 + valid_tags = {"screening", "BSE", "energywindow", "qpointset", "plan"} + assert valid_tags == set(xs_subelement.tag for xs_subelement in xs_subelements) - screening_xml = xs_subelements[0] + screening_xml = xs_xml.find("screening") assert screening_xml.tag == "screening" - assert screening_xml.keys() == ['screentype', 'nempty'] - assert screening_xml.get('screentype') == 'full' - assert screening_xml.get('nempty') == '15' + assert screening_xml.keys() == ["screentype", "nempty"] + assert screening_xml.get("screentype") == "full" + assert screening_xml.get("nempty") == "15" - bse_xml = xs_subelements[1] - assert bse_xml.tag == 'BSE' - assert bse_xml.keys() == ['bsetype', 'xas'] - assert bse_xml.get('bsetype') == 'singlet' - assert bse_xml.get('xas') == 'true' + bse_xml = xs_xml.find("BSE") + assert bse_xml.tag == "BSE" + assert bse_xml.keys() == ["bsetype", "xas"] + assert bse_xml.get("bsetype") == "singlet" + assert bse_xml.get("xas") == "true" - energywindow_xml = xs_subelements[2] + energywindow_xml = xs_xml.find("energywindow") assert energywindow_xml.tag == "energywindow" - assert energywindow_xml.keys() == ['intv', 'points'] - assert energywindow_xml.get('intv') == '5.8 8.3' - assert energywindow_xml.get('points') == '5000' + assert energywindow_xml.keys() == ["intv", "points"] + assert energywindow_xml.get("intv") == "5.8 8.3" + assert energywindow_xml.get("points") == "5000" - qpointset_xml = xs_subelements[3] + qpointset_xml = xs_xml.find("qpointset") assert qpointset_xml.tag == "qpointset" assert qpointset_xml.items() == [] qpoints = list(qpointset_xml) assert len(qpoints) == 2 - assert qpoints[0].tag == 'qpoint' + assert qpoints[0].tag == "qpoint" assert qpoints[0].items() == [] - valid_qpoints = {'0 0 0', '0.5 0.5 0.5'} + valid_qpoints = {"0 0 0", "0.5 0.5 0.5"} assert qpoints[0].text in valid_qpoints valid_qpoints.discard(qpoints[0].text) assert qpoints[1].text in valid_qpoints - plan_xml = xs_subelements[4] + plan_xml = xs_xml.find("plan") assert plan_xml.tag == "plan" assert plan_xml.items() == [] doonlys = list(plan_xml) assert len(doonlys) == 2 - assert doonlys[0].tag == 'doonly' - assert doonlys[0].items() == [('task', 'screen')] - assert doonlys[1].tag == 'doonly' - assert doonlys[1].items() == [('task', 'bse')] + assert doonlys[0].tag == "doonly" + assert doonlys[0].items() == [("task", "screen")] + assert doonlys[1].tag == "doonly" + assert doonlys[1].items() == [("task", "bse")] + + title_xml = subelements[4] + assert title_xml.tag == "keywords" + assert title_xml.keys() == [] + assert title_xml.text == "keyword1 keyword2 keyword3" + + +def test_attribute_modification(exciting_input_xml: ExcitingInputXML): + """Test the XML created for a ground state input is valid. + Test SubTree composition using only mandatory attributes for each XML subtree. + """ + exciting_input_xml.set_title("New Test Case") + exciting_input_xml.structure.crystal_properties.scale = 2.3 + exciting_input_xml.groundstate.rgkmax = 9.0 + exciting_input_xml.xs.energywindow.points = 4000 + input_xml_tree = exciting_input_xml.to_xml() + + subelements = list(input_xml_tree) + assert len(subelements) == 5 + + title_xml = subelements[0] + assert title_xml.tag == "title" + assert title_xml.text == "New Test Case" + + structure_xml = subelements[1] + assert structure_xml[0].get("scale") == "2.3" + + groundstate_xml = subelements[2] + assert groundstate_xml.get("rgkmax") == "9.0" + + xs_xml = subelements[3] + xs_subelements = list(xs_xml) + assert len(xs_subelements) == 5 + + energywindow_xml = xs_xml.find("energywindow") + assert energywindow_xml.get("points") == "4000" + + +@pytest.mark.usefixtures("mock_env_jobflow_missing") +def test_as_dict(exciting_input_xml: ExcitingInputXML): + dict_representation = exciting_input_xml.as_dict() + assert set(dict_representation.keys()) == {"xml_string"} + # check only that the xml string starts with the correct first lines: + assert dict_representation["xml_string"].startswith( + '\n\n\tTest Case\n\t\n\n\tTest Case\n\t + + BN (B3) + + + 0. 0.5 0.5 + 0.5 0. 0.5 + 0.5 0.5 0. + + + + + + + + + + + """ + input_xml = ExcitingInputXML.from_xml(input_str) + assert input_xml.title.title == "BN (B3)" + + assert input_xml.structure.speciespath == "speciespath" + assert input_xml.structure.autormt is True + assert input_xml.structure.species == ["B", "N"] + np.testing.assert_allclose(input_xml.structure.positions, [[0.0] * 3, [0.25] * 3]) + np.testing.assert_allclose(input_xml.structure.lattice, np.full((3, 3), 0.5) - 0.5 * np.eye(3)) + assert input_xml.structure.crystal_properties.scale == pytest.approx(6.816242132875) + + assert input_xml.groundstate.outputlevel == "high" + assert input_xml.groundstate.ngridk == [10, 10, 10] + assert input_xml.groundstate.rgkmax == pytest.approx(7.0) + assert input_xml.groundstate.maxscl == 200 + assert input_xml.groundstate.do == "fromscratch" + assert input_xml.groundstate.xctype == "GGA_PBE" diff --git a/tests/input/test_properties.py b/tests/input/test_properties.py new file mode 100644 index 0000000..1893bc2 --- /dev/null +++ b/tests/input/test_properties.py @@ -0,0 +1,192 @@ +"""Test properties. + +NOTE: +All attribute tests should assert on the XML tree content's as the attribute +order is not preserved by the ElementTree.tostring method. Elements appear to +be fine. +""" + +import re + +import pytest + +from excitingtools.exciting_dict_parsers.input_parser import parse_element_xml +from excitingtools.input.bandstructure import ( + band_structure_input_from_ase_atoms_obj, + band_structure_input_from_cell_or_bandpath, + get_bandstructure_input_from_exciting_structure, +) +from excitingtools.input.input_classes import ExcitingGroundStateInput, ExcitingPropertiesInput +from excitingtools.input.input_xml import ExcitingInputXML +from excitingtools.input.structure import ExcitingStructure + + +@pytest.fixture +def ase_ag(): + """If we cannot import ase we skip tests with this fixture. + + :returns: bulk Silver crystal + """ + ase_build = pytest.importorskip("ase.build") + return ase_build.bulk("Ag") + + +def test_bandstructure_properties_input(): + """Test giving the bandstructure input as a nested dict.""" + properties = { + "bandstructure": { + "plot1d": { + "path": { + "steps": 100, + "point": [ + {"coord": [1, 0, 0], "label": "Gamma"}, + {"coord": [0.625, 0.375, 0], "label": "K"}, + {"coord": [0.5, 0.5, 0], "label": "X", "breakafter": True}, + {"coord": [0, 0, 0], "label": "Gamma"}, + {"coord": [0.5, 0, 0], "label": "L"}, + ], + } + } + } + } + + properties_input = ExcitingPropertiesInput(**properties) + xml_string = properties_input.to_xml_str() + + assert properties == parse_element_xml(xml_string) + + +def test_bs_from_ase(ase_ag): + """Test getting XML bandstructure input for elemental silver. + + We test first that the bandstructure path defined by the high symmetry + points is correct. We also check the high symmetry point names are + correct. + """ + ase_ag.set_cell(ase_ag.cell * 3) + bs = band_structure_input_from_ase_atoms_obj(ase_ag) + properties_input = ExcitingPropertiesInput(bandstructure=bs) + bs_xml_string = properties_input.to_xml_str() + # Get the high symmetry path. + all_labels = re.findall(r"label=\"([A-Z])", bs_xml_string) + assert all_labels == ["G", "X", "W", "K", "G", "L", "U", "W", "L", "K", "U", "X"] + # Ensure there is only one breakafter statement. + assert len(re.findall(r"breakafter", bs_xml_string)) == 1 + + bs_xml = properties_input.to_xml() + points = bs_xml.find("bandstructure").find("plot1d").find("path").findall("point") + assert len(points) == 12 + assert points[0].get("coord") == "0.0 0.0 0.0" + assert points[1].get("coord") == "0.5 0.0 0.5" + assert points[2].get("coord") == "0.5 0.25 0.75" + assert points[3].get("coord") == "0.375 0.375 0.75" + assert points[4].get("coord") == "0.0 0.0 0.0" + assert points[5].get("coord") == "0.5 0.5 0.5" + assert points[6].get("coord") == "0.625 0.25 0.625" + assert points[7].get("coord") == "0.5 0.25 0.75" + assert points[8].get("coord") == "0.5 0.5 0.5" + assert points[9].get("coord") == "0.375 0.375 0.75" + assert points[9].get("breakafter") == "true" + assert points[10].get("coord") == "0.625 0.25 0.625" + assert points[11].get("coord") == "0.5 0.0 0.5" + + +def test_bs_input(ase_ag): + """Test writing exciting full input xml file with bandstructure property.""" + gs = ExcitingGroundStateInput(rgkmax=5.0) + struct = ExcitingStructure(ase_ag) + bs = band_structure_input_from_ase_atoms_obj(ase_ag) + properties_input = ExcitingPropertiesInput(bandstructure=bs) + input_xml = ExcitingInputXML( + structure=struct, groundstate=gs, title="BS exciting", properties=properties_input + ).to_xml() + + assert input_xml.tag == "input" + assert input_xml.keys() == [] + + subelements = list(input_xml) + assert len(subelements) == 4 + title_xml = subelements[0] + assert title_xml.tag == "title" + assert title_xml.keys() == [] + assert title_xml.text == "BS exciting" + + properties_xml = subelements[3] + assert properties_xml.tag == "properties" + assert properties_xml.keys() == [] + assert properties_xml.text == " " + assert properties_xml[0].tag == "bandstructure" + assert properties_xml[0][0].tag == "plot1d" + assert properties_xml[0][0][0].tag == "path" + + first_coordinates = properties_xml[0][0][0][0].get("coord") + first_label = properties_xml[0][0][0][0].get("label") + assert first_coordinates == "0.0 0.0 0.0" + assert first_label == "G" + + +def test_get_bandstructure_input_from_exciting_structure(ase_ag): + structure = ExcitingStructure(ase_ag) + bs_xml = get_bandstructure_input_from_exciting_structure(structure).to_xml() + points = bs_xml.find("plot1d").find("path").findall("point") + assert len(points) == 12 + assert points[0].get("coord") == "0.0 0.0 0.0" + assert points[1].get("coord") == "0.5 0.0 0.5" + + +def test_get_bandstructure_input_from_exciting_structure_stretch(ase_ag): + structure = ExcitingStructure(ase_ag) + structure.crystal_properties.stretch = [2, 1, 1] + bs_xml = get_bandstructure_input_from_exciting_structure(structure).to_xml() + points = bs_xml.find("plot1d").find("path").findall("point") + assert len(points) == 13 + assert points[0].get("coord") == "0.0 0.0 0.0" + assert points[1].get("coord") == "0.5 0.0 0.5" + assert points[2].get("coord") == "0.125 -0.375 0.3125" + + +def test_get_bandstructure_input_from_ase_bandpath(ase_ag): + """Test creating required bandstructure input from an ASE bandpath. + + Someone could create a bandpath that doesn't fall on special points + e.g., 'Kpt0Kpt1,GMX,XZ'. + """ + ase = pytest.importorskip("ase") + bandpath = ase.dft.kpoints.bandpath(cell=ase_ag.cell, path=[(0, 0, 0), (0.1, 0.2, 0.1), (0, 0, 0)]) + bandstructure = band_structure_input_from_cell_or_bandpath(bandpath) + bs_xml = bandstructure.to_xml() + assert bs_xml.tag == "bandstructure", 'Root tag should be "bandstructure"' + plot1d_xml = bs_xml.find("plot1d") + assert plot1d_xml is not None, 'Missing "plot1d" subtree' + + path_xml = plot1d_xml.find("path") + assert path_xml is not None, 'Missing "path" subtree' + assert path_xml.get("steps") == "100", 'Invalid value for "steps" attribute' + + assert len(list(path_xml)) == 3 + point1 = path_xml[0] + assert point1.get("coord") == "0.0 0.0 0.0", 'Invalid value for "coord" attribute of point 1' + assert point1.get("label") == "G", 'Invalid value for "label" attribute of point 1' + + point2 = path_xml[1] + assert point2.get("coord") == "0.1 0.2 0.1", 'Invalid value for "coord" attribute of point 2' + assert point2.get("label") == "Kpt0", 'Invalid value for "label" attribute of point 2' + + point3 = path_xml[2] + assert point3.get("coord") == "0.0 0.0 0.0", 'Invalid value for "coord" attribute of point 3' + assert point3.get("label") == "G", 'Invalid value for "label" attribute of point 3' + + +def test_class_ExcitingKstlistInput(): + properties = {"wfplot": {"kstlist": [[1, 4], [2, 5]]}} + properties_input = ExcitingPropertiesInput(**properties) + properties_tree = properties_input.to_xml() + wfplot = properties_tree.find("wfplot") + kstlist = wfplot.find("kstlist") + + pointstatepair = list(kstlist) + assert len(pointstatepair) == 2 + assert pointstatepair[0].tag == "pointstatepair" + assert pointstatepair[0].items() == [] + assert pointstatepair[0].text == "1 4" + assert pointstatepair[1].text == "2 5" diff --git a/tests/input/test_structure.py b/tests/input/test_structure.py index a97fc1b..1155b76 100644 --- a/tests/input/test_structure.py +++ b/tests/input/test_structure.py @@ -1,7 +1,7 @@ """Test ExcitingStructure, python API that generates exciting's structure XML. NOTE: -All attribute tests should assert on the XML tree content,s as the attribute +All attribute tests should assert on the XML tree content, as the attribute order is not preserved by the ElementTree.tostring method. Elements appear to be fine. @@ -17,15 +17,11 @@ ' ' """ -import sys -import pytest -import numpy as np -try: - import ase -except ImportError: - pass +import numpy as np +import pytest +from excitingtools.input.bandstructure import get_bandstructure_input_from_exciting_structure from excitingtools.input.structure import ExcitingStructure @@ -35,10 +31,12 @@ def xml_structure_H2He(): structure object initialised with a mock crystal, using mandatory arguments only. """ cubic_lattice = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] - arbitrary_atoms = [{'species': 'H', 'position': [0, 0, 0]}, - {'species': 'H', 'position': [1, 0, 0]}, - {'species': 'He', 'position': [2, 0, 0]}] - structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, './') + arbitrary_atoms = [ + {"species": "H", "position": [0, 0, 0]}, + {"species": "H", "position": [1, 0, 0]}, + {"species": "He", "position": [2, 0, 0]}, + ] + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./") return structure.to_xml() @@ -46,9 +44,9 @@ def test_class_exciting_structure_xml(xml_structure_H2He): """ Test input XML attributes from an instance of ExcitingStructure. """ - assert xml_structure_H2He.tag == 'structure', 'XML root should be structure' - assert xml_structure_H2He.keys() == ['speciespath'], 'structure defined to have only speciespath ' - assert xml_structure_H2He.get('speciespath') == './', 'species path set to ./' + assert xml_structure_H2He.tag == "structure", "XML root should be structure" + assert xml_structure_H2He.keys() == ["speciespath"], "structure defined to have only speciespath " + assert xml_structure_H2He.get("speciespath") == "./", "species path set to ./" def test_class_exciting_structure_crystal_xml(xml_structure_H2He): @@ -56,15 +54,15 @@ def test_class_exciting_structure_crystal_xml(xml_structure_H2He): crystal subtree of structure. """ elements = list(xml_structure_H2He) - assert len(elements) == 3, 'Expect structure tree to have 3 sub-elements' + assert len(elements) == 3, "Expect structure tree to have 3 sub-elements" crystal_xml = elements[0] - assert crystal_xml.tag == "crystal", 'First subtree is crystal' + assert crystal_xml.tag == "crystal", "First subtree is crystal" assert crystal_xml.items() == [], "No required attributes in crystal." lattice_vectors = list(crystal_xml) assert len(lattice_vectors) == 3, "Always expect three lattice vectors" - assert lattice_vectors[0].items() == [], 'Lattice vectors have no items' + assert lattice_vectors[0].items() == [], "Lattice vectors have no items" assert lattice_vectors[0].text == "1.0 0.0 0.0", "Lattice vector `a` differs from input" assert lattice_vectors[1].text == "0.0 1.0 0.0", "Lattice vector `b` differs from input" assert lattice_vectors[2].text == "0.0 0.0 1.0", "Lattice vector `c` differs from input" @@ -75,25 +73,25 @@ def test_class_exciting_structure_species_xml(xml_structure_H2He): species subtrees of structure. """ elements = list(xml_structure_H2He) - assert len(elements) == 3, 'Expect structure tree to have 3 sub-elements' + assert len(elements) == 3, "Expect structure tree to have 3 sub-elements" species_h_xml = elements[1] - assert species_h_xml.tag == "species", 'Second subtree is species' + assert species_h_xml.tag == "species", "Second subtree is species" species_he_xml = elements[2] - assert species_he_xml.tag == "species", 'Third subtree is species' + assert species_he_xml.tag == "species", "Third subtree is species" - assert species_h_xml.items() == [('speciesfile', 'H.xml')], 'species is inconsistent' - assert species_he_xml.items() == [('speciesfile', 'He.xml')], 'species is inconsistent' + assert species_h_xml.items() == [("speciesfile", "H.xml")], "species is inconsistent" + assert species_he_xml.items() == [("speciesfile", "He.xml")], "species is inconsistent" atoms_h = list(species_h_xml) - assert len(atoms_h) == 2, 'Number of H atoms is wrong' - assert atoms_h[0].items() == [('coord', '0 0 0')], "Coordinate of first H differs to input" - assert atoms_h[1].items() == [('coord', '1 0 0')], "Coordinate of second H differs to input" + assert len(atoms_h) == 2, "Number of H atoms is wrong" + assert atoms_h[0].items() == [("coord", "0 0 0")], "Coordinate of first H differs to input" + assert atoms_h[1].items() == [("coord", "1 0 0")], "Coordinate of second H differs to input" atoms_he = list(species_he_xml) - assert len(atoms_he) == 1, 'Number of He atoms is wrong' - assert atoms_he[0].items() == [('coord', '2 0 0')], "Coordinate of only He differs to input" + assert len(atoms_he) == 1, "Number of He atoms is wrong" + assert atoms_he[0].items() == [("coord", "2 0 0")], "Coordinate of only He differs to input" @pytest.fixture @@ -103,14 +101,17 @@ def xml_structure_CdS(): Optional atom attributes require the generic container, List[dict]. """ cubic_lattice = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] - arbitrary_atoms = [{ - 'species': 'Cd', - 'position': [0.0, 0.0, 0.0], - 'bfcmt': [1.0, 1.0, 1.0], - 'lockxyz': [False, False, False], - 'mommtfix': [2.0, 2.0, 2.0] - }, {'species': 'S', 'position': [1.0, 0.0, 0.0]}] - structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, './') + arbitrary_atoms = [ + { + "species": "Cd", + "position": [0.0, 0.0, 0.0], + "bfcmt": [1.0, 1.0, 1.0], + "lockxyz": [False, False, False], + "mommtfix": [2.0, 2.0, 2.0], + }, + {"species": "S", "position": [1.0, 0.0, 0.0]}, + ] + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./") return structure.to_xml() @@ -118,43 +119,46 @@ def test_optional_atom_attributes_xml(xml_structure_CdS): """ Test optional atom attributes are correctly set in XML tree. """ - assert xml_structure_CdS.tag == 'structure' - assert xml_structure_CdS.keys() == ['speciespath'], 'structure defined to have only speciespath ' - assert xml_structure_CdS.get('speciespath') == './', 'speciespath set to be ./' + assert xml_structure_CdS.tag == "structure" + assert xml_structure_CdS.keys() == ["speciespath"], "structure defined to have only speciespath " + assert xml_structure_CdS.get("speciespath") == "./", "speciespath set to be ./" elements = list(xml_structure_CdS) - assert len(elements) == 3, 'Expect structure tree to have 3 sub-elements' + assert len(elements) == 3, "Expect structure tree to have 3 sub-elements" # Crystal crystal_xml = elements[0] - assert crystal_xml.tag == "crystal", 'First subtree is crystal' + assert crystal_xml.tag == "crystal", "First subtree is crystal" assert crystal_xml.items() == [], "No required attributes in crystal." # Species species_cd_xml = elements[1] - assert species_cd_xml.tag == "species", 'Second subtree is species' - assert species_cd_xml.items() == [('speciesfile', 'Cd.xml')] + assert species_cd_xml.tag == "species", "Second subtree is species" + assert species_cd_xml.items() == [("speciesfile", "Cd.xml")] species_s_xml = elements[2] - assert species_s_xml.tag == "species", 'Third subtree is species' - assert species_s_xml.items() == [('speciesfile', 'S.xml')] + assert species_s_xml.tag == "species", "Third subtree is species" + assert species_s_xml.items() == [("speciesfile", "S.xml")] # Cd atoms_cd = list(species_cd_xml) - assert len(atoms_cd) == 1, 'Wrong number of Cd atoms' - assert set(atoms_cd[0].keys()) == {'coord', 'bfcmt', 'mommtfix', 'lockxyz'}, \ - 'Cd contains all mandatory and optional atom properties' - assert ('coord', '0.0 0.0 0.0') in atoms_cd[0].items() - assert ('bfcmt', '1.0 1.0 1.0') in atoms_cd[0].items() - assert ('mommtfix', '2.0 2.0 2.0') in atoms_cd[0].items() - assert ('lockxyz', 'false false false') in atoms_cd[0].items() + assert len(atoms_cd) == 1, "Wrong number of Cd atoms" + assert set(atoms_cd[0].keys()) == { + "coord", + "bfcmt", + "mommtfix", + "lockxyz", + }, "Cd contains all mandatory and optional atom properties" + assert ("coord", "0.0 0.0 0.0") in atoms_cd[0].items() + assert ("bfcmt", "1.0 1.0 1.0") in atoms_cd[0].items() + assert ("mommtfix", "2.0 2.0 2.0") in atoms_cd[0].items() + assert ("lockxyz", "false false false") in atoms_cd[0].items() # S atoms_s = list(species_s_xml) - assert len(atoms_s) == 1, 'Wrong number of S atoms' - assert atoms_s[0].keys() == ['coord'], \ - 'S only contains mandatory atom properties' - assert atoms_s[0].items() == [('coord', '1.0 0.0 0.0')] + assert len(atoms_s) == 1, "Wrong number of S atoms" + assert atoms_s[0].keys() == ["coord"], "S only contains mandatory atom properties" + assert atoms_s[0].items() == [("coord", "1.0 0.0 0.0")] @pytest.fixture @@ -163,8 +167,7 @@ def lattice_and_atoms_CdS(): structure object initialised with a mock crystal """ cubic_lattice = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] - arbitrary_atoms = [{'species': 'Cd', 'position': [0.0, 0.0, 0.0]}, - {'species': 'S', 'position': [1.0, 0.0, 0.0]}] + arbitrary_atoms = [{"species": "Cd", "position": [0.0, 0.0, 0.0]}, {"species": "S", "position": [1.0, 0.0, 0.0]}] return cubic_lattice, arbitrary_atoms @@ -173,26 +176,23 @@ def test_optional_structure_attributes_xml(lattice_and_atoms_CdS): Test optional structure attributes. """ cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS - structure_attributes = { - 'autormt': True, 'cartesian': False, 'epslat': 1.e-6, 'primcell': False, 'tshift': True - } - structure = ExcitingStructure( - arbitrary_atoms, cubic_lattice, './', structure_properties=structure_attributes - ) + structure_attributes = {"autormt": True, "cartesian": False, "epslat": 1.0e-6, "primcell": False, "tshift": True} + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./", **structure_attributes) xml_structure = structure.to_xml() - mandatory = ['speciespath'] - optional = list(structure_attributes) + mandatory = {"speciespath"} + optional = set(structure_attributes) - assert xml_structure.tag == 'structure' - assert xml_structure.keys() == mandatory + optional, \ - 'Should contain mandatory speciespath plus all optional attributes' - assert xml_structure.get('speciespath') == './', 'species path should be ./' - assert xml_structure.get('autormt') == 'true' - assert xml_structure.get('cartesian') == 'false' - assert xml_structure.get('epslat') == '1e-06' - assert xml_structure.get('primcell') == 'false' - assert xml_structure.get('tshift') == 'true' + assert xml_structure.tag == "structure" + assert ( + set(xml_structure.keys()) == mandatory | optional + ), "Should contain mandatory speciespath plus all optional attributes" + assert xml_structure.get("speciespath") == "./", "species path should be ./" + assert xml_structure.get("autormt") == "true" + assert xml_structure.get("cartesian") == "false" + assert xml_structure.get("epslat") == "1e-06" + assert xml_structure.get("primcell") == "false" + assert xml_structure.get("tshift") == "true" def test_optional_crystal_attributes_xml(lattice_and_atoms_CdS): @@ -202,21 +202,18 @@ def test_optional_crystal_attributes_xml(lattice_and_atoms_CdS): cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS structure = ExcitingStructure( - arbitrary_atoms, - cubic_lattice, - './', - crystal_properties={'scale': 1.00, 'stretch': 1.00} - ) + arbitrary_atoms, cubic_lattice, "./", crystal_properties={"scale": 1.00, "stretch": [1.00, 1.00, 1.00]} + ) xml_structure = structure.to_xml() elements = list(xml_structure) - assert len(elements) == 3, 'Number of sub-elements in structure tree' + assert len(elements) == 3, "Number of sub-elements in structure tree" crystal_xml = elements[0] - assert crystal_xml.tag == "crystal", 'First subtree is crystal' - assert crystal_xml.keys() == ['scale', 'stretch'], 'Optional crystal properties' - assert crystal_xml.get('scale') == '1.0', 'scale value inconsistent with input' - assert crystal_xml.get('stretch') == '1.0', 'stretch value inconsistent with input' + assert crystal_xml.tag == "crystal", "First subtree is crystal" + assert crystal_xml.keys() == ["scale", "stretch"], "Optional crystal properties" + assert crystal_xml.get("scale") == "1.0", "scale value inconsistent with input" + assert crystal_xml.get("stretch") == "1.0 1.0 1.0", "stretch value inconsistent with input" def test_optional_species_attributes_xml(lattice_and_atoms_CdS): @@ -224,29 +221,138 @@ def test_optional_species_attributes_xml(lattice_and_atoms_CdS): Test optional species attributes. """ cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS - species_attributes = {'Cd': {'rmt': 3.0}, 'S': {'rmt': 4.0}} - - structure = ExcitingStructure( - arbitrary_atoms, cubic_lattice, './', species_properties=species_attributes - ) + species_attributes = { + "Cd": {"rmt": 3.0, "LDAplusU": {"J": 1.5, "U": 2.4, "l": 2}}, + "S": { + "rmt": 4.0, + "dfthalfparam": {"ampl": 1.2, "cut": 1.9, "exponent": 5, "shell": [{"ionization": 0.8, "number": 1}]}, + }, + } + + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./", species_properties=species_attributes) xml_structure = structure.to_xml() elements = list(xml_structure) - assert len(elements) == 3, 'Number of sub-elements in structure tree' + assert len(elements) == 3, "Number of sub-elements in structure tree" species_cd_xml = elements[1] - assert species_cd_xml.tag == "species", 'Second subtree is species' + assert species_cd_xml.tag == "species", "Second subtree is species" species_s_xml = elements[2] - assert species_s_xml.tag == "species", 'Third subtree is species' + assert species_s_xml.tag == "species", "Third subtree is species" + + assert set(species_cd_xml.keys()) == {"speciesfile", "rmt"}, "species attributes differ from expected" + assert species_cd_xml.get("speciesfile") == "Cd.xml", "speciesfile differs from expected" + assert species_cd_xml.get("rmt") == "3.0", "Cd muffin tin radius differs from input" + + species_cd_elements = list(species_cd_xml) + assert len(species_cd_elements) == 2 + ldaplusu_xml = species_cd_elements[0] + assert ldaplusu_xml.tag == "LDAplusU" + + assert set(ldaplusu_xml.keys()) == {"J", "U", "l"} + assert ldaplusu_xml.get("J") == "1.5" + assert ldaplusu_xml.get("U") == "2.4" + assert ldaplusu_xml.get("l") == "2" + + assert set(species_s_xml.keys()) == {"speciesfile", "rmt"}, "species attributes differ from expected" + assert species_s_xml.get("speciesfile") == "S.xml", "speciesfile differs from expected" + assert species_s_xml.get("rmt") == "4.0", "S muffin tin radius differs from input" + + species_s_elements = list(species_s_xml) + assert len(species_s_elements) == 2 + dfthalfparam_xml = species_s_elements[0] + assert dfthalfparam_xml.tag == "dfthalfparam" + + assert set(dfthalfparam_xml.keys()) == {"ampl", "cut", "exponent"} + assert dfthalfparam_xml.get("ampl") == "1.2" + assert dfthalfparam_xml.get("cut") == "1.9" + assert dfthalfparam_xml.get("exponent") == "5" + + dfthalfparam_elements = list(dfthalfparam_xml) + assert len(dfthalfparam_elements) == 1 + shell_xml = dfthalfparam_elements[0] + assert shell_xml.tag == "shell" + + assert set(shell_xml.keys()) == {"ionization", "number"} + assert shell_xml.get("ionization") == "0.8" + assert shell_xml.get("number") == "1" + - assert species_cd_xml.keys() == ['speciesfile', 'rmt'], "species attributes differ from expected" - assert species_cd_xml.get('speciesfile') == 'Cd.xml', 'speciesfile differs from expected' - assert species_cd_xml.get('rmt') == '3.0', 'Cd muffin tin radius differs from input' +ref_dict = { + "xml_string": ' 1.0 0.0 0.0' + "0.0 1.0 0.00.0 0.0 1.0" + ' ' + ' ' +} - assert species_s_xml.keys() == ['speciesfile', 'rmt'], "species attributes differ from expected" - assert species_s_xml.get('speciesfile') == 'S.xml', 'speciesfile differs from expected' - assert species_s_xml.get('rmt') == '4.0', 'S muffin tin radius differs from input' + +@pytest.mark.usefixtures("mock_env_jobflow_missing") +def test_as_dict(lattice_and_atoms_CdS): + cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS + + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./") + + assert structure.as_dict() == ref_dict, "expected different dict representation" + + +@pytest.mark.usefixtures("mock_env_jobflow") +def test_as_dict_jobflow(lattice_and_atoms_CdS): + cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS + + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./") + + assert structure.as_dict() == { + **ref_dict, + "@class": "ExcitingStructure", + "@module": "excitingtools.input.structure", + }, "expected different dict representation" + + +def test_from_dict(lattice_and_atoms_CdS): + cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS + structure = ExcitingStructure.from_dict(ref_dict) + + assert np.allclose(structure.lattice, np.array(cubic_lattice)) + assert structure.species == [d["species"] for d in arbitrary_atoms] + assert structure.positions == [d["position"] for d in arbitrary_atoms] + assert structure.speciespath == "./" # pylint: disable=no-member + + +def test_add_and_remove_atoms(lattice_and_atoms_CdS): + cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS + structure = ExcitingStructure(arbitrary_atoms, cubic_lattice, "./") + + assert len(structure.species) == 2, "initially there are 2 atoms in the structure" + # just confirm that the xml tree can be built, not that it is fully correct + structure.to_xml() + + structure.add_atom("Cd", [0.25, 0.25, 0.25], {"bfcmt": [1.0, 1.0, 1.0]}) + xml_tree = structure.to_xml() + assert xml_tree.findall("species")[0].findall("atom")[1].attrib == { + "coord": "0.25 0.25 0.25", + "bfcmt": "1.0 1.0 1.0", + } + + structure.add_atom("Mg", [0.75, 0.25, 0.0], species_properties={"rmt": 3}) + xml_tree = structure.to_xml() + mg_tree = xml_tree.findall("species")[1] + assert mg_tree.attrib == {"rmt": "3", "speciesfile": "Mg.xml"} + mg_atom = mg_tree.findall("atom")[0] + assert mg_atom.attrib == {"coord": "0.75 0.25 0.0"} + + structure.remove_atom(0) + xml_tree = structure.to_xml() + atoms = xml_tree.findall("species")[0].findall("atom") + assert len(atoms) == 1 + assert atoms[0].attrib == {"coord": "0.25 0.25 0.25", "bfcmt": "1.0 1.0 1.0"} + + structure.remove_atom(-1) + xml_tree = structure.to_xml() + species_trees = xml_tree.findall("species") + assert len(species_trees) == 2 + assert species_trees[0].get("speciesfile") == "Cd.xml" + assert species_trees[1].get("speciesfile") == "S.xml" @pytest.fixture @@ -255,9 +361,11 @@ def lattice_and_atoms_H20(): H20 molecule in a big box (angstrom) """ cubic_lattice = [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]] - atoms = [{'species': 'H', 'position': [0.00000, 0.75545, -0.47116]}, - {'species': 'O', 'position': [0.00000, 0.00000, 0.11779]}, - {'species': 'H', 'position': [0.00000, 0.75545, -0.47116]}] + atoms = [ + {"species": "H", "position": [0.00000, 0.75545, -0.47116]}, + {"species": "O", "position": [0.00000, 0.00000, 0.11779]}, + {"species": "H", "position": [0.00000, 0.75545, -0.47116]}, + ] return cubic_lattice, atoms @@ -266,16 +374,16 @@ def test_group_atoms_by_species(lattice_and_atoms_H20): Test group_atoms_by_species method. """ cubic_lattice, atoms = lattice_and_atoms_H20 - structure = ExcitingStructure(atoms, cubic_lattice, './') - assert structure.species == ['H', 'O', 'H'], 'Species list differs from lattice_and_atoms_H20' + structure = ExcitingStructure(atoms, cubic_lattice, "./") + assert structure.species == ["H", "O", "H"], "Species list differs from lattice_and_atoms_H20" - indices = structure._group_atoms_by_species() - assert set(indices.keys()) == {'H', 'O'}, 'List unique species in structure' - assert indices['H'] == [0, 2], "Expect atoms 0 and 2 to be H" - assert indices['O'] == [1], "Expect atom 1 to be O" + indices = dict(structure._group_atoms_by_species()) + assert set(indices.keys()) == {"H", "O"}, "List unique species in structure" + assert indices["H"] == [0, 2], "Expect atoms 0 and 2 to be H" + assert indices["O"] == [1], "Expect atom 1 to be O" - hydrogen = [structure.species[i] for i in indices['H']] - oxygen = [structure.species[i] for i in indices['O']] + hydrogen = [structure.species[i] for i in indices["H"]] + oxygen = [structure.species[i] for i in indices["O"]] assert hydrogen == ["H", "H"], "Expect list to only contain H symbols" assert oxygen == ["O"], "Expect list to contain only one O symbol" @@ -286,35 +394,120 @@ def ase_atoms_H20(lattice_and_atoms_H20): H20 molecule in a big box (angstrom), in ASE Atoms() Converts a List[dict] to ase.atoms.Atoms. """ + ase = pytest.importorskip("ase") lattice, atoms = lattice_and_atoms_H20 - symbols = [atom['species'] for atom in atoms] + symbols = [atom["species"] for atom in atoms] cubic_cell = np.asarray(lattice) - positions = [atom['position'] for atom in atoms] - if "ase" in sys.modules: - return ase.atoms.Atoms(symbols=symbols, positions=positions, cell=cubic_cell) - # TODO(Alex) Issue 117. Not sure of the best way to handle if ase is not present - return [] + positions = [atom["position"] for atom in atoms] + return ase.Atoms(symbols=symbols, positions=positions, cell=cubic_cell, pbc=True) def test_class_exciting_structure_ase(ase_atoms_H20): """ Test the ASE Atoms object gets used correctly by the ExcitingStructure constructor. """ - if "ase" not in sys.modules: - # ASE not available, so do not run - return - atoms = ase_atoms_H20 - structure = ExcitingStructure(atoms, species_path='./') + structure = ExcitingStructure(atoms, species_path="./") assert structure.species == ["H", "O", "H"] - assert np.allclose(structure.lattice, - [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]]), \ - 'Expect lattice vectors to match input values' + assert np.allclose( + structure.lattice, + [[18.897261246257703, 0.0, 0.0], [0.0, 18.897261246257703, 0.0], [0.0, 0.0, 18.897261246257703]], + ), "Expect lattice vectors to match input values" - assert np.allclose(structure.positions, atoms.positions), 'Expect positions to match input values.' + assert np.allclose(structure.positions, atoms.get_scaled_positions()), "Expect positions to match input values." # TODO(Alex) Issue 117. Compare xml_structure built with and without ASE - should be consistent # This just confirms the XML tree is built, not that it is correct. xml_structure = structure.to_xml() - assert list(xml_structure.keys()) == ['speciespath'], 'Only expect speciespath in structure xml keys' + assert list(xml_structure.keys()) == ["speciespath"], "Only expect speciespath in structure xml keys" + + +def test_using_non_standard_species_symbol(): + cubic_lattice = [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]] + atoms = [{"species": "C_molecule", "position": [0.00000, 0.75545, -0.47116]}] + structure = ExcitingStructure(atoms, cubic_lattice) + + structure_xml = structure.to_xml() + assert structure_xml.tag == "structure", "XML root should be structure" + assert structure_xml.keys() == ["speciespath"], "structure defined to have only speciespath " + assert structure_xml.get("speciespath") == "./", "species path set to ./" + + elements = list(structure_xml) + assert len(elements) == 2, "Expect structure tree to have 2 sub-elements" + + species_c_xml = elements[1] + assert species_c_xml.tag == "species", "Second subtree is species" + + assert species_c_xml.items() == [("speciesfile", "C_molecule.xml")], "species is inconsistent" + + atoms_h = list(species_c_xml) + assert len(atoms_h) == 1 + assert atoms_h[0].items() == [("coord", "0.0 0.75545 -0.47116")] + + +def test_get_full_lattice(lattice_and_atoms_CdS): + cubic_lattice, arbitrary_atoms = lattice_and_atoms_CdS + structure = ExcitingStructure( + arbitrary_atoms, cubic_lattice, "./", crystal_properties={"scale": 1.50, "stretch": [2.00, 1.00, 3.00]} + ) + ref_lattice = np.array([[3, 0, 0], [0, 1.5, 0], [0, 0, 4.5]]) + assert np.allclose(structure.get_lattice(), ref_lattice) + + +def test_structure_input_with_integers(): + atoms = [{"species": "C", "position": [0, 0, 0]}] + structure = ExcitingStructure(atoms, [[1, 0, 0], [0, 1, 0], [0, 0, 1]], "./") + ref_lattice = np.array([[0.52917721, 0.0, 0.0], [0.0, 0.52917721, 0.0], [0.0, 0.0, 0.52917721]]) + assert np.allclose(structure.get_lattice(convert_to_angstrom=True), ref_lattice) + + +def test_get_bandstructure_input_from_exciting_structure(lattice_and_atoms_H20): + pytest.importorskip("ase") + cubic_lattice, atoms = lattice_and_atoms_H20 + structure = ExcitingStructure(atoms, cubic_lattice, "./") + bandstructure = get_bandstructure_input_from_exciting_structure(structure) + bs_xml = bandstructure.to_xml() + + assert bs_xml.tag == "bandstructure", 'Root tag should be "bandstructure"' + + plot1d_xml = bs_xml.find("plot1d") + assert plot1d_xml is not None, 'Missing "plot1d" subtree' + + path_xml = plot1d_xml.find("path") + assert path_xml is not None, 'Missing "path" subtree' + assert path_xml.get("steps") == "100", 'Invalid value for "steps" attribute' + + assert len(list(path_xml)) == 8 + point1 = path_xml[0] + assert point1.get("coord") == "0.0 0.0 0.0", 'Invalid value for "coord" attribute of point 1' + assert point1.get("label") == "G", 'Invalid value for "label" attribute of point 1' + + point2 = path_xml[1] + assert point2.get("coord") == "0.0 0.5 0.0", 'Invalid value for "coord" attribute of point 2' + assert point2.get("label") == "X", 'Invalid value for "label" attribute of point 2' + + point3 = path_xml[2] + assert point3.get("coord") == "0.5 0.5 0.0", 'Invalid value for "coord" attribute of point 3' + assert point3.get("label") == "M", 'Invalid value for "label" attribute of point 3' + + point4 = path_xml[3] + assert point4.get("coord") == "0.0 0.0 0.0", 'Invalid value for "coord" attribute of point 4' + assert point4.get("label") == "G", 'Invalid value for "label" attribute of point 4' + + point5 = path_xml[4] + assert point5.get("coord") == "0.5 0.5 0.5", 'Invalid value for "coord" attribute of point 5' + assert point5.get("label") == "R", 'Invalid value for "label" attribute of point 5' + + point6 = path_xml[5] + assert point6.get("coord") == "0.0 0.5 0.0", 'Invalid value for "coord" attribute of point 6' + assert point6.get("label") == "X", 'Invalid value for "label" attribute of point 6' + assert point6.get("breakafter") == "true", 'Invalid value for "breakafter" attribute of point 6' + + point7 = path_xml[6] + assert point7.get("coord") == "0.5 0.5 0.0", 'Invalid value for "coord" attribute of point 7' + assert point7.get("label") == "M", 'Invalid value for "label" attribute of point 7' + + point8 = path_xml[7] + assert point8.get("coord") == "0.5 0.5 0.5", 'Invalid value for "coord" attribute of point 8' + assert point8.get("label") == "R", 'Invalid value for "label" attribute of point 8' diff --git a/tests/input/test_xml_utils.py b/tests/input/test_xml_utils.py index c262684..bc13974 100644 --- a/tests/input/test_xml_utils.py +++ b/tests/input/test_xml_utils.py @@ -1,9 +1,6 @@ -"""Test XML utilities. -""" -import pytest +"""Test XML utilities.""" -from excitingtools.input.xml_utils import line_reformatter, \ - prettify_tag_attributes +from excitingtools.input.xml_utils import line_reformatter def test_line_reformatter_long_ending(): @@ -11,10 +8,10 @@ def test_line_reformatter_long_ending(): input_str = ( ' ' - '' + "" ) - pretty_input_str = line_reformatter(input_str, ' - -""" +""" assert reference == pretty_input_str @@ -35,8 +31,8 @@ def test_line_reformatter_short_ending(): ' ' ) - pretty_input_str = line_reformatter(input_str, ' -""" + xctype="GGA_PBE_SOL"/>""" assert reference == pretty_input_str @@ -56,8 +51,8 @@ def test_line_reformatter_no_closing(): ' ' ) - pretty_input_str = line_reformatter(input_str, ' -""" + xctype="GGA_PBE_SOL">""" assert reference == pretty_input_str - - -def test_line_reformatter_unmatched_tag(): - """ - Test error occurs when tag is inconsistent with the element name. - - Recall the tag is passed to `line_reformatter`. This should not occur if - `line_reformatter` is only called from `prettify_tag_attributes`. - """ - input_str = ( - ' ' - '' - ) - - with pytest.raises(ValueError) as error: - line_reformatter(input_str, ' ' - '' - ) - - output = prettify_tag_attributes(input_str, ' '} + assert xs_input.as_dict() == ref_dict, "expected different dict representation" + + +@pytest.mark.usefixtures("mock_env_jobflow") +def test_ExcitingXSInput_as_dict_jobflow(): + xs_input = ExcitingXSInput(xstype="BSE") + ref_dict = { + "@class": "ExcitingXSInput", + "@module": "excitingtools.input.input_classes", + "xml_string": ' ', + } + assert xs_input.as_dict() == ref_dict, "expected different dict representation" + + +def test_ExcitingXSInput_from_dict(): + ref_dict = {"xml_string": ' '} + recreated_xs = ExcitingXSInput.from_dict(ref_dict) + assert recreated_xs.to_xml_str() == ref_dict["xml_string"] def test_class_ExcitingXSInput_xs(): - xs = {'broad': 0.32, 'ngridk': [8, 8, 8], 'tevout': True, 'nempty': 52, 'pwmat': 'fft'} - mandatory = ['xstype'] - optional = list(xs) - xs_input = ExcitingXSInput("BSE", xs=xs) + xs = {"broad": 0.32, "ngridk": [8, 8, 8], "tevout": True, "nempty": 52, "pwmat": "fft", "xstype": "BSE"} + xs_input = ExcitingXSInput(**xs) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' - try: - assert xs_xml.keys() == mandatory + optional - except AssertionError: - assert xs_xml.keys() == optional + mandatory, 'Should contain mandatory xstype plus all optional attributes' - assert xs_xml.get('xstype') == 'BSE' - assert xs_xml.get('broad') == '0.32' - assert xs_xml.get('ngridk') == '8 8 8' - assert xs_xml.get('tevout') == 'true' - assert xs_xml.get('nempty') == '52' - assert xs_xml.get('pwmat') == 'fft' + assert xs_xml.tag == "xs" + assert set(xs_xml.keys()) == set(xs) + assert xs_xml.get("xstype") == "BSE" + assert xs_xml.get("broad") == "0.32" + assert xs_xml.get("ngridk") == "8 8 8" + assert xs_xml.get("tevout") == "true" + assert xs_xml.get("nempty") == "52" + assert xs_xml.get("pwmat") == "fft" def test_class_ExcitingXsInput_wrong_key(): - with pytest.raises(ValueError) as error: - ExcitingXSInput("BSE", xs={'wrong_key': 1}) - assert error.value.args[0] == "xs keys are not valid: {'wrong_key'}" + with pytest.raises(ValueError, match="xs keys are not valid: {'wrong_key'}"): + ExcitingXSInput(xstype="BSE", wrong_key=1) def test_class_ExcitingXSInput_BSE_element(): - bse_attributes = {'bsetype': 'singlet', 'xas': True, 'xasspecies': 1} + bse_attributes = {"bsetype": "singlet", "xas": True, "xasspecies": 1} bse_keys = list(bse_attributes) - xs_input = ExcitingXSInput("BSE", BSE=bse_attributes) + xs_input = ExcitingXSInput(xstype="BSE", BSE=bse_attributes) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' - assert xs_xml.keys() == ['xstype'] + assert xs_xml.tag == "xs" + assert set(xs_xml.keys()) == {"xstype"} elements = list(xs_xml) assert len(elements) == 1 bse_xml = elements[0] assert bse_xml.tag == "BSE" - assert bse_xml.keys() == bse_keys, 'Should contain all bse attributes' - assert bse_xml.get('bsetype') == 'singlet' - assert bse_xml.get('xas') == 'true' - assert bse_xml.get('xasspecies') == '1' + assert bse_xml.keys() == bse_keys, "Should contain all bse attributes" + assert bse_xml.get("bsetype") == "singlet" + assert bse_xml.get("xas") == "true" + assert bse_xml.get("xasspecies") == "1" def test_class_ExcitingXSInput_BSE_element_object(): - bse_object = ExcitingXMLInput('BSE', bsetype='singlet', xas=True, xasspecies=1) - bse_keys = ['bsetype', 'xas', 'xasspecies'] - xs_input = ExcitingXSInput("BSE", BSE=bse_object) + bse_object = ExcitingBSEInput(bsetype="singlet", xas=True, xasspecies=1) + bse_keys = {"bsetype", "xas", "xasspecies"} + xs_input = ExcitingXSInput(xstype="BSE", BSE=bse_object) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' - assert xs_xml.keys() == ['xstype'] + assert xs_xml.tag == "xs" + assert set(xs_xml.keys()) == {"xstype"} elements = list(xs_xml) assert len(elements) == 1 bse_xml = elements[0] assert bse_xml.tag == "BSE" - assert bse_xml.keys() == bse_keys, 'Should contain all bse attributes' - assert bse_xml.get('bsetype') == 'singlet' - assert bse_xml.get('xas') == 'true' - assert bse_xml.get('xasspecies') == '1' + assert set(bse_xml.keys()) == bse_keys, "Should contain all bse attributes" + assert bse_xml.get("bsetype") == "singlet" + assert bse_xml.get("xas") == "true" + assert bse_xml.get("xasspecies") == "1" def test_class_ExcitingXSInput_energywindow_element(): - energywindow_attributes = {'intv': [5.8, 8.3], 'points': 5000} - energywindow_keys = list(energywindow_attributes) - xs_input = ExcitingXSInput("BSE", energywindow=energywindow_attributes) + energywindow_attributes = {"intv": [5.8, 8.3], "points": 5000} + xs_input = ExcitingXSInput(xstype="BSE", energywindow=energywindow_attributes) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' + assert xs_xml.tag == "xs" elements = list(xs_xml) assert len(elements) == 1 energywindow_xml = elements[0] assert energywindow_xml.tag == "energywindow" - assert energywindow_xml.keys() == energywindow_keys, 'Should contain all bse attributes' - assert energywindow_xml.get('intv') == '5.8 8.3' - assert energywindow_xml.get('points') == '5000' + assert set(energywindow_xml.keys()) == set(energywindow_attributes), "Should contain all bse attributes" + assert energywindow_xml.get("intv") == "5.8 8.3" + assert energywindow_xml.get("points") == "5000" def test_class_ExcitingXSInput_screening_element(): - screening_attributes = {'screentype': 'full', 'nempty': 15} - screening_keys = list(screening_attributes) - xs_input = ExcitingXSInput("BSE", screening=screening_attributes) + screening_attributes = {"screentype": "full", "nempty": 15} + xs_input = ExcitingXSInput(xstype="BSE", screening=screening_attributes) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' + assert xs_xml.tag == "xs" elements = list(xs_xml) assert len(elements) == 1 screening_xml = elements[0] assert screening_xml.tag == "screening" - assert screening_xml.keys() == screening_keys, 'Should contain all bse attributes' - assert screening_xml.get('screentype') == 'full' - assert screening_xml.get('nempty') == '15' + assert set(screening_xml.keys()) == set(screening_attributes), "Should contain all bse attributes" + assert screening_xml.get("screentype") == "full" + assert screening_xml.get("nempty") == "15" def test_class_ExcitingQpointsetInput_numpy(): qpointset_input = np.array(((0, 0, 0), (0.5, 0.5, 0.5))) - xs_input = ExcitingXSInput("BSE", qpointset=qpointset_input) + xs_input = ExcitingXSInput(xstype="BSE", qpointset=qpointset_input) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' + assert xs_xml.tag == "xs" elements = list(xs_xml) assert len(elements) == 1 @@ -134,19 +160,19 @@ def test_class_ExcitingQpointsetInput_numpy(): qpoints = list(qpointset_xml) assert len(qpoints) == 2 - assert qpoints[0].tag == 'qpoint' + assert qpoints[0].tag == "qpoint" assert qpoints[0].items() == [] - assert qpoints[0].text == '0.0 0.0 0.0' - assert qpoints[1].text == '0.5 0.5 0.5' + assert qpoints[0].text == "0.0 0.0 0.0" + assert qpoints[1].text == "0.5 0.5 0.5" def test_class_ExcitingQpointsetInput_list(): qpointset_input = [[0, 0, 0], [0.5, 0.5, 0.5]] - xs_input = ExcitingXSInput("BSE", qpointset=qpointset_input) + xs_input = ExcitingXSInput(xstype="BSE", qpointset=qpointset_input) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' + assert xs_xml.tag == "xs" elements = list(xs_xml) assert len(elements) == 1 @@ -157,19 +183,19 @@ def test_class_ExcitingQpointsetInput_list(): qpoints = list(qpointset_xml) assert len(qpoints) == 2 - assert qpoints[0].tag == 'qpoint' + assert qpoints[0].tag == "qpoint" assert qpoints[0].items() == [] - assert qpoints[0].text == '0 0 0' - assert qpoints[1].text == '0.5 0.5 0.5' + assert qpoints[0].text == "0 0 0" + assert qpoints[1].text == "0.5 0.5 0.5" def test_class_ExcitingPlanInput(): - plan_input = ['screen', 'bse', 'bsegenspec'] + plan_input = ["screen", "bse", "bsegenspec"] - xs_input = ExcitingXSInput("BSE", plan=plan_input) + xs_input = ExcitingXSInput(xstype="BSE", plan=plan_input) xs_xml = xs_input.to_xml() - assert xs_xml.tag == 'xs' + assert xs_xml.tag == "xs" elements = list(xs_xml) assert len(elements) == 1 @@ -180,17 +206,33 @@ def test_class_ExcitingPlanInput(): doonlys = list(plan_xml) assert len(doonlys) == 3 - assert doonlys[0].tag == 'doonly' - assert doonlys[0].items() == [('task', 'screen')] - assert doonlys[1].tag == 'doonly' - assert doonlys[1].items() == [('task', 'bse')] - assert doonlys[2].tag == 'doonly' - assert doonlys[2].items() == [('task', 'bsegenspec')] + assert doonlys[0].tag == "doonly" + assert doonlys[0].items() == [("task", "screen")] + assert doonlys[1].tag == "doonly" + assert doonlys[1].items() == [("task", "bse")] + assert doonlys[2].tag == "doonly" + assert doonlys[2].items() == [("task", "bsegenspec")] def test_class_ExcitingPlanInput_wrong_plan(): - plan_input = ['screen', 'bse', 'bsegenspec', 'falseplan'] + plan_input = ["screen", "bse", "bsegenspec", "falseplan"] with pytest.raises(ValueError) as error: - ExcitingXSInput("BSE", plan=plan_input) - assert error.value.args[0] == "Plan keys are not valid: {'falseplan'}" + ExcitingXSInput(xstype="BSE", plan=plan_input) + assert error.value.args[0] == "plan keys are not valid: {'falseplan'}" + + +def test_class_ExcitingXSInput_attribute_setting_getting(): + xs_input = ExcitingXSInput(xstype="BSE") + + xs_input.ngridk = [2, 2, 2] + with pytest.raises(ValueError, match="xs keys are not valid: {'abc'}"): + xs_input.abc = 3 + + +def test_class_ExcitingXSInput_attribute_deleting(): + xs_input = ExcitingXSInput(xstype="BSE", ngridk=[2, 2, 2]) + + del xs_input.ngridk + with pytest.warns(UserWarning, match="Attempt to delete mandatory attribute 'xstype' was prevented."): + del xs_input.xstype # pylint: disable=no-member diff --git a/tests/math/test_math_utils.py b/tests/math/test_math_utils.py index d25dfe6..c2c32b0 100644 --- a/tests/math/test_math_utils.py +++ b/tests/math/test_math_utils.py @@ -1,6 +1,7 @@ """ Tests for functions in math_utils.py """ + import numpy as np from excitingtools.math.math_utils import unit_vector @@ -8,6 +9,6 @@ def test_unit_vector(): """Test unit_vector returns a vector with norm of one.""" - x = np.array([0.23, 0.32, 1.394, 99.]) + x = np.array([0.23, 0.32, 1.394, 99.0]) norm_x = np.linalg.norm(unit_vector(x)) - assert np.isclose(norm_x, 1., atol=1.e-8) + assert np.isclose(norm_x, 1.0, atol=1.0e-8) diff --git a/tests/obj_parsers/test_eigenvalue_parser.py b/tests/obj_parsers/test_eigenvalue_parser.py new file mode 100644 index 0000000..d88e5ef --- /dev/null +++ b/tests/obj_parsers/test_eigenvalue_parser.py @@ -0,0 +1,85 @@ +import numpy as np +import pytest + +from excitingtools.exciting_obj_parsers.eigenvalue_parser import parse_eigenvalues +from excitingtools.utils.test_utils import MockFile + + +@pytest.fixture +def eigval_xml_mock(tmp_path) -> MockFile: + """Mock 'eigval.xml' data, containing only two k-sampling points.""" + eigval_xml_str = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + eigval_xml_file = tmp_path / "eigval.xml" + eigval_xml_file.write_text(eigval_xml_str) + return MockFile(eigval_xml_file, eigval_xml_str) + + +def test_parse_eigenvalues(eigval_xml_mock): + eigval_data = parse_eigenvalues(eigval_xml_mock.file) + + ref_k_points = np.array([[0.0, 0.0, 0.0], [0.25, 0.0, 0.0]]) + + ref_eigenvalues = np.array( + [ + [ + -0.37284538, + 0.41711092, + 0.41711254, + 0.41711254, + 0.62407639, + 0.62407733, + 0.62407733, + 0.90577707, + 1.12982943, + 1.39260832, + ], + [ + -0.30988964, + 0.17579802, + 0.35365277, + 0.35365392, + 0.70554696, + 0.70554812, + 0.71764897, + 0.95780094, + 1.18348852, + 1.24223224, + ], + ] + ) + + ref_occupations = np.array([[2, 2, 2, 2, 0, 0, 0, 0, 0, 0], [2, 2, 2, 2, 0, 0, 0, 0, 0, 0]]) + + assert eigval_data.state_range.first_state == 1 + assert eigval_data.state_range.last_state == 10 + assert np.allclose(eigval_data.k_points, ref_k_points) + assert eigval_data.k_indices == [1, 2] + assert np.allclose(eigval_data.all_eigenvalues, ref_eigenvalues) + assert np.allclose(eigval_data.occupations, ref_occupations) diff --git a/tests/obj_parsers/test_gw_dos.py b/tests/obj_parsers/test_gw_dos.py index 83b94c2..d630d62 100644 --- a/tests/obj_parsers/test_gw_dos.py +++ b/tests/obj_parsers/test_gw_dos.py @@ -4,16 +4,16 @@ pytest --capture=tee-sys """ -import pytest -from excitingtools.utils.test_utils import MockFile import numpy as np +import pytest from excitingtools.exciting_obj_parsers.gw_eigenvalues import parse_obj_gw_dos +from excitingtools.utils.test_utils import MockFile + @pytest.fixture def gw_dos_mock(tmp_path) -> MockFile: - """ Mock TDOS.OUT data for 15 energy and DOS value pairs - """ + """Mock TDOS.OUT data for 15 energy and DOS value pairs""" dos_str = """-0.5000000000 0.000000000 -0.4949748744 0.000000000 -0.4899497487 0.000000000 @@ -36,16 +36,47 @@ def gw_dos_mock(tmp_path) -> MockFile: def test_parse_obj_gw_dos(gw_dos_mock): - """ Test that the parser correctly returns to the DOS object. - """ + """Test that the parser correctly returns to the DOS object.""" data = parse_obj_gw_dos(gw_dos_mock.file) - ref_energies = np.array([-0.5000000000, -0.4949748744, -0.4899497487, -0.4849246231, -0.4798994975, -0.4748743719, - -0.4698492462, -0.4648241206, -0.4597989950, -0.4547738693, -0.4497487437, -0.4447236181, - -0.4396984925, -0.4346733668, -0.4296482412]) - ref_dos = np.array([0.000000000, 0.000000000, 0.000000000, 0.000000000, 0.000000000, 0.000000000, - 0.000000000, 0.000000000, 0.000000000, 0.000000000, 0.1025457101E-01, 0.1050068072, 0.3502961458, - 0.6899275378, 1.164919267]) + ref_energies = np.array( + [ + -0.5000000000, + -0.4949748744, + -0.4899497487, + -0.4849246231, + -0.4798994975, + -0.4748743719, + -0.4698492462, + -0.4648241206, + -0.4597989950, + -0.4547738693, + -0.4497487437, + -0.4447236181, + -0.4396984925, + -0.4346733668, + -0.4296482412, + ] + ) + ref_dos = np.array( + [ + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.000000000, + 0.1025457101e-01, + 0.1050068072, + 0.3502961458, + 0.6899275378, + 1.164919267, + ] + ) assert np.allclose(data.energy, ref_energies) assert np.allclose(data.dos, ref_dos) diff --git a/tests/obj_parsers/test_gw_eigenvalues.py b/tests/obj_parsers/test_gw_eigenvalues.py index 7e10ad2..4b33e69 100644 --- a/tests/obj_parsers/test_gw_eigenvalues.py +++ b/tests/obj_parsers/test_gw_eigenvalues.py @@ -1,12 +1,16 @@ -import numpy as np -import pytest from typing import Tuple -from excitingtools.exciting_obj_parsers.gw_eigenvalues import gw_eigenvalue_parser, _file_name, OxygenEvalQPColumns, \ - NitrogenEvalQPColumns -from excitingtools.dataclasses.eigenvalues import EigenValues +import numpy as np +import pytest from excitingtools.dataclasses.data_structs import BandIndices +from excitingtools.dataclasses.eigenvalues import EigenValues +from excitingtools.exciting_obj_parsers.gw_eigenvalues import ( + NitrogenEvalQPColumns, + OxygenEvalQPColumns, + _file_name, + gw_eigenvalue_parser, +) from excitingtools.utils.test_utils import MockFile @@ -51,7 +55,7 @@ def evalqp_oxygen(tmp_path) -> Tuple[MockFile, BandIndices]: 22 0.06097 0.32400 0.08958 -0.36222 -0.22727 -0.00000 -0.62524 0.26303 0.02861 0.80010 23 0.06097 0.32400 0.08957 -0.36222 -0.22728 -0.00000 -0.62524 0.26303 0.02860 0.80006 24 0.17630 0.38074 0.18396 -0.27546 -0.19505 0.00000 -0.47989 0.20443 0.00766 0.81561 - + k-point # 2: 0.000000 0.000000 0.500000 0.500000 state E_KS E_HF E_GW Sx Re(Sc) Im(Sc) Vxc DE_HF DE_GW Znk 19 -0.15356 -0.36518 -0.19801 -0.93999 0.15132 -0.00008 -0.72837 -0.21162 -0.04445 0.73712 @@ -60,7 +64,7 @@ def evalqp_oxygen(tmp_path) -> Tuple[MockFile, BandIndices]: 22 0.08561 0.35313 0.11429 -0.38438 -0.23136 0.00001 -0.65190 0.26752 0.02868 0.79318 23 0.08561 0.35313 0.11430 -0.38438 -0.23135 0.00001 -0.65190 0.26752 0.02869 0.79323 24 0.16667 0.43794 0.19514 -0.48319 -0.23505 0.00001 -0.75446 0.27128 0.02848 0.78602 - + k-point # 3: 0.000000 0.500000 0.500000 0.375000 state E_KS E_HF E_GW Sx Re(Sc) Im(Sc) Vxc DE_HF DE_GW Znk 19 -0.11749 -0.30231 -0.15828 -0.93575 0.13070 -0.00001 -0.75094 -0.18481 -0.04078 0.75361 @@ -76,18 +80,21 @@ def evalqp_oxygen(tmp_path) -> Tuple[MockFile, BandIndices]: def test_parse_evalqp_oxygen(evalqp_oxygen): - """ Test that the parser correctly returns to the EigenValues object. - """ + """Test that the parser correctly returns to the EigenValues object.""" file, band_indices = evalqp_oxygen eigen_values: EigenValues = gw_eigenvalue_parser(file.full_path) - ref_gw_eigenvalues = np.array([[-0.12905, -0.12891, -0.12896, 0.08958, 0.08957, 0.18396], - [-0.19801, -0.17047, -0.17035, 0.11429, 0.11430, 0.19514], - [-0.15828, -0.15818, -0.10809, 0.09569, 0.14613, 0.18404]]) + ref_gw_eigenvalues = np.array( + [ + [-0.12905, -0.12891, -0.12896, 0.08958, 0.08957, 0.18396], + [-0.19801, -0.17047, -0.17035, 0.11429, 0.11430, 0.19514], + [-0.15828, -0.15818, -0.10809, 0.09569, 0.14613, 0.18404], + ] + ) - ref_k_points = np.array([[0.000000, 0.000000, 0.000000], - [0.000000, 0.000000, 0.500000], - [0.000000, 0.500000, 0.500000]]) + ref_k_points = np.array( + [[0.000000, 0.000000, 0.000000], [0.000000, 0.000000, 0.500000], [0.000000, 0.500000, 0.500000]] + ) assert eigen_values.state_range.first_state == 19 assert eigen_values.state_range.last_state == 24 @@ -98,31 +105,37 @@ def test_parse_evalqp_oxygen(evalqp_oxygen): def test_parse_evalqp_oxygen_Znk(evalqp_oxygen): - """ Test that the parser correctly returns to the EigenValues object. - """ + """Test that the parser correctly returns to the EigenValues object.""" file, band_indices = evalqp_oxygen eigen_values: EigenValues = gw_eigenvalue_parser(file.full_path, columns=OxygenEvalQPColumns.Znk) - ref_gw_eigenvalues = np.array([[0.75633, 0.75570, 0.75582, 0.80010, 0.80006, 0.81561], - [0.73712, 0.74983, 0.74940, 0.79318, 0.79323, 0.78602], - [0.75361, 0.75304, 0.75686, 0.79516, 0.78053, 0.78971]]) + ref_gw_eigenvalues = np.array( + [ + [0.75633, 0.75570, 0.75582, 0.80010, 0.80006, 0.81561], + [0.73712, 0.74983, 0.74940, 0.79318, 0.79323, 0.78602], + [0.75361, 0.75304, 0.75686, 0.79516, 0.78053, 0.78971], + ] + ) assert np.allclose(eigen_values.all_eigenvalues, ref_gw_eigenvalues), "Znk values, for all k-points" def test_parse_evalqp_nitrogen(evalqp_nitrogen): - """ Test that the parser correctly returns to the EigenValues object. - """ + """Test that the parser correctly returns to the EigenValues object.""" file, band_indices = evalqp_nitrogen eigen_values: EigenValues = gw_eigenvalue_parser(file.full_path, NitrogenEvalQPColumns.E_GW) - ref_gw_eigenvalues = np.array([[-0.03139, -0.03144, -0.03143, 0.08701, 0.08704], - [-0.17533, -0.05906, -0.05905, 0.06505, 0.12124], - [-0.28494, -0.07605, -0.07607, 0.04622, 0.11503]]) + ref_gw_eigenvalues = np.array( + [ + [-0.03139, -0.03144, -0.03143, 0.08701, 0.08704], + [-0.17533, -0.05906, -0.05905, 0.06505, 0.12124], + [-0.28494, -0.07605, -0.07607, 0.04622, 0.11503], + ] + ) - ref_k_points = np.array([[0.000000, 0.000000, 0.000000], - [0.000000, 0.000000, 0.250000], - [0.000000, 0.000000, 0.500000]]) + ref_k_points = np.array( + [[0.000000, 0.000000, 0.000000], [0.000000, 0.000000, 0.250000], [0.000000, 0.000000, 0.500000]] + ) assert eigen_values.state_range.first_state == 10 assert eigen_values.state_range.last_state == 14 @@ -133,15 +146,15 @@ def test_parse_evalqp_nitrogen(evalqp_nitrogen): def test_parse_evalqp_incompatible(evalqp_nitrogen): - """ Test for the exception. + """Test for the exception. Trying to parse _filename generated with nitrogen, but requesting oxygen column indexing. """ file, band_indices = evalqp_nitrogen with pytest.raises(ValueError) as error: - eigen_values: EigenValues = gw_eigenvalue_parser(file.full_path, - OxygenEvalQPColumns.E_GW) - assert error.value.args[ - 0] == "The requested data column is indexed according to exciting version OxygenEvalQPColumns," \ - "which is not consistent with the columns of the parsed data. " \ - "Check that your data was produced with the same code version." + gw_eigenvalue_parser(file.full_path, OxygenEvalQPColumns.E_GW) + assert ( + error.value.args[0] == "The requested data column is indexed according to exciting version OxygenEvalQPColumns," + "which is not consistent with the columns of the parsed data. " + "Check that your data was produced with the same code version." + ) diff --git a/tests/obj_parsers/test_input_parser.py b/tests/obj_parsers/test_input_parser.py index 02fbde4..12e4c23 100644 --- a/tests/obj_parsers/test_input_parser.py +++ b/tests/obj_parsers/test_input_parser.py @@ -1,9 +1,8 @@ """ Test for the input.xml file parser """ -import pytest -from excitingtools.exciting_obj_parsers.input_xml import parse_groundstate, parse_structure, parse_xs +from excitingtools.exciting_obj_parsers.input_xml import parse_input_xml reference_input_str = """ @@ -57,74 +56,7 @@ """ -def test_parse_groundstate_to_object(): - # just calls the function to see whether the initialization of object returns without errors - ground_state = parse_groundstate(reference_input_str) - assert ground_state.to_xml().tag == 'groundstate' - - -def test_parse_structure_to_object(): - # just calls the function to see whether the initialization of object returns without errors - structure = parse_structure(reference_input_str) - structure_xml = structure.to_xml() - assert structure_xml.tag == 'structure' - - -def test_parse_xs_to_object(): - # just calls the function to see whether the initialization of object returns without errors - xs = parse_xs(reference_input_str) - xs_xml = xs.to_xml() - assert xs_xml.tag == 'xs' - - -reference_input_str_without_xs = """ - - - Lithium Fluoride BSE - - - - 3.80402 3.80402 0.00000 - 3.80402 0.00000 3.80402 - 0.00000 3.80402 3.80402 - - - - - - - - - - - - -""" - - -def test_parse_xs_to_object_no_xs(): - xs = parse_xs(reference_input_str_without_xs) - assert xs is None - - -reference_input_str_warning = """ - - - - - - - -""" - - -def test_parse_xs_to_object_warning(): - with pytest.warns(UserWarning, match='Subelement transitions not yet supported. Its ignored...'): - xs = parse_xs(reference_input_str_warning) - xs_xml = xs.to_xml() - assert xs_xml.tag == 'xs' +def test_parse_input_xml_to_object(): + input_xml = parse_input_xml(reference_input_str) + assert set(vars(input_xml)) == {"xs", "groundstate", "structure", "title"} + assert input_xml.to_xml_str().startswith('\n\n\tLithium Fluoride BSE') diff --git a/tests/parser_utils/test_parser_utils.py b/tests/parser_utils/test_parser_utils.py new file mode 100644 index 0000000..634e7e8 --- /dev/null +++ b/tests/parser_utils/test_parser_utils.py @@ -0,0 +1,63 @@ +"""Tests for parser utils.""" + +import pytest + +from excitingtools.parser_utils.parser_utils import ( + convert_single_entry, + convert_string_dict, + json_convert, + standardise_fortran_exponent, +) + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("true", True), + ("false", False), + ("fromscratch", "fromscratch"), + ("skip", "skip"), + ("3", 3), + ("-1", -1), + ("2.34", 2.34), + ("5.1e3", 5100), + ], +) +def test_convert_json(test_input, expected): + assert json_convert(test_input) == expected + + +@pytest.mark.parametrize("test_input,expected", [("23.2D-1", 2.32), ("1q0", 1)]) +def test_convert_fortran_exponent(test_input, expected): + assert standardise_fortran_exponent(test_input, return_as_str=False) == expected + + +@pytest.mark.parametrize( + "test_input,expected", + [ + ("true", True), + ("skip", "skip"), + ("3", 3), + ("2.34", 2.34), + ("true false false", [True, False, False]), + ("1.3 2.3e4 3 90", [1.3, 2.3e4, 3, 90]), + ("4 4 3", [4, 4, 3]), + ("1 2", [1, 2]), + ("ab cd", "ab cd"), + ], +) +def test_convert_single_entry(test_input, expected): + assert convert_single_entry(test_input) == expected + + +def test_convert_single_entry_to_int(): + converted_int = convert_single_entry("3") + assert converted_int == 3 + # need to test really the type to detect the error + assert isinstance(converted_int, int) + + +def test_convert_string_dict(): + string_dict = {"a": "1", "b": "b", "c": "false", "d": "3 3 3"} + ref_dict = {"a": 1, "b": "b", "c": False, "d": [3, 3, 3]} + assert convert_string_dict(string_dict) == ref_dict diff --git a/tests/parser_utils/test_regex_parser.py b/tests/parser_utils/test_regex_parser.py index c706f33..4109aa8 100644 --- a/tests/parser_utils/test_regex_parser.py +++ b/tests/parser_utils/test_regex_parser.py @@ -1,47 +1,47 @@ """ Test regex parser wrappers """ + import pytest from excitingtools.parser_utils.regex_parser import parse_value_regex def test_parse_value_regex(): - test_string = """ -------------------------------------------------------------------------------- - frequency grid - -------------------------------------------------------------------------------- - - Type: < fgrid > gauleg2 - Frequency axis: < fconv > imfreq + + Type: < fgrid > gauleg2 + Frequency axis: < fconv > imfreq Number of frequencies: < nomeg > 32 - Cutoff frequency: < freqmax > 1.00000000000000 - + Cutoff frequency: < freqmax > 1.00000000000000 + -------------------------------------------------------------------------------- - Kohn-Sham band structure - -------------------------------------------------------------------------------- - + Fermi energy: 0.0000 Energy range: -14.6863 1030.7919 Band index of VBM: 21 Band index of CBm: 22 - + Indirect BandGap (eV): 3.3206 at k(VBM) = 0.000 0.500 0.500 ik = 3 k(CBm) = 0.000 0.000 0.000 ik = 1 Direct Bandgap at k(VBM) (eV): 3.7482 Direct Bandgap at k(CBm) (eV): 3.8653 - + -------------------------------------------------------------------------------- - G0W0 band structure - -------------------------------------------------------------------------------- - + Fermi energy: -0.0054 Energy range: -16.2632 1031.4090 Band index of VBM: 21 Band index of CBm: 22 - + Indirect BandGap (eV): 5.3920 at k(VBM) = 0.000 0.500 0.500 ik = 3 k(CBm) = 0.000 0.000 0.000 ik = 1 @@ -49,17 +49,21 @@ def test_parse_value_regex(): Direct Bandgap at k(CBm) (eV): 5.9646 """ - assert {'Fermi energy': 0.0} == parse_value_regex(test_string, 'Fermi energy:'), \ - "Expect to match first string instance and return {key:float}" + assert {"Fermi energy": 0.0} == parse_value_regex( + test_string, "Fermi energy:" + ), "Expect to match first string instance and return {key:float}" - assert {'Indirect BandGap (eV)': 3.3206} == parse_value_regex(test_string, 'Indirect BandGap \\(eV\\):'), \ - "Expect to match first string instance and return {key:float}" + assert {"Indirect BandGap (eV)": 3.3206} == parse_value_regex( + test_string, "Indirect BandGap \\(eV\\):" + ), "Expect to match first string instance and return {key:float}" - assert {'Energy range': [-14.6863, 1030.7919]} == parse_value_regex(test_string, 'Energy range:'), \ - "Expect to match first string instance and return {key:List[float]}" + assert {"Energy range": [-14.6863, 1030.7919]} == parse_value_regex( + test_string, "Energy range:" + ), "Expect to match first string instance and return {key:List[float]}" - assert {'Type: < fgrid >': 'gauleg2'} == parse_value_regex(test_string, 'Type: < fgrid >'), \ - "Expect to match first string instance and return {key:str}" + assert {"Type: < fgrid >": "gauleg2"} == parse_value_regex( + test_string, "Type: < fgrid >" + ), "Expect to match first string instance and return {key:str}" @pytest.mark.xfail @@ -73,4 +77,4 @@ def test_failures_with_parse_value_regex(): - init_scf : 8.38 """ - data = parse_value_regex(test_string, 'Initialization') + parse_value_regex(test_string, "Initialization") diff --git a/tests/parser_utils/test_simple_parser.py b/tests/parser_utils/test_simple_parser.py index 1e1a9ad..af58af9 100644 --- a/tests/parser_utils/test_simple_parser.py +++ b/tests/parser_utils/test_simple_parser.py @@ -2,12 +2,10 @@ Test parsers in simple_parser """ -from excitingtools.parser_utils.simple_parser import match_current_return_line_n, \ - match_current_extract_from_line_n +from excitingtools.parser_utils.simple_parser import match_current_extract_from_line_n, match_current_return_line_n def test_match_current_return_next_n(): - input_string = """ Tolerance factor to reduce the MB size based on the eigenvectors of the bare Coulomb potential: 0.100000000000000 @@ -19,25 +17,21 @@ def test_match_current_return_next_n(): Some additional data : 5 """ - string_match = match_current_return_line_n( - input_string, 'Screened Coulomb potential:', n_line=1 - ) - assert string_match.strip() == "Full-frequency Random-Phase Approximation", \ - "Expect to return the string one line below the match" + string_match = match_current_return_line_n(input_string, "Screened Coulomb potential:", n_line=1) + assert ( + string_match.strip() == "Full-frequency Random-Phase Approximation" + ), "Expect to return the string one line below the match" - string_match = match_current_return_line_n(input_string, 'Screened Coulomb potential:') - assert string_match.strip() == "Full-frequency Random-Phase Approximation", \ - "Expect default behaviour to return the string one line below the match" + string_match = match_current_return_line_n(input_string, "Screened Coulomb potential:") + assert ( + string_match.strip() == "Full-frequency Random-Phase Approximation" + ), "Expect default behaviour to return the string one line below the match" - string_match = match_current_return_line_n( - input_string, 'Screened Coulomb potential:', n_line=2 - ) - assert string_match.strip() == "Some additional data : 5", \ - "Expect to return the string two lines below the match" + string_match = match_current_return_line_n(input_string, "Screened Coulomb potential:", n_line=2) + assert string_match.strip() == "Some additional data : 5", "Expect to return the string two lines below the match" def test_match_current_extract_from_line_n(): - input_string = """ Tolerance factor to reduce the MB size based on the eigenvectors of the bare Coulomb potential: 0.100000000000000 @@ -50,9 +44,8 @@ def test_match_current_extract_from_line_n(): """ # Extract 5 from "Some additional data : 5" - key_extraction = {'Screened Coulomb potential:': lambda x: int(x.split(':')[-1])} + key_extraction = {"Screened Coulomb potential:": lambda x: int(x.split(":")[-1])} data = match_current_extract_from_line_n(input_string, key_extraction, n_line=2) - assert data == {'Screened Coulomb potential:': 5}, \ - "Expect to return the integer from 2 lines below the match key" + assert data == {"Screened Coulomb potential:": 5}, "Expect to return the integer from 2 lines below the match key" diff --git a/tests/runner/test_runner.py b/tests/runner/test_runner.py new file mode 100644 index 0000000..81bc3fe --- /dev/null +++ b/tests/runner/test_runner.py @@ -0,0 +1,144 @@ +"""Tests for the binary runner.""" + +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from excitingtools.runner.runner import BinaryRunner, RunnerCode + +mock_binary = "false_exciting_binary" + + +@pytest.mark.xfail(shutil.which(mock_binary) is not None, reason="Binary name exists.") +def test_no_binary(): + my_runner = BinaryRunner(mock_binary, "./", 1, 1) + with pytest.raises( + FileNotFoundError, match=rf"{mock_binary} binary is not present in the current directory nor in \$PATH" + ): + my_runner.run() + + +@pytest.fixture +def exciting_smp(tmp_path: Path) -> str: + binary = tmp_path / "exciting_smp" + binary.touch() + return binary.as_posix() + + +def test_no_run_dir(exciting_smp: str): + my_runner = BinaryRunner(exciting_smp, "./", 1, 1, "non_existent_dir") + with pytest.raises(OSError, match="Run directory does not exist: non_existent_dir"): + my_runner.run() + + +def test_false_run_cmd(exciting_smp: str): + false_run_cmd: Any = 3 + with pytest.raises(ValueError, match="Run commands expected in a str or list. For example ['mpirun', '-np', '2']"): + BinaryRunner(exciting_smp, false_run_cmd, 1, 1) + + +@pytest.fixture +def exciting_mpismp(tmp_path: Path) -> str: + binary = tmp_path / "exciting_mpismp" + binary.touch() + return binary.as_posix() + + +def test_false_mpi_command_smaller_than_zero(exciting_mpismp: str): + with pytest.raises(ValueError, match="Number of MPI processes must be > 0"): + BinaryRunner(exciting_mpismp, ["mpirun", "-np", "-1"], 1, 1) + + +def test_false_mpi_command_no_int(exciting_mpismp: str): + with pytest.raises(ValueError, match="Number of MPI processes should be an int"): + BinaryRunner(exciting_mpismp, ["mpirun", "-np", "no_int"], 1, 1) + + +def test_false_mpi_command_no_number_given(exciting_mpismp: str): + with pytest.raises(ValueError, match="Number of MPI processes must be specified after the '-np'"): + BinaryRunner(exciting_mpismp, ["mpirun", "-np"], 1, 1) + + +def test_false_omp(exciting_smp: str): + with pytest.raises(ValueError, match="Number of OMP threads must be > 0"): + BinaryRunner(exciting_smp, [""], -1, 1) + + +def test_false_timeout(exciting_smp: str): + with pytest.raises(ValueError, match="time_out must be a positive integer"): + BinaryRunner(exciting_smp, [""], 1, -1) + + +@pytest.fixture +def runner(tmp_path: Path, exciting_mpismp: str) -> BinaryRunner: + """Produces a runner with binary and run dir mocked up.""" + run_dir = tmp_path / "ab/de" + run_dir.mkdir(parents=True) + return BinaryRunner(exciting_mpismp, ["mpirun", "-np", "3"], 4, 260, run_dir.as_posix(), [">", "std.out"]) + + +@pytest.mark.usefixtures("mock_env_jobflow_missing") +def test_as_dict(tmp_path: Path, runner: BinaryRunner): + assert runner.as_dict() == { + "args": [">", "std.out"], + "binary": (tmp_path / "exciting_mpismp").as_posix(), + "directory": (tmp_path / "ab/de").as_posix(), + "omp_num_threads": 4, + "run_cmd": ["mpirun", "-np", "3"], + "time_out": 260, + } + + +@pytest.mark.usefixtures("mock_env_jobflow") +def test_as_dict_jobflow(tmp_path: Path, runner: BinaryRunner): + assert runner.as_dict() == { + "@class": "BinaryRunner", + "@module": "excitingtools.runner.runner", + "args": [">", "std.out"], + "binary": (tmp_path / "exciting_mpismp").as_posix(), + "directory": (tmp_path / "ab/de").as_posix(), + "omp_num_threads": 4, + "run_cmd": ["mpirun", "-np", "3"], + "time_out": 260, + } + + +def test_from_dict(tmp_path: Path, runner): + new_runner = BinaryRunner.from_dict(runner.as_dict()) + assert new_runner.binary == (tmp_path / "exciting_mpismp").as_posix() + assert new_runner.time_out == 260 + + +def test_run_with_bash_command(tmp_path: Path): + """Produces a runner with binary and run dir mocked up. + Test a simple echo command. + """ + run_dir = tmp_path / "ab/de" + run_dir.mkdir(parents=True) + binary = tmp_path / "hello" + binary.touch() + runner = BinaryRunner(binary.as_posix(), ["echo"], 1, 60, run_dir.as_posix()) + run_results = runner.run() + assert run_results.success + assert run_results.stderr == "" + assert run_results.stdout == binary.as_posix() + "\n" + + +def test_timeout_with_bash_command(tmp_path: Path): + """Produces a runner with binary and run dir mocked up. + + Test a simple sleep command to get a timeout. + """ + time_out = 1 + binary = tmp_path / "sleep.sh" + binary.write_text(f"sleep {time_out + 0.1}") + runner = BinaryRunner(binary.as_posix(), ["sh"], 1, time_out, tmp_path.as_posix()) + run_results = runner.run() + assert not run_results.success + assert run_results.stderr == "BinaryRunner: Job timed out. \n\n" + assert run_results.stdout == "" + assert run_results.process_time == time_out + assert isinstance(run_results.return_code, RunnerCode) + assert run_results.return_code == RunnerCode.time_out diff --git a/tests/species/test_species_file.py b/tests/species/test_species_file.py new file mode 100644 index 0000000..9d4a5c0 --- /dev/null +++ b/tests/species/test_species_file.py @@ -0,0 +1,608 @@ +"""Tests for species file class.""" + +import pytest + +from excitingtools.species.species_file import SpeciesFile + + +@pytest.fixture +def species_C(): + """Species file object for carbon.""" + species = {"chemicalSymbol": "C", "mass": 21.16, "name": "carbon", "z": -6.0} + muffin_tin = {"radialmeshPoints": 250, "radius": 1.45, "rinf": 21.09, "rmin": 1e-05} + atomic_states = [ + {"core": True, "kappa": 1, "l": 0, "n": 1, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 0, "n": 2, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 1, "n": 2, "occ": 1.0}, + {"core": False, "kappa": 2, "l": 1, "n": 2, "occ": 1.0}, + ] + basis = { + "default": [{"searchE": False, "trialEnergy": 0.15, "type": "apw"}], + "custom": [ + {"l": 0, "n": 2, "searchE": False, "type": "apw"}, + {"l": 1, "n": 2, "searchE": False, "type": "apw"}, + ], + "lo": [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}], + }, + ], + } + + species = SpeciesFile(species, muffin_tin, atomic_states, basis) + return species + + +def test_class_species_file_to_xml(species_C): + xml_species_C = species_C.to_xml() + assert xml_species_C.tag == "spdb", "XML root should be spdb" + + +def test_class_species_file_species_to_xml(species_C): + xml_species_C = species_C.to_xml() + sp_element = xml_species_C.find("sp") + ref_attribs = {"chemicalSymbol": "C", "mass": "21.16", "name": "carbon", "z": "-6.0"} + assert sp_element.attrib == ref_attribs, "Mismatch in species attributes." + + +def test_class_species_file_muffin_tin_to_xml(species_C): + xml_species_C = species_C.to_xml() + sp_element = xml_species_C.find("sp") + muffinTin_element = sp_element.find("muffinTin") + ref_attribs = {"radialmeshPoints": "250", "radius": "1.45", "rinf": "21.09", "rmin": "1e-05"} + assert muffinTin_element.attrib == ref_attribs, "Mismatch in muffin-tin attributes." + + +def test_class_species_file_atomic_states_to_xml(species_C): + xml_species_C = species_C.to_xml() + sp_element = xml_species_C.find("sp") + atomicState_elements = sp_element.findall("atomicState") + assert atomicState_elements and len(atomicState_elements) == 4 + + ref_attribs = [ + {"core": "true", "kappa": "1", "l": "0", "n": "1", "occ": "2.0"}, + {"core": "false", "kappa": "1", "l": "0", "n": "2", "occ": "2.0"}, + {"core": "false", "kappa": "1", "l": "1", "n": "2", "occ": "1.0"}, + {"core": "false", "kappa": "2", "l": "1", "n": "2", "occ": "1.0"}, + ] + + for i, atomicState in enumerate(atomicState_elements): + assert atomicState.attrib == ref_attribs[i], f"Mismatch in atomic states at index {i}" + + +def test_class_species_file_basis_to_xml(species_C): + xml_species_C = species_C.to_xml() + sp_element = xml_species_C.find("sp") + basis_element = sp_element.find("basis") + + default_element = basis_element.find("default") + ref_attribs = {"searchE": "false", "trialEnergy": "0.15", "type": "apw"} + assert default_element.attrib == ref_attribs, "Mismatch in default attributes." + + custom_elements = basis_element.findall("custom") + assert len(custom_elements) == 2, f"Expected 2 custom elements, but got {len(custom_elements)}." + ref_attribs = [ + {"l": "0", "n": "2", "searchE": "false", "type": "apw"}, + {"l": "1", "n": "2", "searchE": "false", "type": "apw"}, + ] + for i, customState in enumerate(custom_elements): + assert customState.attrib == ref_attribs[i], f"Mismatch in custom state at index {i}" + + lo_elements = basis_element.findall("lo") + ref_attribs = { + "lo": [ + { + "l": "0", + "wf": [ + {"matchingOrder": "1", "n": "2", "searchE": "false"}, + {"matchingOrder": "2", "n": "2", "trialEnergy": "0.15", "searchE": "false"}, + ], + }, + { + "l": "1", + "wf": [ + {"matchingOrder": "0", "n": "3", "searchE": "true"}, + {"matchingOrder": "1", "n": "3", "searchE": "false"}, + ], + }, + ] + } + for i, loState in enumerate(lo_elements): + wf_elements = loState.findall("wf") + ref_wf_attribs = ref_attribs["lo"][i]["wf"] + for j, wfState in enumerate(wf_elements): + assert ( + wfState.attrib == ref_wf_attribs[j] + ), f"Mismatch in wf attributes at index {j} of loState at index {i}" + + +def test_check_matching_orders(species_C): + species_C.basis = { + "lo": [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + { + "l": 3, + "wf": [ + {"matchingOrder": 0, "n": 5, "searchE": False}, + {"matchingOrder": 4, "n": 5, "searchE": False}, + {"matchingOrder": 3, "n": 3, "searchE": False}, + ], + }, + ] + } + + result_dict = species_C.check_matching_orders() + ref_dict = {0: [2], 3: [5, 3]} + assert result_dict == ref_dict, "check_matching_orders failed" + + +def test_get_helos_from_species(species_C): + species_C.basis = { + "lo": [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 4, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + { + "l": 3, + "wf": [{"matchingOrder": 2, "n": 6, "searchE": False}, {"matchingOrder": 4, "n": 4, "searchE": False}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "n": 2, "searchE": False}, {"matchingOrder": 1, "n": 2, "searchE": False}], + }, + ] + } + + helos_ns_per_l = species_C.get_helos_from_species() + ref_helos_ns_per_l = {0: {4}, 1: set(), 3: {4, 6}} + assert helos_ns_per_l == ref_helos_ns_per_l, "get_helos_from_species failed" + + +def test_get_n_per_l(species_C): + species_C.basis = { + "lo": [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 4, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + { + "l": 3, + "wf": [{"matchingOrder": 2, "n": 6, "searchE": False}, {"matchingOrder": 4, "n": 4, "searchE": False}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "n": 2, "searchE": False}, {"matchingOrder": 1, "n": 2, "searchE": False}], + }, + ] + } + + atomicstate_ns_per_l, lo_ns_per_l = species_C.get_atomicstates_ns_per_l(), species_C.get_lo_ns_per_l() + + ref_lo_ns_per_l = {0: {2, 4}, 1: {2}, 3: {4, 6}} + assert lo_ns_per_l == ref_lo_ns_per_l, "get_n_per_l: get_lo_ns_per_l failed" + + ref_atomicstate_ns_per_l = {0: {1, 2}, 1: {2}} + assert atomicstate_ns_per_l == ref_atomicstate_ns_per_l, "get_n_per_l: get_atomicstates_ns_per_l failed" + + +@pytest.mark.filterwarnings("ignore:HELO skipped for l:") +def test_get_first_helo_n(species_C): + species_C.basis = { + "lo": [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 4, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + { + "l": 3, + "wf": [{"matchingOrder": 2, "n": 6, "searchE": False}, {"matchingOrder": 4, "n": 4, "searchE": False}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "n": 1, "searchE": False}, {"matchingOrder": 1, "n": 1, "searchE": False}], + }, + ] + } + + lO_first_helo_n = species_C.get_first_helo_n(l=0) + ref_lO_first_helo_n = 5 + assert lO_first_helo_n == ref_lO_first_helo_n, "get_first_helo_n: for l=0 failed" + + l1_first_helo_n = species_C.get_first_helo_n(l=1) + ref_l1_first_helo_n = 3 + assert l1_first_helo_n == ref_l1_first_helo_n, "get_first_helo_n: for l=1 failed" + + l2_first_helo_n = species_C.get_first_helo_n(l=2) + ref_l2_first_helo_n = 3 + assert l2_first_helo_n == ref_l2_first_helo_n, "get_first_helo_n: for l=2 failed" + + l3_first_helo_n = species_C.get_first_helo_n(l=3) + ref_l3_first_helo_n = 8 + assert l3_first_helo_n == ref_l3_first_helo_n, "get_first_helo_n: for l=3 failed" + + +@pytest.mark.filterwarnings("ignore:HELO skipped for l:") +def test_add_helos(species_C): + species_C.add_helos(0, 1) + helo_ns_per_l = species_C.get_helos_from_species() + ref_helo_ns_per_l = {0: {4}, 1: {3}} + assert helo_ns_per_l == ref_helo_ns_per_l, "add_helos: for l=0 number=1 failed" + + species_C.add_helos(3, 3) + helo_ns_per_l = species_C.get_helos_from_species() + ref_helo_ns_per_l = {0: {4}, 1: {3}, 3: {4, 5, 6}} + assert helo_ns_per_l == ref_helo_ns_per_l, "add_helos: for l=3 number=3 failed" + + species_C.add_helos(0, 2) + helo_ns_per_l = species_C.get_helos_from_species() + ref_helo_ns_per_l = {0: {4, 5, 6}, 1: {3}, 3: {4, 5, 6}} + assert helo_ns_per_l == ref_helo_ns_per_l, "add_helos: for l=0 number=2 failed" + + species_C.add_helos(1, 2) + helo_ns_per_l = species_C.get_helos_from_species() + ref_helo_ns_per_l = {0: {4, 5, 6}, 1: {3, 4, 5}, 3: {4, 5, 6}} + assert helo_ns_per_l == ref_helo_ns_per_l, "add_helos: for l=1 number=2 failed" + + +def test_get_valence_semicore_atomicstate_ns_per_l(species_C): + ref_states = {0: {2}, 1: {2}} + assert ref_states == species_C.get_atomicstates_ns_per_l( + lambda x: not x["core"] + ), "Failed to get valence/semicore states" + + species_C.atomic_states = [ + {"core": False, "kappa": 1, "l": 0, "n": 1, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 0, "n": 2, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 1, "n": 2, "occ": 1.0}, + {"core": False, "kappa": 2, "l": 1, "n": 2, "occ": 1.0}, + ] + ref_states = {0: {1, 2}, 1: {2}} + assert ref_states == species_C.get_atomicstates_ns_per_l( + lambda x: not x["core"] + ), "Failed to get valence/semicore states" + + +def test_get_valence_and_semicore_atomicstate_ns_per_l(species_C): + valence_and_semicore_states = species_C.get_valence_and_semicore_atomicstate_ns_per_l() + ref_valence_and_semicore_states = {0: {"semicore": set(), "valence": {2}}, 1: {"semicore": set(), "valence": {2}}} + assert ref_valence_and_semicore_states == valence_and_semicore_states, "Failed to get valence and semicore states" + + species_C.atomic_states = [ + {"core": False, "kappa": 1, "l": 0, "n": 1, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 0, "n": 2, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 1, "n": 2, "occ": 1.0}, + {"core": False, "kappa": 2, "l": 1, "n": 2, "occ": 1.0}, + ] + ref_valence_and_semicore_states = {0: {"semicore": {1}, "valence": {2}}, 1: {"semicore": set(), "valence": {2}}} + valence_and_semicore_states = species_C.get_valence_and_semicore_atomicstate_ns_per_l() + assert ref_valence_and_semicore_states == valence_and_semicore_states, "Failed to get valence and semicore states" + + +def test_add_number_los_for_all_valence_semicore_states(species_C): + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 0, + "wf": [{"matchingOrder": 2, "searchE": False, "n": 2}, {"matchingOrder": 3, "searchE": False, "n": 2}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "searchE": False, "n": 2}, {"matchingOrder": 1, "searchE": False, "n": 2}], + }, + ] + species_C.add_number_los_for_all_valence_semicore_states(1) + assert ref_los == species_C.basis["lo"], "add_number_los_for_all_valence_semicore_states failed" + + +def test_add_basic_lo_all_semicore_states(species_C): + species_C.add_basic_lo_all_semicore_states() + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + ] + assert ref_los == species_C.basis["lo"], "add_basic_lo_all_semicore_states failed" + + species_C.atomic_states = [ + {"core": False, "kappa": 1, "l": 0, "n": 1, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 0, "n": 2, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 1, "n": 2, "occ": 1.0}, + {"core": False, "kappa": 2, "l": 1, "n": 2, "occ": 1.0}, + ] + species_C.add_basic_lo_all_semicore_states() + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 0, + "wf": [{"matchingOrder": 0, "searchE": False, "n": 2}, {"matchingOrder": 0, "searchE": False, "n": 1}], + }, + ] + assert ref_los == species_C.basis["lo"], "add_basic_lo_all_semicore_states failed" + + +def test_add_default(species_C): + species_C.basis = {} + species_C.basis.setdefault("default", []) + species_C.add_default(trial_energy=0.2, default_type="lapw") + + ref_default = [{"type": "lapw", "trialEnergy": 0.2, "searchE": False}] + assert ref_default == species_C.basis["default"], "Adding a default element failed" + + +def test_add_custom_for_all_valence_states(species_C): + species_C.basis = {} + species_C.basis.setdefault("custom", []) + species_C.add_custom_for_all_valence_states(custom_type="lapw") + + ref_custom = [ + {"l": 0, "n": 2, "searchE": False, "type": "lapw"}, + {"l": 1, "n": 2, "searchE": False, "type": "lapw"}, + ] + + assert ref_custom == species_C.basis["custom"], "Adding a custom element for all valence states failed" + + +def test_find_highest_matching_order_for_state(species_C): + max_mO_order = species_C.find_highest_matching_order_for_state(l=0, n=2) + assert max_mO_order == 2, "Finding the highest matchingOrder failed for l=0, n=2" + + max_mO_order = species_C.find_highest_matching_order_for_state(l=1, n=3) + assert max_mO_order == 1, "Finding the highest matchingOrder failed for l=1, n=3" + + max_mO_order = species_C.find_highest_matching_order_for_state(l=1, n=2) + assert max_mO_order == 0, "Finding the highest matchingOrder failed for l=1, n=3" + + +@pytest.mark.filterwarnings("ignore:Maximum matchingOrder reached") +def test_add_lo_higher_matching_order(species_C): + species_C.add_lo_higher_matching_order(l=0, n=2) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 0, + "wf": [{"matchingOrder": 2, "searchE": False, "n": 2}, {"matchingOrder": 3, "searchE": False, "n": 2}], + }, + ] + assert ref_los == species_C.basis["lo"], "Adding lo for higher matchingOrder failed for l=0, n=2" + + species_C.add_lo_higher_matching_order(l=0, n=2) + assert ref_los == species_C.basis["lo"], "Adding 2 lo's for higher matchingOrder failed for l=0, n=2" + + species_C.add_lo_higher_matching_order(l=1, n=3) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 0, + "wf": [{"matchingOrder": 2, "searchE": False, "n": 2}, {"matchingOrder": 3, "searchE": False, "n": 2}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 1, "searchE": False, "n": 3}, {"matchingOrder": 2, "searchE": False, "n": 3}], + }, + ] + assert ref_los == species_C.basis["lo"], "Adding lo for higher matchingOrder failed for l=1, n=3" + + species_C.add_lo_higher_matching_order(l=1, n=2) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 0, + "wf": [{"matchingOrder": 2, "searchE": False, "n": 2}, {"matchingOrder": 3, "searchE": False, "n": 2}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 1, "searchE": False, "n": 3}, {"matchingOrder": 2, "searchE": False, "n": 3}], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "searchE": False, "n": 2}, {"matchingOrder": 1, "searchE": False, "n": 2}], + }, + ] + assert ref_los == species_C.basis["lo"], "Adding lo for higher matchingOrder failed for l=1, n=2" + + +@pytest.mark.filterwarnings("ignore:Maximum matchingOrder reached") +def test_add_lo(species_C): + species_C.add_lo(l=1, ns=(2, 2), matching_orders=(0, 1)) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 1, + "wf": [{"matchingOrder": 0, "searchE": False, "n": 2}, {"matchingOrder": 1, "searchE": False, "n": 2}], + }, + ] + assert ref_los == species_C.basis["lo"], "Adding lo failed for l=1 for n=2 with m0 = [0, 1]" + + species_C.add_lo(l=0, ns=(2, 2), matching_orders=(3, 4)) + assert ref_los == species_C.basis["lo"], "Adding lo failed for l=1 for n=2 with m0 = [3, 4]" + + species_C.add_lo(l=3, ns=(4, 4), matching_orders=(0, 1)) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + { + "l": 1, + "wf": [{"matchingOrder": 0, "searchE": False, "n": 2}, {"matchingOrder": 1, "searchE": False, "n": 2}], + }, + { + "l": 3, + "wf": [{"matchingOrder": 0, "searchE": False, "n": 4}, {"matchingOrder": 1, "searchE": False, "n": 4}], + }, + ] + assert ref_los == species_C.basis["lo"], "Adding lo failed for l=3 for n=4 with m0 = [0, 1]" + + +def test_remove_lo(species_C): + species_C.remove_lo(l=0, ns=(2, 2), matching_orders=(1, 2)) + ref_los = [ + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]} + ] + assert ref_los == species_C.basis["lo"], "Removed lo failed for l=0 for n=2 with m0 = [1, 2]" + + species_C.remove_lo(l=1, ns=(3, 3), matching_orders=(0, 1)) + + ref_los = [] + assert ref_los == species_C.basis["lo"], "Removed lo failed for l=1 for n=3 with m0 = [0, 1]" + + +def test_remove_lo_highest_matching_order(species_C): + species_C.remove_lo_highest_matching_order(l=3, n=4) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + }, + {"l": 1, "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}]}, + ] + assert ref_los == species_C.basis["lo"], "Removing highest m0 for lo with l=0, n=2 failed" + + species_C.remove_lo_highest_matching_order(l=1, n=3) + ref_los = [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "trialEnergy": 0.15, "searchE": False}, + ], + } + ] + assert ref_los == species_C.basis["lo"], "Removing highest m0 for lo with l=1, n=3 failed" + + species_C.remove_lo_highest_matching_order(l=0, n=2) + ref_los = [] + assert ref_los == species_C.basis["lo"], "Removing highest m0 for lo with l=0, n=2 failed" + + +serialization_ref_dict = { + "atomic_states": [ + {"core": True, "kappa": 1, "l": 0, "n": 1, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 0, "n": 2, "occ": 2.0}, + {"core": False, "kappa": 1, "l": 1, "n": 2, "occ": 1.0}, + {"core": False, "kappa": 2, "l": 1, "n": 2, "occ": 1.0}, + ], + "basis": { + "custom": [ + {"l": 0, "n": 2, "searchE": False, "type": "apw"}, + {"l": 1, "n": 2, "searchE": False, "type": "apw"}, + ], + "default": [{"searchE": False, "trialEnergy": 0.15, "type": "apw"}], + "lo": [ + { + "l": 0, + "wf": [ + {"matchingOrder": 1, "n": 2, "searchE": False}, + {"matchingOrder": 2, "n": 2, "searchE": False, "trialEnergy": 0.15}, + ], + }, + { + "l": 1, + "wf": [{"matchingOrder": 0, "n": 3, "searchE": True}, {"matchingOrder": 1, "n": 3, "searchE": False}], + }, + ], + }, + "muffin_tin": {"radialmeshPoints": 250, "radius": 1.45, "rinf": 21.09, "rmin": 1e-05}, + "species": {"chemicalSymbol": "C", "mass": 21.16, "name": "carbon", "z": -6.0}, +} + + +@pytest.mark.usefixtures("mock_env_jobflow_missing") +def test_as_dict(species_C: SpeciesFile): + assert species_C.as_dict() == serialization_ref_dict, "as_dict() test failed" + + +@pytest.mark.usefixtures("mock_env_jobflow") +def test_as_dict_jobflow(species_C: SpeciesFile): + assert species_C.as_dict() == { + **serialization_ref_dict, + "@class": "SpeciesFile", + "@module": "excitingtools.species.species_file", + }, "as_dict() with jobflow test failed" + + +@pytest.mark.usefixtures("mock_env_jobflow") +def test_from_dict(species_C: SpeciesFile): + new_species_file = species_C.from_dict(species_C.as_dict()) + assert new_species_file.species == {"chemicalSymbol": "C", "mass": 21.16, "name": "carbon", "z": -6.0} + assert new_species_file.muffin_tin == {"radialmeshPoints": 250, "radius": 1.45, "rinf": 21.09, "rmin": 1e-05} + assert new_species_file.atomic_states[0] == {"core": True, "kappa": 1, "l": 0, "n": 1, "occ": 2.0} + assert set(new_species_file.basis) == {"default", "custom", "lo"} diff --git a/tests/structure/test_ase_utilities.py b/tests/structure/test_ase_utilities.py new file mode 100644 index 0000000..dfbb280 --- /dev/null +++ b/tests/structure/test_ase_utilities.py @@ -0,0 +1,13 @@ +"""Test the utilities for ase.""" + +from ase.build import bulk + +from excitingtools import ExcitingStructure +from excitingtools.structure.ase_utilities import exciting_structure_to_ase + + +def test_class_exciting_structure_to_ase(): + ase_atoms = bulk("Si") + structure = ExcitingStructure(ase_atoms, species_path="./") + new_ase_atoms = exciting_structure_to_ase(structure) + assert ase_atoms.wrap() == new_ase_atoms.wrap() diff --git a/tests/structure/test_lattice.py b/tests/structure/test_lattice.py index 7d0705b..c1cb32d 100644 --- a/tests/structure/test_lattice.py +++ b/tests/structure/test_lattice.py @@ -1,16 +1,16 @@ """ -Tests for functions in lattice.py +Tests for functions in lattice.py """ + import numpy as np -from excitingtools.structure.lattice import reciprocal_lattice_vectors, parallelepiped_volume, plane_transformation from excitingtools.math.math_utils import triple_product +from excitingtools.structure.lattice import parallelepiped_volume, plane_transformation, reciprocal_lattice_vectors def test_parallelepiped_volume(): - # FCC lattice vectors with arbitrary lattice constant - a = 3.2 * np.array([[0., 1., 1.], [1., 0., 1.], [1., 1., 0.]]) + a = 3.2 * np.array([[0.0, 1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 0.0]]) triple_product_result = triple_product(a[:, 0], a[:, 1], a[:, 2]) volume = parallelepiped_volume(np.array([a[:, 0], a[:, 1], a[:, 2]])) @@ -33,7 +33,7 @@ def test_reciprocal_lattice_vectors(): """ # fcc with arbitrary lattice constant - a = 3.2 * np.array([[0., 1., 1.], [1., 0., 1.], [1., 1., 0.]]) + a = 3.2 * np.array([[0.0, 1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 0.0]]) b = reciprocal_lattice_vectors(a) assert a.shape == (3, 3) @@ -44,23 +44,21 @@ def test_reciprocal_lattice_vectors(): off_diagonal_elements = a_dot_b[off_diagonal_indices] assert np.allclose(a_dot_b.diagonal(), 2 * np.pi) - assert np.allclose(off_diagonal_elements, 0.) + assert np.allclose(off_diagonal_elements, 0.0) def test_plane_transformation(): """ Test plane_transformation function. """ - rec_lat_vec = np.array([[-0.042506, -0.042506, 0.050235], - [-0.042506, 0.050235, -0.042506], - [-0.085013, 0.007728, 0.007728]] - ) - - plot_vec = np.array([[-0.5881478, 0.5881478, 0.5881478], - [ 0.5881478, -0.5881478, 0.5881478], - [ 0.5881478, 0.5881478, -0.5881478]] - ) + rec_lat_vec = np.array( + [[-0.042506, -0.042506, 0.050235], [-0.042506, 0.050235, -0.042506], [-0.085013, 0.007728, 0.007728]] + ) + + plot_vec = np.array( + [[-0.5881478, 0.5881478, 0.5881478], [0.5881478, -0.5881478, 0.5881478], [0.5881478, 0.5881478, -0.5881478]] + ) # This is supposed to be 3x3 Identity transformation_matrix = plane_transformation(rec_lat_vec, plot_vec) - assert np.allclose(transformation_matrix.dot(rec_lat_vec)[2,:], 0.0, atol=1e-6) + assert np.allclose(transformation_matrix.dot(rec_lat_vec)[2, :], 0.0, atol=1e-6) diff --git a/tests/structure/test_pymatgen_utilities.py b/tests/structure/test_pymatgen_utilities.py new file mode 100644 index 0000000..58847d7 --- /dev/null +++ b/tests/structure/test_pymatgen_utilities.py @@ -0,0 +1,50 @@ +"""Tests for pymatgen utilities.""" + +import numpy as np +import pytest + + +@pytest.fixture +def pymatgen_atoms_H2O(): + """ + H20 molecule in a big box (angstrom), in pymatgen Structure() + Converts a List[dict] to pymatgen.core.structure.Structure. + """ + pymatgen_struct = pytest.importorskip("pymatgen.core.structure") + cubic_cell = np.array([[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]]) + atoms = [ + {"species": "H", "position": [0.00000, 0.75545, -0.47116]}, + {"species": "O", "position": [0.00000, 0.00000, 0.11779]}, + {"species": "H", "position": [0.00000, 0.75545, -0.47116]}, + ] + + symbols = [atom["species"] for atom in atoms] + positions = [atom["position"] for atom in atoms] + return pymatgen_struct.Structure(lattice=cubic_cell, species=symbols, coords=positions, coords_are_cartesian=True) + + +def test_class_exciting_structure_pymatgen(pymatgen_atoms_H2O): + """ + Test the pymatgen Structure object gets used correctly by the ExcitingStructure constructor. + """ + pymatgen_conversion = pytest.importorskip("excitingtools.structure.pymatgen_utilities") + structure = pymatgen_conversion.pymatgen_to_exciting_structure(pymatgen_atoms_H2O) + + assert structure.species == ["H", "O", "H"] + assert np.allclose( + structure.lattice, + [[18.897261246257703, 0.0, 0.0], [0.0, 18.897261246257703, 0.0], [0.0, 0.0, 18.897261246257703]], + ), "Expect lattice vectors to match input values" + + assert np.allclose(structure.positions, pymatgen_atoms_H2O.frac_coords), "Expect positions to match input values." + + # This just confirms the XML tree is built, not that it is correct. + xml_structure = structure.to_xml() + assert list(xml_structure.keys()) == ["speciespath"], "Only expect speciespath in structure xml keys" + + +def test_class_exciting_structure_to_pymatgen(pymatgen_atoms_H2O): + pymatgen_conversion = pytest.importorskip("excitingtools.structure.pymatgen_utilities") + structure = pymatgen_conversion.pymatgen_to_exciting_structure(pymatgen_atoms_H2O) + new_pymatgen_atoms = pymatgen_conversion.exciting_structure_to_pymatgen(structure) + assert pymatgen_atoms_H2O == new_pymatgen_atoms diff --git a/tests/utils/test_dict_utils.py b/tests/utils/test_dict_utils.py index 7f54104..08467f9 100644 --- a/tests/utils/test_dict_utils.py +++ b/tests/utils/test_dict_utils.py @@ -1,152 +1,97 @@ """ Tests for functions in dict_utils.py """ + import numpy as np -from excitingtools.utils.dict_utils import container_converter, serialise_dict_values, delete_nested_key +from excitingtools.utils.dict_utils import container_converter, delete_nested_key, serialise_dict_values def test_convert_container(): """Test container_converter function on string values in a dict.""" input = { - 'a': '5.0', - 'b': ['1.0', '10.0'], - 'c': { - 'a1': '1.0' - }, - 'd': "blu", - 'e': [['1', '2.3'], '3'], - 'f': ['1', 'a'] - } - expected = { - 'a': 5.0, - 'b': [1.0, 10.0], - 'c': { - 'a1': 1.0 - }, - 'd': "blu", - 'e': [[1, 2.3], 3], - 'f': [1, 'a'] + "a": "5.0", + "b": ["1.0", "10.0"], + "c": {"a1": "1.0"}, + "d": "blu", + "e": [["1", "2.3"], "3"], + "f": ["1", "a"], } + expected = {"a": 5.0, "b": [1.0, 10.0], "c": {"a1": 1.0}, "d": "blu", "e": [[1, 2.3], 3], "f": [1, "a"]} - assert container_converter(input) == expected, ( - 'String value/s failed to convert to numerical values') + assert container_converter(input) == expected, "String value/s failed to convert to numerical values" def test_convert_container_no_strings(): """Test container_converter where there are no string values in dict.""" - input = { - 'a': 5.0, - 'b': [1.0, 10.0], - 'c': { - 'a1': 1.0 - }, - 'd': "blu", - 'e': [[1, 2.3], 3], - 'f': [1, 11.3] - } + input = {"a": 5.0, "b": [1.0, 10.0], "c": {"a1": 1.0}, "d": "blu", "e": [[1, 2.3], 3], "f": [1, 11.3]} - assert container_converter(input) == input, ( - "Expect the converter to do nothing") + assert container_converter(input) == input, "Expect the converter to do nothing" def test_convert_container_data_type(): - input = { - 'a': '5.0', - 'b': ['1.0', '10.0'], - 'c': { - 'a1': '1.0' - }, - 'd': "blu", - 'e': [['1', '2.3'], '3'] - } - expected = { - 'a': 5.0, - 'b': [1.0, 10.0], - 'c': { - 'a1': 1.0 - }, - 'd': "blu", - 'e': [[1, 2.3], 3] - } + input = {"a": "5.0", "b": ["1.0", "10.0"], "c": {"a1": "1.0"}, "d": "blu", "e": [["1", "2.3"], "3"]} + expected = {"a": 5.0, "b": [1.0, 10.0], "c": {"a1": 1.0}, "d": "blu", "e": [[1, 2.3], 3]} output = container_converter(input) for elem1, elem2 in zip(output.values(), expected.values()): assert type(elem1) == type(elem2) - for elem, elem2 in zip(output['c'].values(), expected['c'].values()): + for elem, elem2 in zip(output["c"].values(), expected["c"].values()): assert type(elem) == type(elem2) - for elem, elem2 in zip(output['b'], expected['b']): + for elem, elem2 in zip(output["b"], expected["b"]): assert type(elem) == type(elem2) - for elem, elem2 in zip(output['e'], expected['e']): + for elem, elem2 in zip(output["e"], expected["e"]): assert type(elem) == type(elem2) - for elem, elem2 in zip(output['e'][0], expected['e'][0]): + for elem, elem2 in zip(output["e"][0], expected["e"][0]): assert type(elem) == type(elem2) - assert output == expected, ( - "Output is consistent with the expected dictionary") + assert output == expected, "Output is consistent with the expected dictionary" class Mock: - def __init__(self, a, b): self.a = a self.b = b def test_serialise_dict_values(): - # Value is an object - input = {'mock_key': Mock(1, 2)} + input = {"mock_key": Mock(1, 2)} output = serialise_dict_values(input) - assert output == { - 'mock_key': { - 'a': 1, - 'b': 2 - } - }, "Convert object values to dicts" + assert output == {"mock_key": {"a": 1, "b": 2}}, "Convert object values to dicts" # Object nested in a dictionary - input = {'mock_key': {'another-key': Mock(1, 2)}} + input = {"mock_key": {"another-key": Mock(1, 2)}} output = serialise_dict_values(input) - assert output == { - 'mock_key': { - 'another-key': { - 'a': 1, - 'b': 2 - } - } - }, "Convert nested object values into dicts" + assert output == {"mock_key": {"another-key": {"a": 1, "b": 2}}}, "Convert nested object values into dicts" # Object nested in a list, and within a dictionary within a list - input = {'a': [1, 2, Mock(3, 4), {'b': Mock(5, 6)}]} + input = {"a": [1, 2, Mock(3, 4), {"b": Mock(5, 6)}]} output = serialise_dict_values(input) - assert output == {'a': [1, 2, {'a': 3, 'b': 4}, {'b': {'a': 5, 'b': 6}}]}, \ - "Convert nested object values into dicts, where the top-level container value is a list" + assert output == { + "a": [1, 2, {"a": 3, "b": 4}, {"b": {"a": 5, "b": 6}}] + }, "Convert nested object values into dicts, where the top-level container value is a list" def test_serialise_dict_value_is_tuple(): - # Object nested in a dictionary, nested within a tuple - input = {'mock_key': (1, {'another-key': Mock(1, 2)})} + input = {"mock_key": (1, {"another-key": Mock(1, 2)})} output = serialise_dict_values(input) - assert output == {'mock_key': [1, {'another-key': {'a': 1, 'b': 2}}]}, \ - "Convert nested object values into dicts, where the top-level container value is a tuple." \ + assert output == {"mock_key": [1, {"another-key": {"a": 1, "b": 2}}]}, ( + "Convert nested object values into dicts, where the top-level container value is a tuple." "Note, tuple cannot be mutated so it is converted to a list by serialise_dict_values" + ) def test_serialise_dict_value_is_set(): - # Object nested in a dictionary, nested within a set - input = {'mock_key': {1, 2, Mock(1, 2)}} + input = {"mock_key": {1, 2, Mock(1, 2)}} output = serialise_dict_values(input) - output_keys = [k for k in output.keys()] - output_values = [v for v in output.values()] - - assert len(output_keys) == 1 - assert output_keys[0] == 'mock_key' - assert len(output_values) == 1 + assert len(output) == 1 + assert next(iter(output)) == "mock_key" + assert len(output.values()) == 1 # Mock object -> dictionary, meaning one cannot compare the input and outout with collections.Counter, # sets or sorted (neither object nor dictionary is hashable) @@ -154,35 +99,36 @@ def unordered_lists_the_same(a: list, b: list) -> bool: """ Check contents of two lists are consistent, irregardless of order """ - for element in a: - try: + try: + for element in a: b.remove(element) - except ValueError: - return False + except ValueError: + return False return not b - assert unordered_lists_the_same(output_values[0], [1, 2, {'a': 1, 'b': 2}]), \ - "Convert nested object values into dicts, where the top-level container value is a set. " \ + assert unordered_lists_the_same(next(iter(output.values())), [1, 2, {"a": 1, "b": 2}]), ( + "Convert nested object values into dicts, where the top-level container value is a set. " "Note, set is not iterable and is converted to a list with some arbitrary order by serialise_dict_values" + ) def test_serialise_dict_values_null_behaviour(): - """" + """ " Test serialize_dict_values on different dict key types. """ - input = {'mock_key': [1, 2]} + input = {"mock_key": [1, 2]} output = serialise_dict_values(input) assert output == input, "Pass over list values" - input = {'mock_key': (1, 2)} + input = {"mock_key": (1, 2)} output = serialise_dict_values(input) assert output == input, "Pass over tuple values" - input = {'mock_key': {1, 2}} + input = {"mock_key": {1, 2}} output = serialise_dict_values(input) assert output == input, "Pass over set values" - input = {'mock_key': np.array([1., 2., 3.])} + input = {"mock_key": np.array([1.0, 2.0, 3.0])} output = serialise_dict_values(input) assert output == input, "Pass over np.array values" @@ -191,14 +137,14 @@ def test_delete_nested_key(): """ Test delete_nested_key removes a key from dict. """ - input = {'a': {'b': 1, 'c': {'d': 1, 'e': 2}}} - key_to_remove = ['a'] + input = {"a": {"b": 1, "c": {"d": 1, "e": 2}}} + key_to_remove = ["a"] delete_nested_key(input, key_to_remove) output = {} assert output == input - input = {'a': {'b': 1, 'c': {'d': 1, 'e': 2}}} - key_to_remove = ['a', 'c', 'd'] + input = {"a": {"b": 1, "c": {"d": 1, "e": 2}}} + key_to_remove = ["a", "c", "d"] delete_nested_key(input, key_to_remove) - output = {'a': {'b': 1, 'c': {'e': 2}}} + output = {"a": {"b": 1, "c": {"e": 2}}} assert output == input diff --git a/tests/utils/test_schema_parsing.py b/tests/utils/test_schema_parsing.py new file mode 100644 index 0000000..4a25206 --- /dev/null +++ b/tests/utils/test_schema_parsing.py @@ -0,0 +1,41 @@ +"""Test the schema parsing and whether the file valid_attributes is up-to-date.""" + +from excitingtools.utils.schema_parsing import get_excitingtools_root, list_string_line_limit + + +def test_set_string_line_limit(): + properties_valid_subtrees = [ + "DFTD2", + "EFG", + "LSJ", + "TSvdW", + "bandstructure", + "boltzequ", + "chargedensityplot", + "momentummatrix", + "mossbauer", + "mvecfield", + "polarization", + "raman", + "shg", + "spintext", + "stm", + "wannier", + "wanniergap", + "wannierplot", + "wfplot", + "xcmvecfield", + ] + reference_string = ( + 'properties_valid_subtrees = ["DFTD2", "EFG", "LSJ", "TSvdW", ' + '"bandstructure", "boltzequ", "chargedensityplot", \n' + ' "momentummatrix", "mossbauer", "mvecfield", ' + '"polarization", "raman", "shg", "spintext", \n' + ' "stm", "wannier", "wanniergap", "wannierplot", ' + '"wfplot", "xcmvecfield"]' + ) + assert list_string_line_limit("properties_valid_subtrees", properties_valid_subtrees) == reference_string + + +def test_get_exciting_root(): + assert get_excitingtools_root().name == "exciting_tools" diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 7504f05..2517b12 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -1,36 +1,40 @@ -from excitingtools.utils.utils import can_be_float, convert_to_literal, get_new_line_indices +"""Tests for utils.""" + +from excitingtools.utils.utils import can_be_float, convert_to_literal, flatten_list, get_new_line_indices def test_can_be_float(): """ Test can_be_float function on different input types. """ - assert can_be_float('1.0'), ( - "Expect string of a literal '1.0' can convert to float") - assert can_be_float('1'), ( - "Expect string of a literal '1' can convert to float") - assert can_be_float(True), ( - "Expect True can be converted to a float (would be 1.0)") - assert can_be_float('a') is False, ( - "Expect string of the letter 'a' cannot be converted to a float") + assert can_be_float("1.0"), "Expect string of a literal '1.0' can convert to float" + assert can_be_float("1"), "Expect string of a literal '1' can convert to float" + assert can_be_float(True), "Expect True can be converted to a float (would be 1.0)" + assert can_be_float("a") is False, "Expect string of the letter 'a' cannot be converted to a float" def test_convert_to_literal(): """ Test convert_to_literal turns string reps of numeric data into numeric data. """ - assert convert_to_literal('1.1') == 1.1, "string of literal '1.1' converts to float" - assert convert_to_literal('1.0') == 1.0, "string of literal '1.0' converts to float" - assert convert_to_literal('1') == 1, "string of literal '1' converts to int" + assert convert_to_literal("1.1") == 1.1, "string of literal '1.1' converts to float" + assert convert_to_literal("1.0") == 1.0, "string of literal '1.0' converts to float" + assert convert_to_literal("1") == 1, "string of literal '1' converts to int" def test_get_new_line_indices(): """ Test getting new line indices function. """ - test_string = 'Test Here\n 2nd Line' + test_string = "Test Here\n 2nd Line" expected_line_indices = 2 expected_line_index_list = [0, 10] assert len(get_new_line_indices(test_string)) == expected_line_indices assert get_new_line_indices(test_string)[0] == expected_line_index_list[0] assert get_new_line_indices(test_string)[1] == expected_line_index_list[1] + + +def test_flatten_list(): + input_list = [[1, 2, 3], 4, 5, [6, [7, 8]], {"9": 9, "10": 10}] + ref_list = [1, 2, 3, 4, 5, 6, 7, 8, {"9": 9, "10": 10}] + assert list(flatten_list(input_list)) == ref_list