Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
git clone --depth 1 --branch patched https://github.com/elinscott/node-graph ../node-graph
git clone --depth 1 --branch patched https://github.com/elinscott/plumpy ../plumpy
git clone --depth 1 --branch patched https://github.com/elinscott/aiida-wannier90-workflows ../aiida-wannier90-workflows
git init -q ../aiida-quantumespresso && git -C ../aiida-quantumespresso fetch --depth 1 https://github.com/aiidateam/aiida-quantumespresso 0a15b8ac63d35f1422ba0b2184f420c177bd2d70 && git -C ../aiida-quantumespresso checkout -q FETCH_HEAD
git clone --depth 1 --branch patched https://github.com/elinscott/aiida-quantumespresso ../aiida-quantumespresso
git clone --depth 1 https://github.com/elinscott/aiida-wannierjl ../aiida-wannierjl
git clone --depth 1 https://github.com/elinscott/pydantic-espresso ../pydantic_espresso
git init -q ../aiida-core && git -C ../aiida-core fetch --depth 1 https://github.com/aiidateam/aiida-core 4c81e9d64ac561325b91c7e6a18ea2cf792e0609 && git -C ../aiida-core checkout -q FETCH_HEAD
Expand Down Expand Up @@ -86,7 +86,7 @@ jobs:
git clone --depth 1 --branch patched https://github.com/elinscott/node-graph ../node-graph
git clone --depth 1 --branch patched https://github.com/elinscott/plumpy ../plumpy
git clone --depth 1 --branch patched https://github.com/elinscott/aiida-wannier90-workflows ../aiida-wannier90-workflows
git init -q ../aiida-quantumespresso && git -C ../aiida-quantumespresso fetch --depth 1 https://github.com/aiidateam/aiida-quantumespresso 0a15b8ac63d35f1422ba0b2184f420c177bd2d70 && git -C ../aiida-quantumespresso checkout -q FETCH_HEAD
git clone --depth 1 --branch patched https://github.com/elinscott/aiida-quantumespresso ../aiida-quantumespresso
git clone --depth 1 https://github.com/elinscott/aiida-wannierjl ../aiida-wannierjl
git clone --depth 1 https://github.com/elinscott/pydantic-espresso ../pydantic_espresso
git init -q ../aiida-core && git -C ../aiida-core fetch --depth 1 https://github.com/aiidateam/aiida-core 4c81e9d64ac561325b91c7e6a18ea2cf792e0609 && git -C ../aiida-core checkout -q FETCH_HEAD
Expand Down Expand Up @@ -136,7 +136,7 @@ jobs:
git clone --depth 1 --branch patched https://github.com/elinscott/node-graph ../node-graph
git clone --depth 1 --branch patched https://github.com/elinscott/plumpy ../plumpy
git clone --depth 1 --branch patched https://github.com/elinscott/aiida-wannier90-workflows ../aiida-wannier90-workflows
git init -q ../aiida-quantumespresso && git -C ../aiida-quantumespresso fetch --depth 1 https://github.com/aiidateam/aiida-quantumespresso 0a15b8ac63d35f1422ba0b2184f420c177bd2d70 && git -C ../aiida-quantumespresso checkout -q FETCH_HEAD
git clone --depth 1 --branch patched https://github.com/elinscott/aiida-quantumespresso ../aiida-quantumespresso
git clone --depth 1 https://github.com/elinscott/aiida-wannierjl ../aiida-wannierjl
git clone --depth 1 https://github.com/elinscott/pydantic-espresso ../pydantic_espresso
git init -q ../aiida-core && git -C ../aiida-core fetch --depth 1 https://github.com/aiidateam/aiida-core 4c81e9d64ac561325b91c7e6a18ea2cf792e0609 && git -C ../aiida-core checkout -q FETCH_HEAD
Expand Down
2 changes: 1 addition & 1 deletion .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ build:
- rm -rf ../plumpy && git clone --depth 1 --branch patched https://github.com/elinscott/plumpy ../plumpy
- rm -rf ../aiida-wannier90-workflows && git clone --depth 1 --branch patched https://github.com/elinscott/aiida-wannier90-workflows ../aiida-wannier90-workflows
- rm -rf ../aiida-wannierjl && git clone --depth 1 https://github.com/elinscott/aiida-wannierjl ../aiida-wannierjl
- git init -q ../aiida-quantumespresso && git -C ../aiida-quantumespresso fetch --depth 1 https://github.com/aiidateam/aiida-quantumespresso 0a15b8ac63d35f1422ba0b2184f420c177bd2d70 && git -C ../aiida-quantumespresso checkout -q FETCH_HEAD
- rm -rf ../aiida-quantumespresso && git clone --depth 1 --branch patched https://github.com/elinscott/aiida-quantumespresso ../aiida-quantumespresso
- rm -rf ../pydantic_espresso && git clone --depth 1 https://github.com/elinscott/pydantic-espresso ../pydantic_espresso
- git init -q ../aiida-core && git -C ../aiida-core fetch --depth 1 https://github.com/aiidateam/aiida-core 4c81e9d64ac561325b91c7e6a18ea2cf792e0609 && git -C ../aiida-core checkout -q FETCH_HEAD
- uv venv $READTHEDOCS_VIRTUALENV_PATH
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ pairing (grouping criterion ↔ screening method) as an equivalence.
- Orbital grouping: `group_orbitals_by`/`group_orbitals_tol`. DSCF groups by self-Hartree (kcp.x metric); DFPT groups by wannier90 spread with a per-representative `SCREEN.i_orb` fan-out + alpha broadcast. Criteria and methods are independent in principle; unwired combinations raise. kcw.x's internal `check_spread` shortcut is separate (ak2 graph input, on by default).
- Explicit k-paths resolve against the cell's own Bravais lattice (ASE vocabulary, position-insensitive); seekpath only serves automatic paths.
- aiida-core: builds from `../aiida-core` at current upstream main (post-v2.8, includes the workgraph-dump MRO fix); aiida-shell rides its git master via `[tool.uv.sources]` until a post-`Code.Model` release exists. CI/RTD clone pin: `4c81e9d6`. NOTE: `uv run --project` re-syncs the venv whenever `../aiida-core` changes — keep that checkout where the CI pin points.
- aiida-quantumespresso: upstream main absorbed every `patched`-branch fix (pdos settings threading, PBC handling, hyperqueue default resources), so the `patched` branch is retired; CI/RTD clone upstream at the pinned sha `0a15b8ac`, and the local checkout tracks upstream main.
- aiida-quantumespresso: the `patched` branch is live again, carrying the two protocol commits that let `get_builder_from_protocol` take a pseudo family recommending no cutoffs (request cutoffs only when the overrides supply none; accept any `aiida-pseudo` family). CI and RTD clone `elinscott/aiida-quantumespresso` at `patched`; the local checkout at `../aiida-quantumespresso` tracks upstream main, so run against a `patched` worktree shadowed on `PYTHONPATH`.
- ML descriptors: `self_hartree` wired; `orbital_density` fully built on pw2wannier90 `wan_mode='decompose'` (new ak2 CalcJob+parser, legacy-comparable cross-power, decompose math reproduced to machine precision on live Si) but **gated** pending a live per-block WF-to-alpha alignment regression; flipping the guard is one line.
- Known gaps (raise `NotImplementedError` with pointers in `aiida/workflows/`): corrections `PKIPZ`/`NONE`/`ALL`; `init_orbitals='pz'`; `fix_spin_contamination`; gamma-only/molecular DFPT; `eps_inf='auto'` for DSCF (wired for DFPT); `ml: {mode: predict}` with the `power_spectrum` descriptor; UI inside singlepoints (the open item; the `ui-singlepoint` branch is kept as reference); `convergence` task.
- Standalone UI (unfold-and-interpolate) is unsupported by decision: `task: unfold_and_interpolate` is rejected at parse (no `Task` enum member), while the `unfold_and_interpolate:` input block and `UnfoldAndInterpolateConfig` stay for the future singlepoint use.
Expand Down
69 changes: 59 additions & 10 deletions src/koopmans/aiida/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from __future__ import annotations

