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 ` and name that label;")
+ click.echo("it will recommend no cutoffs, so set `ecutwfc` in your input file.")
+
+
+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.
+
+ Raises:
+ ValueError: If the label names a family koopmans cannot run with, or
+ one it cannot download.
+ """
+ parts = pseudo_family.split("/")
+ library = _LIBRARIES.get(parts[0])
+
+ if library is not None and len(parts) == _LABEL_SEGMENTS[parts[0]]:
+ library.install(pseudo_family, parts)
+ elif parts[0] == "SSSP":
+ raise ValueError(
+ f"'{pseudo_family}' is an SSSP family. SSSP mixes ultrasoft, PAW and "
+ "norm-conserving pseudopotentials, and Koopmans functionals are defined "
+ "for norm-conserving ones. Name a PseudoDojo or SG15 family instead; run "
+ "`koopmans pseudos` for the full list."
+ )
+ 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: run "
+ "`koopmans pseudos` for every label it accepts."
+ )
diff --git a/src/koopmans/aiida/setup/pseudos/_norm_conserving.py b/src/koopmans/aiida/setup/pseudos/_norm_conserving.py
new file mode 100644
index 0000000..ed74a88
--- /dev/null
+++ b/src/koopmans/aiida/setup/pseudos/_norm_conserving.py
@@ -0,0 +1,147 @@
+"""Read a pseudopotential's own header to tell norm-conserving from the rest.
+
+Koopmans functionals are defined for norm-conserving pseudopotentials, and
+``kcp.x`` and ``kcw.x`` accept no other kind. The label a family carries says
+nothing about this once the family is one the user installed themselves, so
+the answer comes from the UPF headers.
+
+Neither ``aiida-pseudo`` nor ``aiida-core`` parses the field: ``UpfData``
+reads the element and z_valence, and ``aiida.orm.nodes.data.upf.parse_upf``
+the version and element. Both layouts are read here instead.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Protocol
+
+
+class _ReadableFile(Protocol):
+ """What this module needs of a pseudopotential node: its bytes as text."""
+
+ def get_content(self) -> str:
+ """Return the file's text."""
+ ...
+
+
+# UPF ``pseudo_type`` values that are not norm-conserving. "NC" and "SL"
+# (semilocal) are; "1/r" is a bare Coulomb potential, which pw.x takes but the
+# Koopmans codes do not. "US" is what a v1 header carries, where there are no
+# flags to fall back on; "USPP" is PSlibrary's v2 spelling, and "1/r" is
+# carried against a header that names a type without flagging itself.
+_NOT_NORM_CONSERVING = {"US", "USPP", "PAW", "1/r"}
+
+# UPF v2 writes the header as XML attributes. Only the first 4 kB after the
+# tag is searched, which covers the longest real header and keeps a stray
+# match in the body out of it.
+_HEADER_V2 = re.compile(r"
+# 0 Version Number
+# C Element
+# US Ultrasoft pseudopotential
+_HEADER_V1 = re.compile(r"(?P.*?) ", re.DOTALL)
+_V1_TYPE_LINE = 2
+
+# UPF booleans are written as T/F, true/false or .true./.false. depending on
+# the generator; PSlibrary writes "true" where SG15 writes "F".
+_TRUE = {"t", "true", ".true."}
+
+
+def non_norm_conserving_kinds(pseudos: dict[str, _ReadableFile]) -> dict[str, str]:
+ """Return the kinds whose pseudopotential is demonstrably not norm-conserving.
+
+ Maps kind name to the offending ``pseudo_type``. A header that states
+ nothing about its type is left out: the check refuses on positive
+ evidence, so an unreadable or minimal header never blocks a run.
+ """
+ offenders: dict[str, str] = {}
+ for kind, pseudo in sorted(pseudos.items()):
+ pseudo_type = _pseudo_type(pseudo)
+ if pseudo_type is not None:
+ offenders[kind] = pseudo_type
+ return offenders
+
+
+def _pseudo_type(pseudo: _ReadableFile) -> str | None:
+ """Return the pseudopotential's type if its header says it is not norm-conserving.
+
+ ``None`` when the header calls it norm-conserving, says nothing, or
+ cannot be read at all.
+ """
+ try:
+ content = pseudo.get_content()
+ except Exception:
+ # Any read failure means "cannot tell", which is not grounds to refuse.
+ return None
+
+ declared = _read_v2_header(content)
+ if declared is None:
+ declared = _read_v1_header(content)
+ if declared is None:
+ return None
+
+ pseudo_type, is_ultrasoft, is_paw = declared
+ if is_paw:
+ return pseudo_type or "PAW"
+ if is_ultrasoft:
+ return pseudo_type or "US"
+ if pseudo_type is not None and pseudo_type.strip().upper() in _NOT_NORM_CONSERVING:
+ return pseudo_type
+ return None
+
+
+def _read_v2_header(content: str) -> tuple[str | None, bool, bool] | None:
+ """Return ``(pseudo_type, is_ultrasoft, is_paw)`` from an XML-attribute header.
+
+ ``None`` when the file carries no such header, which is what sends a v1
+ file on to :func:`_read_v1_header`.
+ """
+ match = _HEADER_V2.search(content)
+ if match is None:
+ return None
+
+ window = content[match.end() : match.end() + _HEADER_SCAN]
+ pseudo_type = _attribute(window, "pseudo_type")
+ is_ultrasoft = _flag(_attribute(window, "is_ultrasoft"))
+ is_paw = _flag(_attribute(window, "is_paw"))
+
+ if pseudo_type is None and not is_ultrasoft and not is_paw:
+ return None
+ return pseudo_type, is_ultrasoft, is_paw
+
+
+def _read_v1_header(content: str) -> tuple[str | None, bool, bool] | None:
+ """Return ``(pseudo_type, False, False)`` from a fixed-format v1 header.
+
+ The type is the first word of the block's third line. ``None`` when there
+ is no such block or it is too short to hold one.
+ """
+ match = _HEADER_V1.search(content)
+ if match is None:
+ return None
+
+ lines = [line for line in match.group("block").splitlines() if line.strip()]
+ if len(lines) <= _V1_TYPE_LINE:
+ return None
+
+ words = lines[_V1_TYPE_LINE].split()
+ if not words:
+ return None
+ return words[0], False, False
+
+
+def _attribute(window: str, name: str) -> str | None:
+ """Return a quoted XML attribute's value, or ``None`` if it is absent."""
+ match = re.search(_ATTRIBUTE.format(re.escape(name)), window)
+ return match.group(1) if match else None
+
+
+def _flag(value: str | None) -> bool:
+ """Read a UPF boolean, which generators spell T, true or .true.."""
+ return value is not None and value.strip().lower() in _TRUE
diff --git a/src/koopmans/aiida/setup/pseudos/_pseudodojo.py b/src/koopmans/aiida/setup/pseudos/_pseudodojo.py
new file mode 100644
index 0000000..dcbda24
--- /dev/null
+++ b/src/koopmans/aiida/setup/pseudos/_pseudodojo.py
@@ -0,0 +1,100 @@
+"""Install PseudoDojo families through ``aiida-pseudo``'s own downloader."""
+
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+
+import click
+
+# PseudoDojo publishes each family in four formats. ``PwCalculation`` declares
+# its ``pseudos`` input with ``valid_type=(LegacyUpfData, UpfData)``, so the
+# psp8, psml and jthxml families install but cannot be handed to pw.x. The
+# filter also settles what the UPF families contain: PseudoDojo's PAW sets are
+# published as jthxml alone, so every label left is norm-conserving ONCV.
+PSEUDO_DOJO_FORMAT = "upf"
+
+
+def available_labels() -> list[str]:
+ """Return every PseudoDojo label koopmans accepts, sorted.
+
+ Asked of ``aiida-pseudo`` rather than written down, so a newly published
+ version needs no edit here.
+ """
+ from aiida_pseudo.groups.family import PseudoDojoFamily
+
+ return sorted(
+ label
+ for label in PseudoDojoFamily.get_valid_labels()
+ if label.rsplit("/", 1)[-1].lower() == PSEUDO_DOJO_FORMAT
+ )
+
+
+def install(label: str, parts: list[str]) -> None:
+ """Install a PseudoDojo pseudopotential family.
+
+ Raises:
+ ValueError: If the label asks for a format other than ``upf``.
+ """
+ import contextlib
+ import io
+ import warnings
+
+ from aiida_pseudo.cli.install import download_pseudo_dojo, install_pseudo_dojo
+ from aiida_pseudo.data.pseudo import UpfData
+ from aiida_pseudo.groups.family import PseudoDojoConfiguration
+
+ _, version, functional, relativistic, protocol, pseudo_format = parts
+
+ if pseudo_format.lower() != PSEUDO_DOJO_FORMAT:
+ raise ValueError(
+ f"PseudoDojo publishes '{label}' in the {pseudo_format} format, which "
+ "pw.x cannot read; it takes UPF only. End the label with "
+ f"'/{PSEUDO_DOJO_FORMAT}'."
+ )
+
+ 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=UpfData,
+ label=label,
+ traceback=False,
+ )
+
+ # Not redundant. ``install_pseudo_dojo`` sets low, normal and high in
+ # that order, and ``set_cutoffs`` makes a stringency the default only
+ # while it is the family's only one -- so the family would be left on
+ # `low`. "normal" is what aiida-pseudo's own `install pseudo-dojo`
+ # command defaults to. A run needing another stringency states
+ # `calculator_parameters.ecutwfc`, which takes precedence over any
+ # recommendation.
+ family.set_default_stringency("normal")
diff --git a/src/koopmans/aiida/setup/pseudos/_sg15.py b/src/koopmans/aiida/setup/pseudos/_sg15.py
new file mode 100644
index 0000000..f02ed93
--- /dev/null
+++ b/src/koopmans/aiida/setup/pseudos/_sg15.py
@@ -0,0 +1,169 @@
+"""Install SG15 ONCV families from the published tarball.
+
+SG15 ONCV is published as a single frozen tarball on quantum-simulation.org,
+one flat directory of ``_ONCV_PBE[_FR]-.upf`` files; the
+label's version/relativistic parts select which of them 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`` comes from the input file.
+"""
+
+from __future__ import annotations
+
+import tempfile
+from pathlib import Path
+
+import click
+
+ARCHIVE_URL = (
+ "http://www.quantum-simulation.org/potentials/sg15_oncv/sg15_oncv_upf_2020-02-06.tar.gz"
+)
+ARCHIVE_SHA256 = "3f3bd74aa5d6e0b038218a6051bb99ed9469dc03d0f05b3ec8a523f0f7a7dff0"
+
+# Which archive files each label installs: the version's own, and for a delta
+# release the versions it is layered on, oldest first. SG15 published 1.1 as a
+# delta of 1.0 -- 17 revised scalar-relativistic files, 12 fully relativistic --
+# so ``SG15/1.1`` is 1.0 with those files laid over it, while 1.0 and 1.2 are
+# each one complete revision. Composed, 1.1 holds 69 SR and 64 FR elements
+# against 1.0's 69 and 52.
+#
+# SG15 also revises element by element, so a version does not carry both
+# relativistic variants just because it carries one: the 2020-02-06 archive
+# publishes nothing fully relativistic at 1.2. A label naming a variant the
+# archive lacks would install nothing, so the offered labels are read from here
+# too.
+VARIANTS: dict[str, dict[str, tuple[str, ...]]] = {
+ "1.0": {"SR": ("1.0",), "FR": ("1.0",)},
+ "1.1": {"SR": ("1.0", "1.1"), "FR": ("1.0", "1.1")},
+ "1.2": {"SR": ("1.2",)},
+}
+
+NOTES = (
+ "SG15/1.2 is the newest scalar-relativistic set and covers all 69 elements; "
+ "1.0 covers the same 69 at the original revision.",
+ "SG15 published 1.1 as a delta of 1.0, revising 17 elements, so koopmans "
+ "composes it: the 1.1 label installs the 1.0 files with the 1.1 ones over "
+ "them. Files keep their archive names, so a 1.1 family holds -1.0.upf files "
+ "as well.",
+ "Name SG15/1.1/PBE/FR for fully relativistic runs: composed it covers 64 "
+ "elements against 1.0's 52, and 1.2 publishes none. Ba, Be, Bi, Li and Ne "
+ "have no fully relativistic SG15 pseudopotential at any version.",
+ "SG15 recommends no cutoffs, so set `ecutwfc` in your input file.",
+)
+
+
+def available_labels() -> list[str]:
+ """Return every SG15 label the 2020-02-06 archive can supply, sorted."""
+ return sorted(
+ f"SG15/{version}/PBE/{relativistic}"
+ for version, relativistic_variants in VARIANTS.items()
+ for relativistic in relativistic_variants
+ )
+
+
+def _select_files(
+ archive_bytes: bytes, source_versions: tuple[str, ...], fr_suffix: str
+) -> dict[str, tuple[str, bytes]]:
+ """Return one archive file per element, keyed by element, as ``(name, content)``.
+
+ ``source_versions`` runs oldest first; where an element appears in more
+ than one, the later file wins.
+ """
+ import io
+ import re
+ import tarfile
+
+ versions = "|".join(re.escape(source) for source in source_versions)
+ filename_re = re.compile(
+ rf"^(?P[A-Z][a-z]?)_ONCV_PBE{fr_suffix}-(?P{versions})\.upf$"
+ )
+ revision = {source: index for index, source in enumerate(source_versions)}
+
+ selected: dict[str, tuple[int, str, bytes]] = {}
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
+ for member in tar.getmembers():
+ if not member.isfile():
+ continue
+ filename = Path(member.name).name
+ match = filename_re.match(filename)
+ if match is None:
+ continue
+ element = match.group("element")
+ index = revision[match.group("version")]
+ if element in selected and selected[element][0] > index:
+ continue
+ extracted = tar.extractfile(member)
+ if extracted is None:
+ continue
+ selected[element] = (index, filename, extracted.read())
+
+ return {element: (name, content) for element, (_, name, content) in selected.items()}
+
+
+def install(label: str, parts: list[str]) -> None:
+ """Install an SG15 ONCV pseudopotential family.
+
+ Each pseudopotential keeps its archive filename, which names the revision
+ it came from.
+
+ Raises:
+ ValueError: If the label names a functional, version or relativistic
+ variant the 2020-02-06 archive does not publish.
+ """
+ import hashlib
+ 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}'.")
+ relativistic_variants = VARIANTS.get(version)
+ if relativistic_variants is None:
+ raise ValueError(
+ f"SG15 version '{version}' is not packaged in the 2020-02-06 archive. "
+ f"Supported versions: {sorted(VARIANTS)}."
+ )
+ if relativistic not in relativistic_variants:
+ raise ValueError(
+ f"SG15 publishes no {relativistic} pseudopotentials at version {version}; "
+ f"at {version} the archive carries {', '.join(relativistic_variants)}. "
+ "Run `koopmans pseudos` for every label koopmans accepts."
+ )
+ source_versions = relativistic_variants[relativistic]
+ fr_suffix = "_FR" if relativistic == "FR" else ""
+
+ click.echo(f" Downloading '{label}' pseudopotentials")
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ tmp = Path(tmpdir)
+
+ with urllib.request.urlopen(ARCHIVE_URL) as response: # noqa: S310
+ archive_bytes = response.read()
+
+ digest = hashlib.sha256(archive_bytes).hexdigest()
+ if digest != ARCHIVE_SHA256:
+ raise ValueError(
+ f"SG15 archive checksum mismatch: got {digest}, "
+ f"expected {ARCHIVE_SHA256}. Upstream may have re-released "
+ f"{ARCHIVE_URL}; pin a new hash after verifying the contents."
+ )
+
+ selected = _select_files(archive_bytes, source_versions, fr_suffix)
+
+ flat = tmp / "flat"
+ flat.mkdir()
+ for filename, content in selected.values():
+ (flat / filename).write_bytes(content)
+
+ if not any(flat.iterdir()):
+ raise ValueError(
+ f"No UPF files matched '{label}' in {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/workflows/__init__.py b/src/koopmans/aiida/workflows/__init__.py
index c34441e..5bc8690 100644
--- a/src/koopmans/aiida/workflows/__init__.py
+++ b/src/koopmans/aiida/workflows/__init__.py
@@ -160,7 +160,10 @@ def prepare_common_inputs(
Returns:
Tuple of (structure, pseudo_family, overrides).
"""
- from koopmans.aiida.setup.pseudos import ensure_pseudo_family_installed
+ from koopmans.aiida.setup.pseudos import (
+ ensure_pseudo_family_installed,
+ require_norm_conserving_family,
+ )
structure = atoms_input_to_structure(koopmans_input.atoms)
parameters = input_to_pw_parameters(koopmans_input)
@@ -168,6 +171,7 @@ def prepare_common_inputs(
ensure_pseudo_family_installed(pseudo_family)
+ require_norm_conserving_family(pseudo_family, structure)
require_cutoffs_for_family(pseudo_family, parameters)
pw_overrides: dict[str, Any] = {"parameters": parameters}
diff --git a/src/koopmans/aiida/workflows/dscf.py b/src/koopmans/aiida/workflows/dscf.py
index cecfb58..b531763 100644
--- a/src/koopmans/aiida/workflows/dscf.py
+++ b/src/koopmans/aiida/workflows/dscf.py
@@ -210,8 +210,11 @@ def dscf_wannier_init_inputs(
validate_blocks_separate_occ_and_emp(blocks, nocc)
validate_blocks_cover_all_occ_bands(blocks, nocc)
- # The DSCF route never calls ``prepare_common_inputs``, so the cutoff check
- # reaches its pw steps only from here.
+ # The DSCF route never calls ``prepare_common_inputs``, so the family
+ # checks reach its pw steps only from here.
+ from koopmans.aiida.setup.pseudos import require_norm_conserving_family
+
+ require_norm_conserving_family(pseudo_family, structure)
require_cutoffs_for_family(pseudo_family, parameters)
wannier_overrides: WannierizeOverrides = {
"scf": {"pseudo_family": pseudo_family, "pw": {"parameters": parameters}},
diff --git a/src/koopmans/aiida/workflows/wannierize.py b/src/koopmans/aiida/workflows/wannierize.py
index 34d9016..a1a3d66 100644
--- a/src/koopmans/aiida/workflows/wannierize.py
+++ b/src/koopmans/aiida/workflows/wannierize.py
@@ -315,7 +315,10 @@ def _build_wannierize_blocks_workgraph(
nscf_parameters = copy.deepcopy(parameters)
nscf_parameters.setdefault("SYSTEM", {})["nbnd"] = nbnd
# This route assembles its own scf/nscf overrides instead of calling
- # ``prepare_common_inputs``, so the cutoff check is its own too.
+ # ``prepare_common_inputs``, so the family checks are its own too.
+ from koopmans.aiida.setup.pseudos import require_norm_conserving_family
+
+ require_norm_conserving_family(pseudo_family, structure)
require_cutoffs_for_family(pseudo_family, parameters)
wannier_overrides: WannierizeOverrides = {
"scf": {
diff --git a/src/koopmans/cli.py b/src/koopmans/cli.py
index ee6e512..2865d47 100644
--- a/src/koopmans/cli.py
+++ b/src/koopmans/cli.py
@@ -294,6 +294,18 @@ def install(
click.echo("\nInstallation complete!")
+@cli.command()
+def pseudos() -> None:
+ """List the pseudopotential families `workflow.pseudo_library` accepts.
+
+ Families koopmans has installed are marked. The listing itself needs no
+ AiiDA profile, so it works before `koopmans install`.
+ """
+ from koopmans.aiida.setup.pseudos import list_pseudo_families
+
+ list_pseudo_families()
+
+
@cli.group()
def backend() -> None:
"""Manage the AiiDA backend."""
diff --git a/src/koopmans/input_file/workflow.py b/src/koopmans/input_file/workflow.py
index 79dbda8..df3c346 100644
--- a/src/koopmans/input_file/workflow.py
+++ b/src/koopmans/input_file/workflow.py
@@ -64,7 +64,7 @@ class WorkflowConfig(BaseModel):
default=True, description="whether or not to calculate the screening parameters ab-initio"
)
pseudo_library: str = Field(
- description="the label of the pseudopotential family to use. Any family you have installed yourself is used as it stands, whatever its label; a label naming no installed family is downloaded, which koopmans can do for 'PseudoDojo/version/functional/relativistic/protocol/format', 'SSSP/version/functional/protocol' and 'SG15/version/functional/relativistic'. A family that publishes no recommended cutoffs takes them from `calculator_parameters.ecutwfc` instead"
+ description="the label of the pseudopotential family to use. Any family you have installed yourself is used as it stands, whatever its label; a label naming no installed family is downloaded, which koopmans can do for the norm-conserving PseudoDojo and SG15 families `koopmans pseudos` lists. A family that publishes no recommended cutoffs takes them from `calculator_parameters.ecutwfc` instead"
)
screening_method: CalculateScreeningMethod = Field(
default=CalculateScreeningMethod.DSCF,
diff --git a/tests/conftest.py b/tests/conftest.py
index 25edd89..0a5934a 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -18,11 +18,14 @@
clear_database_after_test,
code_without_mpi_flag,
compiled_binaries,
+ fake_declared_nc_family,
+ fake_paw_family,
fake_pseudodojo_lda_family,
fake_sg15_cutoffs_family,
fake_sg15_family_without_cutoffs,
fake_sg15_fr_cutoffs_family,
fake_sg15_pseudo_family,
+ fake_ultrasoft_family,
fake_user_built_family,
installed_decompose_code,
installed_fold_codes,
@@ -33,6 +36,7 @@
installed_wannier_codes,
localhost_code,
localhost_computer,
+ offline_sg15_archive,
replay_probes,
serialize_workgraph,
si_external_projector_dir,
diff --git a/tests/fixtures.py b/tests/fixtures.py
index caf7085..fd69fed 100644
--- a/tests/fixtures.py
+++ b/tests/fixtures.py
@@ -479,7 +479,113 @@ def installed_fold_codes(localhost_code: Any) -> dict[str, Any]:
}
-def fake_upf_content(element: str, z_valence: float, has_so: bool | None = False) -> str:
+# Header excerpts transcribed from real pseudopotentials, which the synthetic
+# streams below cannot stand in for: generators disagree on how to spell a UPF
+# boolean, PAW files set the ultrasoft flag as well as their own, and the v1
+# layout is not XML at all.
+
+# PSlibrary's ultrasoft silicon (Si.pbe-n-rrkjus_psl.1.0.0.UPF): "true", not
+# "T", and ``pseudo_type`` reads USPP rather than US.
+UPF_V2_ULTRASOFT_HEADER = """\
+
+
+Pseudopotential type: USPP
+
+
+
+"""
+
+# PSlibrary's PAW silicon (Si.pbe-n-kjpaw_psl.1.0.0.UPF), which sets both
+# flags: reading ``is_ultrasoft`` alone would call this one ultrasoft.
+UPF_V2_PAW_HEADER = """\
+
+
+
+"""
+
+# A UPF v1 ultrasoft carbon (aiida-core's C_pbe_v1.2.uspp.F.UPF): no
+# ```` wrapper and a fixed-format header whose third line
+# carries the type.
+UPF_V1_ULTRASOFT_HEADER = """\
+
+Generated using Vanderbilt code, version 7 3 6
+
+
+ 0 Version Number
+ C Element
+ US Ultrasoft pseudopotential
+ T Nonlinear Core Correction
+SLA PW PBE PBE PBE Exchange-Correlation functional
+ 4.00000000000 Z valence
+
+"""
+
+# Every PSlibrary pseudopotential that embeds its generation input carries a
+# Fortran namelist inside PP_INFO, whose bare ``&`` makes the file invalid
+# XML. An XML parser rejects the whole file; the header still reads.
+UPF_V2_ULTRASOFT_WITH_NAMELIST = """\
+
+
+
+ &input
+ title='O',
+ config='[He] 2s2 2p4 3d-2',
+ /
+
+
+
+
+"""
+
+# A header that flags itself ultrasoft without naming a ``pseudo_type``, which
+# is the only thing the boolean flags decide: every other file states both.
+UPF_V2_FLAGGED_BUT_UNNAMED = """\
+
+
+
+"""
+
+# SG15's ONCV silicon (Si_ONCV_PBE-1.2.upf), the norm-conserving control.
+UPF_V2_NORM_CONSERVING_HEADER = """\
+
+
+
+"""
+
+
+def fake_upf_content(
+ element: str,
+ z_valence: float,
+ has_so: bool | None = False,
+ info: str | None = None,
+ pseudo_type: str | None = None,
+) -> str:
"""Return a synthetic UPF v2 stream for the fake test pseudos.
Shaped for the line-based block extractors in aiida-wannier90-workflows'
@@ -489,13 +595,26 @@ def fake_upf_content(element: str, z_valence: float, has_so: bool | None = False
generators always write it, and an attribute-bearing header without it
makes the upstream sniffing crash, which the dispatcher converts into an
error naming the pseudo. ``has_so=None`` omits the flag to exercise
- exactly that guard.
+ exactly that guard. ``info`` fills the ``PP_INFO`` block real generators
+ write, which gives two otherwise identical streams content of their own.
+ ``pseudo_type`` writes the header attribute real generators use to say
+ what kind of pseudopotential this is ("NC", "US", "PAW"), along with the
+ ``is_ultrasoft``/``is_paw`` flags that agree with it; omitted by default,
+ which is the header that says nothing.
"""
has_so_line = "" if has_so is None else f'has_so="{"T" if has_so else "F"}"\n'
+ info_block = "" if info is None else f"\n{info}\n \n"
+ if pseudo_type is None:
+ type_lines = ""
+ else:
+ ultrasoft = "T" if pseudo_type.upper() in {"US", "USPP"} else "F"
+ paw = "T" if pseudo_type.upper() == "PAW" else "F"
+ type_lines = f'pseudo_type="{pseudo_type}"\nis_ultrasoft="{ultrasoft}"\nis_paw="{paw}"\n'
return (
f'\n'
+ f"{info_block}"
f' \n'
+ f'z_valence="{z_valence}"\n{type_lines}{has_so_line}/>\n'
f"\n"
f' \n \n'
f" \n"
@@ -503,6 +622,69 @@ def fake_upf_content(element: str, z_valence: float, has_so: bool | None = False
)
+# One member per (element, revision, relativistic variant), laid out flat under
+# a single directory as the published SG15 tarball is. The coverage mirrors the
+# real archive's: silicon is fully relativistic only at 1.1 and oxygen only at
+# 1.0, so a family holding both is one that composed 1.1 over 1.0. Every file
+# names its own revision in ``PP_INFO``, which is what lets a test say which of
+# them an installed pseudopotential is.
+SG15_ARCHIVE_MEMBERS: dict[str, tuple[str, float, bool, str]] = {
+ # 1.1 before 1.0 deliberately: overlay precedence must come from the
+ # revision, not from the order the tarball happens to list its members.
+ "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE-1.1.upf": ("Si", 4.0, False, "1.1"),
+ "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE-1.0.upf": ("Si", 4.0, False, "1.0"),
+ "sg15_oncv_upf_2020-02-06/O_ONCV_PBE-1.0.upf": ("O", 6.0, False, "1.0"),
+ "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE-1.2.upf": ("Si", 4.0, False, "1.2"),
+ "sg15_oncv_upf_2020-02-06/O_ONCV_PBE-1.2.upf": ("O", 6.0, False, "1.2"),
+ "sg15_oncv_upf_2020-02-06/O_ONCV_PBE_FR-1.0.upf": ("O", 6.0, True, "1.0"),
+ "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE_FR-1.1.upf": ("Si", 4.0, True, "1.1"),
+}
+
+
+@pytest.fixture
+def offline_sg15_archive(monkeypatch: pytest.MonkeyPatch) -> dict[str, str]:
+ """Serve a synthetic SG15 tarball from ``urlopen``, pinned to its own checksum.
+
+ Returns each member's UPF stream keyed by filename, so a test can assert
+ which revision an installed pseudopotential came from. The published
+ archive is never downloaded.
+ """
+ import hashlib
+ import tarfile
+ import urllib.request
+
+ from koopmans.aiida.setup.pseudos import _sg15
+
+ contents = {
+ Path(name).name: fake_upf_content(
+ element, z_valence, has_so=has_so, info=f"SG15 ONCV revision {revision}"
+ )
+ for name, (element, z_valence, has_so, revision) in SG15_ARCHIVE_MEMBERS.items()
+ }
+
+ buffer = io.BytesIO()
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
+ # The real tarball carries a directory entry and a README the
+ # installer's member walk must step over, so the fixture does too.
+ directory = tarfile.TarInfo("sg15/")
+ directory.type = tarfile.DIRTYPE
+ tar.addfile(directory)
+ readme = b"SG15 ONCV potentials"
+ info = tarfile.TarInfo("sg15/README")
+ info.size = len(readme)
+ tar.addfile(info, io.BytesIO(readme))
+ for name in SG15_ARCHIVE_MEMBERS:
+ payload = contents[Path(name).name].encode("utf-8")
+ info = tarfile.TarInfo(name)
+ info.size = len(payload)
+ tar.addfile(info, io.BytesIO(payload))
+ archive = buffer.getvalue()
+
+ monkeypatch.setattr(_sg15, "ARCHIVE_SHA256", hashlib.sha256(archive).hexdigest())
+ monkeypatch.setattr(urllib.request, "urlopen", lambda url: io.BytesIO(archive))
+ return contents
+
+
@pytest.fixture
def installed_decompose_code(localhost_code: Any) -> Any:
"""Register a dummy ``pw2wannier90@localhost`` code for the decompose pass."""
@@ -515,6 +697,7 @@ def _install_fake_family(
cutoffs: bool = False,
has_so: bool = False,
recommended_cutoffs: bool = True,
+ pseudo_type: str | None = None,
) -> Any:
"""Install (or fetch) a fake pseudopotential family with synthetic UPF streams.
@@ -523,10 +706,11 @@ def _install_fake_family(
``CutoffsPseudoPotentialFamily`` with recommended cutoffs — needed by
a build that states none of its own; ``cutoffs=False`` builds a plain
``PseudoPotentialFamily``, the shape both ``aiida-pseudo install family``
- and ``_install_sg15_family`` produce.
+ and ``_sg15.install`` produce.
``recommended_cutoffs=False`` leaves the cutoffs family with no stringency
defined, the shape ``-F pseudo.family.cutoffs`` produces on its own.
``has_so=True`` marks every pseudo fully relativistic.
+ ``pseudo_type`` writes that kind into every pseudo's header.
"""
from aiida.common.exceptions import NotExistent
from aiida_pseudo.data.pseudo.upf import UpfData
@@ -545,7 +729,7 @@ def _install_fake_family(
family.store()
pseudos = []
for element, z_valence in elements.items():
- content = fake_upf_content(element, z_valence, has_so=has_so)
+ content = fake_upf_content(element, z_valence, has_so=has_so, pseudo_type=pseudo_type)
upf = UpfData(io.BytesIO(content.encode("utf-8")), filename=f"{element}.upf")
pseudos.append(upf.store())
family.add_nodes(pseudos)
@@ -575,7 +759,7 @@ def fake_sg15_cutoffs_family(aiida_profile: Any) -> Any:
@pytest.fixture
def fake_sg15_family_without_cutoffs(aiida_profile: Any) -> Any:
- """Install ``SG15/1.2/PBE/FR`` as a cutoffs family with no stringency defined.
+ """Install ``SG15/1.1/PBE/FR`` as a cutoffs family with no stringency defined.
The half-configured shape a user reaches by passing
``-F pseudo.family.cutoffs`` and never running ``aiida-pseudo family
@@ -583,10 +767,32 @@ def fake_sg15_family_without_cutoffs(aiida_profile: Any) -> Any:
coexists with the other SG15 fixtures in one session profile.
"""
return _install_fake_family(
- "SG15/1.2/PBE/FR", {"Si": 4.0}, cutoffs=True, recommended_cutoffs=False
+ "SG15/1.1/PBE/FR", {"Si": 4.0}, cutoffs=True, recommended_cutoffs=False
)
+@pytest.fixture
+def fake_ultrasoft_family(aiida_profile: Any) -> Any:
+ """Install a self-built family whose Si pseudopotential is ultrasoft.
+
+ The label says nothing about the kind of pseudopotential inside, which is
+ the whole point: only the header does.
+ """
+ return _install_fake_family("MyPseudos/ultrasoft", {"Si": 4.0}, cutoffs=True, pseudo_type="US")
+
+
+@pytest.fixture
+def fake_paw_family(aiida_profile: Any) -> Any:
+ """Install a self-built family whose Si pseudopotential is PAW."""
+ return _install_fake_family("MyPseudos/paw", {"Si": 4.0}, cutoffs=True, pseudo_type="PAW")
+
+
+@pytest.fixture
+def fake_declared_nc_family(aiida_profile: Any) -> Any:
+ """Install a self-built family whose Si pseudopotential declares itself NC."""
+ return _install_fake_family("MyPseudos/nc", {"Si": 4.0}, cutoffs=True, pseudo_type="NC")
+
+
@pytest.fixture
def fake_user_built_family(aiida_profile: Any) -> Any:
"""Install a plain family, the shape ``aiida-pseudo install family`` produces.
diff --git a/tests/test_norm_conserving_families.py b/tests/test_norm_conserving_families.py
new file mode 100644
index 0000000..fde3ce9
--- /dev/null
+++ b/tests/test_norm_conserving_families.py
@@ -0,0 +1,161 @@
+"""A family that is not norm-conserving is refused, on its headers not its label."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from koopmans.input_file import KoopmansInput
+from tests.fixtures import silicon_pw_input
+
+
+def _dispatch(label: str) -> Any:
+ """Run the dispatch-time checks against a family label."""
+ from koopmans.aiida.workflows import prepare_common_inputs
+
+ inp = KoopmansInput.model_validate(
+ silicon_pw_input(
+ pseudo_library=label,
+ calculator_parameters={"pw": {"system": {"ecutwfc": 20.0}}},
+ )
+ )
+ return prepare_common_inputs(inp, ["scf", "bands"])
+
+
+class TestTheHeaderDecides:
+ """The check reads each pseudopotential, not the family's label."""
+
+ @pytest.mark.parametrize(
+ ("fixture", "label", "pseudo_type"),
+ [
+ ("fake_ultrasoft_family", "MyPseudos/ultrasoft", "US"),
+ ("fake_paw_family", "MyPseudos/paw", "PAW"),
+ ],
+ )
+ def test_an_ultrasoft_or_paw_family_is_refused(
+ self,
+ aiida_profile_clean: Any,
+ request: pytest.FixtureRequest,
+ fixture: str,
+ label: str,
+ pseudo_type: str,
+ ) -> None:
+ """Both kinds koopmans cannot use are caught, and the message names them.
+
+ Nothing in either label says ``US`` or ``PAW``, so a check reading the
+ label would pass both; these families are exactly the shape
+ ``aiida-pseudo install family`` produces from a downloaded directory.
+ """
+ request.getfixturevalue(fixture)
+
+ with pytest.raises(ValueError) as excinfo:
+ _dispatch(label)
+
+ message = str(excinfo.value)
+ assert "not norm-conserving" in message
+ assert f"Si ({pseudo_type})" in message
+ assert "workflow.pseudo_library" in message
+
+ def test_a_family_declaring_itself_nc_is_accepted(
+ self, aiida_profile_clean: Any, fake_declared_nc_family: Any
+ ) -> None:
+ """The positive control: the same fixture machinery, one header value apart.
+
+ Without this a check that refused every family would pass the two
+ cases above.
+ """
+ structure, pseudo_family, _ = _dispatch("MyPseudos/nc")
+ assert pseudo_family == "MyPseudos/nc"
+ assert structure.get_kind_names() == ["Si"]
+
+ def test_a_header_that_says_nothing_is_accepted(
+ self, aiida_profile_clean: Any, fake_sg15_cutoffs_family: Any
+ ) -> None:
+ """A header without the attribute cannot be judged, so it does not block.
+
+ Real generators write it, but a hand-trimmed or unparseable header
+ must not stop a legitimate run: the check refuses only on positive
+ evidence. Pinned so that turning it into a hard requirement is a
+ deliberate change rather than a silent one.
+ """
+ _, pseudo_family, _ = _dispatch("SG15/1.0/PBE/SR")
+ assert pseudo_family == "SG15/1.0/PBE/SR"
+
+
+class _Pseudo:
+ """The one thing the reader asks of a pseudopotential node."""
+
+ def __init__(self, content: str) -> None:
+ self._content = content
+
+ def get_content(self) -> str:
+ """Return the stub's text."""
+ return self._content
+
+
+class TestTheCheckReadsRealHeaders:
+ """The reader is exercised against UPF text, not only through the dispatcher."""
+
+ @pytest.mark.parametrize(
+ ("pseudo_type", "expected"),
+ [("NC", None), ("US", "US"), ("PAW", "PAW"), (None, None)],
+ )
+ def test_each_header_kind_reads_back(
+ self, pseudo_type: str | None, expected: str | None
+ ) -> None:
+ """``NC`` and a missing attribute pass; ``US`` and ``PAW`` are named.
+
+ Covers the reader without a profile, so a failure here separates a
+ parsing bug from a wiring one.
+ """
+ from koopmans.aiida.setup.pseudos._norm_conserving import _pseudo_type
+ from tests.fixtures import fake_upf_content
+
+ content = fake_upf_content("Si", 4.0, pseudo_type=pseudo_type)
+ assert _pseudo_type(_Pseudo(content)) == expected
+
+ @pytest.mark.parametrize(
+ ("fixture", "expected"),
+ [
+ ("UPF_V2_NORM_CONSERVING_HEADER", None),
+ ("UPF_V2_ULTRASOFT_HEADER", "USPP"),
+ ("UPF_V2_PAW_HEADER", "PAW"),
+ ("UPF_V1_ULTRASOFT_HEADER", "US"),
+ ("UPF_V2_ULTRASOFT_WITH_NAMELIST", "USPP"),
+ ("UPF_V2_FLAGGED_BUT_UNNAMED", "US"),
+ ],
+ )
+ def test_headers_transcribed_from_real_pseudopotentials(
+ self, fixture: str, expected: str | None
+ ) -> None:
+ """Each real-world spelling the synthetic stream does not cover.
+
+ PSlibrary writes ``is_ultrasoft="true"`` where SG15 writes ``"F"``; a
+ PAW file sets the ultrasoft flag too, so flag order decides what it is
+ called; the v1 layout is a fixed-format block rather than attributes;
+ and a PP_INFO carrying a Fortran namelist makes the file invalid XML
+ while leaving the header perfectly readable. The last states no
+ ``pseudo_type`` at all, which is the only case the flags decide.
+ """
+ from koopmans.aiida.setup.pseudos import _norm_conserving
+ from tests import fixtures
+
+ assert _norm_conserving._pseudo_type(_Pseudo(getattr(fixtures, fixture))) == expected
+
+ def test_an_unparseable_stream_does_not_raise(self) -> None:
+ """A file that is not UPF at all reads as "cannot tell", not as a crash."""
+ from koopmans.aiida.setup.pseudos._norm_conserving import _pseudo_type
+
+ assert _pseudo_type(_Pseudo("this is not a pseudopotential")) is None
+
+ def test_a_v1_header_too_short_to_hold_a_type_is_not_guessed(self) -> None:
+ """A truncated v1 block reads as "cannot tell" rather than as its second line.
+
+ Indexing a fixed-format block is only safe while the block has the
+ lines; without this a short header would refuse on an element symbol.
+ """
+ from koopmans.aiida.setup.pseudos._norm_conserving import _pseudo_type
+
+ truncated = "\n 0 Version Number\n C Element\n \n"
+ assert _pseudo_type(_Pseudo(truncated)) is None
diff --git a/tests/test_pseudo_cutoffs.py b/tests/test_pseudo_cutoffs.py
index 33710d3..e23a97b 100644
--- a/tests/test_pseudo_cutoffs.py
+++ b/tests/test_pseudo_cutoffs.py
@@ -170,7 +170,7 @@ class TestFamilyWithoutCutoffs:
@pytest.mark.parametrize(
("fixture", "label"),
[
- ("fake_sg15_family_without_cutoffs", "SG15/1.2/PBE/FR"),
+ ("fake_sg15_family_without_cutoffs", "SG15/1.1/PBE/FR"),
("fake_user_built_family", "MyPseudos/local"),
],
)
@@ -216,7 +216,7 @@ def test_a_pw_block_ecutwfc_alone_is_enough(
inp = KoopmansInput.model_validate(
silicon_pw_input(
- pseudo_library="SG15/1.2/PBE/FR",
+ pseudo_library="SG15/1.1/PBE/FR",
calculator_parameters={"pw": {"system": {"ecutwfc": 20.0}}},
)
)
@@ -239,12 +239,12 @@ def test_no_cutoffs_at_all_names_the_family_and_the_keyword(
from koopmans.aiida.workflows import prepare_common_inputs
inp = KoopmansInput.model_validate(
- silicon_pw_input(pseudo_library="SG15/1.2/PBE/FR", calculator_parameters={})
+ silicon_pw_input(pseudo_library="SG15/1.1/PBE/FR", calculator_parameters={})
)
with pytest.raises(ValueError) as excinfo:
prepare_common_inputs(inp, ["scf", "bands"])
message = str(excinfo.value)
- assert "SG15/1.2/PBE/FR" in message
+ assert "SG15/1.1/PBE/FR" in message
assert "calculator_parameters.ecutwfc" in message
assert "is not installed" not in message
diff --git a/tests/test_pseudo_install.py b/tests/test_pseudo_install.py
index 9b68d6c..4a315ac 100644
--- a/tests/test_pseudo_install.py
+++ b/tests/test_pseudo_install.py
@@ -1,86 +1,232 @@
"""Tests for the SG15 pseudopotential family installer.
-The SG15 archive is never downloaded: a synthetic tarball built from the
-``fake_upf_content`` streams is served through a patched ``urlopen``, with the
-pinned checksum swapped for that tarball's own.
+The SG15 archive is never downloaded: the ``offline_sg15_archive`` fixture
+serves a synthetic tarball through a patched ``urlopen``, with the pinned
+checksum swapped for that tarball's own.
"""
from __future__ import annotations
-import hashlib
-import io
-import tarfile
-import urllib.request
from typing import Any
-import pytest
-
-from tests.fixtures import fake_upf_content
-
SG15_LABEL = "SG15/1.2/PBE/SR"
-# One member per (element, version, relativistic variant) the flat archive
-# bundles, so the installer has to select the label's subset rather than take
-# whatever it finds.
-_ARCHIVE_MEMBERS = {
- "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE-1.2.upf": ("Si", 4.0),
- "sg15_oncv_upf_2020-02-06/O_ONCV_PBE-1.2.upf": ("O", 6.0),
- "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE-1.0.upf": ("Si", 4.0),
- "sg15_oncv_upf_2020-02-06/Si_ONCV_PBE_FR-1.2.upf": ("Si", 4.0),
-}
-
-def _synthetic_archive() -> bytes:
- """Return a gzipped tarball shaped like the published SG15 archive."""
- buffer = io.BytesIO()
- with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
- for name, (element, z_valence) in _ARCHIVE_MEMBERS.items():
- payload = fake_upf_content(element, z_valence).encode("utf-8")
- info = tarfile.TarInfo(name)
- info.size = len(payload)
- tar.addfile(info, io.BytesIO(payload))
- return buffer.getvalue()
+def _installed(label: str) -> Any:
+ """Install the family and return it."""
+ from aiida_pseudo.groups.family import PseudoPotentialFamily
+ from koopmans.aiida.setup.pseudos import install_pseudo_family
-@pytest.fixture
-def offline_sg15_archive(monkeypatch: pytest.MonkeyPatch) -> bytes:
- """Serve the synthetic archive from ``urlopen`` and pin its checksum."""
- from koopmans.aiida.setup import pseudos
-
- archive = _synthetic_archive()
- monkeypatch.setattr(pseudos, "_SG15_ARCHIVE_SHA256", hashlib.sha256(archive).hexdigest())
- monkeypatch.setattr(urllib.request, "urlopen", lambda url: io.BytesIO(archive))
- return archive
+ install_pseudo_family(label)
+ return PseudoPotentialFamily.collection.get(label=label)
class TestInstallSg15Family:
"""The class the installer builds, and the pseudos it selects."""
def test_installs_a_family_that_recommends_no_cutoffs(
- self, aiida_profile_clean: Any, offline_sg15_archive: bytes
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
) -> None:
"""SG15 publishes no cutoffs, so the family must not claim to have any."""
from aiida_pseudo.groups.family import PseudoPotentialFamily
from aiida_pseudo.groups.mixins import RecommendedCutoffMixin
- from koopmans.aiida.setup.pseudos import install_pseudo_family
+ family = _installed(SG15_LABEL)
- install_pseudo_family(SG15_LABEL)
-
- family = PseudoPotentialFamily.collection.get(label=SG15_LABEL)
assert type(family) is PseudoPotentialFamily
assert not isinstance(family, RecommendedCutoffMixin)
def test_installs_only_the_labelled_version_and_variant(
- self, aiida_profile_clean: Any, offline_sg15_archive: bytes
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
) -> None:
- """The 1.0 and fully relativistic members of the archive stay out."""
- from aiida_pseudo.groups.family import PseudoPotentialFamily
-
- from koopmans.aiida.setup.pseudos import install_pseudo_family
+ """The other revisions and the fully relativistic members stay out.
- install_pseudo_family(SG15_LABEL)
+ 1.2 is a complete release, so nothing is laid over it: the silicon it
+ installs is 1.2's own, not the 1.1 file sitting beside it.
+ """
+ family = _installed(SG15_LABEL)
- family = PseudoPotentialFamily.collection.get(label=SG15_LABEL)
assert {pseudo.element for pseudo in family.nodes} == {"Si", "O"}
assert family.count() == 2
+ assert family.get_pseudo("Si").get_content() == offline_sg15_archive["Si_ONCV_PBE-1.2.upf"]
+
+
+class TestVersion11IsComposedOver10:
+ """SG15 published 1.1 as a delta of 1.0, so the label installs both."""
+
+ def test_the_fully_relativistic_set_gains_the_elements_1_0_lacks(
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
+ ) -> None:
+ """Silicon is fully relativistic only at 1.1, oxygen only at 1.0.
+
+ Composing is what puts the two under one label; installing 1.1 alone
+ would drop the oxygen a run also needs. The 1.0 half is the control:
+ it must stay the release SG15 published, without the silicon 1.1 adds.
+ """
+ composed = _installed("SG15/1.1/PBE/FR")
+ pure = _installed("SG15/1.0/PBE/FR")
+
+ assert set(composed.elements) == {"Si", "O"}
+ assert set(pure.elements) == {"O"}
+
+ def test_a_revised_element_comes_from_the_1_1_file(
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
+ ) -> None:
+ """Both revisions of silicon match the label, and the newer one wins.
+
+ Element coverage cannot see this: 1.1 revises silicon rather than
+ adding it, so an overlay that kept the older file installs the same
+ 69 elements carrying the pseudopotential the release replaced. Oxygen,
+ which 1.1 leaves alone, must still arrive from 1.0.
+ """
+ family = _installed("SG15/1.1/PBE/SR")
+
+ assert family.get_pseudo("Si").get_content() == offline_sg15_archive["Si_ONCV_PBE-1.1.upf"]
+ assert family.get_pseudo("O").get_content() == offline_sg15_archive["O_ONCV_PBE-1.0.upf"]
+
+ def test_the_installed_files_keep_the_names_that_date_them(
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
+ ) -> None:
+ """A composed family holds files from two revisions, and says so.
+
+ The filename is the only place the revision survives, and `koopmans
+ pseudos` promises a 1.1 family holds ``-1.0.upf`` files as well.
+ """
+ family = _installed("SG15/1.1/PBE/SR")
+
+ assert {pseudo.filename for pseudo in family.nodes} == {
+ "Si_ONCV_PBE-1.1.upf",
+ "O_ONCV_PBE-1.0.upf",
+ }
+
+ def test_1_0_installs_its_own_revision_alone(
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
+ ) -> None:
+ """1.0 is complete on its own terms, so nothing is laid over it.
+
+ Composition running one version too far would hand a user who asked
+ for the original revision the 1.1 silicon.
+ """
+ family = _installed("SG15/1.0/PBE/SR")
+
+ assert family.get_pseudo("Si").get_content() == offline_sg15_archive["Si_ONCV_PBE-1.0.upf"]
+ assert set(family.elements) == {"Si", "O"}
+
+
+class TestTheInstallerRefusesWhatTheArchiveLacks:
+ """Each guard names what to change, before any pseudo lands in the profile."""
+
+ def test_a_non_pbe_functional_is_refused(self, aiida_profile_clean: Any) -> None:
+ """The archive is PBE-only, so the label's functional part must be PBE."""
+ import pytest
+
+ from koopmans.aiida.setup.pseudos import install_pseudo_family
+
+ with pytest.raises(ValueError, match="SG15 only provides PBE"):
+ install_pseudo_family("SG15/1.2/LDA/SR")
+
+ def test_an_unpackaged_version_is_refused(self, aiida_profile_clean: Any) -> None:
+ """A version the archive does not carry is named alongside the ones it does."""
+ import pytest
+
+ from koopmans.aiida.setup.pseudos import install_pseudo_family
+
+ with pytest.raises(ValueError, match=r"version '9.9' is not packaged"):
+ install_pseudo_family("SG15/9.9/PBE/SR")
+
+ def test_a_missing_variant_is_refused_before_the_download(
+ self, aiida_profile_clean: Any, monkeypatch: Any
+ ) -> None:
+ """1.2 publishes no FR members, and no bytes move before the refusal."""
+ import urllib.request
+
+ import pytest
+
+ from koopmans.aiida.setup.pseudos import install_pseudo_family
+
+ def _no_download(url: str) -> None:
+ raise AssertionError("the guard must fire before urlopen")
+
+ monkeypatch.setattr(urllib.request, "urlopen", _no_download)
+ with pytest.raises(ValueError, match=r"publishes no FR pseudopotentials at version 1\.2"):
+ install_pseudo_family("SG15/1.2/PBE/FR")
+
+ def test_a_tampered_archive_is_refused(
+ self, aiida_profile_clean: Any, offline_sg15_archive: dict[str, str], monkeypatch: Any
+ ) -> None:
+ """A checksum other than the pinned one stops the install cold."""
+ import pytest
+
+ from koopmans.aiida.setup.pseudos import _sg15, install_pseudo_family
+
+ monkeypatch.setattr(_sg15, "ARCHIVE_SHA256", "0" * 64)
+ with pytest.raises(ValueError, match="checksum mismatch"):
+ install_pseudo_family(SG15_LABEL)
+
+
+def test_ensure_installs_an_absent_family(
+ aiida_profile_clean: Any, offline_sg15_archive: dict[str, str]
+) -> None:
+ """``ensure_pseudo_family_installed`` falls through to the installer when nothing matches."""
+ from aiida_pseudo.groups.family import PseudoPotentialFamily
+
+ from koopmans.aiida.setup.pseudos import ensure_pseudo_family_installed
+
+ ensure_pseudo_family_installed(SG15_LABEL)
+
+ assert PseudoPotentialFamily.collection.get(label=SG15_LABEL).count() == 2
+
+
+def test_an_archive_with_no_matching_members_is_named(
+ aiida_profile_clean: Any, offline_sg15_archive: dict[str, str], monkeypatch: Any
+) -> None:
+ """A version the table offers but the archive lacks fails naming the label.
+
+ The pre-download guard reads the table, so a table entry with no archive
+ members behind it is only caught here — the layout-changed error is what
+ a user would see if upstream re-released the tarball differently.
+ """
+ import pytest
+
+ from koopmans.aiida.setup.pseudos import _sg15, install_pseudo_family
+
+ monkeypatch.setitem(_sg15.VARIANTS, "1.3", {"SR": ("1.3",)})
+ with pytest.raises(ValueError, match=r"No UPF files matched .SG15/1\.3/PBE/SR."):
+ install_pseudo_family("SG15/1.3/PBE/SR")
+
+
+def test_installed_labels_come_from_the_profile(
+ aiida_profile_clean: Any, offline_sg15_archive: dict[str, str], monkeypatch: Any
+) -> None:
+ """The installed-label set reflects what the profile holds, not a cache."""
+ from koopmans.aiida.setup import profile as profile_mod
+ from koopmans.aiida.setup import pseudos as pseudos_mod
+
+ monkeypatch.setattr(profile_mod, "profile_exists", lambda: True)
+ monkeypatch.setattr(profile_mod, "load_koopmans_profile", lambda: None)
+
+ assert SG15_LABEL not in pseudos_mod.installed_pseudo_family_labels()
+ _installed(SG15_LABEL)
+ assert SG15_LABEL in pseudos_mod.installed_pseudo_family_labels()
+
+
+def test_an_unrecognized_label_points_at_both_routes(aiida_profile_clean: Any) -> None:
+ """A label naming no downloadable family says so, and offers both ways on.
+
+ Not a format error: the label may be a perfectly good one for a family
+ the user installs themselves, so the message must lead with what was
+ checked rather than with the shape a label should have.
+ """
+ import pytest
+
+ from koopmans.aiida.setup.pseudos import install_pseudo_family
+
+ with pytest.raises(ValueError) as excinfo:
+ install_pseudo_family("my-own-pseudos")
+
+ message = str(excinfo.value)
+ assert "Unrecognized pseudo family format" not in message
+ assert "No installed pseudopotential family has the label 'my-own-pseudos'" in message
+ assert "aiida-pseudo install family my-own-pseudos\n" in message
+ assert "koopmans pseudos" in message
diff --git a/tests/test_pseudos.py b/tests/test_pseudos.py
index 07a9028..4248e8c 100644
--- a/tests/test_pseudos.py
+++ b/tests/test_pseudos.py
@@ -24,7 +24,13 @@ def _fail(label: str) -> None:
pseudos.ensure_pseudo_family_installed(fake_user_built_family.label)
def test_uninstallable_label_reports_both_routes(self, aiida_profile_clean: Any) -> None:
- """An unknown label names the install command and the download formats."""
+ """An unknown label names the install command and where to find the rest.
+
+ The download route points at ``koopmans pseudos`` rather than at a
+ label grammar: a grammar ending in ``/format`` invites the psp8 and
+ psml labels koopmans refuses, and leaves the reader to guess which
+ versions and protocols exist.
+ """
from koopmans.aiida.setup import pseudos
with pytest.raises(ValueError) as excinfo:
@@ -34,7 +40,8 @@ def test_uninstallable_label_reports_both_routes(self, aiida_profile_clean: Any)
assert "No installed pseudopotential family has the label 'my-gaas-fr'" in message
assert "aiida-pseudo install family my-gaas-fr\n" in message
assert "calculator_parameters.ecutwfc" in message
- assert "PseudoDojo/version/functional/relativistic/protocol/format" in message
+ assert "koopmans pseudos" in message
+ assert "protocol/format" not in message
def test_the_install_command_asks_for_no_cutoffs_family(self, aiida_profile_clean: Any) -> None:
"""The command offered is the plain-family one, with no cutoffs to set.
diff --git a/tests/test_pseudos_command.py b/tests/test_pseudos_command.py
new file mode 100644
index 0000000..843f77b
--- /dev/null
+++ b/tests/test_pseudos_command.py
@@ -0,0 +1,259 @@
+"""``koopmans pseudos`` lists the values ``workflow.pseudo_library`` accepts."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+from click.testing import CliRunner
+
+from koopmans.aiida.setup import pseudos as pseudos_mod
+from koopmans.cli import cli
+
+
+def _run(monkeypatch: Any, installed: set[str] | None = None) -> str:
+ """Return the command's output with a fixed installed set, touching no profile."""
+ monkeypatch.setattr(pseudos_mod, "installed_pseudo_family_labels", lambda: installed or set())
+ result = CliRunner().invoke(cli, ["pseudos"])
+ assert result.exit_code == 0, result.output
+ return result.output
+
+
+class TestTheListingIsDerived:
+ """The labels come from ``aiida-pseudo``, not from a list in this repo."""
+
+ def test_a_relabelled_library_is_what_gets_printed(self, monkeypatch: Any) -> None:
+ """Renaming what PseudoDojo reports renames what the command prints.
+
+ A pasted list would keep printing the real labels and fail here, and
+ would keep matching ``get_valid_labels`` only until PseudoDojo
+ publishes its next version.
+ """
+ from aiida_pseudo.groups.family import PseudoDojoFamily
+
+ monkeypatch.setattr(
+ PseudoDojoFamily,
+ "get_valid_labels",
+ classmethod(lambda cls: ("PseudoDojo/9.9/XC/SR/standard/upf",)),
+ )
+ output = _run(monkeypatch)
+ assert "PseudoDojo/9.9/XC/SR/standard/upf" in output
+ assert "PseudoDojo/0.4/PBE/SR/standard/upf" not in output
+
+ def test_every_upf_label_pseudo_dojo_publishes_is_printed(self, monkeypatch: Any) -> None:
+ """The PseudoDojo section is exactly the UPF labels the library publishes.
+
+ Filtering must drop the other three formats and nothing else, so this
+ pins both directions: a filter that also lost a UPF version, and one
+ that kept a psp8 label, each fail.
+ """
+ from aiida_pseudo.groups.family import PseudoDojoFamily
+
+ published = set(PseudoDojoFamily.get_valid_labels())
+ printed = {
+ line.split()[0]
+ for line in _run(monkeypatch).splitlines()
+ if line.startswith(" Pseudo")
+ }
+ assert printed == {label for label in published if label.endswith("/upf")}
+ assert printed < published
+
+ def test_sg15_comes_from_the_installers_own_constants(self, monkeypatch: Any) -> None:
+ """SG15 has no ``aiida-pseudo`` library, so its labels track the installer."""
+ from koopmans.aiida.setup.pseudos import _sg15
+
+ monkeypatch.setattr(_sg15, "VARIANTS", {"7.7": {"SR": ("7.7",)}})
+ output = _run(monkeypatch)
+ assert "SG15/7.7/PBE/SR" in output
+ assert "SG15/1.2/PBE/SR" not in output
+
+
+class TestOnlyRunnableFamiliesAreOffered:
+ """Koopmans functionals need norm-conserving pseudos, and pw.x reads UPF alone."""
+
+ def test_sssp_is_not_offered(self, monkeypatch: Any) -> None:
+ """SSSP is not uniformly norm-conserving, so koopmans cannot use it.
+
+ The positives are pinned alongside, so a listing that printed nothing
+ at all would not pass.
+ """
+ output = _run(monkeypatch)
+ assert "SSSP" not in output
+ assert "PseudoDojo/0.4/LDA/SR/standard/upf" in output
+ assert "SG15/1.2/PBE/SR" in output
+
+ def test_no_format_pw_cannot_read_is_offered(self, monkeypatch: Any) -> None:
+ """``PwCalculation`` takes ``UpfData``; psp8, psml and jthxml install but cannot run.
+
+ PseudoDojo publishes the same family in all four, so the format suffix
+ is the only thing separating the offered label from the unusable one.
+ """
+ output = _run(monkeypatch)
+ for pseudo_format in ("psp8", "psml", "jthxml"):
+ assert f"/{pseudo_format}" not in output
+ assert "PseudoDojo/0.4/PBE/SR/standard/upf" in output
+
+ def test_sg15_is_offered_only_where_the_archive_publishes_it(self, monkeypatch: Any) -> None:
+ """The 2020-02-06 tarball holds no ``*_ONCV_PBE_FR-1.2.upf``.
+
+ A label built by crossing every version with every relativistic
+ variant offers ``SG15/1.2/PBE/FR``, which downloads the archive and
+ matches nothing in it. The two fully relativistic labels that do
+ exist are pinned alongside, so dropping FR wholesale also fails.
+ """
+ output = _run(monkeypatch)
+ assert "SG15/1.2/PBE/FR" not in output
+ assert "SG15/1.0/PBE/FR" in output
+ assert "SG15/1.1/PBE/FR" in output
+
+ def test_no_lda_full_relativistic_family_is_offered(self, monkeypatch: Any) -> None:
+ """PseudoDojo publishes full-relativistic pseudos for PBE and PBEsol only.
+
+ The combination a spin-orbit user goes looking for, since ``kcw.x`` is
+ LDA-only for noncollinear runs; a grammar of the label's parts would
+ promise it.
+ """
+ assert "LDA/FR" not in _run(monkeypatch)
+
+
+class TestTheSG15NotesDescribeTheArchive:
+ """The element counts printed are the ones in the pinned 2020-02-06 tarball.
+
+ Enumerating that tarball by the installer's own filename pattern gives 69
+ scalar-relativistic elements at 1.0 and at 1.2 and 17 at 1.1, and fully
+ relativistic files at 1.0 (52 elements) and 1.1 (12) alone. Composed over
+ 1.0, version 1.1 covers 69 elements scalar-relativistic and 64 fully
+ relativistic.
+ """
+
+ def test_no_version_is_described_as_carrying_a_handful_of_elements(
+ self, monkeypatch: Any
+ ) -> None:
+ """1.0 is a full 69-element release, not a two-element one.
+
+ A hand-built family holding H and O alone can sit in a profile under
+ an SG15 label and look like the library; the archive is what decides.
+ """
+ output = _run(monkeypatch)
+ assert "only H and O" not in output
+ assert "1.0 covers the same 69" in output
+
+ def test_the_composition_of_1_1_is_explained(self, monkeypatch: Any) -> None:
+ """A 1.1 family holds files named -1.0.upf, and the note says why.
+
+ Nothing else tells the user that the label installs two revisions, so
+ a listing that described 1.1 as its 17 revised elements leaves them
+ reading the install as broken.
+ """
+ output = _run(monkeypatch)
+ assert "composes it" in output
+ assert "-1.0.upf" in output
+
+ def test_the_fully_relativistic_coverage_is_stated(self, monkeypatch: Any) -> None:
+ """Neither FR label is a whole periodic table, and users pick by element.
+
+ Silicon has no fully relativistic SG15 pseudopotential at 1.0 and
+ oxygen none at 1.1, so a note naming either release on its own sends a
+ spin-orbit run to a label that cannot supply one of them.
+ """
+ output = _run(monkeypatch)
+ assert "Name SG15/1.1/PBE/FR for fully relativistic runs" in output
+ assert "64 elements against 1.0's 52" in output
+ assert "Ba, Be, Bi, Li and Ne have no fully relativistic" in output
+
+ def test_only_ecutwfc_is_asked_for(self, monkeypatch: Any) -> None:
+ """``ecutrho`` follows at four times ``ecutwfc`` for norm-conserving pseudos.
+
+ Stating both was right before koopmans derived the dual; asking for
+ ``ecutrho`` now invites an input the conversion would reject.
+ """
+ from koopmans.aiida.conversion import NORM_CONSERVING_DUAL
+
+ assert NORM_CONSERVING_DUAL == 4.0
+ output = _run(monkeypatch)
+ assert "set `ecutwfc` in your input file" in output
+ assert "ecutrho" not in output
+
+
+class TestInstalledMarkers:
+ """Installed families are marked; the rest of the listing does not change."""
+
+ def test_only_the_installed_labels_are_marked(self, monkeypatch: Any) -> None:
+ """One installed family, one marker."""
+ output = _run(monkeypatch, installed={"SG15/1.2/PBE/SR"})
+ marked = [line for line in output.splitlines() if "[installed]" in line]
+ assert len(marked) == 1
+ assert marked[0].split()[0] == "SG15/1.2/PBE/SR"
+
+ def test_the_listing_works_without_a_profile(self, monkeypatch: Any) -> None:
+ """Before ``koopmans install`` there is no profile, and nothing is marked."""
+ from koopmans.aiida.setup import profile as profile_mod
+
+ monkeypatch.setattr(profile_mod, "profile_exists", lambda: False)
+ result = CliRunner().invoke(cli, ["pseudos"])
+ assert result.exit_code == 0, result.output
+ assert "[installed]" not in result.output
+ assert "PseudoDojo/0.4/LDA/SR/standard/upf" in result.output
+
+
+class TestInstallRefusesWhatItCannotRun:
+ """A label the listing does not offer is refused, not downloaded."""
+
+ def test_an_sssp_label_is_refused_by_name(self) -> None:
+ """Naming SSSP explains the constraint rather than reporting an unknown format."""
+ with pytest.raises(ValueError, match="norm-conserving"):
+ pseudos_mod.install_pseudo_family("SSSP/1.3/PBE/efficiency")
+
+ def test_an_sg15_variant_the_archive_lacks_is_refused_before_downloading(self) -> None:
+ """``SG15/1.2/PBE/FR`` names a version and a variant that each exist, apart.
+
+ The guard runs before the 6 MB download, so a refusal here is also
+ what keeps the test offline; a version-only check would let this
+ through to unpack the archive and find nothing.
+ """
+ with pytest.raises(ValueError, match=r"no FR pseudopotentials at version 1\.2"):
+ pseudos_mod.install_pseudo_family("SG15/1.2/PBE/FR")
+
+ def test_a_non_upf_format_is_refused_and_upf_still_installs(self, monkeypatch: Any) -> None:
+ """psp8 stops at the guard; the same family in UPF reaches the installer.
+
+ Stubbing the downloader is what discriminates: a guard that refused
+ every PseudoDojo label, and one that let psp8 through, each fail one
+ half of this.
+ """
+ from aiida_pseudo.cli import install as install_mod
+ from aiida_pseudo.data.pseudo import UpfData
+
+ received: dict[str, Any] = {}
+
+ class _Family:
+ def set_default_stringency(self, stringency: str) -> None:
+ """Record the stringency the installer asks for."""
+ received["stringency"] = stringency
+
+ def _install(**kwargs: Any) -> _Family:
+ received.update(kwargs)
+ return _Family()
+
+ monkeypatch.setattr(install_mod, "download_pseudo_dojo", lambda **kwargs: None)
+ monkeypatch.setattr(install_mod, "install_pseudo_dojo", _install)
+
+ with pytest.raises(ValueError, match="UPF"):
+ pseudos_mod.install_pseudo_family("PseudoDojo/0.4/PBE/SR/standard/psp8")
+ assert received == {}
+
+ pseudos_mod.install_pseudo_family("PseudoDojo/0.4/PBE/SR/standard/upf")
+ assert received["pseudo_type"] is UpfData
+
+
+class TestTheFieldPointsAtTheCommand:
+ """The schema's advice names a command that exists."""
+
+ def test_the_description_names_a_real_command(self) -> None:
+ """``koopmans pseudos list`` was the previous package's command."""
+ from koopmans.input_file.workflow import WorkflowConfig
+
+ description = WorkflowConfig.model_fields["pseudo_library"].description or ""
+ assert "koopmans pseudos" in description
+ assert "koopmans pseudos list" not in description
+ assert "pseudos" in cli.commands
diff --git a/tests/test_wannierize_blocks_dispatcher.py b/tests/test_wannierize_blocks_dispatcher.py
index 4dc31b8..05249cd 100644
--- a/tests/test_wannierize_blocks_dispatcher.py
+++ b/tests/test_wannierize_blocks_dispatcher.py
@@ -34,8 +34,8 @@ def _si_split_dict(**workflow_updates: Any) -> dict[str, Any]:
"workflow": {
"task": "wannierize",
# The cutoffs family fixture: the split builder calls
- # get_builder_from_protocol eagerly at build time, which only
- # accepts SSSP / PseudoDojo / cutoffs families.
+ # get_builder_from_protocol eagerly at build time, and this input
+ # states no cutoffs of its own for the family to go without.
"pseudo_library": "SG15/1.0/PBE/SR",
"block_wannierization_threshold": 1.5,
},