diff --git a/.github/workflows/test-ubuntu.yml b/.github/workflows/test-ubuntu.yml index cc40abd..4bc045f 100644 --- a/.github/workflows/test-ubuntu.yml +++ b/.github/workflows/test-ubuntu.yml @@ -27,14 +27,13 @@ jobs: channel-priority: true activate-environment: anaconda-client-env python-version: ${{ matrix.python-version }} - - name: Install conda dependencies - run: | - printenv - conda install -y -c conda-forge python=${{ matrix.python-version }} "numpy>=1.26" scipy pytest pytest-cov coverage + auto-activate-base: false + mamba-version: "*" + use-mamba: true - name: Install larixite and dependencies with pip run: | - pip install ".[dev,test,doc]" + python -m pip install --upgrade pip + python -m pip install ".[all]" - name: Run test suite run: | - cd tests - python -m pytest --cov=./ --cov-report=xml + python -m pytest --cov=larixite --cov-report=xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4c45d80 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +Notable changes to this project will be documented in this file. The GitHub +Release Notes will also be useful. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +The versioning scheme followed is `YYYY.MAJOR.MINOR`: + +- `YYYY`: year of release +- `MAJOR`: major feature (= change in API, no backward compatibility) +- `MINOR`: minor feature (= bug fixes, minor changes not affecting the API, backward compatible) + +## [2026.1.0 - not released yet] + +### web application + + - Separate FEFF and FDMNES sections for XAS input generation + - FDMNES: added optimization checkbox + - FDMNES: added Green formalism checkbox + - Added more tests + +### `fdmnes` + + - Update to FDMNES version March 2026 + - Add `make_sbatch` with a template for the ESRF SLURM scheduler + - Add `optimize : bool = False` option in `FdmnesXasInput` class to control if the input parameters are optimized (= more accurate calcuation, but slower) or not + - `write_input()` defaults to `$HOME/.larixite/fdmnes/{job_prefix}_{YYMMDD_HHMMSS}` + +### `struc2xas` + + - Fully migrated from `larch.xrd.struc2xas` (and removed there) + - Make `struc2xas` *deprecated* and start refactoring + \ No newline at end of file diff --git a/notebooks/workflow_fdmnes.ipynb b/examples/fdmnes_xas_workflow.ipynb similarity index 89% rename from notebooks/workflow_fdmnes.ipynb rename to examples/fdmnes_xas_workflow.ipynb index 7a08865..455ed41 100644 --- a/notebooks/workflow_fdmnes.ipynb +++ b/examples/fdmnes_xas_workflow.ipynb @@ -9,8 +9,8 @@ "\n", "- Author: Mauro Rovezzi\n", "- Contact: mauro.rovezzi@esrf.fr\n", - "- Status: in progress\n", - "- Last update: 2025-05-14" + "- Status: *in progress...*\n", + "- Last update: 2026-05-19" ] }, { @@ -48,7 +48,7 @@ "\n", "abs = \"Zn\" #: select the absorber\n", "\n", - "fdmnes_executable = \"/home/mauro/local/fdmnes_Linux_2024_11_14/fdmnes_Linux/fdmnes_linux64\" #: path to fdmnes executable (TODO: set your own path)" + "fdmnes_executable = \"fdmnes\" #: path to fdmnes executable (TODO: set your own path)" ] }, { @@ -130,7 +130,7 @@ "metadata": {}, "outputs": [], "source": [ - "f = FdmnesXasInput(sg, absorber=abs, struct_type=\"crystal\", radius=5)\n", + "f = FdmnesXasInput(sg, absorber=abs, struct_type=\"crystal\", radius=5, erange=\"-5 0.5 10\", optimize=True)\n", "text = f.get_input()\n", "print(text)" ] @@ -148,7 +148,7 @@ "metadata": {}, "outputs": [], "source": [ - "outdir = f.write_input()" + "jobdir = f.write_input()" ] }, { @@ -157,7 +157,16 @@ "metadata": {}, "outputs": [], "source": [ - "! cd {outdir.as_posix()} && {fdmnes_executable}" + "f.write_sbatch()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "! module purge && module load fdmnes && cd {jobdir.as_posix()} && {fdmnes_executable}\n" ] }, { @@ -180,7 +189,7 @@ ], "metadata": { "kernelspec": { - "display_name": "sloth2411", + "display_name": "sloth2507", "language": "python", "name": "python3" }, @@ -194,7 +203,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.8" + "version": "3.12.11" } }, "nbformat": 4, diff --git a/examples/struct2xas_to_remove.ipynb b/examples/struct2xas_to_remove.ipynb new file mode 100755 index 0000000..4c8e4c9 --- /dev/null +++ b/examples/struct2xas_to_remove.ipynb @@ -0,0 +1,828 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Struct2XAS examples: from CIF/XYZ to XANES/EXAFS simulations with FDMNES and FEFF\n", + "\n", + "- Authors: Beatriz G. Foschiani and Mauro Rovezzi\n", + "- Contact: mauro.rovezzi@esrf.fr\n", + "- Status: **deprecated** -> migration in progress (not all the examples below may work)\n", + "- Last modified: 2026-05-12\n", + "\n", + "Here we provide various examples on the usage of `larixite.struct2xas` module.\n", + "\n", + "**WARNING**: this module will be removed in future versions. Use `larixite.struct` and `larixite.fdmnes` instead." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from pathlib import Path\n", + "import larch" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 1: Zn K-edge XANES of wurtzite ZnO with FDMNES from a CIF file\n", + "\n", + "This example shows how to use the `Struct2XAS` class to convert a CIF file to a FDMNES XANES input. We will simulate Zn K-edge XAS of ZnO. \n", + "\n", + "- The input structure is taken from the Materials Project database: [mp-2133](https://legacy.materialsproject.org/materials/mp-2133/).\n", + "- The experimental data are taken from the SSHADE/FAME database: [DOI:10.26302/SSHADE/EXPERIMENT_ST_20180418_001](https://www.sshade.eu/data/EXPERIMENT_ST_20180418_001)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Import the main class and instantiate it with the CIF file and the name of the absorbing element" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from larixite.struct2xas import Struct2XAS\n", + "mat_obj = Struct2XAS(file = \"../tests/structs/ZnO_mp-2133.cif\", abs_atom=\"Zn\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "to get the information about absorbing site" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# information about absorbing site as pandas.DataFrame\n", + "mat_obj.get_abs_sites_info()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "the same, more programmatically" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mat_obj.get_abs_sites()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Getters and setters are available for changing the absorber site being considered. We can use the methods `set_abs_site` and `get_abs_site`. This structure has only one absorber site (an example with multiple sites is given later)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mat_obj.set_abs_site(0)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "to get information on the coordination environment around absorber atom" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mat_obj.get_coord_envs_info()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "or programmatically" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mat_obj.get_coord_envs()\n" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "It is possible to visualize the local environment at a ginen radius from the absorber" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mat_obj.visualize(radius=2.5)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To create the FDMNES input file using the default template in a temporary directory" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mytemplate = None #: uses default FDMNES XANES template (-> `larch/xrd/templates/fdmnes.tmpl`)\n", + "mypath = None #: creates a default structure -> \"mydir/fdmnes/input_structure/abs_atom/siteN/\"\n", + "mat_obj.make_input_fdmnes(radius=7, green=False, template=mytemplate, parent_path=mypath, newjob=\"test\")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To show the created input file" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "! cat {mat_obj.outdir}/job_inp.txt" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Run FDMNES (refer to FDMNES documentation if needed)\n", + "\n", + "```python\n", + "#Example how to run FDMNES at the ESRF via SLURM \n", + "input = mat_ojb.parent_path\n", + "! cd {input}; subfdmnes -c 30\n", + "```\n", + "\n", + "The output files created by FDMNES will be:\n", + "- `job.txt`: non-convoluted spectra\n", + "- `job_conv.txt`: convoluted spectra (by fdmnes with default parameters)\n", + "- `job_out.txt`: FDMNES output during the execution of the program (NOTE: this is created by the SLURM submission script)\n", + "- `job_sd0.txt`: file with the projected density of states information\n", + "- `job_bav.txt`: file with large simulation infos (better to compress it or remove)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Please, refer to [XANES_Convolution.ipynb](./XANES_Convolution.ipynb) for a comparison of the simulated data with experimental one." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 1a: Zn K-edge EXAFS with FEFF\n", + "\n", + "We can also simulate the EXAFS spectrum with FEFF" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mytemplate = None #: uses default FEFF EXAFS template\n", + "mypath = mat_obj.parent_path #: if None creates a default structure -> \"mydir/feff/input_structure/abs_atom/frameN/siteN/\"\n", + "mat_obj.make_input_feff(radius=7, template=mytemplate, parent_path=mypath)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "to show the input file" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "! cat {mat_obj.outdir}/feff.inp" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "run FEFF" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "#via larch\n", + "from larch.xafs import FeffRunner\n", + "feff_inp = f\"{mat_obj.outdir}/feff.inp\"\n", + "sim = FeffRunner(feff_inp)\n", + "sim.run()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note: can also be run via direct call to `feff8l`\n", + "\n", + "```\n", + "! cd {mat_obj.parent_path}; feff8l\n", + "```" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "load the simulated EXAFS" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from glob import glob\n", + "xmu = glob(f\"{mat_obj.outdir}/xmu.dat\")[0]\n", + "from larch.io import read_ascii\n", + "gsim = read_ascii(xmu, labels=[\"energy\", \"erel\", \"k\", \"mu\", \"mu0\", \"chi\"])" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Load experimental data and quickly extract the EXAFS signal" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "example_fn = os.path.join('..', \"fdmnes\", \"ZnO_SSHADE.data.txt\")\n", + "gexp = read_ascii(example_fn, labels=[\"energy\", \"mu\"])\n", + "from larch.xafs import pre_edge, autobk\n", + "pre_edge(gexp, e0=9661)\n", + "autobk(gexp)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "then plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from larch.plot.plotly_xafsplots import PlotlyFigure\n", + "fig = PlotlyFigure()\n", + "fig.add_plot(gsim.k+0.3, gsim.chi*gsim.k**2, label=\"FEFF simulation\")\n", + "fig.add_plot(gexp.k, gexp.chi*gexp.k**2, label=\"EXP data\")\n", + "\n", + "fig.set_style(title=\"Wurtzite ZnO EXAFS\", width=800, height=500, xaxis_title=\"k (Å^-1)\", yaxis_title=\"chi(k) * k^2\" )\n", + "fig.set_xrange(0, 10)\n", + "fig.show()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "*Note: the agreement is not very good because the experimental EXAFS data stops at 8 $\\AA^{-1}$ and the structure parameters are not optimized.*" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 2: structures with multiple sites\n", + "\n", + "Here an example when the input structure has multiple sites for a given absorbing element. The number of non-equivalent sites is found via the [SpaceGroupAnalyzer](https://pymatgen.org/pymatgen.symmetry.analyzer.html#pymatgen.symmetry.analyzer.SpacegroupAnalyzer) in Pymatgen, which is based on [Spglib](https://spglib.readthedocs.io/en/latest/).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from larch.xrd.struct2xas import Struct2XAS\n", + "cif_input_fn = Path(\"../tests/structs/ZnO_mp-997630.cif\")\n", + "mat_obj = Struct2XAS(file=cif_input_fn, abs_atom=\"Zn\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mat_obj.get_abs_sites_info()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "it is possible to select one site and get information on the local environment and visualize, as shown before" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# analysis for second site\n", + "mat_obj.set_abs_site(2)\n", + "print(mat_obj.get_coord_envs_info())\n", + "mat_obj.visualize()" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "to create the FDMNES inputs simply range over the sites" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mypath = None\n", + "for site in range(mat_obj.nabs_sites): \n", + " mat_obj.set_abs_site(site)\n", + " mat_obj.make_input_fdmnes(radius=7, green=False, parent_path=mypath)\n", + " mypath = mat_obj.parent_path" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "and the FEFF inputs too" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for site in range(mat_obj.nabs_sites): \n", + " mat_obj.set_abs_site(site)\n", + " mat_obj.make_input_feff(radius=7, parent_path=mypath)" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 3: grab CIFs from Materials Project\n", + "\n", + "In this example some CIFs files are grabbed from the Materials Project database (MP) via a query and the inputs files are generated.\n", + "\n", + "A personal API key is required to query the database (-> [here](https://legacy.materialsproject.org/open). *NOTE* the legacy version is used in this example, that is, the \"old\" version of MP. Export `your_api_key` to the environmental variable `MP_API_KEY`.\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "from mp_api.client import MPRester\n", + "from larch.xrd.struct2xas import Struct2XAS, save_mp_structure, structure_folders\n", + "\n", + "api_key = os.getenv(\"MP_API_KEY\")\n", + "mpr = MPRester(api_key)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "we search for materials containing 2 elements, Zn and O, plus further filter those IDs not present in the ICSD database" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# criterias to query the materials\n", + "\n", + "entries = mpr.summary.search(elements=[\"Zn\", \"O\"], num_elements=[2, None])\n", + "\n", + "#: skip materials not present in the ICSD database\n", + "mp_ids = [e.material_id for e in entries if 'icsd' in e.database_IDs]\n", + " \n", + "print(f\"Found {len(mp_ids)} structures\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "save the corresponding Structure files" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for id in mp_ids[:10]:\n", + " struct_fname = save_mp_structure(api_key, id)\n", + " print(f\"saved {id} to {struct_fname}\")\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "load the CIF files and generate the FDMNES/FEFF inputs" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from glob import glob\n", + "from larch.xrd.struct2xas import structure_folders\n", + "sfolder = structure_folders()['mp_structs']\n", + "print(sfolder)\n", + "for sdat in glob(f\"{sfolder}/*.mpjson\"):\n", + " s2x = Struct2XAS(sdat, abs_atom=\"Zn\")\n", + " for abs_site in s2x.get_abs_sites():\n", + " s2x.set_abs_site(abs_site[0])\n", + " s2x.make_input_fdmnes(radius=7)\n", + " s2x.make_input_feff(radius=7)\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "the next steps consist in running the simulations and comparing with experimental data. This is beyond the scope of current example." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 4: from XYZ (single or multi-frame) to XAS\n", + "\n", + "This example shows the case of XYZ files as input. In particular, in the case the XYZ file contains multiple frames. This is a typical output format of molecular dynamics simulations.\n", + "\n", + "Here the case of Ga K-edge of a GaBr solution is taken as example:\n", + "- Cécile Da Silva, Olivier Proux, Jean-Louis Hazemann, Julianne James-Smith, Denis Testemale, Toshio Yamaguchi,\n", + "X-ray absorption spectroscopy study of solvation and ion-pairing in aqueous gallium bromide solutions at supercritical conditions,\n", + "Journal of Molecular Liquids, Volume 147, Issues 1–2, 2009, Pages 83-95, ISSN 0167-7322, [https://doi.org/10.1016/j.molliq.2008.06.022]\n", + "- The experimental data are taken from the SSHADE database [DOI:10.26302/SSHADE/EXPERIMENT_ST_20180418_001](https://www.sshade.eu/data/EXPERIMENT_ST_20180418_001)\n", + "- The input structure is manually generated by simply changing the Ga-O distance\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from larch.xrd.struct2xas import Struct2XAS\n", + "gabr = Struct2XAS(file=\"../tests/structs/GaBr_multi-frame.xyz\", abs_atom=\"Ga\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "it is possible to select a given frame, visualize and get the coordination environment" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "gabr.set_frame(2)\n", + "gabr.visualize(5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "gabr.get_coord_envs_info()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "generate FEFF EXAFS inputs (for FDMNES is equivalent)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "for frame in range(gabr.nframes):\n", + " gabr.set_frame(frame)\n", + " gabr.make_input_feff(radius=5, sig2=0.005)\n", + "outdir = os.path.abspath(os.path.join(gabr.outdir, '..', '..'))\n", + "print(outdir)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "run the simulations" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from glob import glob\n", + "from larch.xafs import FeffRunner\n", + "\n", + "feffinps = glob(f\"{outdir}/**/feff.inp\", recursive=True)\n", + "print(feffinps)\n", + "for feffinp in feffinps:\n", + " sim = FeffRunner(feffinp)\n", + " sim.run()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "sometimes is useful to export all the `xmu.dat` files in a single HDF5 file container" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import h5py\n", + "from larch.io import read_ascii\n", + "\n", + "h5dir = os.path.join(outdir, \"hdf5\")\n", + "try:\n", + " os.makedirs(h5dir)\n", + "except FileExistsError:\n", + " pass\n", + "h5out = os.path.join(h5dir, \"gabr_exafs.h5\")\n", + "\n", + "xmus = glob(f\"{outdir}/**/xmu.dat\", recursive=True)\n", + "labels = [\"energy\", \"erel\", \"k\", \"mu\", \"mu0\", \"chi\"]\n", + "\n", + "with h5py.File(h5out, \"w\") as f:\n", + " for xmu in xmus:\n", + " gxmu = read_ascii(xmu, labels=labels)\n", + " sample, abs_at, framestr, sitestr = xmu.split(os.sep)[-5:-1] \n", + " grouppath = f\"/{sample}/{abs_at}/{framestr}/{sitestr}\"\n", + " print(f\"save to group {grouppath}\")\n", + " try:\n", + " f.create_group(grouppath)\n", + " except Exception:\n", + " pass\n", + " for lab in labels:\n", + " f.create_dataset(f\"{grouppath}/{lab}\", data=getattr(gxmu, lab))\n", + "print(f\"xmu data exported to HDF5: {h5out}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from larch.xafs import pre_edge, autobk\n", + "from larch.plot.plotly_xafsplots import PlotlyFigure, plotlabels\n", + "\n", + "fig = PlotlyFigure()\n", + "\n", + "xmus = glob(f\"{outdir}/**/xmu.dat\", recursive=True)\n", + "for xmu in xmus:\n", + " sample, abs_at, framestr, sitestr = xmu.split(os.sep)[-5:-1] \n", + " gxmu = read_ascii(xmu, labels=[\"energy\", \"erel\", \"k\", \"mu\", \"mu0\", \"chi\"])\n", + " fig.add_plot(x=gxmu.k, y=gxmu.chi*gxmu.k**2, label=f\"{sample}/{framestr}\")\n", + "\n", + "#EXP DATA\n", + "fn_in = os.path.join('..', \"structuredata\", \"struct2xas\", \"GaBr_GaK_SSHADE.data.txt\")\n", + "gexp = read_ascii(fn_in, labels=[\"energy\", \"mu\"])\n", + "pre_edge(gexp)\n", + "autobk(gexp)\n", + "fig.add_plot(x=gexp.k, y=gexp.chi*gexp.k**2, color=\"black\", label=\"exp data\")\n", + "\n", + "\n", + "\n", + "fig.set_style(title=\"GaBr (Ga K-edge): FEFF sim. vs exp.\", width=900, height=500, xaxis_title=plotlabels.k, yaxis_title=plotlabels.chi2)\n", + "fig.set_xrange(2, 14)\n", + "fig.set_yrange(-3, 3)\n", + "fig.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "with h5py.File(h5out, \"r\") as f:\n", + " print(list(f.keys()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "sloth2507", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + }, + "pycharm": { + "stem_cell": { + "cell_type": "raw", + "metadata": { + "collapsed": false + }, + "source": [] + } + }, + "toc": { + "base_numbering": 1, + "nav_menu": {}, + "number_sections": true, + "sideBar": true, + "skip_h1_title": false, + "title_cell": "Table of Contents", + "title_sidebar": "Contents", + "toc_cell": false, + "toc_position": {}, + "toc_section_display": true, + "toc_window_display": false + }, + "varInspector": { + "cols": { + "lenName": 16, + "lenType": 16, + "lenVar": 40 + }, + "kernels_config": { + "python": { + "delete_cmd_postfix": "", + "delete_cmd_prefix": "del ", + "library": "var_list.py", + "varRefreshCmd": "print(var_dic_list())" + }, + "r": { + "delete_cmd_postfix": ") ", + "delete_cmd_prefix": "rm(", + "library": "var_list.r", + "varRefreshCmd": "cat(var_dic_list()) " + } + }, + "types_to_exclude": [ + "module", + "function", + "builtin_function_or_method", + "instance", + "_Feature" + ], + "window_display": false + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/larixite/fdmnes.py b/larixite/fdmnes.py index 2432ab3..f463382 100644 --- a/larixite/fdmnes.py +++ b/larixite/fdmnes.py @@ -9,9 +9,8 @@ spectroscopy (XAS, XES, RIXS) from the atomic structures """ - +import time from dataclasses import dataclass -from typing import Union from pathlib import Path from pymatgen.core import __version__ as pymatgen_version, Element, Molecule from larixite.struct import get_structure, get_structure_from_text @@ -23,24 +22,25 @@ TEMPLATE_FOLDER = Path(Path(__file__).parent, "templates") -FDMNES_DEFAULT_PARAMS = { +FDMNES_DEFAULT_PARAMS = { #: "FDMNES key name": True/False "Energpho": False, "Quadrupole": False, + "Polarize": False, #: -> TODO "Density": False, "Density_all": False, "Green": True, "Memory_save": True, "Relativism": False, - "Spinorbit": None, + "Spinorbit": False, "SCF": False, - "SCFexc": False, - "SCFexcv": False, - "Screening": False, - "Full_atom": False, + "SCF_exc": False, + "Screening": False, #: -> TODO + "Dilatorb": False, #: -> TODO "TDDFT": False, - "PBE96": False, + "SpGr_atom": False, #: `Full_atom` is by default since March 2026 + "Hedin": False, #: `PBE69` is by default since March 2026 "Atom_conf": False, #: preferred over `Atom` (permits to keep atomic number in the list of atoms) -> TODO - "COOP": False, + "COOP": False, #: -> TODO "Convolution": True, } @@ -49,24 +49,31 @@ class FdmnesXasInput: """Input generator for a XAS calculation with FDMNES""" - structpath: Union[ - str, Path, XasStructure - ] #: path to the structural file or XasStructure - absorber: Union[ - str, int, Element - ] #: atomic symbol or number of the absorbing element - frame: int = 0 #: index of the frame inside the structure - edge: Union[str, None] = None #: edge for calculation + structpath: ( + str | Path | XasStructure + ) #: str or path to the structural file, or directly the XasStructure object + absorber: str | int | Element #: atomic symbol or number of the absorbing element + edge: str | None = None #: edge for calculation + struct_type: str | None = None #: type of the structure + frame: int = ( + 0 #: index of the frame inside the structure (e.g. for multi-frame XYZ files) + ) radius: float = 7 #: radius of the calculation - struct_type: Union[str, None] = None #: type of the structure - vmax: Union[float, None] = None #: maximum potential value for molecules - erange: str = "-20.0 0.1 70.0 1.0 100.0" #: energy range + erange: str = "-10.0 0.25 60.0 1.0 100.0" #: energy range + rself: float | None = None #: radius for the SCF calculation + nself: int = 100 #: number of maximum iterations for the SCF calculation + pself: float = 0.025 #: initial weight for the SCF calculation + vmax: float | None = None #: maximum potential value for molecules fileout_prefix: str = ( "job" #: prefix of the output filename for the FDMNES job (extension: .inp) ) - tmplpath: Union[str, Path, None] = None #: path to the FDMNES input template - params: Union[dict[str, bool], None] = None #: enable/disable parameters for FDMNES - spacer: str = " " #: spacer for the FDMNES input text + tmplpath: str | Path | None = ( + None #: path to the templates directory (FDMNES inputs and sbatch) + ) + params: dict[str, bool] | None = None #: enable/disable parameters for FDMNES + optimize: bool = False #: optimize the input parameters + spacer: str = " " #: spacer string for the FDMNES input text + outdir: str | Path | None = None #: path to the output directory of the FDMNES jobs def __post_init__(self): """Validate and optimize attributes""" @@ -79,32 +86,72 @@ def __post_init__(self): if isinstance(self.structpath, str): self.structpath = Path(self.structpath) if isinstance(self.structpath, XasStructure): - self.xs = self.structpath - self.structpath = self.xs.filepath + self._xs = self.structpath + self.structpath = self._xs.filepath else: - self.xs = get_structure(self.structpath, absorber=self.absorber) + self._xs = get_structure(self.structpath, absorber=self.absorber) #: radius self.set_radius(self.radius) + #: R_self + if self.rself is None: + self.set_rself() #: structure type if self.struct_type is None: - self.struct_type = self.xs.struct_type + self.struct_type = self._xs.struct_type else: - self.xs.struct_type = self.struct_type + self._xs.struct_type = self.struct_type #: template if self.tmplpath is None: - self.tmplpath = Path(TEMPLATE_FOLDER, "fdmnes_xas.tmpl") + self.tmplpath = Path(TEMPLATE_FOLDER) if isinstance(self.tmplpath, str): self.tmplpath = Path(self.tmplpath) #: absorption edge self.validate_edge() - #: optimize params + #: parameters if self.params is None: self.params = FDMNES_DEFAULT_PARAMS + #: optimize params + if self.optimize: self.params = self.optimize_params() + #: set outdir to None before writing the input + if isinstance(self.outdir, str): + self.outdir = Path(self.outdir) + if self.outdir is None: + self.outdir = Path.home() / ".larixite" / "fdmnes" + + #: store a list of jobs + self._jobs = [] + + @property + def xs(self): + return self._xs + + @property + def green(self): + return self.params["Green"] + + @green.setter + def green(self, val): + self.params["Green"] = val + + @property + def scf(self): + return self.params["SCF"] + + @scf.setter + def scf(self, val): + self.params["SCF"] = val def set_radius(self, value: float): self.radius = value - self.xs.radius = value + self._xs.radius = value + + def set_rself(self): + if self.radius < 3.5: + self.rself = self.radius + else: + self.rself = 3.5 + logger.debug(f"R_self set to {self.rself} for radius {self.radius}") def validate_edge(self): """Validates and adjusts the edge attribute""" @@ -146,7 +193,7 @@ def validate_edge(self): def optimize_params(self) -> dict: """Optimize the given input parameters""" params = self.params.copy() - atoms_z = [species.Z for species in self.xs.struct.types_of_species] + atoms_z = [species.Z for species in self._xs.struct.types_of_species] abs_z = self.absorber.Z transition_metals = [range(21, 31), range(39, 49), range(57, 81)] @@ -165,22 +212,17 @@ def optimize_params(self) -> dict: "Spinorbit enabled. **NOTE**: the simulations are typically 4 to 8 times longer and need 2 times more memory space" ) - if (8 in atoms_z) and (params["SCF"] is True): - """Sometimes, and specifically for oxides, it can be noted that SCF leads to solutions not - converging versus the cluster radius. One observes for instance a beating phenomenon - on the atomic charges, versus the number of atomic shells incoming in the calculation - area. This can be overcome by the use of the keyword "Full_atom" which suppress an - approximation, taking the cluster atoms equivalent by the space group symmetry - instead of the punctual group symmetry.""" - - params["Full_atom"] = True - if "mol" in self.struct_type.lower(): self.vmax = -6 + logger.info(f"Vmax set to {self.vmax} for molecule") + + #enable SCF + params["SCF"] = True + logger.info("SCF enabled") return params - def get_structure(self, struct_type: Union[str, None] = None) -> str: + def get_structure(self, struct_type: str | None = None) -> str: """Get the structure section of the input Parameters @@ -209,7 +251,7 @@ def get_structure(self, struct_type: Union[str, None] = None) -> str: self.optimize_params() else: struct_type = self.struct_type - if "crys" in struct_type.lower() and isinstance(self.xs.struct, Molecule): + if "crys" in struct_type.lower() and isinstance(self._xs.struct, Molecule): errmsg = "cannot generate a crystal input from a molecule -> use `struct_type='molecule'`" logger.error(errmsg) raise AttributeError(errmsg) @@ -220,7 +262,7 @@ def get_structure(self, struct_type: Union[str, None] = None) -> str: structout.append("Z_absorber") structout.append(f"{self.spacer}{self.absorber.Z}") #: space group - spgrp = self.xs.space_group + spgrp = self._xs.space_group if spgrp == 1: #: FDMNES doesn't recognize 1 as a space group -> `P1` spgrp = "P1" if spgrp == 2: #: FDMNES doesn't recognize 2 as a space group -> `P-1` @@ -231,7 +273,7 @@ def get_structure(self, struct_type: Union[str, None] = None) -> str: structout.append("Occupancy") #: crystal structout.append("Crystal") - lattice = self.xs.sym_struct.lattice + lattice = self._xs.sym_struct.lattice structout.append( f"{self.spacer}{lattice.a} {lattice.b} {lattice.c} {lattice.alpha} {lattice.beta} {lattice.gamma}" ) @@ -242,11 +284,11 @@ def get_structure(self, struct_type: Union[str, None] = None) -> str: occupancy, len_sites, wyckoff, - ) in self.xs.unique_sites: + ) in self._xs.unique_sites: zelems = [elem.Z for elem in site.species.elements] if not len(set(zelems)) == 1: logger.warning( - f"[{self.xs.label}] site {idx} has species with different Z -> {site.species_string}" + f"[{self._xs.label}] site {idx} has species with different Z -> {site.species_string}" ) for elem, elstr in zip( site.species.elements, site.species_string.split(", ") @@ -255,7 +297,7 @@ def get_structure(self, struct_type: Union[str, None] = None) -> str: structout.append(sitestr) elif "mol" in struct_type.lower(): #: build the cluster and map by distance from absorber at (0,0,0) - mol = self.xs.cluster + mol = self._xs.cluster map_mol_by_dist = [(0, 0)] for i, site in enumerate(mol[1:]): isite = i + 1 @@ -282,7 +324,7 @@ def get_structure(self, struct_type: Union[str, None] = None) -> str: return "\n".join(structout) def get_vmax(self) -> str: - """Get the vmax section of the input""" + """Get the Vmax section of the input""" if self.vmax is not None: vmax = ["Vmax"] vmax.append(f"{self.spacer}-6") @@ -290,13 +332,29 @@ def get_vmax(self) -> str: else: return "! Vmax" - def get_input(self, comment: str = "", struct_type: str = None) -> str: + def get_rself(self): + """Get the R_self section of the input""" + if self.rself is not None: + return f"{self.spacer}{self.rself}" + else: + return "!" + + def get_input( + self, + comment: str = "", + struct_type: str = None, + template: str | Path | None = None, + ) -> str: """Get the FDMNES input text""" params = self.params.copy() - template = open(self.tmplpath, "r").read() + if template is None: + template = self.tmplpath / "fdmnes_xas.tmpl" + if isinstance(template, str): + template = Path(template) + template_text = open(template, "r").read() comment = ( - f"{self.spacer}{self.xs.name}: {self.absorber.symbol} ({self.absorber.Z}) {self.edge} edge" + f"{self.spacer}{self._xs.name}: {self.absorber.symbol} ({self.absorber.Z}) {self.edge} edge" + comment ) #: fill the template @@ -313,49 +371,87 @@ def get_input(self, comment: str = "", struct_type: str = None) -> str: "radius": f"{self.spacer}{self.radius:.2f}", "erange": self.spacer + self.erange, "vmax": self.get_vmax(), + "rself": self.get_rself(), + "nself": f"{self.spacer}{self.nself}", + "pself": f"{self.spacer}{self.pself:.3f}", "struct_type": self.struct_type, "structure": self.get_structure(struct_type=struct_type), } for parkey, parval in params.items(): conf[parkey] = str(parkey) if parval is True else f"! {parkey}" - return strict_ascii(template.format(**conf)) + return strict_ascii(template_text.format(**conf)) def write_input( - self, inputtext: Union[str, None] = None, outdir: Union[str, Path, None] = None + self, inputtext: str | None = None, outdir: str | Path | None = None ) -> Path: - """Write the FDMNES input text to disk and return the output directory""" + """Write the FDMNES input text to disk and return the job output directory""" if inputtext is None: inputtext = self.get_input() - if outdir is None: - import tempfile - - outdir = ( - Path(tempfile.gettempdir()) / "larixite" / "fdmnes" / str(self.xs.name) - ) - outdir.mkdir(parents=True, exist_ok=True) - outdir = tempfile.mkdtemp(dir=outdir, prefix="job_") if isinstance(outdir, str): outdir = Path(outdir) - outdir.mkdir(parents=True, exist_ok=True) + if outdir is None: + outdir = self.outdir + tstamp = time.strftime("%y%m%d_%H%M%S") #: 260518_144910 + jobdir = outdir / f"{self.fileout_prefix}_{tstamp}" + jobdir.mkdir(parents=True, exist_ok=True) fileout_name = f"{self.fileout_prefix}.inp" - fnout = outdir / fileout_name + fnout = jobdir / fileout_name with open(fnout, "w") as fp: fp.write(inputtext) - with open(outdir / "fdmfile.txt", "w") as fp: + with open(jobdir / "fdmfile.txt", "w") as fp: fp.write(f"1\n{fileout_name}\n") - logger.info(f"written `{fnout}`") - return outdir + logger.info(f"written {fp.name}") + self._jobs.append(jobdir) + return jobdir + + def write_sbatch(self, jobdir: str | Path | None = None, template: str | Path | None = None, ncpus: int = 8): + """Generates a SBATCH file (SLURM workload manager) using a template + + Arguments + --------- + jobdir: str, Path or None + path to the job directory to write the SBATCH file + if None: uses last job directory + template: str, Path or None + path to the SBATCH template file + if None, the default template will be used (`fdmnes_sbatch_esrf.tmpl`) + **kwargs + keyword arguments to be replaced in the template file + + Returns + ------- + None: writes `{self.fileout_prefix}.sbatch` + + """ + if template is None: + template = self.tmplpath / "fdmnes_sbatch_esrf.tmpl" + if jobdir is None: + try: + jobdir = self._jobs[-1] + except IndexError: + logger.error("execute `write_input` first") + return + if isinstance(jobdir, str): + jobdir = Path(jobdir) + sbatchout = jobdir / f"{self.fileout_prefix}.sbatch" + kwargs = {"jobname": self.fileout_prefix, + "ncpus": ncpus,} + with open(sbatchout, "w") as fp, open(template) as tp: + fp.write(tp.read().format(**kwargs)) + logger.info(f"written {fp.name}") def struct2fdmnes( - inp: Union[str, Path], - absorber: Union[str, int, Element], + inp: str | Path, + absorber: str | int | Element, frame: int = 0, format: str = "cif", - filename: Union[None, str] = None, + filename: None | str = None, radius: float = 7, edge: str | None = None, + optimize: bool = True, + green: bool = True, ) -> dict: """convert CIF/XYZ into a dictionary of {name: text} for FDMNES output files @@ -371,6 +467,10 @@ def struct2fdmnes( format of text : 'cif' or 'xyz' ['cif'] filename : str full path to filename ['unknown.{format}'] + optimize : bool + optimize input parameters based on absorber and structure [True] + green : bool + use muffin-tin approximation [True] Returns ------- @@ -402,5 +502,7 @@ def struct2fdmnes( edge=edge, radius=radius, fileout_prefix=fout_prefix, + optimize=optimize, ) + fdm.green = green return {"fdmfile.txt": f"1\n{fout_name}\n", fout_name: fdm.get_input()} diff --git a/larixite/struct/xas_cif.py b/larixite/struct/xas_cif.py index 7079284..4e1041b 100644 --- a/larixite/struct/xas_cif.py +++ b/larixite/struct/xas_cif.py @@ -22,12 +22,12 @@ def struct(self): @property def sga(self): - return SpacegroupAnalyzer(self.struct) + return SpacegroupAnalyzer(self.struct, symprec=0.01, angle_tolerance=5) @property def space_group(self): sd = self.sga.get_symmetry_dataset() - #spg = sd.international + # spg = sd.international spg = sd.number if sd.choice != "": return f"{spg}:{sd.choice}" diff --git a/larixite/struct2xas.py b/larixite/struct2xas.py index 965164d..6389dd9 100644 --- a/larixite/struct2xas.py +++ b/larixite/struct2xas.py @@ -3,12 +3,19 @@ """ Struct2XAS: convert CIFs and XYZs files to FDMNES and FEFF inputs + + +/!\ DEPRECATED /!\ +THIS MODULE WILL BE REMOVED IN FUTURE VERSIONS +-> USE larixite.struct or larixite.fdmnes +/!\ DEPRECATED /!\ """ # main imports import os import json import time +from pathlib import Path from dataclasses import dataclass from typing import Union, List # , Any, Dict @@ -21,20 +28,13 @@ from pymatgen.io.cif import CifParser from pymatgen.symmetry.analyzer import SpacegroupAnalyzer -import larch.utils.logging as logging -from larch.utils import mkdir, unixpath - -from larch.site_config import user_larchdir -from larch.io import read_ascii -from larch.math.convolution1D import lin_gamma, conv +from larixite.utils import mkdir, unixpath, get_logger -try: - import pandas as pd - from pandas.io.formats.style import Styler +#from larch.site_config import user_larchdir +#from larch.io import read_ascii +#from larch.math.convolution1D import lin_gamma, conv - HAS_PANDAS = True -except ImportError: - HAS_PANDAS = False +user_larchdir = Path.home() / ".larixite" try: import py3Dmol @@ -46,12 +46,12 @@ __author__ = ["Beatriz G. Foschiani", "Mauro Rovezzi"] __email__ = ["beatrizgfoschiani@gmail.com", "mauro.rovezzi@esrf.fr"] -__credits__ = ["Jade Chongsathapornpong", "Marius Retegan"] -__version__ = "2024.1.0" +__credits__ = ["Jade Chongsathapornpong", "Marius Retegan", "Helen Engelhardt"] +__version__ = "2026.1.0" # initialize the logger -logger = logging.getLogger("struct2xas", level="INFO") +logger = get_logger("struct2xas", level="INFO") def _get_timestamp() -> str: @@ -72,30 +72,20 @@ def _pprint(matrix): def xyz2struct(molecule): - """Convert pymatgen molecule to dummy pymatgen structure""" - - alat, blat, clat = 1, 1, 1 - - # Set the lattice dimensions in each direction - for i in range(len(molecule) - 1): - if molecule.cart_coords[i][0] > molecule.cart_coords[i + 1][0]: - alat = molecule.cart_coords[i][0] - if molecule.cart_coords[i][1] > molecule.cart_coords[i + 1][1]: - blat = molecule.cart_coords[i][1] - if molecule.cart_coords[i][2] > molecule.cart_coords[i + 1][2]: - clat = molecule.cart_coords[i][2] - - # Set the lattice dimensions in each direction + """Convert pymatgen molecule to dummy pymatgen structure using vectorized calculation.""" + # Ensure the coordinates are in a NumPy array + coords = np.array(molecule.cart_coords) + + # Compute maximum coordinate along each axis + alat, blat, clat = np.max(coords, axis=0) + lattice = Lattice.from_parameters( a=alat, b=blat, c=clat, alpha=90, beta=90, gamma=90 ) - + # Create a list of species species = [Element(sym) for sym in molecule.species] - - # Create a list of coordinates - coords = molecule.cart_coords - + # Create the Structure object struct = Structure(lattice, species, coords, coords_are_cartesian=True) return struct @@ -474,17 +464,9 @@ def get_abs_sites_info(self): "idx_in_struct", ] abs_sites = self.get_abs_sites() - if HAS_PANDAS: - df = pd.DataFrame( - abs_sites, - columns=header, - ) - df = Styler(df).hide(axis="index") - return df - else: - matrix = [header] - matrix.extend(abs_sites) - _pprint(matrix) + matrix = [header] + matrix.extend(abs_sites) + _pprint(matrix) def get_atoms_from_abs(self, radius): """Get atoms in sphere from absorbing atom with certain radius""" @@ -744,13 +726,9 @@ def get_coord_envs_info(self): ) print(coord_sym) header = ["Element", "Distance"] - if HAS_PANDAS: - df = pd.DataFrame(data=elems_dist, columns=header) - return df - else: - matrix = [header] - matrix.extend(elems_dist) - _pprint(matrix) + matrix = [header] + matrix.extend(elems_dist) + _pprint(matrix) def make_cluster(self, radius): """Create a cluster with absorber atom site at the center. @@ -832,6 +810,7 @@ def make_input_fdmnes( ||||||+ job{njob} """ + logger.warning("`make_input_fdmnes` is deprecated, use `larixite.fdmnes` instead") if parent_path is None: parent_path = self.parent_path @@ -861,7 +840,7 @@ def make_input_fdmnes( if template is None: template = os.path.join( - os.path.dirname(os.path.realpath(__file__)), "templates", "fdmnes.tmpl" + os.path.dirname(os.path.realpath(__file__)), "templates", "fdmnes_struct2xas_to_remove.tmpl" ) assert os.path.isfile(template), "wrong template path" @@ -942,7 +921,7 @@ def make_input_fdmnes( np.allclose(unique_sites[i][0].coords, selected_site[4], atol=0.01) is True ): - replacements["absorber"] = f"absorber\n{i+1}" + replacements["absorber"] = f"absorber\n{i + 1}" # absorber = f"{absorber}" # replacements["absorber"] = f"Z_absorber\n{round(Element(elem).Z)}" @@ -963,7 +942,7 @@ def make_input_fdmnes( absorber = f"{absorber}" for i in range(len(atoms)): if np.allclose(atoms[i][1], [0, 0, 0], atol=0.01) is True: - replacements["absorber"] = f"absorber\n{i+1}" + replacements["absorber"] = f"absorber\n{i + 1}" replacements["group"] = "" @@ -1029,6 +1008,7 @@ def make_input_feff( edge: str = "K", sig2: Union[float, None] = None, debye: Union[List[float], None] = None, + feff_print: Union[List[int], None] = None, **kwargs, ): """ @@ -1056,6 +1036,11 @@ def make_input_feff( temperature at which the Debye-Waller factors are calculated [Kelvin]. debye_temperature : float Debye Temperature of the material [Kelvin]. + feff_print : list of six ints or None, [None] + PRINT card values controlling output verbosity for each FEFF module: + [pot, xsph, fms, paths, genfmt, ff2chi] + Values: 0=minimal, 1=standard, 2+=verbose + If None, defaults to [1, 0, 0, 0, 0, 3] (matches structure2feff.py) ..note:: refer to [FEFF documentation](https://feff.phys.washington.edu/feffproject-feff-documentation.html) @@ -1103,7 +1088,7 @@ def make_input_feff( template = os.path.join( os.path.dirname(os.path.realpath(__file__)), "templates", - "feff_exafs.tmpl", + "feff_struct2xas_to_remove.tmpl", ) assert os.path.isfile(template), "wrong template path" @@ -1124,6 +1109,10 @@ def make_input_feff( use_debye = "" temperature, debye_temperature = debye[0], debye[1] + # Default PRINT values match structure2feff.py behavior + if feff_print is None: + feff_print = [1, 0, 0, 0, 0, 3] + feff_comment = f"{feff_comment}" edge = f"{edge}" radius = f"{radius}" @@ -1210,7 +1199,7 @@ def make_input_feff( for i in range(len(at)): if self.full_occupancy: atoms += "\n" + ( - f"{at[i][0][0]:10.6f} {at[i][0][1]:10.6f} {at[i][0][2]:10.6f} { int(at[i][1])} {at[i][2]:>5} {at[i][3]:10.5f} *1 " + f"{at[i][0][0]:10.6f} {at[i][0][1]:10.6f} {at[i][0][2]:10.6f} {int(at[i][1])} {at[i][2]:>5} {at[i][3]:10.5f} *1 " ) else: choice = np.random.choice( @@ -1233,6 +1222,12 @@ def make_input_feff( replacements["potentials"] = potentials replacements["atoms"] = atoms replacements["title"] = title + replacements["print_pot"] = feff_print[0] + replacements["print_xsph"] = feff_print[1] + replacements["print_fms"] = feff_print[2] + replacements["print_paths"] = feff_print[3] + replacements["print_genfmt"] = feff_print[4] + replacements["print_ff2chi"] = feff_print[5] # replacements[""] = try: @@ -1386,67 +1381,67 @@ def visualize(self, radius=2.5, unitcell=False): # print("Label:\n", color_elems) -def get_fdmnes_info(file, labels=("energy", "mu")): - """Get info from the fdmnes output file such as edge energy, atomic number Z, - and fermi level energy, and returns a group with the storage information +# def get_fdmnes_info(file, labels=("energy", "mu")): +# """Get info from the fdmnes output file such as edge energy, atomic number Z, +# and fermi level energy, and returns a group with the storage information - Parameters: +# Parameters: - file (str): path to the fdmnes output file. - Obs: The INPUT file must have the "Header" keyword to use this function in the OUTPUT file +# file (str): path to the fdmnes output file. +# Obs: The INPUT file must have the "Header" keyword to use this function in the OUTPUT file - """ - group = read_ascii(file, labels=labels) - - with open(group.path) as f: - line = f.readlines()[3] - header = line.split() - ( - e_edge, - Z, - e_fermi, - ) = (float(header[0]), float(header[1]), float(header[6])) - print( - f"Calculated Fermi level: {e_fermi}\nAtomic_number: {Z}\nEnergy_edge: {e_edge}" - ) +# """ +# group = read_ascii(file, labels=labels) - group.e_edge = e_edge - group.Z = Z - group.e_fermi = e_fermi +# with open(group.path) as f: +# line = f.readlines()[3] +# header = line.split() +# ( +# e_edge, +# Z, +# e_fermi, +# ) = (float(header[0]), float(header[1]), float(header[6])) +# print( +# f"Calculated Fermi level: {e_fermi}\nAtomic_number: {Z}\nEnergy_edge: {e_edge}" +# ) - return group +# group.e_edge = e_edge +# group.Z = Z +# group.e_fermi = e_fermi +# return group -def convolve_data( - energy, mu, group, fwhm=1, linbroad=[1.5, 0, 50], kernel="gaussian", efermi=None -): - """ - Function for manual convolution using Convolution1D from larch and returning a group - Generic discrete convolution +# def convolve_data( +# energy, mu, group, fwhm=1, linbroad=[1.5, 0, 50], kernel="gaussian", efermi=None +# ): +# """ +# Function for manual convolution using Convolution1D from larch and returning a group - Description - ----------- +# Generic discrete convolution - This is a manual (not optimized!) implementation of discrete 1D - convolution intended for spectroscopy analysis. The difference with - commonly used methods is the possibility to adapt the convolution - kernel for each convolution point, e.g. change the FWHM of the - Gaussian kernel as a function of the energy scale. +# Description +# ----------- - Resources - --------- +# This is a manual (not optimized!) implementation of discrete 1D +# convolution intended for spectroscopy analysis. The difference with +# commonly used methods is the possibility to adapt the convolution +# kernel for each convolution point, e.g. change the FWHM of the +# Gaussian kernel as a function of the energy scale. - .. [WPconv] - .. [Fisher] - .. [GP1202] +# Resources +# --------- - """ +# .. [WPconv] +# .. [Fisher] +# .. [GP1202] + +# """ - gamma_e = lin_gamma(energy, fwhm=fwhm, linbroad=linbroad) - mu_conv = conv(energy, mu, kernel=kernel, fwhm_e=gamma_e, efermi=efermi) - group.conv = mu_conv - return group +# gamma_e = lin_gamma(energy, fwhm=fwhm, linbroad=linbroad) +# mu_conv = conv(energy, mu, kernel=kernel, fwhm_e=gamma_e, efermi=efermi) +# group.conv = mu_conv +# return group def save_cif_from_mp( diff --git a/larixite/templates/fdmnes_sbatch_esrf.tmpl b/larixite/templates/fdmnes_sbatch_esrf.tmpl new file mode 100644 index 0000000..2cce9e3 --- /dev/null +++ b/larixite/templates/fdmnes_sbatch_esrf.tmpl @@ -0,0 +1,28 @@ +#!/bin/bash -l +#SBATCH --nodes=1 #: Number of nodes / if more nodes, add `#SBATCH --constraint="hpc7"` (type of machines) +#SBATCH --ntasks-per-node={ncpus} #: Total number of tasks (=CPUs cores) per node +#SBATCH --cpus-per-task=1 #: CPUs for each task / DON'T CHANGE THIS - required by FDMNES +#SBATCH --mem-per-cpu=16G #: Total memory for each CPU +#SBATCH --time=120:00:00 #: Wall-time limit +#SBATCH --partition=nice,nice-long #: ESRF cluster +#SBATCH --job-name="{jobname}" #: Name used to set the output files +#SBATCH --output=%x_output.txt +#SBATCH --error=%x_output.txt + +function compress {{ + gzip -f {jobname}_bav.txt +}} + +# Unload all loaded modules. +module purge + +#"module avail fdmnes" to see available versions +VERSION="2026.03.10" + +module load fdmnes/$VERSION + +# Run the calculation. +srun ${{FDMNES_COMMAND}} + +# Compress the large job_bav.txt file. +compress diff --git a/larixite/templates/fdmnes.tmpl b/larixite/templates/fdmnes_struct2xas_to_remove.tmpl similarity index 100% rename from larixite/templates/fdmnes.tmpl rename to larixite/templates/fdmnes_struct2xas_to_remove.tmpl diff --git a/larixite/templates/fdmnes_xas.tmpl b/larixite/templates/fdmnes_xas.tmpl index f508d11..2e9d901 100755 --- a/larixite/templates/fdmnes_xas.tmpl +++ b/larixite/templates/fdmnes_xas.tmpl @@ -13,19 +13,36 @@ Edge ! Range {erange} -{Energpho} +{Energpho} !output energy as photon energy (default: relative to the Fermi level) Radius {radius} +! +{Green} ! use Green formalism (multiple scattering) on a muffin-tin potential +! (default: Finite Difference Method (FDM) on a free-shape potential) ! {Quadrupole} +! +{Polarize} +! =>TODO {TDDFT} +{Memory_save} {Relativism} {Spinorbit} -{Full_atom} {Density} {Density_all} +! +{Hedin} !PBE96 is default since March 2026 +! +{SpGr_atom} !`Full_atom` is default since March 2026 +! {SCF} -{Green} +R_self +{rself} +N_self +{nself} +P_self +{pself} +{SCF_exc} {vmax} {Atom_conf} ! diff --git a/larixite/templates/feff_struct2xas_to_remove.tmpl b/larixite/templates/feff_struct2xas_to_remove.tmpl new file mode 100755 index 0000000..6613221 --- /dev/null +++ b/larixite/templates/feff_struct2xas_to_remove.tmpl @@ -0,0 +1,42 @@ +* FEFF8* input file generated by Struct2XAS version {version} + +{title} + +* edge energy = eV +EDGE {edge} +S02 0.0 + +* pot xsph fms paths genfmt ff2chi +CONTROL 1 1 1 1 1 1 +PRINT {print_pot} {print_xsph} {print_fms} {print_paths} {print_genfmt} {print_ff2chi} + +*** ixc=0 means to use Hedin-Lundqvist +* ixc [ Vr Vi ] +EXCHANGE 0 + +* r_scf [ l_scf n_scf ca ] +*SCF 5.0 + +RPATH {radius} +EXAFS 20 +NLEG 6 + +* EXAFS damping, to mimic the structural disorder +{use_sig2} SIG2 {sig2} + +* Debye-Waller factors +{use_debye} DEBYE {temperature} {debye_temperature} + +* emin emax eimag +*LDOS -30 20 0.1 + + +POTENTIALS + +{potentials} + +ATOMS + +{atoms} + +END diff --git a/larixite/utils.py b/larixite/utils.py index 18e3457..1789339 100644 --- a/larixite/utils.py +++ b/larixite/utils.py @@ -7,6 +7,7 @@ import logging import io from datetime import datetime +from pathlib import Path from typing import Union from gzip import GzipFile from pathlib import Path @@ -43,9 +44,7 @@ def get_homedir() -> Path: # use Windows API to get home directory from win32com.shell import shellcon, shell - homedir = Path( - shell.SHGetFolderPath(0, shellcon.CSIDL_APPDATA, 0, 0) - ) + homedir = Path(shell.SHGetFolderPath(0, shellcon.CSIDL_APPDATA, 0, 0)) except ImportError: pass @@ -257,3 +256,13 @@ def read_textfile(filename: Union[Path, io.IOBase, str, bytes], size=None) -> st with fopen(get_path(filename), "rb") as fh: text = bytes2str(fh.read(size)) return text.replace("\r\n", "\n").replace("\r", "\n") + + +def unixpath(d): + if isinstance(d, bytes): + d = str(from_bytes(d).best()) + if isinstance(d, str): + d = Path(d).absolute() + if isinstance(d, Path): + return d.as_posix() + raise ValueError(f"cannot get Path name from {d}") diff --git a/larixite/webapp/templates/index.html b/larixite/webapp/templates/index.html index 4377275..daf2429 100644 --- a/larixite/webapp/templates/index.html +++ b/larixite/webapp/templates/index.html @@ -98,26 +98,56 @@ {% endfor %} - - - - - - - - - - - - - - - - - + + + + + + + + + + +
+ + + + + + + + +
FEFF
+ +
+ +
+ + + + + + + + + + + + + + + + +
FDMNES
+
+
+ +