import math
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any

Expand Down Expand Up @@ -39,6 +41,10 @@ def _convert_paths_to_strings(obj: Any) -> Any:
# Quantum ESPRESSO's own value, so that converted quantities match QE output
BOHR_TO_ANGSTROM: float = CONSTANTS.bohr_to_ang

# ``ecutrho / ecutwfc`` for a norm-conserving pseudopotential, which is what
# kcp.x and kcw.x accept — and so the only kind koopmans runs.
NORM_CONSERVING_DUAL: float = 4.0


def code_parallelization(
config: CodeParallelization | None,
Expand Down Expand Up @@ -575,6 +581,54 @@ def kpoints_input_to_kpoints_path(
return kpts


def _resolve_pw_cutoffs(system: dict[str, Any], kcp_ecutrho: float) -> None:
"""Complete the ``SYSTEM`` cutoff pair in place, from ``ecutwfc``.

An unstated ``ecutrho`` becomes ``kcp_ecutrho``, or
``NORM_CONSERVING_DUAL * ecutwfc`` when that is unset too. Whatever its
source, an ``ecutrho`` that is not :data:`NORM_CONSERVING_DUAL` times
``ecutwfc`` takes effect with a warning naming the key it came from. With
neither cutoff stated the pair is left empty, for the pseudopotential
family to recommend.

Raises:
ValueError: If ``ecutrho`` is stated and ``ecutwfc`` is not.
"""
ecutwfc = system.get("ecutwfc")
ecutrho = system.get("ecutrho")
source = "calculator_parameters.pw.system.ecutrho"

if ecutwfc is None:
if ecutrho is not None:
raise ValueError(
"`calculator_parameters.pw.system.ecutrho` is set without a wavefunction "
"cutoff, which would pair it with whatever the pseudopotential family "
"recommends. Set `calculator_parameters.ecutwfc`; `ecutrho` follows at "
f"{NORM_CONSERVING_DUAL:g} times it on its own."
)
return

if ecutrho is None:
if kcp_ecutrho:
# An explicit ``kcp.system.ecutrho`` keeps the pw.x runs on the grid
# of the CP supercell run the dft_init consistency checks compare
# against.
ecutrho = kcp_ecutrho
source = "calculator_parameters.kcp.system.ecutrho"
else:
ecutrho = NORM_CONSERVING_DUAL * ecutwfc
system["ecutrho"] = ecutrho

if not math.isclose(ecutrho, NORM_CONSERVING_DUAL * ecutwfc):
warnings.warn(
f"`{source}` = {ecutrho:g} Ry: ecutrho should be {NORM_CONSERVING_DUAL:g} x "
f"ecutwfc = {NORM_CONSERVING_DUAL * ecutwfc:g} Ry for norm-conserving "
f"pseudopotentials. Drop `{source}` to take that.",
UserWarning,
stacklevel=3,
)


def input_to_pw_parameters(koopmans_input: KoopmansInput) -> dict[str, dict[str, Any]]:
"""Convert KoopmansInput to a PW input-parameter namelist dict.

Expand All @@ -596,16 +650,6 @@ def input_to_pw_parameters(koopmans_input: KoopmansInput) -> dict[str, dict[str,
# Add ecutwfc if specified
if calc_params.ecutwfc is not None:
parameters["SYSTEM"]["ecutwfc"] = calc_params.ecutwfc
# Pin ecutrho alongside it (kcp.x convention: 4x for norm-conserving,
# same fallback as ``_extract_kcp_scalar_inputs``). Without this the
# pseudo family's placeholder ecutrho wins in protocol-built PW runs,
# putting them on a different grid from the CP supercell run that the
# dft_init consistency checks compare against. An explicit
# ``pw.system.ecutrho`` still overrides via the update below.
kcp_system = calc_params.kcp.system
parameters["SYSTEM"]["ecutrho"] = (
kcp_system.ecutrho if kcp_system.ecutrho else 4.0 * calc_params.ecutwfc
)

# Add nbnd if specified
if calc_params.nbnd is not None:
Expand All @@ -624,6 +668,11 @@ def input_to_pw_parameters(koopmans_input: KoopmansInput) -> dict[str, dict[str,
parameters["ELECTRONS"].update(
pw_params.electrons.model_dump(exclude_none=True, exclude_defaults=True)
)

# After the merge, so the pair is resolved from the cutoffs pw.x will run
# rather than from the top-level shorthand a ``pw.system`` block may replace.
_resolve_pw_cutoffs(parameters["SYSTEM"], calc_params.kcp.system.ecutrho)

# Ensure all Path objects are converted to strings for JSON serialization
parameters = _convert_paths_to_strings(parameters)

Expand Down
23 changes: 20 additions & 3 deletions src/koopmans/aiida/setup/pseudos.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ def ensure_pseudo_family_installed(pseudo_family: str) -> None:
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:
"""Install a pseudopotential family. Parse the label and dispatch."""
parts = pseudo_family.split("/")
Expand Down Expand Up @@ -179,9 +195,10 @@ def _install_sssp_family(label: str, parts: list[str]) -> None:
# 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 install as a plain
# ``CutoffsPseudoPotentialFamily`` so recommended cutoffs can be attached later
# via ``family.set_cutoffs`` without a reinstall.
# upstream ``aiida-pseudo`` installer for SG15, so we install a
# ``CutoffsPseudoPotentialFamily`` directly. SG15 publishes no recommended
# cutoffs, so the family carries none and the input file must set ``ecutwfc``
# and ``ecutrho`` itself.
_SG15_ARCHIVE_URL = (
"http://www.quantum-simulation.org/potentials/sg15_oncv/sg15_oncv_upf_2020-02-06.tar.gz"
)
Expand Down
33 changes: 32 additions & 1 deletion src/koopmans/aiida/workflows/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,32 @@ def load_codes_for_task(workflow: WorkflowConfig) -> Codes:
return codes


def require_cutoffs_for_family(pseudo_family: str, parameters: dict[str, Any]) -> None:
"""Reject an input that names no cutoffs against a family recommending none.

Args:
pseudo_family: Label of the family the pw.x steps will use.
parameters: The pw.x parameters the input file produced, whose
``SYSTEM`` block carries ``ecutwfc`` when the input states it.

Raises:
ValueError: If the family publishes no recommended cutoffs and the
input states none either.
"""
from koopmans.aiida.setup.pseudos import pseudo_family_has_cutoffs

if pseudo_family_has_cutoffs(pseudo_family):
return

if "ecutwfc" not in parameters.get("SYSTEM", {}):
raise ValueError(
f"The pseudopotential family `{pseudo_family}` publishes no recommended "
"cutoffs, so they must come from the input file: set "
"`calculator_parameters.ecutwfc`. `ecutrho` follows at four times it "
"unless `calculator_parameters.pw.system.ecutrho` states otherwise."
)


def prepare_common_inputs(
koopmans_input: KoopmansInput,
override_keys: list[str],
Expand All @@ -123,7 +149,9 @@ def prepare_common_inputs(

Converts the koopmans input into a structure, ensures the pseudo family is
installed, and builds an overrides dict with a PW parameters entry for each
of the requested sub-workflow keys.
of the requested sub-workflow keys. An input naming no cutoffs against a
family recommending none is rejected here
(:func:`require_cutoffs_for_family`).

Args:
koopmans_input: The parsed koopmans input.
Expand All @@ -140,7 +168,10 @@ def prepare_common_inputs(

ensure_pseudo_family_installed(pseudo_family)

require_cutoffs_for_family(pseudo_family, parameters)

pw_overrides: dict[str, Any] = {"parameters": parameters}

# The pw entry carries the pw.x parallelization directive: -npool rides
# settings.cmdline; ntasks rides metadata.options.resources — both survive
# get_builder_from_protocol's override merge (verified by eager build).
Expand Down
5 changes: 4 additions & 1 deletion src/koopmans/aiida/workflows/dscf.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from aiida_quantumespresso.common.types import SpinType

from koopmans.aiida.conversion import atoms_input_to_structure, input_to_pw_parameters
from koopmans.aiida.workflows import load_code, reject_kpoint_overrides
from koopmans.aiida.workflows import load_code, reject_kpoint_overrides, require_cutoffs_for_family
from koopmans.aiida.workflows.blocks import (
create_explicit_blocks,
validate_blocks_cover_all_occ_bands,
Expand Down Expand Up @@ -210,6 +210,9 @@ 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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why doesn't it call prepare_common_inputs?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because prepare_common_inputs builds a shape this route does not use, and resolves things it has already resolved.

It returns (structure, pseudo_family, overrides) where overrides is flat — one entry per sub-workflow key, {"scf": {...}, "nscf": {...}} — built for a route that hands the whole thing to one graph builder. dscf_wannier_init_inputs is a helper, called with structure and nbnd already in hand from its caller, and what it assembles is a WannierizeOverrides feeding a per-block fan-out. Calling prepare_common_inputs would redo the structure conversion and the family install, then discard most of what came back.

Worth saying plainly though: three call sites for one check is a wart, and you are right to poke at it. The map today is

module prepare_common_inputs own cutoff check
dft, eps, dfpt yes via that
wannierize (whole-manifold) yes via that
wannierize (block-by-block) no its own
dscf, trajectory no its own

So it is not one route being awkward — it is that "prepare the shared pw inputs" and "validate the family against the input" got bundled into one function, and only half the routes want the first. Splitting the validation out, so every route calls it once regardless of how it builds its overrides, would be the real fix. I did not do it here because it touches every route's entry point and this PR was already large; happy to open an issue.

# reaches its pw steps only from here.
require_cutoffs_for_family(pseudo_family, parameters)
wannier_overrides: WannierizeOverrides = {
"scf": {"pseudo_family": pseudo_family, "pw": {"parameters": parameters}},
"nscf": {"pseudo_family": pseudo_family, "pw": {"parameters": parameters}},
Expand Down
19 changes: 16 additions & 3 deletions src/koopmans/aiida/workflows/wannierize.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
from aiida_quantumespresso.common.types import SpinType

from koopmans.aiida.conversion import atoms_input_to_structure, input_to_pw_parameters
from koopmans.aiida.workflows import pin_step_kpoints, prepare_common_inputs
from koopmans.aiida.workflows import (
pin_step_kpoints,
prepare_common_inputs,
require_cutoffs_for_family,
)
from koopmans.aiida.workflows.blocks import (
create_automatic_blocks,
create_explicit_blocks,
Expand Down Expand Up @@ -310,9 +314,18 @@ def _build_wannierize_blocks_workgraph(
scf_parameters.get("SYSTEM", {}).pop("nbnd", None)
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.
Comment on lines +317 to +318

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, why?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same root cause, different reason for the split — and this one is a branch rather than a helper.

build_wannierize_workgraph returns early to _build_wannierize_blocks_workgraph at lines 189 and 193, and only reaches prepare_common_inputs at line 197. So the whole-manifold route uses it and the block-by-block route never gets there. The block route needs per-step overrides that feed a fan-out, not the flat one-entry-per-key shape.

Worth flagging that this is exactly where the risk was: I originally briefed this as "only DSCF needs its own check, because wannierize.py calls prepare_common_inputs". It does — on the other branch. Following that brief would have left the block route silently uncovered, and it was caught by writing the negative control rather than by reading.

The two also fail differently when the check is removed, which is why each has its own test: here you get aiida-quantumespresso's stringency message back, because this route builds the pw protocol eagerly. On DSCF you get nothing at all — the graph builds and carries no wavefunction cutoff into its pw.x steps.

Fix for the duplication is the same one as on the DSCF thread: split the validation out of prepare_common_inputs so every route calls it once, however it builds its overrides.

require_cutoffs_for_family(pseudo_family, parameters)
wannier_overrides: WannierizeOverrides = {
"scf": {"pseudo_family": pseudo_family, "pw": {"parameters": scf_parameters}},
"nscf": {"pseudo_family": pseudo_family, "pw": {"parameters": nscf_parameters}},
"scf": {
"pseudo_family": pseudo_family,
"pw": {"parameters": scf_parameters},
},
"nscf": {
"pseudo_family": pseudo_family,
"pw": {"parameters": nscf_parameters},
},
}

# User wannier90 keywords (disentanglement windows, iteration counts, ...)
Expand Down
Loading