diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 024da7b..f9d3d89 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -150,13 +150,20 @@ setup, database included. You do not have to install pseudopotentials up front. The ``pseudo_library`` keyword of your input file names a family, and ``koopmans`` downloads that family the first time it -is needed. It can fetch `PseudoDojo `_, `SSSP -`_ and `SG15 +is needed. It can fetch `PseudoDojo `_ and `SG15 `_ families, named like +``PseudoDojo/0.4/LDA/SR/standard/upf`` or ``SG15/1.2/PBE/SR``. To see every label it +accepts, with the families you already have marked, run -- ``PseudoDojo/0.4/LDA/SR/standard/upf`` -- ``SSSP/1.3/PBEsol/efficiency`` -- ``SG15/1.2/PBE/SR`` +.. code-block:: console + + $ koopmans pseudos + +Every family it offers is norm-conserving and in UPF format, which is what Koopmans +functionals and ``pw.x`` require. Libraries that mix in ultrasoft or PAW +pseudopotentials — `SSSP +`_ among them — are +refused. A family that you install yourself works just as well, under whatever label you give it: ``koopmans`` downloads a family only when no installed one carries the label you asked diff --git a/docs/source/tutorials/orbital_energies/ozone/automatically.rst b/docs/source/tutorials/orbital_energies/ozone/automatically.rst index aac12bb..544cc91 100644 --- a/docs/source/tutorials/orbital_energies/ozone/automatically.rst +++ b/docs/source/tutorials/orbital_energies/ozone/automatically.rst @@ -57,7 +57,9 @@ number will refine them self-consistently. :end-at: pseudo_library determines that PBE will be the base functional that the KI correction is applied on top -of. +of. Run ``koopmans pseudos`` to see every library you can name here; koopmans installs +the one you choose the first time it is used. They are all norm-conserving, which is +what Koopmans functionals are defined for. The ``atoms`` block describes the cell and the atoms in it, much like a ``Quantum ESPRESSO`` input file. The positions are Cartesian, in the units the block declares. diff --git a/src/koopmans/aiida/setup/pseudos.py b/src/koopmans/aiida/setup/pseudos.py deleted file mode 100644 index 15fa7f2..0000000 --- a/src/koopmans/aiida/setup/pseudos.py +++ /dev/null @@ -1,288 +0,0 @@ -"""Pseudopotential family installers (PseudoDojo / SSSP / SG15).""" - -from __future__ import annotations - -import logging -import tempfile -from pathlib import Path - -import click - -logger = logging.getLogger(__name__) - - -def ensure_pseudo_family_installed(pseudo_family: str) -> None: - """Ensure a pseudopotential family is installed, installing it if necessary. - - Any already-installed family is used as it stands, whatever its label. A - label that names no installed family is downloaded, which koopmans can do - for three families: - 'PseudoDojo/0.4/LDA/SR/standard/upf' - 'SSSP/1.3/PBEsol/efficiency' - 'SG15/1.2/PBE/SR' - - Raises: - ValueError: If no family carries the label and koopmans cannot - download it, or if the download fails. - """ - from aiida.common.exceptions import NotExistent - from aiida_pseudo.groups.family import PseudoPotentialFamily - - try: - PseudoPotentialFamily.collection.get(label=pseudo_family) - logger.debug("Pseudo family '%s' already installed", pseudo_family) - return - except NotExistent: - pass - - logger.info("Installing pseudo family '%s'...", pseudo_family) - install_pseudo_family(pseudo_family) - logger.info("Successfully installed pseudo family '%s'", pseudo_family) - - -def pseudo_family_has_cutoffs(pseudo_family: str) -> bool: - """Report whether an installed family publishes recommended cutoffs. - - True only if the family defines at least one cutoff stringency; without one - ``get_recommended_cutoffs`` has nothing to return. - - Raises: - NotExistent: If the family is not installed. - """ - from aiida_pseudo.groups.family import PseudoPotentialFamily - - family = PseudoPotentialFamily.collection.get(label=pseudo_family) - stringencies = getattr(family, "get_cutoff_stringencies", None) - return stringencies is not None and bool(stringencies()) - - -def install_pseudo_family(pseudo_family: str) -> None: - """Download and install a pseudopotential family. Parse the label and dispatch. - - No family may already carry the label. - """ - parts = pseudo_family.split("/") - - if parts[0] == "PseudoDojo" and len(parts) == 6: - _install_pseudo_dojo_family(pseudo_family, parts) - elif parts[0] == "SSSP" and len(parts) == 4: - _install_sssp_family(pseudo_family, parts) - elif parts[0] == "SG15" and len(parts) == 4: - _install_sg15_family(pseudo_family, parts) - else: - raise ValueError( - f"No installed pseudopotential family has the label '{pseudo_family}', and " - "koopmans cannot download one under that label.\n" - "Install the pseudopotentials yourself, from a directory holding one file " - "per element:\n" - f" aiida-pseudo install family {pseudo_family}\n" - "A family installed this way publishes no recommended cutoffs, so set " - "`calculator_parameters.ecutwfc` in your input file; `ecutrho` follows at " - "four times it.\n" - "Alternatively, name a family koopmans can download for you, as " - "'PseudoDojo/version/functional/relativistic/protocol/format', " - "'SSSP/version/functional/protocol', " - "or 'SG15/version/functional/relativistic'." - ) - - -def _install_pseudo_dojo_family(label: str, parts: list[str]) -> None: - """Install a PseudoDojo pseudopotential family.""" - import contextlib - import io - import warnings - - from aiida_pseudo.cli.install import download_pseudo_dojo, install_pseudo_dojo - from aiida_pseudo.data.pseudo import JthXmlData, PsmlData, Psp8Data, UpfData - from aiida_pseudo.groups.family import PseudoDojoConfiguration - - _, version, functional, relativistic, protocol, pseudo_format = parts - - format_to_type = { - "upf": UpfData, - "psp8": Psp8Data, - "psml": PsmlData, - "jthxml": JthXmlData, - } - - pseudo_type = format_to_type.get(pseudo_format.lower()) - if pseudo_type is None: - raise ValueError( - f"Unknown pseudo format '{pseudo_format}'. " - f"Supported formats: {list(format_to_type.keys())}" - ) - - configuration = PseudoDojoConfiguration( - version=version, - functional=functional, - relativistic=relativistic, - protocol=protocol, - pseudo_format=pseudo_format, - ) - - click.echo(f" Downloading '{label}' pseudopotentials") - - with tempfile.TemporaryDirectory() as tmpdir: - filepath_archive = Path(tmpdir) / "archive.tgz" - filepath_metadata = Path(tmpdir) / "metadata.tgz" - - with ( - warnings.catch_warnings(), - contextlib.redirect_stdout(io.StringIO()), - contextlib.redirect_stderr(io.StringIO()), - ): - warnings.simplefilter("ignore") - - download_pseudo_dojo( - configuration=configuration, - filepath_archive=filepath_archive, - filepath_metadata=filepath_metadata, - traceback=False, - ) - - family = install_pseudo_dojo( - configuration=configuration, - filepath_archive=filepath_archive, - filepath_metadata=filepath_metadata, - pseudo_type=pseudo_type, - label=label, - traceback=False, - ) - - family.set_default_stringency("normal") - - -def _install_sssp_family(label: str, parts: list[str]) -> None: - """Install an SSSP pseudopotential family.""" - import contextlib - import io - import warnings - - from aiida_pseudo.cli.install import download_sssp, install_sssp - from aiida_pseudo.groups.family import SsspConfiguration - - _, version, functional, protocol = parts - - configuration = SsspConfiguration( - version=version, - functional=functional, - protocol=protocol, - ) - - click.echo(f" Downloading pseudopotentials for '{label}'...") - - with tempfile.TemporaryDirectory() as tmpdir: - filepath_archive = Path(tmpdir) / "archive.tar.gz" - filepath_metadata = Path(tmpdir) / "metadata.json" - - with ( - warnings.catch_warnings(), - contextlib.redirect_stdout(io.StringIO()), - contextlib.redirect_stderr(io.StringIO()), - ): - warnings.simplefilter("ignore") - - download_sssp( - configuration=configuration, - filepath_archive=filepath_archive, - filepath_metadata=filepath_metadata, - traceback=False, - ) - - install_sssp( - filepath_archive=filepath_archive, - filepath_metadata=filepath_metadata, - label=label, - traceback=False, - ) - - from aiida_pseudo.groups.family import SsspFamily - - family = SsspFamily.collection.get(label=label) - click.echo(f" Successfully installed '{label}' ({family.count()} pseudopotentials)") - - -# SG15 ONCV is published as a single frozen tarball on quantum-simulation.org. It -# bundles every version x relativistic variant in one flat archive; the label's -# version/relativistic parts select which subset of UPFs to install. There is no -# upstream ``aiida-pseudo`` installer for SG15, so we build the family ourselves. -# SG15 publishes no recommended cutoffs, so it is a plain -# ``PseudoPotentialFamily`` and ``ecutwfc``/``ecutrho`` come from the input file. -_SG15_ARCHIVE_URL = ( - "http://www.quantum-simulation.org/potentials/sg15_oncv/sg15_oncv_upf_2020-02-06.tar.gz" -) -_SG15_ARCHIVE_SHA256 = "3f3bd74aa5d6e0b038218a6051bb99ed9469dc03d0f05b3ec8a523f0f7a7dff0" -_SG15_SUPPORTED_VERSIONS = {"1.0", "1.2"} -_SG15_SUPPORTED_RELATIVISTIC = {"SR", "FR"} - - -def _install_sg15_family(label: str, parts: list[str]) -> None: - """Install an SG15 ONCV pseudopotential family.""" - import hashlib - import io - import re - import tarfile - import urllib.request - - from aiida_pseudo.data.pseudo import UpfData - from aiida_pseudo.groups.family import PseudoPotentialFamily - - _, version, functional, relativistic = parts - - if functional != "PBE": - raise ValueError(f"SG15 only provides PBE pseudopotentials; got functional='{functional}'.") - if version not in _SG15_SUPPORTED_VERSIONS: - raise ValueError( - f"SG15 version '{version}' is not packaged in the 2020-02-06 archive. " - f"Supported versions: {sorted(_SG15_SUPPORTED_VERSIONS)}." - ) - if relativistic not in _SG15_SUPPORTED_RELATIVISTIC: - raise ValueError( - f"SG15 relativistic variant '{relativistic}' is not supported. " - f"Expected one of: {sorted(_SG15_SUPPORTED_RELATIVISTIC)}." - ) - - fr_suffix = "_FR" if relativistic == "FR" else "" - filename_re = re.compile( - rf"^(?P[A-Z][a-z]?)_ONCV_PBE{fr_suffix}-{re.escape(version)}\.upf$" - ) - - click.echo(f" Downloading '{label}' pseudopotentials") - - with tempfile.TemporaryDirectory() as tmpdir: - tmp = Path(tmpdir) - - with urllib.request.urlopen(_SG15_ARCHIVE_URL) as response: # noqa: S310 - archive_bytes = response.read() - - digest = hashlib.sha256(archive_bytes).hexdigest() - if digest != _SG15_ARCHIVE_SHA256: - raise ValueError( - f"SG15 archive checksum mismatch: got {digest}, " - f"expected {_SG15_ARCHIVE_SHA256}. Upstream may have re-released " - f"{_SG15_ARCHIVE_URL}; pin a new hash after verifying the contents." - ) - - flat = tmp / "flat" - flat.mkdir() - with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar: - for member in tar.getmembers(): - if not member.isfile(): - continue - match = filename_re.match(Path(member.name).name) - if match is None: - continue - extracted = tar.extractfile(member) - if extracted is None: - continue - (flat / f"{match.group('element')}.upf").write_bytes(extracted.read()) - - if not any(flat.iterdir()): - raise ValueError( - f"No UPF files matched '{label}' in {_SG15_ARCHIVE_URL}. " - "The archive layout may have changed." - ) - - family = PseudoPotentialFamily.create_from_folder(flat, label, pseudo_type=UpfData) - - click.echo(f" Successfully installed '{label}' ({family.count()} pseudopotentials)") diff --git a/src/koopmans/aiida/setup/pseudos/__init__.py b/src/koopmans/aiida/setup/pseudos/__init__.py new file mode 100644 index 0000000..bfa80ce --- /dev/null +++ b/src/koopmans/aiida/setup/pseudos/__init__.py @@ -0,0 +1,201 @@ +"""Resolve a ``pseudo_library`` label to an installed pseudopotential family. + +Each library koopmans can download has a module of its own +(:mod:`._pseudodojo`, :mod:`._sg15`) exposing ``available_labels``, +``install`` and, where it has something to say, ``NOTES``. This module holds +what is common to all of them: the label registry, the profile query, and the +listing ``koopmans pseudos`` prints. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import click + +from . import _pseudodojo, _sg15 + +if TYPE_CHECKING: + from aiida import orm + +logger = logging.getLogger(__name__) + +# Libraries koopmans can download, keyed by the label's first segment. +_LIBRARIES = { + "PseudoDojo": _pseudodojo, + "SG15": _sg15, +} + +# How many segments each library's label has, so a label of the wrong shape +# falls through to the "cannot download this" message rather than raising on +# the unpacking inside an installer. +_LABEL_SEGMENTS = { + "PseudoDojo": 6, + "SG15": 4, +} + + +def ensure_pseudo_family_installed(pseudo_family: str) -> None: + """Ensure a pseudopotential family is installed, installing it if necessary. + + Any already-installed family is used as it stands, whatever its label. A + label that names no installed family is downloaded, which koopmans can do + for the two norm-conserving libraries: + 'PseudoDojo/0.4/LDA/SR/standard/upf' + 'SG15/1.2/PBE/SR' + + Raises: + ValueError: If no family carries the label and koopmans cannot + download it, or if the download fails. + """ + from aiida.common.exceptions import NotExistent + from aiida_pseudo.groups.family import PseudoPotentialFamily + + try: + PseudoPotentialFamily.collection.get(label=pseudo_family) + logger.debug("Pseudo family '%s' already installed", pseudo_family) + return + except NotExistent: + pass + + logger.info("Installing pseudo family '%s'...", pseudo_family) + install_pseudo_family(pseudo_family) + logger.info("Successfully installed pseudo family '%s'", pseudo_family) + + +def pseudo_family_has_cutoffs(pseudo_family: str) -> bool: + """Report whether an installed family publishes recommended cutoffs. + + True only if the family defines at least one cutoff stringency; without one + ``get_recommended_cutoffs`` has nothing to return. + + Raises: + NotExistent: If the family is not installed. + """ + from aiida_pseudo.groups.family import PseudoPotentialFamily + + family = PseudoPotentialFamily.collection.get(label=pseudo_family) + stringencies = getattr(family, "get_cutoff_stringencies", None) + return stringencies is not None and bool(stringencies()) + + +def require_norm_conserving_family(pseudo_family: str, structure: orm.StructureData) -> None: + """Reject a family whose pseudopotentials are not norm-conserving. + + Reads the UPF header of the pseudopotential each of ``structure``'s kinds + would use. A header that states nothing about its type passes: the check + refuses on positive evidence of an ultrasoft or PAW pseudopotential, never + on a header it cannot read. + + Raises: + ValueError: If any pseudopotential the run would use is ultrasoft or PAW. + """ + from aiida_pseudo.groups.family import PseudoPotentialFamily + + from ._norm_conserving import non_norm_conserving_kinds + + family = PseudoPotentialFamily.collection.get(label=pseudo_family) + offenders = non_norm_conserving_kinds(family.get_pseudos(structure=structure)) + if not offenders: + return + + named = ", ".join(f"{kind} ({pseudo_type})" for kind, pseudo_type in offenders.items()) + raise ValueError( + f"The pseudopotential family `{pseudo_family}` is not norm-conserving: {named}. " + "Koopmans functionals are defined for norm-conserving pseudopotentials, and " + "kcp.x and kcw.x accept no other kind. Set `workflow.pseudo_library` to a " + "norm-conserving family; run `koopmans pseudos` for the ones koopmans can " + "download." + ) + + +def available_pseudo_families() -> dict[str, list[str]]: + """Return every valid ``pseudo_library`` label, sorted, keyed by library. + + Every label is norm-conserving and in UPF format: Koopmans functionals are + defined for norm-conserving pseudopotentials, and ``pw.x`` reads UPF alone. + + No profile is needed. + """ + return {library: module.available_labels() for library, module in _LIBRARIES.items()} + + +def installed_pseudo_family_labels() -> set[str]: + """Return the labels of the families installed in the koopmans profile. + + Empty when no profile exists yet. + """ + from aiida import orm + + from ..profile import load_koopmans_profile, profile_exists + + if not profile_exists(): + return set() + + from aiida_pseudo.groups.family import PseudoPotentialFamily + + load_koopmans_profile() + query = orm.QueryBuilder().append(PseudoPotentialFamily, project=["label"]) + return {label for (label,) in query.all()} + + +def list_pseudo_families() -> None: + """Print every value ``workflow.pseudo_library`` accepts, marking the installed ones.""" + available = available_pseudo_families() + installed = installed_pseudo_family_labels() + + width = max(len(label) for labels in available.values() for label in labels) + for library, labels in available.items(): + click.echo(f"\n{library}") + for label in labels: + mark = " [installed]" if label in installed else "" + click.echo(f" {label.ljust(width)}{mark}".rstrip()) + notes = getattr(_LIBRARIES[library], "NOTES", ()) + if notes: + click.echo("") + for note in notes: + click.echo(f" {note}") + + click.echo("\nEvery family listed is norm-conserving and in UPF format, which is what") + click.echo("Koopmans functionals and `pw.x` require.") + click.echo("\nName one of these as `pseudo_library` in the input file's `workflow` block, and") + click.echo("koopmans installs it the first time it is used. To use pseudopotentials of your") + click.echo("own, run `aiida-pseudo install family