From 60f8742c9a5b7d86717dccf1e18e699beb22b205 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 18 Aug 2026 13:18:31 +0200 Subject: [PATCH 1/2] feat(parser): add typed gap datasets --- pyproject.toml | 2 + src/aiida_epw/calculations/epw.py | 18 +++ src/aiida_epw/data/__init__.py | 4 +- src/aiida_epw/data/gap_function.py | 246 ++++++++++++++++++++++++++++- src/aiida_epw/parsers/epw.py | 26 +++ src/aiida_epw/tools/parsers.py | 77 +++++++++ tests/data/test_gap_data.py | 91 +++++++++++ 7 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 tests/data/test_gap_data.py diff --git a/pyproject.toml b/pyproject.toml index 9c6a64de..2bf0c743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,8 @@ Source = "https://github.com/aiidaplugins/aiida-epw" 'epw.phdos' = 'aiida_epw.data.dos:PhDosData' 'epw.pdos' = 'aiida_epw.data.dos:PDosData' 'epw.gap_function' = 'aiida_epw.data.gap_function:GapFunctionData' +'epw.iso_gap' = 'aiida_epw.data.gap_function:IsoGapData' +'epw.aniso_gap0' = 'aiida_epw.data.gap_function:AnisoGap0Data' 'epw.lambda_fs' = 'aiida_epw.data.lambda_fs:LambdaFSData' [project.entry-points.'aiida.workflows'] diff --git a/src/aiida_epw/calculations/epw.py b/src/aiida_epw/calculations/epw.py index 13cb48c6..14851cf1 100644 --- a/src/aiida_epw/calculations/epw.py +++ b/src/aiida_epw/calculations/epw.py @@ -21,11 +21,13 @@ from aiida_epw.data import ( A2fData, + AnisoGap0Data, PA2fData, DosData, PDosData, PhDosData, GapFunctionData, + IsoGapData, LambdaFSData, ) @@ -339,6 +341,18 @@ def define(cls, spec): required=False, help="The interpolated anisotropic gap function.", ) + spec.output( + "iso_gap_data", + valid_type=IsoGapData, + required=False, + help="Typed isotropic imaginary-axis and Pade gap data.", + ) + spec.output( + "aniso_gap0_data", + valid_type=AnisoGap0Data, + required=False, + help="Typed anisotropic gap-zero imaginary-axis and Pade data.", + ) spec.output( "aniso_gap_FS", valid_type=orm.ArrayData, @@ -919,11 +933,15 @@ def get_additional_retrieve_list(self, parameters): "tc_linear", False ): retrieve_list.append("aiida.imag_iso_*") + if parameters["INPUTEPW"].get("lpade", False): + retrieve_list.append("aiida.pade_iso_*") if parameters["INPUTEPW"].get("laniso", False): retrieve_list.append(self._OUTPUT_LAMBDA_FS_FILE) retrieve_list.append(self._OUTPUT_LAMBDA_K_PAIRS_FILE) retrieve_list.append("aiida.imag_aniso_gap*") + if parameters["INPUTEPW"].get("lpade", False): + retrieve_list.append("aiida.pade_aniso_gap*") return retrieve_list diff --git a/src/aiida_epw/data/__init__.py b/src/aiida_epw/data/__init__.py index e1cd2d51..8373d665 100644 --- a/src/aiida_epw/data/__init__.py +++ b/src/aiida_epw/data/__init__.py @@ -2,7 +2,7 @@ from .a2f import A2fData, PA2fData from .dos import DosData, PDosData, PhDosData -from .gap_function import GapFunctionData +from .gap_function import AnisoGap0Data, GapFunctionData, IsoGapData from .lambda_fs import LambdaFSData __all__ = ( @@ -12,5 +12,7 @@ "PhDosData", "PDosData", "GapFunctionData", + "IsoGapData", + "AnisoGap0Data", "LambdaFSData", ) diff --git a/src/aiida_epw/data/gap_function.py b/src/aiida_epw/data/gap_function.py index cd5048fa..0d715ccc 100644 --- a/src/aiida_epw/data/gap_function.py +++ b/src/aiida_epw/data/gap_function.py @@ -1,4 +1,6 @@ -"""Domain-specific data type for EPW gap-function tables.""" +"""Domain-specific data types for EPW gap-function tables.""" + +from pathlib import Path import numpy from aiida import orm @@ -142,3 +144,245 @@ def from_directory(cls, dirpath, prefix="aiida", kind="iso"): ) return cls.from_files(filepaths, prefix=prefix, kind=kind) + + +def _temperature_label(temperature): + """Return a stable array-name fragment for a temperature value.""" + return f"{float(temperature):06.2f}".replace(".", "_") + + +def _read_file_contents(file_contents_or_paths): + """Return a filename-to-content mapping from paths or an existing mapping.""" + if isinstance(file_contents_or_paths, dict): + return file_contents_or_paths + + contents = {} + for filepath in file_contents_or_paths: + path = Path(filepath) + contents[path.name] = path.read_text(encoding="utf-8") + return contents + + +class _RaggedGapData(orm.ArrayData): + """Store gap-data tables without requiring equal temperature grids.""" + + ATTRIBUTE_ENTRIES = "entries" + ARRAY_TEMPERATURES = "temperatures" + + def set_gap_data(self, gap_data): + """Store a mapping keyed by ``(source, temperature)``.""" + if not gap_data: + raise exceptions.ValidationError("`gap_data` cannot be empty.") + + self._delete_gap_arrays() + entries = [] + temperatures = [] + for (source, temperature), columns in sorted( + gap_data.items(), key=lambda item: (item[0][0], float(item[0][1])) + ): + if not columns: + raise exceptions.ValidationError( + "Gap-data column mappings cannot be empty." + ) + + source = str(source) + temperature = float(temperature) + label = _temperature_label(temperature) + row_count = None + column_names = [] + column_arrays = [] + for column_name, values in columns.items(): + array = numpy.array(values, dtype=float) + if array.ndim != 1: + raise exceptions.ValidationError( + f"Column `{column_name}` for {source}_{label} must be one-dimensional." + ) + if row_count is None: + row_count = array.shape[0] + elif array.shape[0] != row_count: + raise exceptions.ValidationError( + f"Columns for {source}_{label} must have the same length." + ) + + column_names.append(column_name) + column_arrays.append(array) + + self.set_array(f"{source}_{label}", numpy.column_stack(column_arrays)) + + entries.append( + { + "source": source, + "temperature": temperature, + "array_name": f"{source}_{label}", + "columns": column_names, + } + ) + temperatures.append(temperature) + + self.set_array(self.ARRAY_TEMPERATURES, numpy.array(temperatures, dtype=float)) + self.base.attributes.set(self.ATTRIBUTE_ENTRIES, entries) + + def get_temperatures(self, source=None): + """Return temperatures, optionally restricted to one source.""" + return numpy.array( + [entry["temperature"] for entry in self._get_entries(source)], dtype=float + ) + + def get_data(self, temperature, *, source=None, atol=1.0e-8): + """Return named arrays for one temperature/source entry.""" + entry = self._find_entry(temperature, source=source, atol=atol) + table = self.get_array(entry["array_name"]) + return { + column: table[:, index] for index, column in enumerate(entry["columns"]) + } + + def get_table(self, temperature, *, source=None, atol=1.0e-8): + """Return the stored two-dimensional table for one entry.""" + entry = self._find_entry(temperature, source=source, atol=atol) + return self.get_array(entry["array_name"]) + + def get_iterdata(self, source=None): + """Yield ``(source, temperature, columns)`` in stored order.""" + for entry in self._get_entries(source): + yield ( + entry["source"], + entry["temperature"], + self.get_data(entry["temperature"], source=entry["source"]), + ) + + def to_dict(self, source=None): + """Return entries as plain dictionaries suitable for plotting utilities.""" + data = {} + for entry in self._get_entries(source): + table = self.get_array(entry["array_name"]) + columns = list(entry["columns"]) + data.setdefault(entry["source"], {})[entry["temperature"]] = { + "columns": columns, + "table": table, + "data": { + column: table[:, index] for index, column in enumerate(columns) + }, + } + return data + + @property + def sources(self): + """Return available source labels, such as ``imag`` and ``pade``.""" + return sorted({entry["source"] for entry in self._get_entries()}) + + def _get_entries(self, source=None): + entries = self.base.attributes.get(self.ATTRIBUTE_ENTRIES, []) + return [ + entry for entry in entries if source is None or entry["source"] == source + ] + + def _find_entry(self, temperature, *, source=None, atol=1.0e-8): + matches = [ + entry + for entry in self._get_entries(source) + if numpy.isclose( + entry["temperature"], float(temperature), atol=atol, rtol=0.0 + ) + ] + if not matches: + raise KeyError(f"No gap data stored for temperature {float(temperature)}.") + if len(matches) > 1: + raise KeyError("Multiple gap data entries found; pass `source`.") + return matches[0] + + def _delete_gap_arrays(self): + for entry in self._get_entries(): + array_name = entry.get( + "array_name", + f"{entry['source']}_{_temperature_label(entry['temperature'])}", + ) + if array_name in self.get_arraynames(): + self.delete_array(array_name) + if self.ARRAY_TEMPERATURES in self.get_arraynames(): + self.delete_array(self.ARRAY_TEMPERATURES) + + +class IsoGapData(_RaggedGapData): + """Typed data for isotropic imaginary-axis and Pade gap functions.""" + + def get_gap_fs(self, source="imag", component=None, unit="meV", drop_nan=True): + """Return the first-frequency gap as a temperature series.""" + factor = {"eV": 1.0, "meV": 1000.0}[unit] + temperatures = [] + gaps = [] + for _, temperature, columns in self.get_iterdata(source=source): + column = component + if column is None: + column = "deltaw" if "deltaw" in columns else "deltaw_real" + gap = float(columns[column][0]) * factor + if drop_nan and numpy.isnan(gap): + continue + temperatures.append(float(temperature)) + gaps.append(gap) + return {"T": temperatures, "gap": gaps, "unit": unit, "source": source} + + @classmethod + def from_files(cls, file_contents_or_paths, prefix="aiida"): + """Build a node from ``imag_iso`` and ``pade_iso`` files.""" + from aiida_epw.tools.parsers import parse_epw_iso_gap_files + + node = cls() + node.set_gap_data( + parse_epw_iso_gap_files( + _read_file_contents(file_contents_or_paths), prefix=prefix + ) + ) + return node + + +class AnisoGap0Data(_RaggedGapData): + """Typed data for anisotropic gap-zero distributions.""" + + def get_averaged_gap(self, source="imag", bandwidth_factor=1.5): + """Return representative anisotropic gap values for each temperature.""" + temperatures = [] + gaps = [] + for _, temperature, _ in self.get_iterdata(source=source): + temperatures.append(float(temperature)) + gaps.append( + self._find_averaged_gap( + self.get_table(temperature, source=source), bandwidth_factor + ) + ) + return {"T": temperatures, "gap": gaps, "source": source} + + @staticmethod + def _find_averaged_gap(data, bandwidth_factor): + """Find gap peaks from a smoothed distribution.""" + from scipy.signal import find_peaks + + data = numpy.array(data, dtype=float) + gaps = data[:, 1] + signal = data[:, 0] - numpy.min(data[:, 0]) + maximum = numpy.max(signal) + if maximum == 0: + return [float(numpy.mean(gaps))] + + window = max(3, int(len(signal) * 0.03 * bandwidth_factor)) + if window % 2 == 0: + window += 1 + density = numpy.convolve( + signal / maximum, numpy.ones(window) / window, mode="same" + ) + peaks, _ = find_peaks(density, prominence=numpy.max(density) * 0.1) + if len(peaks) == 0: + return [float(gaps[numpy.argmax(density)])] + return sorted(float(gaps[index]) for index in peaks) + + @classmethod + def from_files(cls, file_contents_or_paths, prefix="aiida"): + """Build a node from ``imag_aniso_gap0`` and Pade equivalents.""" + from aiida_epw.tools.parsers import parse_epw_aniso_gap0_files + + node = cls() + node.set_gap_data( + parse_epw_aniso_gap0_files( + _read_file_contents(file_contents_or_paths), prefix=prefix + ) + ) + return node diff --git a/src/aiida_epw/parsers/epw.py b/src/aiida_epw/parsers/epw.py index dd48419a..cf784072 100644 --- a/src/aiida_epw/parsers/epw.py +++ b/src/aiida_epw/parsers/epw.py @@ -12,11 +12,13 @@ from aiida_epw.calculations.epw import EpwCalculation from aiida_epw.data import ( A2fData, + AnisoGap0Data, PA2fData, DosData, PDosData, PhDosData, GapFunctionData, + IsoGapData, LambdaFSData, ) from aiida_epw.tools.parsers import ( @@ -176,6 +178,30 @@ def parse(self, **kwargs): self.parse_aniso_gap_functions(self.retrieved), ) + iso_gap_data_pattern = re.compile( + rf"^{EpwCalculation._PREFIX}\.(imag|pade)_iso_\d+\.\d+$" + ) + iso_gap_contents = self.get_retrieved_contents_matching(iso_gap_data_pattern) + if iso_gap_contents: + self.out( + "iso_gap_data", + IsoGapData.from_files(iso_gap_contents, prefix=EpwCalculation._PREFIX), + ) + + aniso_gap_data_pattern = re.compile( + rf"^{EpwCalculation._PREFIX}\.(imag|pade)_aniso_gap0_\d+\.\d+$" + ) + aniso_gap_contents = self.get_retrieved_contents_matching( + aniso_gap_data_pattern + ) + if aniso_gap_contents: + self.out( + "aniso_gap0_data", + AnisoGap0Data.from_files( + aniso_gap_contents, prefix=EpwCalculation._PREFIX + ), + ) + aniso_gap_fs_pattern = re.compile( rf"^{EpwCalculation._PREFIX}\.imag_aniso_gap_FS_\d+\.\d+$" ) diff --git a/src/aiida_epw/tools/parsers.py b/src/aiida_epw/tools/parsers.py index e2f2f771..15d7bc3c 100644 --- a/src/aiida_epw/tools/parsers.py +++ b/src/aiida_epw/tools/parsers.py @@ -328,6 +328,83 @@ def _parse_epw_lambda_distribution(file_content, file_label, *, x_key, y_key): } +def parse_epw_iso_gap_files(file_contents, prefix="aiida"): + """Parse isotropic imaginary-axis and Pade gap files into named columns.""" + parsed_data = {} + pattern = re.compile(rf"^{prefix}\.(imag|pade)_iso_(\d{{3}}\.\d{{2}})$") + for filename, content in file_contents.items(): + match = pattern.match(filename) + if not match: + continue + try: + table = _load_numeric_table(content, comments="#", skiprows=1) + except Exception as exc: + raise ValueError( + f"Failed to parse gap function file {filename}: {exc}" + ) from exc + parsed_data[(match.group(1), float(match.group(2)))] = ( + _columns_from_iso_gap_table(table, filename) + ) + if not parsed_data: + raise ValueError(f"No {prefix} isotropic imag/Pade gap files were parsed.") + return parsed_data + + +def parse_epw_aniso_gap0_files(file_contents, prefix="aiida"): + """Parse anisotropic gap-zero distributions into named columns.""" + parsed_data = {} + pattern = re.compile(rf"^{prefix}\.(imag|pade)_aniso_gap0_(\d{{3}}\.\d{{2}})$") + for filename, content in file_contents.items(): + match = pattern.match(filename) + if not match: + continue + try: + table = _load_numeric_table(content, comments="#") + except Exception as exc: + raise ValueError( + f"Failed to parse gap function file {filename}: {exc}" + ) from exc + if table.shape[1] < 5: + raise ValueError( + f"Malformed gap function file {filename}: expected at least 5 columns." + ) + parsed_data[(match.group(1), float(match.group(2)))] = { + "T_dist_scaled": table[:, 0], + "delta_nk": table[:, 1], + "T": table[:, 2], + "dist_scaled": table[:, 3], + "dist_not_scaled": table[:, 4], + } + if not parsed_data: + raise ValueError(f"No {prefix} anisotropic imag/Pade gap files were parsed.") + return parsed_data + + +def _columns_from_iso_gap_table(table, filename): + """Return named columns for supported isotropic gap table layouts.""" + columns_by_count = { + 3: ("omega", "znorm", "deltaw"), + 4: ("omega", "znorm", "deltaw", "shift"), + 5: ("omega", "znorm_real", "znorm_imag", "deltaw_real", "deltaw_imag"), + 7: ( + "omega", + "znorm_real", + "znorm_imag", + "deltaw_real", + "deltaw_imag", + "shift_real", + "shift_imag", + ), + } + try: + column_names = columns_by_count[table.shape[1]] + except KeyError as exc: + raise ValueError( + f"Malformed gap function file {filename}: expected 3, 4, 5 or 7 columns." + ) from exc + return {name: table[:, index] for index, name in enumerate(column_names)} + + def _get_files_from_folder(folder): """Yield tuples of (filename, open_callable) from pathlib.Path, FolderData, or dict.""" if isinstance(folder, (str, pathlib.Path)): diff --git a/tests/data/test_gap_data.py b/tests/data/test_gap_data.py new file mode 100644 index 00000000..ee0a3fbe --- /dev/null +++ b/tests/data/test_gap_data.py @@ -0,0 +1,91 @@ +"""Tests for the additive typed gap-data classes.""" + +import pytest +from aiida.plugins import DataFactory + +from aiida_epw.data import AnisoGap0Data, IsoGapData +from aiida_epw.tools.parsers import parse_epw_iso_gap_files + + +def test_iso_gap_data_keeps_imaginary_and_pade_sources_separate(): + """The same temperature can hold distinct imaginary-axis and Pade tables.""" + node = IsoGapData() + node.set_gap_data( + { + ("imag", 3.0): {"omega": [0.1, 0.2], "deltaw": [0.01, 0.02]}, + ("pade", 3.0): {"omega": [0.1], "deltaw_real": [0.01]}, + } + ) + + assert node.sources == ["imag", "pade"] + assert node.get_data(3.0, source="imag")["deltaw"].tolist() == [0.01, 0.02] + with pytest.raises(KeyError, match="Multiple gap data"): + node.get_data(3.0) + + +def test_aniso_gap_data_and_entry_points_are_available(): + """Anisotropic data remains an additive, separately registered type.""" + node = AnisoGap0Data() + node.set_gap_data( + { + ("imag", 4.0): { + "T_dist_scaled": [4.0], + "delta_nk": [1.0], + "T": [4.0], + "dist_scaled": [0.1], + "dist_not_scaled": [10.0], + } + } + ) + + assert node.get_temperatures().tolist() == [4.0] + assert DataFactory("epw.iso_gap") is IsoGapData + assert DataFactory("epw.aniso_gap0") is AnisoGap0Data + + +def test_gap_data_stores_one_table_per_source_and_temperature(): + """Columns are kept together, while ragged tables remain supported.""" + node = IsoGapData() + node.set_gap_data({("imag", 4.0): {"omega": [0.1], "deltaw": [0.02]}}) + + assert node.get_table(4.0, source="imag").tolist() == [[0.1, 0.02]] + assert sorted(node.get_arraynames()) == ["imag_004_00", "temperatures"] + + +def test_gap_data_exposes_plotting_series(): + """Typed data can be converted into plain temperature/gap series.""" + iso = IsoGapData() + iso.set_gap_data({("imag", 3.0): {"omega": [0.1], "deltaw": [0.002]}}) + assert iso.get_gap_fs() == { + "T": [3.0], + "gap": [2.0], + "unit": "meV", + "source": "imag", + } + assert iso.to_dict()["imag"][3.0]["columns"] == ["omega", "deltaw"] + + +def test_aniso_gap_data_exposes_averaged_gap_series(): + """A flat distribution produces one representative gap value.""" + aniso = AnisoGap0Data() + aniso.set_gap_data( + { + ("imag", 3.0): { + "T_dist_scaled": [3.0, 3.0, 3.0], + "delta_nk": [1.0, 2.0, 3.0], + "T": [3.0, 3.0, 3.0], + "dist_scaled": [0.1, 0.1, 0.1], + "dist_not_scaled": [1.0, 1.0, 1.0], + } + } + ) + assert aniso.get_averaged_gap()["gap"] == [[2.0]] + + +def test_parse_pade_iso_gap_file(): + """Pade file names and their five-column layout are supported.""" + parsed = parse_epw_iso_gap_files( + {"aiida.pade_iso_003.00": "w ReZ ImZ ReD ImD\n1.0 2.0 0.2 0.01 0.001\n"} + ) + + assert parsed[("pade", 3.0)]["deltaw_imag"].tolist() == [0.001] From 64299cfadff37f4f0555b81625ec5acf4349b318 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 18 Aug 2026 14:13:47 +0200 Subject: [PATCH 2/2] refactor(parser): drop legacy gap-function outputs --- pyproject.toml | 1 - src/aiida_epw/calculations/epw.py | 13 --- src/aiida_epw/data/__init__.py | 3 +- src/aiida_epw/data/gap_function.py | 141 +------------------------ src/aiida_epw/parsers/epw.py | 41 ------- src/aiida_epw/tools/plot.py | 7 +- tests/calculations/test_epw_calcjob.py | 10 ++ tests/data/test_gap_function.py | 95 ----------------- tests/parsers/test_epw.py | 35 ------ 9 files changed, 13 insertions(+), 333 deletions(-) delete mode 100644 tests/data/test_gap_function.py diff --git a/pyproject.toml b/pyproject.toml index 2bf0c743..2f031b1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ Source = "https://github.com/aiidaplugins/aiida-epw" 'epw.dos' = 'aiida_epw.data.dos:DosData' 'epw.phdos' = 'aiida_epw.data.dos:PhDosData' 'epw.pdos' = 'aiida_epw.data.dos:PDosData' -'epw.gap_function' = 'aiida_epw.data.gap_function:GapFunctionData' 'epw.iso_gap' = 'aiida_epw.data.gap_function:IsoGapData' 'epw.aniso_gap0' = 'aiida_epw.data.gap_function:AnisoGap0Data' 'epw.lambda_fs' = 'aiida_epw.data.lambda_fs:LambdaFSData' diff --git a/src/aiida_epw/calculations/epw.py b/src/aiida_epw/calculations/epw.py index 14851cf1..abd1abf0 100644 --- a/src/aiida_epw/calculations/epw.py +++ b/src/aiida_epw/calculations/epw.py @@ -26,7 +26,6 @@ DosData, PDosData, PhDosData, - GapFunctionData, IsoGapData, LambdaFSData, ) @@ -329,18 +328,6 @@ def define(cls, spec): required=False, help="The interpolated phonon band structure.", ) - spec.output( - "iso_gap_functions", - valid_type=GapFunctionData, - required=False, - help="The interpolated isotropic gap function.", - ) - spec.output( - "aniso_gap_functions", - valid_type=GapFunctionData, - required=False, - help="The interpolated anisotropic gap function.", - ) spec.output( "iso_gap_data", valid_type=IsoGapData, diff --git a/src/aiida_epw/data/__init__.py b/src/aiida_epw/data/__init__.py index 8373d665..d9db33c6 100644 --- a/src/aiida_epw/data/__init__.py +++ b/src/aiida_epw/data/__init__.py @@ -2,7 +2,7 @@ from .a2f import A2fData, PA2fData from .dos import DosData, PDosData, PhDosData -from .gap_function import AnisoGap0Data, GapFunctionData, IsoGapData +from .gap_function import AnisoGap0Data, IsoGapData from .lambda_fs import LambdaFSData __all__ = ( @@ -11,7 +11,6 @@ "DosData", "PhDosData", "PDosData", - "GapFunctionData", "IsoGapData", "AnisoGap0Data", "LambdaFSData", diff --git a/src/aiida_epw/data/gap_function.py b/src/aiida_epw/data/gap_function.py index 0d715ccc..6d0a7e8d 100644 --- a/src/aiida_epw/data/gap_function.py +++ b/src/aiida_epw/data/gap_function.py @@ -1,4 +1,4 @@ -"""Domain-specific data types for EPW gap-function tables.""" +"""Domain-specific data types for typed EPW gap data.""" from pathlib import Path @@ -7,145 +7,6 @@ from aiida.common import exceptions -class GapFunctionData(orm.ArrayData): - """Store a temperature-indexed collection of EPW gap-function tables.""" - - ATTRIBUTE_TEMPERATURES = "temperatures" - ATTRIBUTE_ARRAY_NAMES = "array_names" - ATTRIBUTE_KIND = "kind" - ARRAY_NAME_TEMPLATE = "gap_function_{index:03d}" - - def set_gap_functions(self, gap_functions, *, kind=None): - """Store gap-function tables keyed by temperature.""" - if not gap_functions: - raise exceptions.ValidationError("`gap_functions` cannot be empty.") - - normalized = [] - for temperature, gap_function in sorted( - gap_functions.items(), key=lambda item: float(item[0]) - ): - table = numpy.array(gap_function, dtype=float) - if table.ndim != 2: - raise exceptions.ValidationError( - "Each gap-function entry must be a two-dimensional array." - ) - normalized.append((float(temperature), table)) - - self._delete_gap_function_arrays() - - temperatures = [] - array_names = [] - for index, (temperature, table) in enumerate(normalized): - array_name = self.ARRAY_NAME_TEMPLATE.format(index=index) - self.set_array(array_name, table) - temperatures.append(temperature) - array_names.append(array_name) - - self.base.attributes.set(self.ATTRIBUTE_TEMPERATURES, temperatures) - self.base.attributes.set(self.ATTRIBUTE_ARRAY_NAMES, array_names) - self._set_optional_attribute(self.ATTRIBUTE_KIND, kind) - - def get_temperatures(self): - """Return the stored temperatures in Kelvin.""" - return numpy.array( - self.base.attributes.get(self.ATTRIBUTE_TEMPERATURES, []), dtype=float - ) - - def get_gap_functions(self): - """Return all stored gap-function tables keyed by temperature.""" - return { - temperature: self.get_array(array_name) - for temperature, array_name in self._get_temperature_array_pairs() - } - - def get_gap_function(self, temperature, *, atol=1e-8): - """Return the gap-function table for a specific temperature.""" - target = float(temperature) - - for stored_temperature, array_name in self._get_temperature_array_pairs(): - if numpy.isclose(stored_temperature, target, atol=atol, rtol=0.0): - return self.get_array(array_name) - - raise KeyError(f"No gap function stored for temperature {target}.") - - def get_itergap_functions(self): - """Yield `(temperature, table)` pairs in ascending temperature order.""" - for temperature, array_name in self._get_temperature_array_pairs(): - yield temperature, self.get_array(array_name) - - @property - def kind(self): - """Return the optional gap-function kind, e.g. `iso` or `aniso`.""" - return self.base.attributes.get(self.ATTRIBUTE_KIND, None) - - def _get_temperature_array_pairs(self): - """Return the stored temperature-to-array mapping.""" - temperatures = self.base.attributes.get(self.ATTRIBUTE_TEMPERATURES, []) - array_names = self.base.attributes.get(self.ATTRIBUTE_ARRAY_NAMES, []) - return list(zip(temperatures, array_names, strict=True)) - - def _delete_gap_function_arrays(self): - """Delete previously stored gap-function tables.""" - for array_name in self.base.attributes.get(self.ATTRIBUTE_ARRAY_NAMES, []): - if array_name in self.get_arraynames(): - self.delete_array(array_name) - - def _set_optional_attribute(self, key, value): - """Set or clear an optional scalar attribute.""" - if value is None: - try: - self.base.attributes.delete(key) - except AttributeError: - pass - return - - self.base.attributes.set(key, value) - - @classmethod - def from_files(cls, file_contents_or_paths, prefix="aiida", kind="iso"): - """Instantiate and populate a `GapFunctionData` node from multiple files. - - :param file_contents_or_paths: list of filepaths, a dict mapping filenames to string contents, - a folder path/object, or FolderData. - :param prefix: prefix of the files (e.g. 'aiida'). - :param kind: kind of gap function ('iso' or 'aniso'). - """ - from aiida_epw.tools.parsers import ( - parse_epw_imag_iso, - parse_epw_imag_aniso_gap0, - ) - - if kind == "iso": - gap_functions = parse_epw_imag_iso(file_contents_or_paths, prefix=prefix) - elif kind == "aniso": - gap_functions = parse_epw_imag_aniso_gap0( - file_contents_or_paths, prefix=prefix - ) - else: - raise ValueError(f"Unknown kind '{kind}': Must be either 'iso' or 'aniso'.") - - node = cls() - node.set_gap_functions(gap_functions, kind=kind) - return node - - @classmethod - def from_directory(cls, dirpath, prefix="aiida", kind="iso"): - """Instantiate and populate a `GapFunctionData` node from gap files in a directory.""" - from pathlib import Path - - path = Path(dirpath) - pattern = ( - f"{prefix}.imag_iso_*" if kind == "iso" else f"{prefix}.imag_aniso_gap0_*" - ) - filepaths = list(path.glob(pattern)) - if not filepaths: - raise FileNotFoundError( - f"No files matching '{pattern}' in directory '{dirpath}'" - ) - - return cls.from_files(filepaths, prefix=prefix, kind=kind) - - def _temperature_label(temperature): """Return a stable array-name fragment for a temperature value.""" return f"{float(temperature):06.2f}".replace(".", "_") diff --git a/src/aiida_epw/parsers/epw.py b/src/aiida_epw/parsers/epw.py index cf784072..b175dc02 100644 --- a/src/aiida_epw/parsers/epw.py +++ b/src/aiida_epw/parsers/epw.py @@ -17,7 +17,6 @@ DosData, PDosData, PhDosData, - GapFunctionData, IsoGapData, LambdaFSData, ) @@ -26,8 +25,6 @@ parse_epw_a2f_proj, parse_epw_bands, parse_epw_dos, - parse_epw_imag_aniso_gap0, - parse_epw_imag_iso, parse_epw_lambda_fs, parse_epw_lambda_k_pairs, parse_epw_max_eigenvalue, @@ -158,26 +155,6 @@ def parse(self, **kwargs): self.parse_lambda_k_pairs(lambda_k_pairs_contents), ) - iso_gap_pattern = re.compile(rf"^{EpwCalculation._PREFIX}\.imag_iso_\d+\.\d+$") - if self.retrieved and any( - iso_gap_pattern.match(name) for name in self.retrieved.list_object_names() - ): - self.out( - "iso_gap_functions", - self.parse_iso_gap_functions(self.retrieved), - ) - - aniso_gap_pattern = re.compile( - rf"^{EpwCalculation._PREFIX}\.imag_aniso_gap0_\d+\.\d+$" - ) - if self.retrieved and any( - aniso_gap_pattern.match(name) for name in self.retrieved.list_object_names() - ): - self.out( - "aniso_gap_functions", - self.parse_aniso_gap_functions(self.retrieved), - ) - iso_gap_data_pattern = re.compile( rf"^{EpwCalculation._PREFIX}\.(imag|pade)_iso_\d+\.\d+$" ) @@ -533,24 +510,6 @@ def parse_a2f(content): } return a2f_data, parsed_data - @staticmethod - def parse_iso_gap_functions(file_contents): - """Parse isotropic gap-function files into a typed datatype.""" - gap_functions = parse_epw_imag_iso(file_contents, prefix=EpwCalculation._PREFIX) - gap_function_data = GapFunctionData() - gap_function_data.set_gap_functions(gap_functions, kind="iso") - return gap_function_data - - @staticmethod - def parse_aniso_gap_functions(file_contents): - """Parse anisotropic gap-function files into a typed datatype.""" - gap_functions = parse_epw_imag_aniso_gap0( - file_contents, prefix=EpwCalculation._PREFIX - ) - gap_function_data = GapFunctionData() - gap_function_data.set_gap_functions(gap_functions, kind="aniso") - return gap_function_data - @staticmethod def parse_aniso_gap_fs(folder): """Parse the imag_aniso_gap_FS files into an ArrayData node.""" diff --git a/src/aiida_epw/tools/plot.py b/src/aiida_epw/tools/plot.py index 19372805..93e86778 100644 --- a/src/aiida_epw/tools/plot.py +++ b/src/aiida_epw/tools/plot.py @@ -11,7 +11,6 @@ from aiida import orm from scipy.optimize import curve_fit -from aiida_epw.data import GapFunctionData from aiida_epw.tools.calculators import bcs_gap_function @@ -63,11 +62,7 @@ def plot_max_eigenvalue(temps, evs, ax=None, **kwargs): def _iter_gap_functions(gap_functions): - """Yield `(temperature, table)` pairs from typed or legacy gap-function data.""" - if isinstance(gap_functions, GapFunctionData): - yield from gap_functions.get_itergap_functions() - return - + """Yield `(temperature, table)` pairs from an ``ArrayData`` node.""" for arrayname, array in gap_functions.get_iterarrays(): yield float(arrayname.replace("_", ".")), array diff --git a/tests/calculations/test_epw_calcjob.py b/tests/calculations/test_epw_calcjob.py index 42b54e8d..fbb426ac 100644 --- a/tests/calculations/test_epw_calcjob.py +++ b/tests/calculations/test_epw_calcjob.py @@ -27,6 +27,16 @@ def generate_kpoints_list(points): return kpoints +def test_epw_exposes_only_typed_gap_data_outputs(): + """Test that legacy raw gap-function outputs are no longer exposed.""" + output_names = EpwCalculation.spec().outputs.keys() + + assert "iso_gap_data" in output_names + assert "aniso_gap0_data" in output_names + assert "iso_gap_functions" not in output_names + assert "aniso_gap_functions" not in output_names + + @pytest.fixture def generate_inputs_epw(fixture_code): """Return basic inputs for `EpwCalculation`.""" diff --git a/tests/data/test_gap_function.py b/tests/data/test_gap_function.py deleted file mode 100644 index 1932a20e..00000000 --- a/tests/data/test_gap_function.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Tests for the ``GapFunctionData`` datatype.""" - -import numpy -import pytest -from aiida.common import exceptions -from aiida.plugins import DataFactory - -from aiida_epw.data import GapFunctionData - - -def test_gap_function_data_roundtrip(): - """Test storing and retrieving gap-function tables by temperature.""" - node = GapFunctionData() - node.set_gap_functions( - { - 5.0: [[0.4, 1.0], [0.8, 0.7]], - 3.0: [[0.2, 1.3], [0.6, 0.9]], - }, - kind="iso", - ) - - assert node.kind == "iso" - assert node.get_temperatures().tolist() == [3.0, 5.0] - assert node.get_gap_function(3.0).tolist() == [[0.2, 1.3], [0.6, 0.9]] - assert list(node.get_gap_functions()) == [3.0, 5.0] - assert [ - (temperature, table.tolist()) - for temperature, table in node.get_itergap_functions() - ] == [ - (3.0, [[0.2, 1.3], [0.6, 0.9]]), - (5.0, [[0.4, 1.0], [0.8, 0.7]]), - ] - - -def test_gap_function_data_replaces_previous_tables(): - """Test resetting gap functions drops stale arrays from a previous payload.""" - node = GapFunctionData() - node.set_gap_functions({3.0: [[0.1, 0.2]]}, kind="iso") - node.set_gap_functions({4.0: [[0.3, 0.4], [0.5, 0.6]]}, kind="aniso") - - assert node.kind == "aniso" - assert node.get_temperatures().tolist() == [4.0] - assert node.get_gap_function(4.0).tolist() == [[0.3, 0.4], [0.5, 0.6]] - assert node.get_arraynames() == ["gap_function_000"] - - -def test_gap_function_data_validates_shape_contract(): - """Test invalid gap-function payloads are rejected.""" - node = GapFunctionData() - - with pytest.raises(exceptions.ValidationError): - node.set_gap_functions({}) - - with pytest.raises(exceptions.ValidationError): - node.set_gap_functions({3.0: numpy.array([0.1, 0.2])}) - - -def test_gap_function_data_entry_point(): - """Test the datatype is registered through ``aiida.data``.""" - assert DataFactory("epw.gap_function") is GapFunctionData - - -def test_gap_function_serialization_factories(files_path): - """Test GapFunctionData.from_files and from_directory classmethods.""" - iso_dir = files_path / "tools" / "parsers" / "full_iso_eliashberg" - - # Test from_directory - node_dir = GapFunctionData.from_directory(iso_dir, prefix="aiida", kind="iso") - assert isinstance(node_dir, GapFunctionData) - assert node_dir.kind == "iso" - assert node_dir.get_temperatures().tolist() == [3.0, 4.0, 5.0] - assert node_dir.get_gap_function(3.0).shape[1] == 3 - - # Test from_files (dict of contents) - file_contents = { - path.name: path.read_text() - for path in sorted(iso_dir.iterdir()) - if path.is_file() - } - node_files_dict = GapFunctionData.from_files( - file_contents, prefix="aiida", kind="iso" - ) - assert node_files_dict.get_temperatures().tolist() == [3.0, 4.0, 5.0] - - # Test from_files (list of filepaths) - filepaths = [str(path) for path in sorted(iso_dir.iterdir()) if path.is_file()] - node_files_list = GapFunctionData.from_files(filepaths, prefix="aiida", kind="iso") - assert node_files_list.get_temperatures().tolist() == [3.0, 4.0, 5.0] - - # Test anisotropic loading - aniso_dir = files_path / "tools" / "parsers" / "fsr_aniso_eliashberg" - node_aniso = GapFunctionData.from_directory(aniso_dir, prefix="aiida", kind="aniso") - assert node_aniso.kind == "aniso" - assert node_aniso.get_temperatures().tolist() == [3.0, 4.0, 5.0] - assert node_aniso.get_gap_function(3.0).shape[1] == 5 diff --git a/tests/parsers/test_epw.py b/tests/parsers/test_epw.py index 6f53f481..096d498d 100644 --- a/tests/parsers/test_epw.py +++ b/tests/parsers/test_epw.py @@ -16,7 +16,6 @@ PA2fData, DosData, PDosData, - GapFunctionData, LambdaFSData, ) from aiida_epw.parsers.epw import EpwParser @@ -303,40 +302,6 @@ def test_parse_lambda_k_pairs_returns_typed_data(): assert lambda_k_pairs.get_dos().tolist() == [1.5, 2.5] -def test_parse_iso_gap_functions_returns_typed_data(files_path): - """Test isotropic gap-function files are wrapped in `GapFunctionData`.""" - iso_dir = files_path / "tools" / "parsers" / "full_iso_eliashberg" - file_contents = { - path.name: path.read_text() - for path in sorted(iso_dir.iterdir()) - if path.is_file() - } - - gap_functions = EpwParser.parse_iso_gap_functions(file_contents) - - assert isinstance(gap_functions, GapFunctionData) - assert gap_functions.kind == "iso" - assert gap_functions.get_temperatures().tolist() == [3.0, 4.0, 5.0] - assert gap_functions.get_gap_function(3.0).shape[1] == 3 - - -def test_parse_aniso_gap_functions_returns_typed_data(files_path): - """Test anisotropic gap-function files are wrapped in `GapFunctionData`.""" - aniso_dir = files_path / "tools" / "parsers" / "fsr_aniso_eliashberg" - file_contents = { - path.name: path.read_text() - for path in sorted(aniso_dir.iterdir()) - if path.is_file() - } - - gap_functions = EpwParser.parse_aniso_gap_functions(file_contents) - - assert isinstance(gap_functions, GapFunctionData) - assert gap_functions.kind == "aniso" - assert gap_functions.get_temperatures().tolist() == [3.0, 4.0, 5.0] - assert gap_functions.get_gap_function(3.0).shape[1] == 5 - - def test_parse_aniso_gap_fs_returns_typed_data(): """Test parsing of imag_aniso_gap_FS file.""" content = """# kx ky kz band energy delta