diff --git a/.gitignore b/.gitignore index 06e245c0..a6ac2892 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,5 @@ pip-wheel-metadata/ poetry.lock publish_to_pypi.sh secrets.tar.gz +run_bdg_test.py +test_bdg_compare.py diff --git a/aiida_kkr/parsers/kkr.py b/aiida_kkr/parsers/kkr.py index e1203dec..7cf5dc2b 100644 --- a/aiida_kkr/parsers/kkr.py +++ b/aiida_kkr/parsers/kkr.py @@ -112,8 +112,8 @@ def parse(self, debug=False, **kwargs): outfile_2_name = KkrCalculation._OUTPUT_2 if outfile_2_name not in out_folder.list_object_names(): if not only_000_present: - file_errors.append((1 + self.icrit, f'Critical error! OUTPUT_2 not found {outfile_2_name}')) - outfile_2_name = None + file_errors.append((2, f'Warning! OUTPUT_2 not found {outfile_2_name}, using OUTPUT_000 instead')) + outfile_2_name = outfile_000_name else: outfile_2_name = outfile_000_name potfile_out_name = KkrCalculation._OUT_POTENTIAL diff --git a/aiida_kkr/workflows/__init__.py b/aiida_kkr/workflows/__init__.py index 481210ea..0f772042 100644 --- a/aiida_kkr/workflows/__init__.py +++ b/aiida_kkr/workflows/__init__.py @@ -17,3 +17,4 @@ from .jijs import kkr_jij_wc from .imp_BdG import kkrimp_BdG_wc from .kkr_STM import kkr_STM_wc +from .kkr_bdg_wc import kkr_bdg_wc diff --git a/aiida_kkr/workflows/kkr_bdg_wc.py b/aiida_kkr/workflows/kkr_bdg_wc.py new file mode 100644 index 00000000..c97d2cad --- /dev/null +++ b/aiida_kkr/workflows/kkr_bdg_wc.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +WorkChain for automated Bogoliubov-de Gennes (BdG) calculations for host materials +using the AiiDA-KKR plugin. + +Sequence: + 1. Normal KKR SCF (optional, bypass with `remote_data_normal`) + 2. Semi-circle contour restart calculation + 3. BdG initialization (one-shot raw KkrCalculation, NSTEPS=1) + 4. Final BdG SCF (full self-consistency of the anomalous density) +""" + +from aiida import orm +from aiida.engine import WorkChain, ToContext, if_, calcfunction +from aiida_kkr.workflows.kkr_scf import kkr_scf_wc +from aiida_kkr.tools.common_workfunctions import test_and_get_codenode, get_inputs_kkr +from aiida_kkr.tools.save_output_nodes import create_out_dict_node +from aiida_kkr.calculations.kkr import KkrCalculation +from masci_tools.io.kkr_params import kkrparams + +__copyright__ = (u'Copyright (c), 2026, Forschungszentrum Jülich GmbH, ' + 'IAS-1/PGI-1, Germany. All rights reserved.') +__license__ = 'MIT license, see LICENSE.txt file' +__version__ = '0.2.0' +__contributors__ = u'Philipp Rüßmann, Mohammad Hemmati' + + +# ── Helper calcfunctions (maintain provenance) ──────────────────────────────── + +@calcfunction +def update_params_semi_circle(params_node, semi_circle_settings): + _unregistered_keys = ['NPT1'] + # Strip SCF-specific keys inherited from kkr_scf_wc convergence loop. + # The tutorial never sets these — KKR uses its own internal defaults. + _scf_inherited_keys = ['STRMIX', 'BRYMIX', 'QBOUND', 'HFIELD', 'LINIPOL'] + clean_dict = {k: v for k, v in params_node.get_dict().items() + if v is not None and k not in _scf_inherited_keys} + para = kkrparams(**clean_dict) + for k in ['TEMPR', 'NPT2', 'NPT3', 'NPOL']: + try: + para.remove_value(k) + except KeyError: + pass + settings_dict = semi_circle_settings.get_dict() + unregistered = {k: settings_dict.pop(k) for k in _unregistered_keys if k in settings_dict} + para.set_multiple_values(**settings_dict) + result_dict = para.get_dict() + result_dict.update(unregistered) + return orm.Dict(dict=result_dict) + +@calcfunction +def update_params_bdg(params_node, bdg_settings): + """ + Update KKR parameters for a BdG step. + + Uses a dict-level merge (not kkrparams reconstruction) to preserve ALL + keys from the parent params — including RUNOPT, bracket keys + (, , …), and list values + (BZDIVIDE) — which kkrparams(**parent_dict) would silently drop. + + Strategy: + 1. Start from the full parent dict (preserves everything) + 2. Run bdg_settings through an EMPTY kkrparams to normalize aliases + 3. Merge normalized BdG keys on top + """ + # Step 1: preserve every key from the parent + result_dict = {k: v for k, v in params_node.get_dict().items() if v is not None} + + # Step 2: normalize BdG aliases through an empty kkrparams instance + para = kkrparams() + para.set_multiple_values(**bdg_settings.get_dict()) + bdg_normalized = {k: v for k, v in para.get_dict().items() if v is not None} + + # Step 3: BdG settings take precedence over parent + result_dict.update(bdg_normalized) + + return orm.Dict(dict=result_dict) +# ── WorkChain ───────────────────────────────────────────────────────────────── + +class kkr_bdg_wc(WorkChain): + """ + Workchain for performing a Bogoliubov-de Gennes (BdG) host calculation. + + :param kkr: (Code) KKRhost code for normal-state & semi-circle steps. + :param kkr_bdg: (Code) KKRhost BdG code for the superconducting solver. + :param voronoi: (Code, optional) Voronoi code (only needed from scratch). + :param structure: (StructureData, optional) Crystal structure. + :param calc_parameters: (Dict) Initial KKR parameters (LMAX, RMAX, …). + :param remote_data_normal: (RemoteData, optional) Bypass normal SCF. + :param remote_data_semi_circle: (RemoteData, optional) Bypass normal + semi-circle SCF. + :param semi_circle_settings: (Dict) Parameters injected for the semi-circle step. + :param bdg_init_settings: (Dict) Parameters for the one-shot BdG initialisation. + :param bdg_settings: (Dict) Parameters for the final BdG SCF. + :param options: (Dict) Computer options (queue, wallclock, …). + :param wf_parameters: (Dict) Workflow parameters forwarded to sub-workflows. + + :return output_kkr_bdg_wc_ParameterResults: (Dict) Summary of the workflow. + :return last_RemoteData: (RemoteData) Remote folder of the final BdG calculation. + :return last_InputParameters: (Dict) Input parameters of the final BdG calculation. + """ + + _workflowversion = __version__ + _wf_label = 'kkr_bdg_wc' + _wf_description = ( + 'Workflow for a BdG KKR calculation starting either from a structure ' + '(with automatic Voronoi + normal SCF) or from a converged RemoteData node.' + ) + + # ── Default workflow settings ────────────────────────────────────────────── + _wf_default = { + # semi-circle contour defaults + 'semi_circle': { + 'USE_SEMI_CIRCLE_CONTOUR': True, + 'IM_E_CIRC_MIN': 5e-5, + 'NPT1': 32, + 'MAX_NUM_KMESH': 4, + 'BZDIVIDE': [100, 100, 100], + 'RCLUSTZ': 3.5, + 'NSTEPS': 200, + 'IMIX': 4, + 'DISABLE_CHARGE_NEUTRALITY': True, + 'RUNOPT': ['NEWSOSOL'], + 'R_LOG': 0.6, + 'NPAN_EQ': 7, + 'NPAN_LOG': 18, + 'NCHEB': 12, + 'DECOUPLE_SPIN_CHEBY': True, + }, + # BdG one-shot init defaults + 'bdg_init': { + 'NSTEPS': 1, + 'use_BdG': True, + 'Delta_BdG': 5e-4, + 'use_e_symm_BdG': True, + 'at_scale_BdG': [1.0], # must match number of atoms + 'RUNOPT': ['NEWSOSOL'], + 'DECOUPLE_SPIN_CHEBY': True, + }, + # BdG SCF defaults + 'bdg_scf': { + 'NSTEPS': 200, + 'use_BdG': True, + 'lambda_BdG': 0.025, + 'mixfac_BdG': 0.3, + 'memlen_Broyden_BdG': 20, + 'Ninit_Broyden_BdG': 5, + 'NSIMPLEMIXFIRST': 25, + 'IMIX': 4, + 'DISABLE_CHARGE_NEUTRALITY': True, + 'use_e_symm_BdG': True, + 'FORCE_BZ_SYMM': False, + }, + } + + _options_default = { + 'queue_name': '', + 'resources': {'num_machines': 1}, + 'max_wallclock_seconds': 60 * 60 * 4, + 'withmpi': True, + 'custom_scheduler_commands': '', + } + + @classmethod + def get_wf_defaults(cls, silent=False): + """ + Print and return the default workflow settings. + + :returns: (_wf_default dict, _options_default dict) + """ + if not silent: + print(f'Version of workflow: {cls._workflowversion}') + return cls._wf_default.copy(), cls._options_default.copy() + + # ── Spec ────────────────────────────────────────────────────────────────── + + @classmethod + def define(cls, spec): + super(kkr_bdg_wc, cls).define(spec) + + # Codes + spec.input('kkr', valid_type=orm.Code, required=True, + help='KKRhost code for the normal-state and semi-circle contour steps.') + spec.input('kkr_bdg', valid_type=orm.Code, required=True, + help='KKRhost BdG code for the superconducting solver.') + spec.input('voronoi', valid_type=orm.Code, required=False, + help='Voronoi code (required only when starting from scratch).') + + # Optional bypass inputs + spec.input('remote_data_normal', valid_type=orm.RemoteData, required=False, + help='Converged RemoteData of the normal-state SCF (skips normal SCF step).') + spec.input('remote_data_semi_circle', valid_type=orm.RemoteData, required=False, + help='Converged RemoteData of the semi-circle SCF (skips normal + semi-circle steps).') + + # Structure and parameters + spec.input('structure', valid_type=orm.StructureData, required=False, + help='Crystal structure (required only when starting from scratch).') + spec.input('calc_parameters', valid_type=orm.Dict, required=True, + help='Initial KKR parameters (LMAX, NSPIN, RMAX, GMAX, …).') + + # Settings dicts + spec.input('semi_circle_settings', valid_type=orm.Dict, required=False, + default=lambda: orm.Dict(dict=cls._wf_default['semi_circle']), + help='Parameters injected for the semi-circle contour step.') + spec.input('bdg_init_settings', valid_type=orm.Dict, required=False, + default=lambda: orm.Dict(dict=cls._wf_default['bdg_init']), + help='Parameters for the one-shot BdG initialisation (NSTEPS=1).') + spec.input('bdg_settings', valid_type=orm.Dict, required=False, + default=lambda: orm.Dict(dict=cls._wf_default['bdg_scf']), + help='Parameters for the final BdG SCF convergence loop.') + + # Computer/workflow options + spec.input('options', valid_type=orm.Dict, required=False, + default=lambda: orm.Dict(dict=cls._options_default), + help='Computer options (queue, wallclock, resources, …).') + spec.input('wf_parameters', valid_type=orm.Dict, required=False, + help='Workflow parameters forwarded to kkr_scf_wc sub-workflows.') + + # Outputs + spec.output('output_kkr_bdg_wc_ParameterResults', valid_type=orm.Dict, required=True, + help='Summary dictionary of the BdG workflow.') + spec.output('last_RemoteData', valid_type=orm.RemoteData, required=True, + help='Remote folder of the final converged BdG calculation.') + spec.output('last_InputParameters', valid_type=orm.Dict, required=True, + help='Input parameters used in the final BdG calculation.') + + # Outline + spec.outline( + cls.start, + cls.validate_inputs, + if_(cls.should_run_normal_scf)( + cls.run_normal_scf, + cls.check_normal_scf, + ), + if_(cls.should_run_semi_circle)( + cls.run_semi_circle_scf, + cls.check_semi_circle_scf, + ), + cls.run_bdg_init, + cls.check_bdg_init, + cls.run_bdg_scf, + cls.check_bdg_scf, + cls.results, + ) + + # Exit codes + spec.exit_code(301, 'ERROR_NORMAL_SCF_FAILED', + message='The normal-state KKR SCF step failed.') + spec.exit_code(302, 'ERROR_SEMI_CIRCLE_SCF_FAILED', + message='The semi-circle contour SCF step failed.') + spec.exit_code(303, 'ERROR_BDG_INIT_FAILED', + message='The BdG one-shot initialisation failed.') + spec.exit_code(304, 'ERROR_BDG_SCF_FAILED', + message='The final BdG SCF step failed.') + + # ── Workflow steps ──────────────────────────────────────────────────────── + + def start(self): + """Initialise context variables and parse compute options.""" + self.report(f'INFO: Started kkr_bdg_wc version {self._workflowversion}') + self.ctx.current_remote = None + self.ctx.current_params = self.inputs.calc_parameters + self.ctx.successful = True + self.ctx.errors = [] + + # Parse compute options into ctx (mirrors pattern from dos.py / bs.py) + options_dict = self.inputs.options.get_dict() if 'options' in self.inputs else {} + if not options_dict: + options_dict = self._options_default + self.ctx.withmpi = options_dict.get('withmpi', self._options_default['withmpi']) + self.ctx.resources = options_dict.get('resources', self._options_default['resources']) + self.ctx.max_wallclock_seconds = options_dict.get('max_wallclock_seconds', self._options_default['max_wallclock_seconds']) + self.ctx.queue = options_dict.get('queue_name', self._options_default['queue_name']) + self.ctx.custom_scheduler_commands = options_dict.get('custom_scheduler_commands', self._options_default['custom_scheduler_commands']) + self.ctx.description_wf = self.inputs.get('description', self._wf_description) + self.ctx.label_wf = self.inputs.get('label', self._wf_label) + + self.report( + f'INFO: use the following settings:\n' + f'withmpi: {self.ctx.withmpi}\n' + f'Resources: {self.ctx.resources}\n' + f'Walltime (s): {self.ctx.max_wallclock_seconds}\n' + f'queue name: {self.ctx.queue}\n' + f'description: {self.ctx.description_wf}\n' + f'label: {self.ctx.label_wf}\n' + ) + + def validate_inputs(self): + test_and_get_codenode(self.inputs.kkr, 'kkr.kkr', use_exceptions=True) + test_and_get_codenode(self.inputs.kkr_bdg, 'kkr.kkr', use_exceptions=True) + + if 'remote_data_semi_circle' in self.inputs: + self.ctx.current_remote = self.inputs.remote_data_semi_circle + self.report('INFO: Bypassing normal and semi-circle SCF, starting from remote_data_semi_circle.') + # ← NEW: load full params from the parent semi-circle KkrCalculation + try: + parent_calc = self.inputs.remote_data_semi_circle.creator + self.ctx.current_params = parent_calc.inputs.parameters + self.report('INFO: Loaded calc_parameters from parent semi-circle KKR calculation.') + except Exception as e: + self.report(f'WARNING: Could not load params from parent semi-circle calc ({e}), ' + f'falling back to input calc_parameters.') + + elif 'remote_data_normal' in self.inputs: + self.ctx.current_remote = self.inputs.remote_data_normal + self.report('INFO: Bypassing normal SCF, starting from remote_data_normal.') + try: + parent_calc = self.inputs.remote_data_normal.creator + self.ctx.current_params = parent_calc.inputs.parameters + self.report('INFO: Loaded calc_parameters from parent KKR calculation.') + except Exception as e: + self.report(f'WARNING: Could not load params from parent calc ({e}), ' + f'falling back to input calc_parameters.') + + else: + if 'structure' not in self.inputs: + self.report('ERROR: `structure` is required when starting from scratch.') + return self.exit_codes.ERROR_NORMAL_SCF_FAILED + + def should_run_normal_scf(self): + """Return True if no starting remote data was provided.""" + return self.ctx.current_remote is None + + def run_normal_scf(self): + """Submit the normal-state KKR SCF.""" + self.report('INFO: Submitting normal-state KKR SCF.') + builder = kkr_scf_wc.get_builder() + builder.kkr = self.inputs.kkr + builder.calc_parameters = self.ctx.current_params + builder.structure = self.inputs.structure + if 'voronoi' in self.inputs: + builder.voronoi = self.inputs.voronoi + if 'options' in self.inputs: + builder.options = self.inputs.options + if 'wf_parameters' in self.inputs: + builder.wf_parameters = self.inputs.wf_parameters + return ToContext(normal_scf=self.submit(builder)) + + def check_normal_scf(self): + """Check normal SCF result and update context.""" + if not self.ctx.normal_scf.is_finished_ok: + self.report('ERROR: normal_scf failed.') + return self.exit_codes.ERROR_NORMAL_SCF_FAILED + self.ctx.current_remote = self.ctx.normal_scf.outputs.last_RemoteData + self.ctx.current_params = self.ctx.normal_scf.outputs.last_InputParameters + + def should_run_semi_circle(self): + """Return True if a semi-circle SCF is still needed.""" + return 'remote_data_semi_circle' not in self.inputs + + def run_semi_circle_scf(self): + """ + Submit the semi-circle contour SCF as a raw KkrCalculation. + + Using a raw KkrCalculation (not kkr_scf_wc) is essential here because + kkr_scf_wc internally overrides the energy contour parameters (NPT1, + NPT2, NPT3, NPOL, TEMPR) with its own convergence_setting_fine values, + silently destroying the semi-circle contour settings (e.g. NPT1=32 + becomes 7, causing 'too many ranks' MPI errors with 32 processes). + """ + self.report('INFO: Submitting semi-circle contour KKR SCF (raw KkrCalculation).') + new_params = update_params_semi_circle(self.ctx.current_params, + self.inputs.semi_circle_settings) + options_dict = self.inputs.options.get_dict() if 'options' in self.inputs else {} + inputs = get_inputs_kkr( + code=self.inputs.kkr, + remote=self.ctx.current_remote, + options=options_dict, + label='semi_circle_scf', + description='Semi-circle contour KKR SCF (NEWSOSOL + BdG code)', + parameters=new_params, + ) + return ToContext(semi_circle_scf=self.submit(KkrCalculation, **inputs)) + + def check_semi_circle_scf(self): + """Check semi-circle SCF result and extract remote folder and parameters.""" + if not self.ctx.semi_circle_scf.is_finished_ok: + self.report('ERROR: semi_circle_scf failed.') + return self.exit_codes.ERROR_SEMI_CIRCLE_SCF_FAILED + # Read outputs directly from the KkrCalculation (same pattern as check_bdg_init) + self.ctx.current_remote = self.ctx.semi_circle_scf.outputs.remote_folder + self.ctx.current_params = self.ctx.semi_circle_scf.inputs.parameters + + def run_bdg_init(self): + """ + Submit the one-shot BdG initialisation using a raw KkrCalculation. + + Using a raw KkrCalculation (not kkr_scf_wc) ensures that NSTEPS=1 is + respected exactly — kkr_scf_wc would silently override NSTEPS via its + own convergence loop logic, causing the anomalous density to oscillate + and collapse to zero. + """ + self.report('INFO: Submitting BdG one-shot initialisation (NSTEPS=1).') + new_params = update_params_bdg(self.ctx.current_params, self.inputs.bdg_init_settings) + options_dict = self.inputs.options.get_dict() if 'options' in self.inputs else {} + inputs = get_inputs_kkr( + code=self.inputs.kkr_bdg, + remote=self.ctx.current_remote, + options=options_dict, + label='BdG_init_1step', + description='BdG one-shot initialisation (NSTEPS=1)', + parameters=new_params, + ) + return ToContext(bdg_init=self.submit(KkrCalculation, **inputs)) + + def check_bdg_init(self): + """Check BdG init and extract remote folder and parameters.""" + if not self.ctx.bdg_init.is_finished_ok: + self.report('ERROR: bdg_init failed.') + return self.exit_codes.ERROR_BDG_INIT_FAILED + self.ctx.current_remote = self.ctx.bdg_init.outputs.remote_folder + self.ctx.current_params = self.ctx.bdg_init.inputs.parameters + + def run_bdg_scf(self): + """ + Submit the final BdG SCF as a raw KkrCalculation. + + kkr_scf_wc must NOT be used here: it internally resets NPT1, BZDIVIDE, + and the energy contour to convergence_setting_fine values, destroying the + semi-circle contour and causing 'too many ranks' MPI errors. + The tutorial pattern is: raw KkrCalculation with NSTEPS=200. + """ + self.report('INFO: Submitting final BdG SCF (raw KkrCalculation, NSTEPS=200).') + new_params = update_params_bdg(self.ctx.current_params, self.inputs.bdg_settings) + options_dict = self.inputs.options.get_dict() if 'options' in self.inputs else {} + inputs = get_inputs_kkr( + code=self.inputs.kkr_bdg, + remote=self.ctx.current_remote, + options=options_dict, + label='BdG_scf', + description='BdG SCF convergence (NSTEPS=200)', + parameters=new_params, + ) + return ToContext(bdg_scf=self.submit(KkrCalculation, **inputs)) + + + def check_bdg_scf(self): + """Check final BdG SCF result.""" + if not self.ctx.bdg_scf.is_finished_ok: + self.report('ERROR: bdg_scf failed.') + self.ctx.successful = False + self.ctx.errors.append('Final BdG SCF step failed.') + return self.exit_codes.ERROR_BDG_SCF_FAILED + self.ctx.current_remote = self.ctx.bdg_scf.outputs.remote_folder + self.ctx.current_params = self.ctx.bdg_scf.inputs.parameters + + def results(self): + """Collect outputs and publish them with full data provenance.""" + last_remote = self.ctx.bdg_scf.outputs.remote_folder + last_params = self.ctx.bdg_scf.inputs.parameters + last_output = self.ctx.bdg_scf.outputs.output_parameters + + outputnode_dict = { + 'workflow_name': self.__class__.__name__, + 'workflow_version': self._workflowversion, + 'successful': self.ctx.successful, + 'list_of_errors': self.ctx.errors, + 'withmpi': self.ctx.withmpi, + 'resources': self.ctx.resources, + 'max_wallclock_seconds': self.ctx.max_wallclock_seconds, + 'queue_name': self.ctx.queue, + 'last_calc_pk': self.ctx.bdg_scf.pk, + } + outputnode = orm.Dict(dict=outputnode_dict) + outputnode.label = 'kkr_bdg_wc_results' + outputnode.description = 'Summary of the kkr_bdg_wc workflow.' + + result_node = create_out_dict_node(outputnode, last_output_parameters=last_output) + + self.out('output_kkr_bdg_wc_ParameterResults', result_node) + self.out('last_RemoteData', last_remote) + self.out('last_InputParameters', last_params) + self.report('INFO: kkr_bdg_wc finished successfully.') \ No newline at end of file diff --git a/examples/Test_kkr_bdg_wc.ipynb b/examples/Test_kkr_bdg_wc.ipynb new file mode 100644 index 00000000..d3dbf55c --- /dev/null +++ b/examples/Test_kkr_bdg_wc.ipynb @@ -0,0 +1,137 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 3, + "id": "5f8248b7-e075-4c0b-8710-bccf039f7d30", + "metadata": {}, + "outputs": [], + "source": [ + "from aiida import load_profile\n", + "load_profile()\n", + "\n", + "from aiida.orm import Dict, load_node, load_code\n", + "from aiida.orm import StructureData, InstalledCode, Computer\n", + "from aiida.engine import submit\n", + "from aiida_kkr.workflows.kkr_scf import kkr_scf_wc\n", + "from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc\n", + "import numpy as np\n", + "\n", + "# struc = load_node('95fdec5c-17bf-4894-9867-d3031531e11e')\n", + "struc = StructureData(cell=np.array([[0.5, 0.5, -0.5], [0.5, -0.5, 0.5], [-0.5, 0.5, 0.5]])*3.3)\n", + "struc.append_atom(position=[0,0,0], symbols='Nb')\n", + "struc.label = 'Nb_bulk'\n", + "struc.description = 'bcc Nb bulk structure'\n", + "\n", + "\n", + "voro_code = load_code('voronoi_3.5_AMD@iffslurm') \n", + "kkr_code = load_code('kkrhost_BdG_AMD@iffslurm')\n", + "kkr_bdg_code = load_code('kkrhost_BdG_AMD@iffslurm')\n", + "\n", + "\n", + "options = Dict(dict={\n", + " 'withmpi': True,\n", + " 'resources': {'num_machines': 1, 'tot_num_mpiprocs': 32},\n", + " 'queue_name': 'th1-2020-32',\n", + " 'max_wallclock_seconds': 3600 * 12,\n", + "})\n", + "\n", + "calc_params = Dict(dict={\n", + " 'LMAX': 2,\n", + " 'NSPIN': 1,\n", + " 'RMAX': 10.0,\n", + " 'GMAX': 100.0,\n", + "})\n", + "# SCF workflow settings\n", + "scf_settings_dict = kkr_scf_wc.get_wf_defaults(silent=True)[0]\n", + "scf_settings_dict['mag_init'] = False\n", + "scf_settings_dict['check_dos'] = False\n", + "scf_settings_dict['nsteps'] = 100 \n", + "scf_settings_dict['convergence_setting_coarse'] = scf_settings_dict['convergence_setting_fine']\n", + "wf_params = Dict(dict=scf_settings_dict)\n", + "# Semi-circle settings\n", + "semi_circle_settings = Dict(dict={\n", + " 'USE_SEMI_CIRCLE_CONTOUR': True,\n", + " 'IM_E_CIRC_MIN': 5e-5,\n", + " 'NPT1': 32,\n", + " 'MAX_NUM_KMESH': 4,\n", + " 'BZDIVIDE': [100, 100, 100],\n", + " 'RCLUSTZ': 3.5,\n", + " 'NSTEPS': 200,\n", + " 'IMIX': 4,\n", + " 'DISABLE_CHARGE_NEUTRALITY': True,\n", + " 'RUNOPT': ['NEWSOSOL'],\n", + " 'R_LOG': 0.6,\n", + " 'NPAN_EQ': 7,\n", + " 'NPAN_LOG': 18,\n", + " 'NCHEB': 12,\n", + " 'DECOUPLE_SPIN_CHEBY': True,\n", + "})\n", + "\n", + "# BdG settings \n", + "bdg_init_settings = Dict(dict={\n", + " 'NSTEPS': 1,\n", + " 'use_BdG': True,\n", + " 'Delta_BdG': 5e-4,\n", + " 'use_e_symm_BdG': True,\n", + " 'at_scale_BdG': [1.0 for _ in struc.sites],\n", + "})\n", + "\n", + "bdg_settings = Dict(dict={\n", + " 'NSTEPS': 200,\n", + " 'NSIMPLEMIXFIRST': 25,\n", + " 'at_scale_BdG': [1.0],\n", + " 'lambda_BdG': 0.025,\n", + " 'mixfac_BdG': 0.3,\n", + " 'Ninit_Broyden_BdG': 5,\n", + " 'IMIX': 4,\n", + "})\n", + "\n", + "\n", + "builder = kkr_bdg_wc.get_builder()\n", + "builder.structure = struc\n", + "builder.voronoi = voro_code\n", + "builder.kkr = kkr_code\n", + "builder.kkr_bdg = kkr_bdg_code\n", + "builder.calc_parameters = calc_params\n", + "builder.semi_circle_settings = semi_circle_settings\n", + "builder.bdg_init_settings = bdg_init_settings\n", + "builder.bdg_settings = bdg_settings\n", + "builder.options = options\n", + "builder.wf_parameters = wf_params\n", + "\n", + "node = submit(builder)\n", + "print(f'Submitted: {node.pk}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c43c2532-1271-4063-87f0-47214655c942", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "AiiDA", + "language": "python", + "name": "aiida" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index 022b4bef..f73c0cb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,6 +123,7 @@ include-package-data = false "kkr.jij" = "aiida_kkr.workflows.jijs:kkr_jij_wc" "kkr.combine_imp" = "aiida_kkr.workflows._combine_imps:combine_imps_wc" "kkr.STM" = "aiida_kkr.workflows.kkr_STM:kkr_STM_wc" +"kkr.bdg" = "aiida_kkr.workflows.kkr_bdg_wc:kkr_bdg_wc" [tool.setuptools.packages.find] namespaces = false diff --git a/tests/workflows/test_kkr_bdg_wc.py b/tests/workflows/test_kkr_bdg_wc.py new file mode 100644 index 00000000..65d02523 --- /dev/null +++ b/tests/workflows/test_kkr_bdg_wc.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Local (offline) tests for the kkr_bdg_wc WorkChain. + +Tests the spec, calcfunctions, default settings, and outline branching logic +without any daemon, real KKR calculations, or cluster access. + +Tests are divided into: + - DB-free tests: spec validation, defaults, outline logic (no aiida_profile needed) + - DB tests: calcfunction logic (need aiida_profile for Dict node storage) +""" + +import pytest +from unittest.mock import MagicMock +from plumpy.utils import AttributesFrozendict + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Group 1 — Spec validation (no DB needed — just class introspection) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestBdgSpec: + """Verify that kkr_bdg_wc spec is correctly defined.""" + + def test_spec_required_inputs(self): + """kkr, kkr_bdg, calc_parameters must be required; others optional.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + spec = kkr_bdg_wc.spec() + + # Required inputs + for name in ('kkr', 'kkr_bdg', 'calc_parameters'): + port = spec.inputs[name] + assert port.required, f"'{name}' should be required" + + # Optional inputs + for name in ('voronoi', 'structure', 'remote_data_normal', + 'remote_data_semi_circle', 'semi_circle_settings', + 'bdg_init_settings', 'bdg_settings', 'options', + 'wf_parameters'): + port = spec.inputs[name] + assert not port.required, f"'{name}' should be optional" + + def test_spec_input_types(self): + """All inputs should have the correct valid_type.""" + from aiida import orm + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + spec = kkr_bdg_wc.spec() + + def _check_valid_type(port, expected_type): + """Check that a port's valid_type includes the expected type. + Optional ports may have valid_type as a tuple including NoneType.""" + vt = port.valid_type + if isinstance(vt, tuple): + assert expected_type in vt, ( + f"Expected {expected_type} in valid_type tuple {vt}" + ) + else: + assert issubclass(vt, expected_type), ( + f"Expected subclass of {expected_type}, got {vt}" + ) + + # Code inputs (required) + _check_valid_type(spec.inputs['kkr'], orm.Code) + _check_valid_type(spec.inputs['kkr_bdg'], orm.Code) + + # Dict inputs + _check_valid_type(spec.inputs['calc_parameters'], orm.Dict) + + # Optional inputs (valid_type may be tuple with NoneType) + _check_valid_type(spec.inputs['structure'], orm.StructureData) + _check_valid_type(spec.inputs['remote_data_normal'], orm.RemoteData) + _check_valid_type(spec.inputs['remote_data_semi_circle'], orm.RemoteData) + + def test_spec_outputs(self): + """All three declared outputs must exist with correct valid_type.""" + from aiida import orm + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + spec = kkr_bdg_wc.spec() + + assert 'output_kkr_bdg_wc_ParameterResults' in spec.outputs + assert spec.outputs['output_kkr_bdg_wc_ParameterResults'].valid_type is orm.Dict + + assert 'last_RemoteData' in spec.outputs + assert spec.outputs['last_RemoteData'].valid_type is orm.RemoteData + + assert 'last_InputParameters' in spec.outputs + assert spec.outputs['last_InputParameters'].valid_type is orm.Dict + + def test_spec_exit_codes(self): + """Exit codes 301–304 must be defined with correct labels.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + spec = kkr_bdg_wc.spec() + exit_codes = spec.exit_codes + + # Exit codes are keyed by name (str), so look them up by name + expected = { + 'ERROR_NORMAL_SCF_FAILED': 301, + 'ERROR_SEMI_CIRCLE_SCF_FAILED': 302, + 'ERROR_BDG_INIT_FAILED': 303, + 'ERROR_BDG_SCF_FAILED': 304, + } + for label, expected_status in expected.items(): + assert label in exit_codes, f"Exit code '{label}' not defined" + assert exit_codes[label].status == expected_status, ( + f"Exit code '{label}' has status {exit_codes[label].status}, " + f"expected {expected_status}" + ) + + def test_spec_outline_has_all_steps(self): + """The outline should reference all expected step methods.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + # Verify the class has all the step methods we expect + expected_steps = [ + 'start', 'validate_inputs', + 'should_run_normal_scf', 'run_normal_scf', 'check_normal_scf', + 'should_run_semi_circle', 'run_semi_circle_scf', 'check_semi_circle_scf', + 'run_bdg_init', 'check_bdg_init', + 'run_bdg_scf', 'check_bdg_scf', + 'results', + ] + for step in expected_steps: + assert hasattr(kkr_bdg_wc, step), ( + f"WorkChain missing step method '{step}'" + ) + assert callable(getattr(kkr_bdg_wc, step)), ( + f"'{step}' should be callable" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Group 2 — Default settings (no DB needed) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestBdgDefaults: + """Verify the default workflow settings are correct.""" + + def test_get_wf_defaults_returns_two_dicts(self): + """get_wf_defaults() should return a 2-tuple of dicts.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + result = kkr_bdg_wc.get_wf_defaults(silent=True) + assert isinstance(result, tuple) and len(result) == 2 + wf_defaults, options_defaults = result + assert isinstance(wf_defaults, dict) + assert isinstance(options_defaults, dict) + + def test_wf_defaults_structure(self): + """wf_defaults should have three sub-dicts: semi_circle, bdg_init, bdg_scf.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + wf_defaults, _ = kkr_bdg_wc.get_wf_defaults(silent=True) + + assert 'semi_circle' in wf_defaults + assert 'bdg_init' in wf_defaults + assert 'bdg_scf' in wf_defaults + + def test_semi_circle_defaults(self): + """Semi-circle defaults should have the key physics settings.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + sc = kkr_bdg_wc.get_wf_defaults(silent=True)[0]['semi_circle'] + + assert sc['USE_SEMI_CIRCLE_CONTOUR'] is True + assert sc['NPT1'] == 32 + assert sc['BZDIVIDE'] == [100, 100, 100] + assert sc['NSTEPS'] == 200 + assert sc['DECOUPLE_SPIN_CHEBY'] is True + assert 'NEWSOSOL' in sc['RUNOPT'] + + def test_bdg_init_defaults(self): + """BdG init defaults should have NSTEPS=1 and use_BdG=True.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + bdg_init = kkr_bdg_wc.get_wf_defaults(silent=True)[0]['bdg_init'] + + assert bdg_init['NSTEPS'] == 1 + assert bdg_init['use_BdG'] is True + assert bdg_init['Delta_BdG'] == 5e-4 + + def test_bdg_scf_defaults(self): + """BdG SCF defaults should have NSTEPS=200 and mixing settings.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + bdg_scf = kkr_bdg_wc.get_wf_defaults(silent=True)[0]['bdg_scf'] + + assert bdg_scf['NSTEPS'] == 200 + assert bdg_scf['use_BdG'] is True + assert bdg_scf['lambda_BdG'] == 0.025 + assert bdg_scf['mixfac_BdG'] == 0.3 + + def test_options_defaults(self): + """Options defaults should have standard compute keys.""" + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + _, options = kkr_bdg_wc.get_wf_defaults(silent=True) + + assert options['resources'] == {'num_machines': 1} + assert options['withmpi'] is True + assert options['max_wallclock_seconds'] == 60 * 60 * 4 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Group 3 — Outline & step logic (mock-based, no DB needed) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestBdgOutlineLogic: + """Test the branching/conditional logic in the workchain steps.""" + + def _make_wc_instance(self): + """ + Create a bare workchain instance for testing step methods. + + Uses __new__ to skip __init__, then sets up the backing stores + for the `ctx` property (_context) and `inputs` property + (_parsed_inputs) plus other mocked attributes. + """ + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + wc = kkr_bdg_wc.__new__(kkr_bdg_wc) + # Set backing stores for ctx and inputs properties + wc._context = AttributesFrozendict() + wc._parsed_inputs = AttributesFrozendict() # empty → 'x' in wc.inputs is False + # Mock report and provide exit_codes + wc.report = MagicMock() + wc.exit_codes = kkr_bdg_wc.spec().exit_codes + return wc + + def test_should_run_normal_scf_true_when_no_remote(self): + """should_run_normal_scf() returns True when ctx.current_remote is None.""" + wc = self._make_wc_instance() + wc.ctx.current_remote = None + assert wc.should_run_normal_scf() is True + + def test_should_run_normal_scf_false_when_remote_provided(self): + """should_run_normal_scf() returns False when ctx.current_remote is set.""" + wc = self._make_wc_instance() + wc.ctx.current_remote = MagicMock() # simulate a RemoteData + assert wc.should_run_normal_scf() is False + + def test_should_run_semi_circle_true_when_no_bypass(self): + """ + should_run_semi_circle() returns True when 'remote_data_semi_circle' + is NOT in inputs. + """ + wc = self._make_wc_instance() + # MagicMock(spec=[]) has no attributes → + # 'remote_data_semi_circle' in wc.inputs → False + assert wc.should_run_semi_circle() is True + + def test_should_run_semi_circle_false_when_bypass(self): + """ + should_run_semi_circle() returns False when 'remote_data_semi_circle' + IS in inputs. + """ + wc = self._make_wc_instance() + # Set inputs backing store with remote_data_semi_circle present + wc._parsed_inputs = AttributesFrozendict( + {'remote_data_semi_circle': MagicMock()} + ) + assert wc.should_run_semi_circle() is False + + def test_validate_inputs_missing_structure_returns_error(self): + """ + When starting from scratch (no remote_data_*) and no structure is + provided, validate_inputs() should return ERROR_NORMAL_SCF_FAILED (301). + """ + wc = self._make_wc_instance() + + # Simulate: codes exist with correct plugin, but no remote/structure + mock_code = MagicMock() + mock_code.get_input_plugin_name.return_value = 'kkr.kkr' + + # Set inputs with kkr/kkr_bdg but no remote_data or structure + wc._parsed_inputs = AttributesFrozendict( + {'kkr': mock_code, 'kkr_bdg': mock_code} + ) + + result = wc.validate_inputs() + assert result is not None, "validate_inputs should return an exit code" + assert result.status == 301, ( + f"Expected exit code 301 (ERROR_NORMAL_SCF_FAILED), got {result.status}" + ) + + def test_check_normal_scf_returns_error_on_failure(self): + """check_normal_scf should return ERROR_NORMAL_SCF_FAILED when sub-wc fails.""" + wc = self._make_wc_instance() + mock_scf = MagicMock() + mock_scf.is_finished_ok = False + wc.ctx.normal_scf = mock_scf + + result = wc.check_normal_scf() + assert result is not None + assert result.status == 301 + + def test_check_normal_scf_updates_context_on_success(self): + """check_normal_scf should update ctx on success.""" + wc = self._make_wc_instance() + mock_scf = MagicMock() + mock_scf.is_finished_ok = True + mock_remote = MagicMock(name='remote') + mock_params = MagicMock(name='params') + mock_scf.outputs.last_RemoteData = mock_remote + mock_scf.outputs.last_InputParameters = mock_params + wc.ctx.normal_scf = mock_scf + + result = wc.check_normal_scf() + assert result is None # no exit code on success + assert wc.ctx.current_remote is mock_remote + assert wc.ctx.current_params is mock_params + + def test_check_semi_circle_scf_returns_error_on_failure(self): + """check_semi_circle_scf should return ERROR_SEMI_CIRCLE_SCF_FAILED.""" + wc = self._make_wc_instance() + wc.ctx.semi_circle_scf = MagicMock() + wc.ctx.semi_circle_scf.is_finished_ok = False + + result = wc.check_semi_circle_scf() + assert result is not None + assert result.status == 302 + + def test_check_bdg_init_returns_error_on_failure(self): + """check_bdg_init should return ERROR_BDG_INIT_FAILED.""" + wc = self._make_wc_instance() + wc.ctx.bdg_init = MagicMock() + wc.ctx.bdg_init.is_finished_ok = False + + result = wc.check_bdg_init() + assert result is not None + assert result.status == 303 + + def test_check_bdg_scf_returns_error_on_failure(self): + """check_bdg_scf should return ERROR_BDG_SCF_FAILED and track error.""" + wc = self._make_wc_instance() + wc.ctx.successful = True + wc.ctx.errors = [] + wc.ctx.bdg_scf = MagicMock() + wc.ctx.bdg_scf.is_finished_ok = False + + result = wc.check_bdg_scf() + assert result is not None + assert result.status == 304 + assert wc.ctx.successful is False + assert len(wc.ctx.errors) == 1 + + def test_check_bdg_scf_updates_context_on_success(self): + """check_bdg_scf should update ctx.current_remote and params on success.""" + wc = self._make_wc_instance() + wc.ctx.successful = True + wc.ctx.errors = [] + mock_bdg = MagicMock() + mock_bdg.is_finished_ok = True + mock_remote = MagicMock(name='remote') + mock_params = MagicMock(name='params') + mock_bdg.outputs.remote_folder = mock_remote + mock_bdg.inputs.parameters = mock_params + wc.ctx.bdg_scf = mock_bdg + + result = wc.check_bdg_scf() + assert result is None + assert wc.ctx.current_remote is mock_remote + assert wc.ctx.current_params is mock_params + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Group 4 — Calcfunction logic (needs AiiDA profile for Dict node storage) +# ═══════════════════════════════════════════════════════════════════════════════ + +@pytest.mark.usefixtures('aiida_profile') +class TestBdgCalcfunctions: + """ + Test the @calcfunction helpers used by kkr_bdg_wc. + + These tests require an AiiDA profile (via aiida_profile fixture) because + @calcfunction needs to store Dict nodes in the database. + """ + + def test_update_params_semi_circle_strips_scf_keys(self): + """ + update_params_semi_circle should: + - Strip SCF-inherited keys (STRMIX, BRYMIX, QBOUND, HFIELD, LINIPOL) + - Remove TEMPR, NPT2, NPT3, NPOL + - Set semi-circle settings + - Preserve unregistered keys like NPT1 + """ + from aiida import orm + from aiida_kkr.workflows.kkr_bdg_wc import update_params_semi_circle + from masci_tools.io.kkr_params import kkrparams + + # Build a parent parameter set mimicking post-SCF output + parent_dict = kkrparams(LMAX=2, NSPIN=1, RMAX=10.0, GMAX=100.0).get_dict() + # Add SCF-inherited keys that should be stripped + parent_dict['STRMIX'] = 0.03 + parent_dict['BRYMIX'] = 0.05 + parent_dict['QBOUND'] = 1e-7 + parent_dict['TEMPR'] = 800.0 + parent_dict['NPT2'] = 50 + + params_node = orm.Dict(dict=parent_dict) + + semi_settings = orm.Dict(dict={ + 'USE_SEMI_CIRCLE_CONTOUR': True, + 'IM_E_CIRC_MIN': 5e-5, + 'NPT1': 32, + 'NSTEPS': 200, + 'IMIX': 4, + }) + + result = update_params_semi_circle(params_node, semi_settings) + result_dict = result.get_dict() + + # SCF-inherited keys should be gone + for key in ('STRMIX', 'BRYMIX', 'QBOUND'): + assert key not in result_dict or result_dict[key] is None, ( + f"SCF key '{key}' should have been stripped" + ) + + # TEMPR should be removed + assert result_dict.get('TEMPR') is None, "TEMPR should have been removed" + + # Semi-circle settings should be present + assert result_dict.get('NSTEPS') == 200 + assert result_dict.get('IMIX') == 4 + + # NPT1 is an unregistered key, should be preserved + assert result_dict.get('NPT1') == 32, "Unregistered key NPT1 should be preserved" + + def test_update_params_bdg_preserves_parent_keys(self): + """ + update_params_bdg should: + - Preserve all parent keys including bracket keys + - Merge BdG settings on top (BdG takes precedence) + """ + from aiida import orm + from aiida_kkr.workflows.kkr_bdg_wc import update_params_bdg + + parent_dict = { + 'LMAX': 2, + 'NSPIN': 1, + 'NSTEPS': 200, + '': True, + '': True, + 'BZDIVIDE': [100, 100, 100], + 'RUNOPT': ['NEWSOSOL'], + } + params_node = orm.Dict(dict=parent_dict) + + bdg_settings = orm.Dict(dict={ + 'NSTEPS': 1, + 'use_BdG': True, + 'Delta_BdG': 5e-4, + }) + + result = update_params_bdg(params_node, bdg_settings) + result_dict = result.get_dict() + + # BdG setting should override NSTEPS + assert result_dict['NSTEPS'] == 1, "NSTEPS should be overridden to 1" + + # Parent bracket keys should be preserved + assert result_dict.get('') is True, ( + "Bracket key should be preserved" + ) + + # BZDIVIDE and RUNOPT should survive (dict-level merge) + assert result_dict.get('BZDIVIDE') == [100, 100, 100], ( + "BZDIVIDE should be preserved" + ) + assert result_dict.get('RUNOPT') == ['NEWSOSOL'], ( + "RUNOPT should be preserved" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Group 5 — Entry point (needs installed package) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestBdgEntryPoint: + """Verify the kkr.bdg entry point is registered correctly.""" + + def test_bdg_entry_point(self): + """ + WorkflowFactory('kkr.bdg') must resolve to kkr_bdg_wc. + + NOTE: This test only passes when aiida-kkr is pip-installed (not just + on PYTHONPATH), because entry points require package registration. + If it fails with MissingEntryPointError, run: pip install -e . + """ + from aiida_kkr.workflows.kkr_bdg_wc import kkr_bdg_wc + + try: + from aiida.plugins import WorkflowFactory + wf = WorkflowFactory('kkr.bdg') + assert wf is kkr_bdg_wc + except Exception as e: + if 'MissingEntryPointError' in type(e).__name__: + pytest.skip( + "Entry point 'kkr.bdg' not found — aiida-kkr may need " + "reinstall: pip install -e ." + ) + raise