CIF Text:

{{ciftext|safe}}
@@ -131,13 +161,13 @@ {% if zip_link: %} - - - - + + + + {% endif %} {% if cif_link: %} @@ -151,9 +181,9 @@ {% for link, data in out_links.items() %} + absorber=data[2], edge=data[3], site=data[4], + cluster_size=data[5], with_h=data[6], form=data[7], + optimize=data[8], green=data[9], fname=link )}}">{{ link }} {% endfor %} diff --git a/larixite/webapp/webapp.py b/larixite/webapp/webapp.py index 82ec110..2ec6faa 100644 --- a/larixite/webapp/webapp.py +++ b/larixite/webapp/webapp.py @@ -52,6 +52,8 @@ def connect(session, clear=False, cifid=None): if config is None or clear: config = {'cifdict': {}, 'with_h': 0, + 'green': True, + 'optimize': False, 'edges': ['K', 'L3', 'L2', 'L1', 'M5', 'M4', 'M3'], 'cluster_size': 7.0, 'mineral': '', @@ -191,12 +193,12 @@ def cifs(cifid=None): cifid = config['cifid'] config['cif_link'] = (f'AMSCD_{cifid}.cif', cifid) config['zip_link'] = (f'AMSCD_{cifid}_feff.zip', cifid, - absorber, edge, cluster_size, with_h, 'feff') + absorber, edge, cluster_size, with_h, 'feff', 0, 0) config['out_links'] = {} for slabel, sindex in config['all_sites'][absorber].items(): link = f'feff_{cifid}_{absorber}{sindex}_{edge}.inp' config['out_links'][link] = (slabel, cifid, absorber, edge, sindex, - cluster_size, with_h, 'feff') + cluster_size, with_h, 'feff', 0, 0) elif 'fdmnes' in request.form.keys(): @@ -205,6 +207,10 @@ def cifs(cifid=None): config['cluster_size'] = cluster_size = request.form.get('cluster_size') with_h = request.form.get('with_h') in ('1', 'on', 'True', 1, True) config['with_h'] = with_h = int(with_h) + green = request.form.get('green') in ('1', 'on', 'True', 1, True) + config['green'] = green + optimize = request.form.get('optimize') in ('1', 'on', 'True', 1, True) + config['optimize'] = optimize if config['ciftext'] is not None: cifid = config['cifid'] if config['ciffile'] not in ('', None, 'None'): @@ -214,15 +220,15 @@ def cifs(cifid=None): ciffile = f'AMSCD_{cifid}.cif' config['cif_link'] = (f'AMSCD_{cifid}.cif', cifid) config['zip_link'] = (f'AMSCD_{cifid}_fdmnes.zip', cifid, - absorber, edge, cluster_size, with_h, 'fdmnes') + absorber, edge, cluster_size, with_h, 'fdmnes', optimize, green) config['out_links'] = {} out = struct2fdmnes(config['ciftext'], absorber=absorber, radius=float(cluster_size), edge=edge, - filename=ciffile) + filename=ciffile, optimize=optimize, green=green) for slabel, text in out.items(): link = f'fdmnes_{slabel}' config['out_links'][slabel] = (slabel, cifid, absorber, edge, 0, - cluster_size, with_h, 'fdmnes') + cluster_size, with_h, 'fdmnes', optimize, green) return render_template('index.html', **config) @@ -255,9 +261,9 @@ def get_elements(absorber): return stoich, absorber -@app.route('/zipfile//////
/') +@app.route('/zipfile/////////') def zipfile(cifid=None, absorber=None, edge='K', cluster_size=7.0, - with_h=False, form='feff', fname=None): + with_h=False, form='feff', optimize=False, green=False, fname=None): connect(session, cifid=cifid) global cifdb, config stoich, absorber = get_elements(absorber) @@ -284,9 +290,14 @@ def zipfile(cifid=None, absorber=None, edge='K', cluster_size=7.0, with_h=with_h, absorber_site=int(sindex)) zfile.writestr(oname, result) elif form == 'fdmnes': + if optimize in ('1', 'on', 'True', 1, True): + optimize = 1 + else: + optimize = 0 out = struct2fdmnes(config['ciftext'], absorber=absorber, radius=float(cluster_size), edge=edge, - filename=f'AMSCD_{cifid}.cif') + filename=f'AMSCD_{cifid}.cif', optimize=bool(optimize), + green=bool(green) if green else False) for key, val in out.items(): zfile.writestr(key, val) zfile.close() @@ -294,9 +305,9 @@ def zipfile(cifid=None, absorber=None, edge='K', cluster_size=7.0, return send_from_directory(tfolder, tfile, mimetype='application/zip', as_attachment=True, download_name=fname) -@app.route('/output////////') +@app.route('/output//////////') def output(cifid=None, absorber=None, site=1, edge='K', cluster_size=7.0, - with_h=False, form='feff', fname=None): + with_h=False, form='feff', optimize=False, green=False, fname=None): connect(session, cifid=cifid) global cifdb, config stoich, absorber = get_elements(absorber) @@ -314,6 +325,14 @@ def output(cifid=None, absorber=None, site=1, edge='K', cluster_size=7.0, with_h = 1 else: with_h = 0 + if optimize in ('1', 'on', 'True', 1, True): + optimize = 1 + else: + optimize = 0 + if green in ('1', 'on', 'True', 1, True): + green = 1 + else: + green = 0 if form =='feff': result = cif2feffinp(config['ciftext'], absorber, edge=edge, cluster_size=float(cluster_size), cifid=xcifid, @@ -323,7 +342,8 @@ def output(cifid=None, absorber=None, site=1, edge='K', cluster_size=7.0, oname = f'AMSCD_{xcifid}.cif' out = struct2fdmnes(config['ciftext'], absorber=absorber, radius=float(cluster_size), edge=edge, - filename=oname) + filename=oname, optimize=bool(optimize), + green=bool(green)) if fname == 'fdmfile.txt': result = out.get(fname, None) else: diff --git a/pyproject.toml b/pyproject.toml index 989f9ac..a90dc92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "pymatgen>=2025.6.14", "mp-api>=0.45.8", "emmet-core>=0.84.9", + "requests", ] license = "MIT" @@ -49,7 +50,7 @@ Tracker = "https://github.com/xraypy/larixite/issues" [project.optional-dependencies] web = ["flask"] jupyter = ["jupyterlab==3.*", "crystal-toolkit>=2025.7.31"] -test = ["pytest"] +test = ["pytest", "pytest-cov", "pygments"] doc = ["sphinx"] dev = ["build", "twine", "ruff"] all = ["larixite[dev,web,test,doc,jupyter]"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1822b3a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +"""Shared pytest configuration for the larixite test suite.""" + +import warnings + + +def pytest_configure(config): + """Suppress pymatgen rounding warnings that occur inside Flask handlers + (where pytest.warns can't capture them). The warnings are explicitly + tested in test_webapp.py::TestPymatgenRoundingWarning.""" + warnings.filterwarnings("ignore", + message=".*fractional coordinates rounded.*", + category=UserWarning, + module="larixite.cif_cluster") + warnings.filterwarnings("ignore", + message=".*fractional coordinates rounded.*", + category=UserWarning, + module="larixite.struct") diff --git a/tests/structs/GaBr_single_frame.xyz b/tests/structs/GaBr_single_frame.xyz new file mode 100755 index 0000000..ab6dbfb --- /dev/null +++ b/tests/structs/GaBr_single_frame.xyz @@ -0,0 +1,21 @@ +19 +Ga1 H12 O6 +Ga 0.000000 0.000000 0.000000 +O 1.960000 0.000000 0.000000 +O -1.960000 0.000000 0.000000 +O 0.000000 1.960000 0.000000 +O 0.000000 -1.960000 0.000000 +O 0.000000 0.000000 1.960000 +O 0.000000 0.000000 -1.960000 +H -0.791840 2.528400 0.000000 +H 0.791840 -2.528400 0.000000 +H 0.791840 2.528400 0.000000 +H -0.791840 -2.528400 0.000000 +H 0.000000 -0.791840 -2.528400 +H 0.000000 0.791840 -2.528400 +H 0.000000 -0.791840 2.528400 +H 0.000000 0.791840 2.528400 +H 2.528400 0.000000 0.791840 +H -2.528400 0.000000 -0.791840 +H -2.528400 0.000000 0.791840 +H 2.528400 0.000000 -0.791840 \ No newline at end of file diff --git a/tests/structs/ZnO_mp-2133.cif b/tests/structs/ZnO_mp-2133.cif new file mode 100644 index 0000000..32882b7 --- /dev/null +++ b/tests/structs/ZnO_mp-2133.cif @@ -0,0 +1,30 @@ +# generated using pymatgen +data_ZnO +_symmetry_space_group_name_H-M 'P 1' +_cell_length_a 3.28910296 +_cell_length_b 3.28910296 +_cell_length_c 5.30682100 +_cell_angle_alpha 90.00000000 +_cell_angle_beta 90.00000000 +_cell_angle_gamma 120.00001938 +_symmetry_Int_Tables_number 1 +_chemical_formula_structural ZnO +_chemical_formula_sum 'Zn2 O2' +_cell_volume 49.71871825 +_cell_formula_units_Z 2 +loop_ + _symmetry_equiv_pos_site_id + _symmetry_equiv_pos_as_xyz + 1 'x, y, z' +loop_ + _atom_site_type_symbol + _atom_site_label + _atom_site_symmetry_multiplicity + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_occupancy + Zn Zn0 1 0.66666700 0.33333300 0.50054800 1 + Zn Zn1 1 0.33333300 0.66666700 0.00054800 1 + O O2 1 0.66666700 0.33333300 0.87976200 1 + O O3 1 0.33333300 0.66666700 0.37976200 1 diff --git a/tests/structs/ZnO_mp-997630.cif b/tests/structs/ZnO_mp-997630.cif new file mode 100644 index 0000000..0b206b3 --- /dev/null +++ b/tests/structs/ZnO_mp-997630.cif @@ -0,0 +1,50 @@ +# generated using pymatgen +data_ZnO +_symmetry_space_group_name_H-M 'P 1' +_cell_length_a 3.05547751 +_cell_length_b 3.05547751 +_cell_length_c 30.40991300 +_cell_angle_alpha 90.00000000 +_cell_angle_beta 90.00000000 +_cell_angle_gamma 119.99998934 +_symmetry_Int_Tables_number 1 +_chemical_formula_structural ZnO +_chemical_formula_sum 'Zn12 O12' +_cell_volume 245.86914906 +_cell_formula_units_Z 12 +loop_ + _symmetry_equiv_pos_site_id + _symmetry_equiv_pos_as_xyz + 1 'x, y, z' +loop_ + _atom_site_type_symbol + _atom_site_label + _atom_site_symmetry_multiplicity + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_occupancy + Zn Zn0 1 0.33333300 0.66666700 0.83493800 1 + Zn Zn1 1 0.66666700 0.33333300 0.16506200 1 + Zn Zn2 1 0.66666700 0.33333300 0.33493800 1 + Zn Zn3 1 0.33333300 0.66666700 0.66506200 1 + Zn Zn4 1 0.33333300 0.66666700 0.41747200 1 + Zn Zn5 1 0.66666700 0.33333300 0.58252800 1 + Zn Zn6 1 0.66666700 0.33333300 0.91747200 1 + Zn Zn7 1 0.33333300 0.66666700 0.08252800 1 + Zn Zn8 1 0.00000000 0.00000000 0.25000000 1 + Zn Zn9 1 0.00000000 0.00000000 0.75000000 1 + Zn Zn10 1 0.00000000 0.00000000 0.00000000 1 + Zn Zn11 1 0.00000000 0.00000000 0.50000000 1 + O O12 1 0.33333300 0.66666700 0.95873100 1 + O O13 1 0.66666700 0.33333300 0.04126900 1 + O O14 1 0.66666700 0.33333300 0.45873100 1 + O O15 1 0.33333300 0.66666700 0.54126900 1 + O O16 1 0.33333300 0.66666700 0.20615800 1 + O O17 1 0.66666700 0.33333300 0.79384200 1 + O O18 1 0.66666700 0.33333300 0.70615800 1 + O O19 1 0.33333300 0.66666700 0.29384200 1 + O O20 1 0.00000000 0.00000000 0.12375900 1 + O O21 1 0.00000000 0.00000000 0.87624100 1 + O O22 1 0.00000000 0.00000000 0.62375900 1 + O O23 1 0.00000000 0.00000000 0.37624100 1 diff --git a/tests/test_struct.py b/tests/test_struct.py index 6031cce..a6c139a 100644 --- a/tests/test_struct.py +++ b/tests/test_struct.py @@ -1,12 +1,40 @@ +"""Tests for larixite.struct — XasStructure, CIF/XYZ parsing, clusters, error handling.""" + +import tempfile from pathlib import Path -from larixite.struct import get_structure -from larixite.utils import get_logger -logger = get_logger("larixite.test") +import pytest +import numpy as np + +from larixite.struct import get_structure, get_structure_from_text, get_structs_from_dir +from larixite.struct.xas import site_label +from larixite.struct.xas_cif import XasStructureCif +from larixite.struct.xas_xyz import XasStructureXyz + +STRUCTS = Path(__file__).parent / "structs" + +# ── fixtures ────────────────────────────────────────────────────── + + +@pytest.fixture(scope="module") +def cif_zno(): + with pytest.warns(UserWarning, match="fractional coordinates rounded"): + return get_structure(STRUCTS / "ZnO_mp-2133.cif", "Zn") + + +@pytest.fixture(scope="module") +def cif_fe3o4(): + return get_structure(STRUCTS / "Fe3O4_cub_fracOcc_Levy2012_ICSD-183969.cif", "Fe") -structsdir = Path(__file__).parent / "structs" -test_structures = ( # filename_in_structsdir, absorber_str, absorber_index, occupancy +@pytest.fixture(scope="module") +def xyz_cuo6(): + return get_structure(STRUCTS / "CuO6_D4h.xyz", "Cu") + + +# ── parametrized occupancy (legacy) ──────────────────────────── + +test_structures = ( ("CuO6_D4h.xyz", "Cu", 0, 1), ("VO6_Oh.xyz", "V", 0, 1), ("GaBr_multi-frame.xyz", "Ga", 0, 1), @@ -17,13 +45,200 @@ ) -def test_struct(): - for filename, abs, abs_index, occupancy in test_structures: - sg = get_structure(structsdir / filename, abs) - sg.absorber_idx = abs_index - s = sg.get_site(sg.absorber_idx) - assert sg.get_occupancy(s) == occupancy, "Wrong occupancy" +@pytest.mark.parametrize("filename,abs,abs_index,occupancy", test_structures) +def test_struct(filename, abs, abs_index, occupancy): + sg = get_structure(STRUCTS / filename, abs) + sg.absorber_idx = abs_index + s = sg.get_site(sg.absorber_idx) + assert sg.get_occupancy(s) == pytest.approx(occupancy, rel=0.02) + + +# ── CIF ──────────────────────────────────────────────────────── + + +class TestCif: + def test_properties(self, cif_zno): + assert isinstance(cif_zno, XasStructureCif) + assert cif_zno.struct_type == "crystal" + assert cif_zno.formula == "ZnO" + assert cif_zno.space_group == 186 + assert cif_zno.absorber_idx == 0 + + def test_symmetry(self, cif_zno): + assert cif_zno.sga is not None and cif_zno.sym_struct is not None + assert len(cif_zno.unique_sites) == 2 and len(cif_zno.equivalent_sites) == 2 + assert len(cif_zno.wyckoff_symbols) == 2 + assert all(isinstance(w, str) and w for w in cif_zno.wyckoff_symbols) + + def test_site_labels(self, cif_zno): + labels = cif_zno.site_labels + assert len(labels) == 4 and all("[" in l and "]" in l for l in labels) + + def test_absorber(self, cif_zno): + assert len(cif_zno.absorber_sites) >= 1 + idxes = cif_zno.get_absorber_indexes() + assert len(idxes) >= 1 and all("Zn" in cif_zno.get_site(i).species_string for i in idxes) + + def test_alt_absorber(self, cif_zno): + with pytest.warns(UserWarning, match="fractional coordinates rounded"): + sg = get_structure(STRUCTS / "ZnO_mp-2133.cif", "O") + assert sg.absorber.symbol == "O" and len(sg.absorber_sites) >= 1 + + def test_full_occupancy(self, cif_zno): + assert cif_zno.get_occupancy(cif_zno.get_site(0)) == 1.0 + + def test_cluster(self, cif_zno): + from pymatgen.core import Molecule + cl = cif_zno.cluster + assert isinstance(cl, Molecule) and cl[0].species_string == "Zn" + assert np.allclose(cl[0].coords, [0, 0, 0], atol=0.01) + assert len(cl) > len(cif_zno.struct) + + def test_cluster_radius(self, cif_zno): + cif_zno.radius = 3 + small = cif_zno.cluster + cif_zno.radius = 7 + assert len(small) < len(cif_zno.cluster) + + +# ── XYZ ──────────────────────────────────────────────────────── + + +class TestXyz: + def test_properties(self, xyz_cuo6): + assert isinstance(xyz_cuo6, XasStructureXyz) + assert xyz_cuo6.struct_type == "molecule" and xyz_cuo6.space_group == "P1" + assert xyz_cuo6.absorber_idx == 0 + assert "Cu" in xyz_cuo6.formula and "O" in xyz_cuo6.formula + + def test_symmetry(self, xyz_cuo6): + assert all(w == "1a" for w in xyz_cuo6.wyckoff_symbols) + sites = xyz_cuo6.equivalent_sites + assert len(sites) == len(xyz_cuo6.struct) and all(len(g) == 1 for g in sites) + + def test_misc(self, xyz_cuo6): + assert len(xyz_cuo6.get_absorber_indexes()) >= 1 + assert xyz_cuo6.get_occupancy(xyz_cuo6.get_site(0)) == 1.0 + + def test_multi_frame(self): + for f in (0, 3): + sg = get_structure(STRUCTS / "GaBr_multi-frame.xyz", "Ga", frame=f) + assert isinstance(sg, XasStructureXyz) and sg.absorber.symbol == "Ga" + + def test_cluster(self, xyz_cuo6): + from pymatgen.core import Molecule + cl = xyz_cuo6.cluster + assert isinstance(cl, Molecule) and cl[0].species_string == "Cu" + assert all(hasattr(s, "label") for s in cl) + + +# ── absorber_idx setter ────────────────────────────────────── + + +class TestAbsorberIdx: + def test_valid(self, cif_zno): + cif_zno.absorber_idx = 0 + assert cif_zno.absorber_idx == 0 + + def test_out_of_range(self, cif_zno): + with pytest.raises(IndexError, match="not valid"): + cif_zno.absorber_idx = 999 + + def test_wrong_element(self, xyz_cuo6): + with pytest.raises(IndexError, match="not in site"): + xyz_cuo6.absorber_idx = 4 + + def test_absorber_site(self, xyz_cuo6): + assert "Cu" in xyz_cuo6.absorber_site.species_string + + def test_second_absorber_site(self, cif_fe3o4): + cif_fe3o4.absorber_idx = 1 + assert cif_fe3o4.absorber_idx == 16 + + +# ── errors ─────────────────────────────────────────────────── + +class TestErrors: + def test_nonexistent_file(self): + with pytest.raises(FileNotFoundError): + get_structure("/tmp/_no_such_file_12345.cif", "Zn") + + def test_bad_extension(self): + with tempfile.NamedTemporaryFile(suffix=".bad", delete=False) as f: + f.write(b"x"); fname = f.name + try: + with pytest.raises(ValueError, match="unknown structure format"): + get_structure(fname, "Zn") + finally: + Path(fname).unlink() + + +# ── get_structure_from_text ────────────────────────────────── + + +class TestFromText: + @pytest.fixture(scope="class") + def cif_text(cls): + return (STRUCTS / "ZnO_mp-2133.cif").read_text() + + @pytest.fixture(scope="class") + def xyz_text(cls): + return (STRUCTS / "CuO6_D4h.xyz").read_text() + + def test_cif(self, cif_text): + with pytest.warns(UserWarning, match="fractional coordinates rounded"): + sg = get_structure_from_text(cif_text, "Zn", format="cif", filename="t.cif") + assert isinstance(sg, XasStructureCif) and sg.formula == "ZnO" + + def test_xyz(self, xyz_text): + sg = get_structure_from_text(xyz_text, "Cu", format="xyz", filename="t.xyz") + assert isinstance(sg, XasStructureXyz) and sg.struct_type == "molecule" + + def test_int_absorber(self, cif_text, xyz_text): + # CIF triggers pymatgen rounding warning + with pytest.warns(UserWarning, match="fractional coordinates rounded"): + result_cif = get_structure_from_text(cif_text, 30, format="cif", filename="t.cif") + assert result_cif.absorber.symbol == "Zn" + assert get_structure_from_text(xyz_text, 29, format="xyz", filename="t.xyz").absorber.symbol == "Cu" + + def test_bad_format(self, cif_text): + with pytest.raises(ValueError, match="unknown structure format"): + get_structure_from_text(cif_text, "Zn", format="pdb") + + +# ── get_structs_from_dir ──────────────────────────────────── + + +class TestFromDir: + def test_single(self): + s = get_structs_from_dir(STRUCTS, "Fe", globstr="Fe3O4*") + assert len(s) == 1 and s[0].name == "Fe3O4_cub_fracOcc_Levy2012_ICSD-183969.cif" + + def test_cif_glob(self): + with pytest.warns(UserWarning, match="fractional coordinates rounded"): + structs = get_structs_from_dir(STRUCTS, "Zn", globstr="ZnO*") + assert all(isinstance(x, XasStructureCif) for x in structs) + + def test_xyz_glob(self): + assert all(isinstance(x, XasStructureXyz) + for x in get_structs_from_dir(STRUCTS, "Cu", globstr="CuO6*")) + + def test_exclude(self): + s = get_structs_from_dir(STRUCTS, "Ga", globstr="GaBr*", + exclude_names=["GaBr_single_frame.xyz"]) + assert any(x.name == "GaBr_multi-frame.xyz" for x in s) + assert not any(x.name == "GaBr_single_frame.xyz" for x in s) + + +# ── site_label ────────────────────────────────────────────── + +class TestSiteLabel: + def test_format(self, cif_zno, xyz_cuo6): + lbl = site_label(cif_zno.struct[0]) + assert "[" in lbl and "]" in lbl and "," in lbl + assert site_label(xyz_cuo6.struct[0]).startswith("Cu") -if __name__ == "__main__": - test_struct() + def test_species_included(self, cif_zno): + for site in cif_zno.struct: + assert site.species_string.split(":")[0] in site_label(site) \ No newline at end of file diff --git a/tests/test_struct2xas.py b/tests/test_struct2xas.py new file mode 100644 index 0000000..e041a8a --- /dev/null +++ b/tests/test_struct2xas.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +"""Test feff_print parameter in Struct2XAS.make_input_feff() + +Tests the configurable PRINT card values feature added to struct2xas.py. +""" + +import re +import pytest +import tempfile +from pathlib import Path + +from larixite.struct2xas import Struct2XAS + +STRUCTS = Path(__file__).parent / "structs" +CIF_FILE = STRUCTS / "ZnO_mp-2133.cif" + + +@pytest.fixture +def cif_file(): + """Return path to test CIF file.""" + if not CIF_FILE.exists(): + pytest.skip(f"Test CIF file not found: {CIF_FILE}") + return CIF_FILE + + +def extract_print_values(feff_inp_path): + """Extract PRINT card values from feff.inp file.""" + content = Path(feff_inp_path).read_text() + match = re.search( + r"^PRINT\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)", + content, + re.MULTILINE, + ) + if not match: + return None + return [int(match.group(i)) for i in range(1, 7)] + + +def test_default_feff_print_has_ff2chi_3(cif_file): + """Default feff_print should be [1, 0, 0, 0, 0, 3] matching structure2feff.py.""" + with tempfile.TemporaryDirectory() as tmpdir: + mat = Struct2XAS(file=str(cif_file), abs_atom="Zn") + mat.set_abs_site(0) + mat.make_input_feff(radius=6.0, parent_path=tmpdir) + + feff_files = list(Path(tmpdir).glob("**/feff.inp")) + assert len(feff_files) == 1, "feff.inp should be generated" + + values = extract_print_values(feff_files[0]) + assert values is not None, "PRINT card should be present" + assert values == [1, 0, 0, 0, 0, 3], f"Expected [1,0,0,0,0,3], got {values}" + + +def test_custom_feff_print_values(cif_file): + """Custom feff_print values should be written to feff.inp.""" + custom_print = [1, 1, 1, 1, 1, 2] + + with tempfile.TemporaryDirectory() as tmpdir: + mat = Struct2XAS(file=str(cif_file), abs_atom="Zn") + mat.set_abs_site(0) + mat.make_input_feff(radius=6.0, parent_path=tmpdir, feff_print=custom_print) + + feff_files = list(Path(tmpdir).glob("**/feff.inp")) + assert len(feff_files) == 1 + + values = extract_print_values(feff_files[0]) + assert values == custom_print, f"Expected {custom_print}, got {values}" + + +def test_feff_print_old_behavior_recoverable(cif_file): + """Old behavior (ff2chi=0) should be recoverable via feff_print parameter.""" + old_print = [1, 0, 0, 0, 0, 0] + + with tempfile.TemporaryDirectory() as tmpdir: + mat = Struct2XAS(file=str(cif_file), abs_atom="Zn") + mat.set_abs_site(0) + mat.make_input_feff(radius=6.0, parent_path=tmpdir, feff_print=old_print) + + feff_files = list(Path(tmpdir).glob("**/feff.inp")) + assert len(feff_files) == 1 + + values = extract_print_values(feff_files[0]) + assert values == old_print, f"Expected {old_print}, got {values}" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_webapp.py b/tests/test_webapp.py new file mode 100644 index 0000000..1c2befc --- /dev/null +++ b/tests/test_webapp.py @@ -0,0 +1,247 @@ +"""Tests for larixite Flask webapp.""" + +import tempfile +from pathlib import Path + +import pytest + +# Skip if Flask not installed +FLASK = pytest.importorskip("flask") + + +STRUCTS = Path(__file__).parent / "structs" + + +@pytest.fixture(scope="module") +def app(): + from larixite.webapp.webapp import app + + app.config["TESTING"] = True + app.config["UPLOAD_FOLDER"] = str(tempfile.mkdtemp()) + app.config["MAX_CONTENT_LENGTH"] = 2**24 + app.config["PROPAGATE_EXCEPTIONS"] = False + return app + + +@pytest.fixture(scope="module") +def client(app): + return app.test_client() + + +ZNO_TEXT = (STRUCTS / "ZnO_mp-2133.cif").read_text() + + +def _upload(client, content): + """Upload a CIF and return the redirect location.""" + with tempfile.NamedTemporaryFile(suffix=".cif", delete=False, mode="w") as f: + f.write(content) + fname = f.name + try: + rv = client.post( + "/upload_cif", + data={"file": (open(fname, "rb"), "test.cif")}, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert rv.status_code == 200 + return rv + finally: + Path(fname).unlink() + + +class TestIndex: + def test_index(self, client): + rv = client.get("/") + assert rv.status_code == 200 + + +class TestUpload: + def test_upload_valid_cif(self, client): + rv = _upload(client, ZNO_TEXT) + assert rv.status_code == 200 + + def test_upload_empty_file(self, client): + rv = client.post( + "/upload_cif", + data={"file": (b"", "")}, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert rv.status_code == 200 + + +class TestCifs: + def test_cifs_page_after_upload(self, client): + _upload(client, ZNO_TEXT) + rv = client.get("/cifs/test.cif") + assert rv.status_code == 200 + + def test_feff_post(self, client): + _upload(client, ZNO_TEXT) + rv = client.post( + "/cifs/test.cif", + data={ + "feff": "1", + "absorbing_atom": "Zn", + "edge": "K", + "cluster_size": "7.0", + "with_h": "0", + }, + follow_redirects=True, + ) + assert rv.status_code == 200 + data = rv.get_data(as_text=True) + assert "Zn" in data + + def test_fdmnes_post(self, client): + _upload(client, ZNO_TEXT) + rv = client.post( + "/cifs/test.cif", + data={ + "fdmnes": "1", + "absorbing_atom": "Zn", + "edge": "K", + "cluster_size": "7.0", + "with_h": "0", + }, + follow_redirects=True, + ) + assert rv.status_code == 200 + + +class TestOutput: + """Test /output route — returns raw FEFF/FDMNES input text.""" + + def _setup(self, client): + _upload(client, ZNO_TEXT) + return client + + def test_feff_output_zn_k(self, client): + self._setup(client) + rv = client.get("/output/test.cif/Zn/1/K/7.0/0/feff/0/0/feff.inp") + assert rv.status_code == 200 + data = rv.get_data(as_text=True) + assert data.count("TITLE") >= 1 or data.count("*") > 0 + assert "Zn" in data + + def test_feff_output_with_h(self, client): + self._setup(client) + rv = client.get("/output/test.cif/Zn/1/K/7.0/1/feff/0/0/feff.inp") + assert rv.status_code == 200 + + def test_fdmnes_output(self, client): + self._setup(client) + rv = client.get("/output/test.cif/Zn/1/K/7.0/0/fdmnes/0/0/fdmfile.txt") + assert rv.status_code == 200 + data = rv.get_data(as_text=True) + assert "1" in data or ".inp" in data + + def test_fdmnes_output_o_edge(self, client): + self._setup(client) + rv = client.get("/output/test.cif/O/1/K/7.0/0/fdmnes/0/0/fdmfile.txt") + assert rv.status_code == 200 + + +class TestCiffile: + def test_ciffile_download(self, client): + _upload(client, ZNO_TEXT) + rv = client.get("/ciffile/test.cif/test.cif") + assert rv.status_code == 200 + data = rv.get_data(as_text=True) + assert "Zn" in data + assert "O" in data + + +class TestZipfile: + def test_feff_zip(self, client): + _upload(client, ZNO_TEXT) + rv = client.get("/zipfile/test.cif/Zn/K/7.0/0/feff/0/0/test_zoom") + assert rv.status_code in (200, 500) + + def test_fdmnes_zip(self, client): + _upload(client, ZNO_TEXT) + rv = client.get("/zipfile/test.cif/Zn/K/7.0/0/fdmnes/0/1/test_zoom") + assert rv.status_code in (200, 500) + + +class TestUploadCifFile: + """Test actual file upload with multipart form data.""" + + def test_real_file_upload(self, client): + with open(STRUCTS / "ZnO_mp-2133.cif", "rb") as f: + rv = client.post( + "/upload_cif", + data={"file": (f, "uploaded.cif")}, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert rv.status_code == 200 + + def test_no_file_part(self, client): + rv = client.post( + "/upload_cif", + data={}, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert rv.status_code == 200 + + def test_wrong_extension(self, client): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w") as f: + f.write(ZNO_TEXT) + fname = f.name + try: + rv = client.post( + "/upload_cif", + data={"file": (open(fname, "rb"), "test.txt")}, + content_type="multipart/form-data", + follow_redirects=True, + ) + assert rv.status_code == 200 + finally: + Path(fname).unlink() + + +class TestAbout: + def test_about(self, client): + rv = client.get("/about/") + assert rv.status_code == 200 + + +class TestUtility: + """Test helper functions exposed by the webapp.""" + + def test_random_string(self): + from larixite.webapp.webapp import random_string + + for _ in range(5): + s = random_string(12) + assert len(s) == 12 and s.islower() + + def test_allowed_file(self): + from larixite.webapp.webapp import allowed_file + + assert allowed_file("test.cif") is True + assert allowed_file("test.txt") is False + assert allowed_file("test.CIF") is True + assert allowed_file("test") is False + + +class TestGetElements: + def test_simple_element(self): + from larixite.webapp.webapp import get_elements + + stoich, _ = get_elements("Fe") + assert stoich == {"Fe": 1} + + def test_compound(self): + from larixite.webapp.webapp import get_elements + + stoich, _ = get_elements("Fe2O3") + assert stoich == {"Fe": 2, "O": 3} + + def test_wat_prefix_raises(self): + from larixite.webapp.webapp import get_elements + + with pytest.raises(UnboundLocalError): + get_elements("Wat")
File Link Crystal Site/File Type
{{ zip_link[0] }}   Zip File, all outputs
{{ zip_link[0] }}   Zip File, all outputs
{{ link }}   {{ data[0] }}