From 17a2832c4f5aafc9703b00b28723c77b0e3f9f91 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:44:50 +0200 Subject: [PATCH 01/13] feat: add get_fcidump to output --- examples/exmp054_casscf_fcidump/job.py | 19 +-- src/opi/output/core.py | 21 ++++ src/opi/output/fcidump_parser.py | 108 ++++++++++++++++++ tests/examples/test_exmp054_casscf_fcidump.py | 14 ++- tests/unit/test_output_fcidump_parser.py | 79 +++++++++++++ 5 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 src/opi/output/fcidump_parser.py create mode 100644 tests/unit/test_output_fcidump_parser.py diff --git a/examples/exmp054_casscf_fcidump/job.py b/examples/exmp054_casscf_fcidump/job.py index fcbad4e5..8ecaac96 100755 --- a/examples/exmp054_casscf_fcidump/job.py +++ b/examples/exmp054_casscf_fcidump/job.py @@ -23,8 +23,8 @@ def run_exmp054( calc = Calculator(basename="job", working_dir=working_dir) calc.structure = structure - - calc.input.add_blocks(BlockCasscf(nel=2, norb=2), BlockOutput(dumpactints=True)) + casscf_block = BlockCasscf(nel=2, norb=2) + calc.input.add_blocks(casscf_block, BlockOutput(dumpactints=True)) calc.input.ncores = 4 calc.write_input() @@ -49,12 +49,17 @@ def run_exmp054( print(output.results_properties.geometries[0].energy[0].totalenergy[0][0]) # > retrieve the path to the fcidump file - fcidump_file = output.get_outfile().with_suffix(".fcidump") + fcidump = output.get_fcidump() + + if fcidump is None: + print(f"FCIDUMP generation failed, see output file: {output.get_outfile()}") + print(output.error_message()) + sys.exit(1) - # > Read and print the file - print("FCIDUMP file:") - with open(fcidump_file, "r") as f: - print(f.read()) + print("hcore matrix") + print(fcidump.hcore_matrix) + print("ERI tensor") + print(fcidump.eri_tensor) return output diff --git a/src/opi/output/core.py b/src/opi/output/core.py index faab0b02..c54dc793 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -16,6 +16,7 @@ from opi.execution.core import Runner from opi.input.structures import Atom, Coordinates, Structure from opi.output.cube import CubeOutput +from opi.output.fcidump_parser import Fcidump from opi.output.gbw_suffix import GbwSuffix from opi.output.grepper.recipes import ( get_error_message, @@ -2742,3 +2743,23 @@ def get_free_solvation_energy_opencosmors(self) -> float | None: except (KeyError, IndexError): return None return float(free_solv_energy) + + def get_fcidump(self) -> Fcidump | None: + """ + Parses a fcidump file generated by ORCA and return its data in the `Fcidump` data class. + ORCA can generate fcidump files via the `dumpactints true` flag in the `%casscf` block. + + Returns + ------- + fcidump_data: Fcidump | None + The parsed fcidump data or None if the file is not present or could not be parsed. + """ + + # > Get path to the fcidump file + fci_path = self.get_outfile().with_suffix(".fcidump") + + # > If there is no file we return None + if not fci_path.is_file(): + return None + + return Fcidump.parse_fcidump(fci_path) diff --git a/src/opi/output/fcidump_parser.py b/src/opi/output/fcidump_parser.py new file mode 100644 index 00000000..abc00dc4 --- /dev/null +++ b/src/opi/output/fcidump_parser.py @@ -0,0 +1,108 @@ +"""Parse a potential FCIDUMP file""" + +import re +from dataclasses import dataclass, field +from functools import cached_property +from pathlib import Path + +import numpy as np + + +@dataclass +class Fcidump: + norb: int + nelec: int + ms2: int + orbsym: list[int] + isym: int + one_electron: dict[tuple[int, int], float] = field(default_factory=dict) + two_electron: dict[tuple[int, int, int, int], float] = field(default_factory=dict) + e_nuc: float = 0.0 + path: Path = field(default_factory=Path) + + @cached_property + def hcore_matrix(self) -> np.ndarray: + """Return the one-electron integrals as a symmetric (norb, norb) matrix.""" + mat = np.zeros((self.norb, self.norb)) + for (i, j), val in self.one_electron.items(): + mat[i - 1, j - 1] = val + mat[j - 1, i - 1] = val + return mat + + @cached_property + def eri_tensor(self) -> np.ndarray: + """Return the two-electron integrals as a (norb, norb, norb, norb) tensor. + + Uses chemist's notation (ij|kl) with 8-fold permutation symmetry applied. + """ + tensor = np.zeros((self.norb,) * 4) + # > use ll instead of l to satisfy ruff + for (i, j, k, ll), val in self.two_electron.items(): + a, b, c, d = i - 1, j - 1, k - 1, ll - 1 + for p, q, r, s in [ + (a, b, c, d), + (b, a, c, d), + (a, b, d, c), + (b, a, d, c), + (c, d, a, b), + (d, c, a, b), + (c, d, b, a), + (d, c, b, a), + ]: + tensor[p, q, r, s] = val + return tensor + + @classmethod + def parse_fcidump(cls, path: Path | str) -> "Fcidump": + + if isinstance(path, str): + path = Path(path) + + with open(path) as f: + text = f.read() + + # Split header and body + end_match = re.search(r"&END|/", text, re.IGNORECASE) + if end_match is None: + raise ValueError(f"Could not find header terminator (&END or /) in {path}") + header = text[: end_match.end()] + body = text[end_match.end() :] + + # Parse header fields + def get_int(key: str) -> int: + m = re.search(rf"{key}\s*=\s*(\d+)", header, re.IGNORECASE) + return int(m.group(1)) if m else 0 + + def get_int_list(key: str) -> list[int]: + m = re.search(rf"{key}\s*=\s*([\d,\s]+)", header, re.IGNORECASE) + return [int(x) for x in re.split(r"[,\s]+", m.group(1).strip()) if x] if m else [] + + dump = cls( + norb=get_int("NORB"), + nelec=get_int("NELEC"), + ms2=get_int("MS2"), + orbsym=get_int_list("ORBSYM"), + isym=get_int("ISYM"), + path=Path(path), + ) + + # Parse integral lines + for line in body.splitlines(): + parts = line.split() + if len(parts) != 5: + continue + val, i, j, k, ll = ( + float(parts[0]), + int(parts[1]), + int(parts[2]), + int(parts[3]), + int(parts[4]), + ) + if i == 0 and j == 0 and k == 0 and ll == 0: + dump.e_nuc = val + elif k == 0 and ll == 0: + dump.one_electron[(i, j)] = val + else: + dump.two_electron[(i, j, k, ll)] = val + + return dump diff --git a/tests/examples/test_exmp054_casscf_fcidump.py b/tests/examples/test_exmp054_casscf_fcidump.py index 40a081fb..2cade6d5 100644 --- a/tests/examples/test_exmp054_casscf_fcidump.py +++ b/tests/examples/test_exmp054_casscf_fcidump.py @@ -1,3 +1,4 @@ +import numpy as np import pytest from examples.exmp054_casscf_fcidump.job import run_exmp054 @@ -15,6 +16,17 @@ def test_exmp054_casscf_fcidump(example_input_file, tmp_path) -> None: # Run the example in tmp_path output = run_exmp054(structure=structure, working_dir=tmp_path) - fcidump_file = output.get_outfile().with_suffix(".fcidump") + fcidump = output.get_fcidump() + assert fcidump is not None + fcidump_file = fcidump.path assert fcidump_file.exists() and fcidump_file.is_file() + + norb = fcidump.norb + assert fcidump.hcore_matrix.shape == (norb, norb) + assert fcidump.eri_tensor.shape == (norb, norb, norb, norb) + + # hcore must be symmetric + assert np.allclose(fcidump.hcore_matrix, fcidump.hcore_matrix.T) + # eri must satisfy (ij|kl) == (kl|ij) + assert np.allclose(fcidump.eri_tensor, fcidump.eri_tensor.transpose(2, 3, 0, 1)) diff --git a/tests/unit/test_output_fcidump_parser.py b/tests/unit/test_output_fcidump_parser.py new file mode 100644 index 00000000..a6a51780 --- /dev/null +++ b/tests/unit/test_output_fcidump_parser.py @@ -0,0 +1,79 @@ +import textwrap +from pathlib import Path + +import pytest + +from opi.output.fcidump_parser import Fcidump + + +@pytest.mark.unit +def test_parse_fcidump_header(tmp_path: Path) -> None: + fcidump_text = textwrap.dedent("""\ + &FCI + NORB= 2, NELEC= 2, MS2= 0, + ORBSYM=1,1, + ISYM=0, + / + 0.5000000000 1 1 1 1 + 0.1000000000 2 1 1 1 + 0.2000000000 2 2 1 1 + 0.3000000000 2 2 2 2 + -0.1234567890 1 1 0 0 + 0.0987654321 2 1 0 0 + 0.0500000000 2 2 0 0 + -1.2345678901 0 0 0 0 + """) + fci_file = tmp_path / "test.fcidump" + fci_file.write_text(fcidump_text) + + dump = Fcidump.parse_fcidump(fci_file) + + assert dump.norb == 2 + assert dump.nelec == 2 + assert dump.ms2 == 0 + assert dump.orbsym == [1, 1] + assert dump.isym == 0 + assert pytest.approx(dump.e_nuc) == -1.2345678901 + + +@pytest.mark.unit +def test_hcore_matrix_shape_and_symmetry() -> None: + dump = Fcidump( + norb=2, + nelec=2, + ms2=0, + orbsym=[1, 1], + isym=0, + one_electron={(1, 1): -0.5, (2, 1): 0.1, (2, 2): -0.3}, + ) + mat = dump.hcore_matrix + + assert mat.shape == (2, 2) + assert pytest.approx(mat[0, 0]) == -0.5 + assert pytest.approx(mat[1, 1]) == -0.3 + # off-diagonal must be symmetric + assert pytest.approx(mat[1, 0]) == 0.1 + assert pytest.approx(mat[0, 1]) == 0.1 + + +@pytest.mark.unit +def test_eri_tensor_shape_and_symmetry() -> None: + dump = Fcidump( + norb=2, + nelec=2, + ms2=0, + orbsym=[1, 1], + isym=0, + two_electron={(1, 1, 1, 1): 0.5, (2, 1, 1, 1): 0.1, (2, 2, 1, 1): 0.2, (2, 2, 2, 2): 0.3}, + ) + tensor = dump.eri_tensor + + assert tensor.shape == (2, 2, 2, 2) + assert pytest.approx(tensor[0, 0, 0, 0]) == 0.5 + assert pytest.approx(tensor[1, 1, 1, 1]) == 0.3 + # check 8-fold symmetry for (2,1,1,1) -> index (1,0,0,0) + val = 0.1 + assert pytest.approx(tensor[1, 0, 0, 0]) == val # (ij|kl) + assert pytest.approx(tensor[0, 1, 0, 0]) == val # (ji|kl) + assert pytest.approx(tensor[0, 0, 1, 0]) == val # (kl|ij) + assert pytest.approx(tensor[0, 0, 0, 1]) == val # (lk|ij) From ea5ce7409e87fd8e5808c1b5162f603c91002632 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:47:45 +0200 Subject: [PATCH 02/13] doc: improve get_fcidump docstring --- src/opi/output/core.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/opi/output/core.py b/src/opi/output/core.py index c54dc793..682017af 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -2746,8 +2746,10 @@ def get_free_solvation_energy_opencosmors(self) -> float | None: def get_fcidump(self) -> Fcidump | None: """ - Parses a fcidump file generated by ORCA and return its data in the `Fcidump` data class. - ORCA can generate fcidump files via the `dumpactints true` flag in the `%casscf` block. + Parse the fcidump file generated by ORCA and return its data in the `Fcidump` data class. + The fcidump file has to be generated by the ORCA job and cannot be generated on-the-fly after the calculation. + ORCA can generate fcidump files via the `dumpactints true` flag in the `%ouput` block. + See `BlockOutput` for the related block option. Returns ------- From b050e0dc7094bebaf8af79d8975e1a6302626085 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:49:01 +0200 Subject: [PATCH 03/13] doc: fix typo --- src/opi/output/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opi/output/core.py b/src/opi/output/core.py index 682017af..cb3d53f0 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -2748,7 +2748,7 @@ def get_fcidump(self) -> Fcidump | None: """ Parse the fcidump file generated by ORCA and return its data in the `Fcidump` data class. The fcidump file has to be generated by the ORCA job and cannot be generated on-the-fly after the calculation. - ORCA can generate fcidump files via the `dumpactints true` flag in the `%ouput` block. + ORCA can generate fcidump files via the `dumpactints true` flag in the `%output` block. See `BlockOutput` for the related block option. Returns From 97f24c8e3248fd67433ed63e791dca67e5c11bef Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:54:00 +0200 Subject: [PATCH 04/13] feat: Small improvements to Fcidump --- src/opi/output/fcidump_parser.py | 107 +++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 27 deletions(-) diff --git a/src/opi/output/fcidump_parser.py b/src/opi/output/fcidump_parser.py index abc00dc4..6988de32 100644 --- a/src/opi/output/fcidump_parser.py +++ b/src/opi/output/fcidump_parser.py @@ -10,6 +10,28 @@ @dataclass class Fcidump: + """ + Reads and stores data from a FCIDUMP file. One and two-electrons integrals are stored as dicts and can be + accessed as numpy arrays. + + Attributes + -------- + norb: int + Number of active orbitals. + nelec: int + Number of active electrons. + ms2: int + Electron spin multiplicity. + orbsym: int + Symmetry labels of the orbitals. + isym: int + Overall symmetry of the electronic structure. + e_nuc: float + Electronic core contribution. Contains the contracted energy of the inactive space. + path: Path + Path to the FCIDUMP file. + """ + norb: int nelec: int ms2: int @@ -22,7 +44,7 @@ class Fcidump: @cached_property def hcore_matrix(self) -> np.ndarray: - """Return the one-electron integrals as a symmetric (norb, norb) matrix.""" + """Return the one-electron integrals as a symmetric (norb, norb) numpy array.""" mat = np.zeros((self.norb, self.norb)) for (i, j), val in self.one_electron.items(): mat[i - 1, j - 1] = val @@ -31,7 +53,7 @@ def hcore_matrix(self) -> np.ndarray: @cached_property def eri_tensor(self) -> np.ndarray: - """Return the two-electron integrals as a (norb, norb, norb, norb) tensor. + """Return the two-electron integrals as a (norb, norb, norb, norb) numpy array. Uses chemist's notation (ij|kl) with 8-fold permutation symmetry applied. """ @@ -54,55 +76,86 @@ def eri_tensor(self) -> np.ndarray: @classmethod def parse_fcidump(cls, path: Path | str) -> "Fcidump": + """ + Parse a FCIDUMP file and return the populated `Fcidump` object. + + Raises + ------- + ValueError + If the FCIDUMP file cannot be parsed. + FileNotFoundError + If the FCIDUMP file cannot be found at the given path. + """ if isinstance(path, str): path = Path(path) + if not path.is_file(): + raise FileNotFoundError(f"{cls.__name__}: FCIDUMP file not found at {path}") + with open(path) as f: text = f.read() - # Split header and body + # > Split header and body end_match = re.search(r"&END|/", text, re.IGNORECASE) if end_match is None: - raise ValueError(f"Could not find header terminator (&END or /) in {path}") + raise ValueError( + f"{cls.__name__}: Could not find header terminator (&END or /) in {path}" + ) header = text[: end_match.end()] body = text[end_match.end() :] - # Parse header fields - def get_int(key: str) -> int: - m = re.search(rf"{key}\s*=\s*(\d+)", header, re.IGNORECASE) - return int(m.group(1)) if m else 0 - - def get_int_list(key: str) -> list[int]: - m = re.search(rf"{key}\s*=\s*([\d,\s]+)", header, re.IGNORECASE) - return [int(x) for x in re.split(r"[,\s]+", m.group(1).strip()) if x] if m else [] - + # > Parse the header dump = cls( - norb=get_int("NORB"), - nelec=get_int("NELEC"), - ms2=get_int("MS2"), - orbsym=get_int_list("ORBSYM"), - isym=get_int("ISYM"), + norb=cls._get_int("NORB", header), + nelec=cls._get_int("NELEC", header), + ms2=cls._get_int("MS2", header), + orbsym=cls._get_int_list("ORBSYM", header), + isym=cls._get_int("ISYM", header), path=Path(path), ) - # Parse integral lines + # > Parse the integrals from the body for line in body.splitlines(): parts = line.split() - if len(parts) != 5: + if not parts: continue - val, i, j, k, ll = ( - float(parts[0]), - int(parts[1]), - int(parts[2]), - int(parts[3]), - int(parts[4]), - ) + if len(parts) != 5: + raise ValueError(f"{cls.__name__}: Could not parse {line} in {path}") + try: + val, i, j, k, ll = ( + float(parts[0]), + int(parts[1]), + int(parts[2]), + int(parts[3]), + int(parts[4]), + ) + except ValueError: + raise ValueError(f"{cls.__name__}: Could not parse {line} in {path}") + # > Inactive contribution if i == 0 and j == 0 and k == 0 and ll == 0: dump.e_nuc = val + # > One-electron matrix elif k == 0 and ll == 0: dump.one_electron[(i, j)] = val + # > Two-electron tensor else: dump.two_electron[(i, j, k, ll)] = val return dump + + @classmethod + def _get_int(cls, key: str, header: str) -> int: + """Return the integer value of the given key.""" + m = re.search(rf"{key}\s*=\s*(\d+)", header, re.IGNORECASE) + if m is None: + raise ValueError(f"{cls.__name__}: Could not parse {key}") + return int(m.group(1)) + + @classmethod + def _get_int_list(cls, key: str, header: str) -> list[int]: + """Return a list of integers corresponding to the given key.""" + m = re.search(rf"{key}\s*=\s*([\d,\s]+)", header, re.IGNORECASE) + if m is None: + raise ValueError(f"{cls.__name__}: Could not parse {key}") + return [int(x) for x in re.split(r"[,\s]+", m.group(1).strip()) if x] From d06ca8d6877e69e556184932550d03cb4533ae95 Mon Sep 17 00:00:00 2001 From: Hagen Neugebauer <38649381+haneug@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:14:28 +0200 Subject: [PATCH 05/13] Update src/opi/output/core.py Co-authored-by: Tim Tetenberg <123412573+timmyte@users.noreply.github.com> --- src/opi/output/core.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/opi/output/core.py b/src/opi/output/core.py index cb3d53f0..a7399b0c 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -2748,7 +2748,12 @@ def get_fcidump(self) -> Fcidump | None: """ Parse the fcidump file generated by ORCA and return its data in the `Fcidump` data class. The fcidump file has to be generated by the ORCA job and cannot be generated on-the-fly after the calculation. - ORCA can generate fcidump files via the `dumpactints true` flag in the `%output` block. + To generate FCIDUMP files, set the following options: + ``` + %output + dumpactints true + end + ``` See `BlockOutput` for the related block option. Returns From 50cd0f1278b454690e568d84c75edd40fa78abf1 Mon Sep 17 00:00:00 2001 From: Hagen Neugebauer <38649381+haneug@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:41:26 +0200 Subject: [PATCH 06/13] Update src/opi/output/fcidump_parser.py Co-authored-by: Tim Tetenberg <123412573+timmyte@users.noreply.github.com> --- src/opi/output/fcidump_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opi/output/fcidump_parser.py b/src/opi/output/fcidump_parser.py index 6988de32..cd71ac2f 100644 --- a/src/opi/output/fcidump_parser.py +++ b/src/opi/output/fcidump_parser.py @@ -155,7 +155,7 @@ def _get_int(cls, key: str, header: str) -> int: @classmethod def _get_int_list(cls, key: str, header: str) -> list[int]: """Return a list of integers corresponding to the given key.""" - m = re.search(rf"{key}\s*=\s*([\d,\s]+)", header, re.IGNORECASE) + m = re.search(rf"{key}\s*=\s*(\d+(\s*,\s*\d+)+)", header, re.IGNORECASE) if m is None: raise ValueError(f"{cls.__name__}: Could not parse {key}") return [int(x) for x in re.split(r"[,\s]+", m.group(1).strip()) if x] From 30a18a474d48e6069cbd7efa47e6d6e4b2d8a1bb Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:44:06 +0200 Subject: [PATCH 07/13] fix: minor renames --- src/opi/output/core.py | 12 ++++++------ src/opi/output/{fcidump_parser.py => fcidump.py} | 7 +++++-- tests/unit/test_output_fcidump_parser.py | 4 ++-- 3 files changed, 13 insertions(+), 10 deletions(-) rename src/opi/output/{fcidump_parser.py => fcidump.py} (93%) diff --git a/src/opi/output/core.py b/src/opi/output/core.py index a7399b0c..da52959e 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -16,7 +16,7 @@ from opi.execution.core import Runner from opi.input.structures import Atom, Coordinates, Structure from opi.output.cube import CubeOutput -from opi.output.fcidump_parser import Fcidump +from opi.output.fcidump import Fcidump from opi.output.gbw_suffix import GbwSuffix from opi.output.grepper.recipes import ( get_error_message, @@ -2746,8 +2746,8 @@ def get_free_solvation_energy_opencosmors(self) -> float | None: def get_fcidump(self) -> Fcidump | None: """ - Parse the fcidump file generated by ORCA and return its data in the `Fcidump` data class. - The fcidump file has to be generated by the ORCA job and cannot be generated on-the-fly after the calculation. + Parse the FCIDUMP file generated by ORCA and return its data in the `Fcidump` data class. + The FCIDUMP file has to be generated by the ORCA job and cannot be generated on-the-fly after the calculation. To generate FCIDUMP files, set the following options: ``` %output @@ -2759,14 +2759,14 @@ def get_fcidump(self) -> Fcidump | None: Returns ------- fcidump_data: Fcidump | None - The parsed fcidump data or None if the file is not present or could not be parsed. + The parsed FCIDUMP data or None if the file is not present or could not be parsed. """ - # > Get path to the fcidump file + # > Get path to the FCIDUMP file fci_path = self.get_outfile().with_suffix(".fcidump") # > If there is no file we return None if not fci_path.is_file(): return None - return Fcidump.parse_fcidump(fci_path) + return Fcidump.from_file(fci_path) diff --git a/src/opi/output/fcidump_parser.py b/src/opi/output/fcidump.py similarity index 93% rename from src/opi/output/fcidump_parser.py rename to src/opi/output/fcidump.py index cd71ac2f..42833f26 100644 --- a/src/opi/output/fcidump_parser.py +++ b/src/opi/output/fcidump.py @@ -75,9 +75,12 @@ def eri_tensor(self) -> np.ndarray: return tensor @classmethod - def parse_fcidump(cls, path: Path | str) -> "Fcidump": + def from_file(cls, path: Path | str) -> "Fcidump": """ Parse a FCIDUMP file and return the populated `Fcidump` object. + The FCIDUMP file is documented in the paper: + Knowles, P. J.; Handy, N. C. A Determinant Based Full Configuration Interaction Program. + Computer Physics Communications 1989, 54, 75–83. https://doi.org/10.1016/0010-4655(89)90033-7 Raises ------- @@ -146,7 +149,7 @@ def parse_fcidump(cls, path: Path | str) -> "Fcidump": @classmethod def _get_int(cls, key: str, header: str) -> int: - """Return the integer value of the given key.""" + """Return the positive integer value of the given key.""" m = re.search(rf"{key}\s*=\s*(\d+)", header, re.IGNORECASE) if m is None: raise ValueError(f"{cls.__name__}: Could not parse {key}") diff --git a/tests/unit/test_output_fcidump_parser.py b/tests/unit/test_output_fcidump_parser.py index a6a51780..3aaa8869 100644 --- a/tests/unit/test_output_fcidump_parser.py +++ b/tests/unit/test_output_fcidump_parser.py @@ -3,7 +3,7 @@ import pytest -from opi.output.fcidump_parser import Fcidump +from opi.output.fcidump import Fcidump @pytest.mark.unit @@ -26,7 +26,7 @@ def test_parse_fcidump_header(tmp_path: Path) -> None: fci_file = tmp_path / "test.fcidump" fci_file.write_text(fcidump_text) - dump = Fcidump.parse_fcidump(fci_file) + dump = Fcidump.from_file(fci_file) assert dump.norb == 2 assert dump.nelec == 2 From 3323a3b8cc4604cc6e9747ec4b000349374a7599 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:44:55 +0200 Subject: [PATCH 08/13] chore: run nox session --- src/opi/output/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opi/output/core.py b/src/opi/output/core.py index da52959e..922a7721 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -2752,7 +2752,7 @@ def get_fcidump(self) -> Fcidump | None: ``` %output dumpactints true - end + end ``` See `BlockOutput` for the related block option. From c805f53676695a17a8249aacefbb0af6cc295943 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:36:38 +0200 Subject: [PATCH 09/13] feat: improve eri_tensor generation and add more tests for it --- src/opi/output/fcidump.py | 54 ++++++++++++------- tests/examples/test_exmp054_casscf_fcidump.py | 43 ++++++++++++++- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/opi/output/fcidump.py b/src/opi/output/fcidump.py index 42833f26..3a038010 100644 --- a/src/opi/output/fcidump.py +++ b/src/opi/output/fcidump.py @@ -12,7 +12,7 @@ class Fcidump: """ Reads and stores data from a FCIDUMP file. One and two-electrons integrals are stored as dicts and can be - accessed as numpy arrays. + accessed as numpy arrays via `hcore_matrix` and `eri_tensor`. Attributes -------- @@ -26,6 +26,10 @@ class Fcidump: Symmetry labels of the orbitals. isym: int Overall symmetry of the electronic structure. + one_electron: dict[tuple[int, int], float] + Dictionary that contains the one-electron integrals. + two_electron: dict[tuple[int, int, int, int], float] + Dictionary that contains the two-electron integrals. e_nuc: float Electronic core contribution. Contains the contracted energy of the inactive space. path: Path @@ -58,20 +62,33 @@ def eri_tensor(self) -> np.ndarray: Uses chemist's notation (ij|kl) with 8-fold permutation symmetry applied. """ tensor = np.zeros((self.norb,) * 4) - # > use ll instead of l to satisfy ruff - for (i, j, k, ll), val in self.two_electron.items(): - a, b, c, d = i - 1, j - 1, k - 1, ll - 1 - for p, q, r, s in [ - (a, b, c, d), - (b, a, c, d), - (a, b, d, c), - (b, a, d, c), - (c, d, a, b), - (d, c, a, b), - (c, d, b, a), - (d, c, b, a), - ]: - tensor[p, q, r, s] = val + + # > Pull all stored indices/values into arrays once + idx = np.array(list(self.two_electron.keys()), dtype=np.int64) - 1 # (norb, 4) + vals = np.array(list(self.two_electron.values()), dtype=np.float64) # (norb,) + a, b, c, d = idx[:, 0], idx[:, 1], idx[:, 2], idx[:, 3] + + # > Build all 8 permutations as stacked index arrays + perms = [ + (a, b, c, d), + (b, a, c, d), + (a, b, d, c), + (b, a, d, c), + (c, d, a, b), + (d, c, a, b), + (c, d, b, a), + (d, c, b, a), + ] + # > Concatenate the different permutations of the index arrays + p = np.concatenate([p[0] for p in perms]) + q = np.concatenate([p[1] for p in perms]) + r = np.concatenate([p[2] for p in perms]) + s = np.concatenate([p[3] for p in perms]) + # > Set up a value array with the same 8 x norb dimension + v = np.tile(vals, 8) + + # > Vectorized generation of the eri array + tensor[p, q, r, s] = v return tensor @classmethod @@ -89,15 +106,12 @@ def from_file(cls, path: Path | str) -> "Fcidump": FileNotFoundError If the FCIDUMP file cannot be found at the given path. """ - - if isinstance(path, str): - path = Path(path) + path = Path(path) if not path.is_file(): raise FileNotFoundError(f"{cls.__name__}: FCIDUMP file not found at {path}") - with open(path) as f: - text = f.read() + text = path.read_text() # > Split header and body end_match = re.search(r"&END|/", text, re.IGNORECASE) diff --git a/tests/examples/test_exmp054_casscf_fcidump.py b/tests/examples/test_exmp054_casscf_fcidump.py index 2cade6d5..0ecf6e9e 100644 --- a/tests/examples/test_exmp054_casscf_fcidump.py +++ b/tests/examples/test_exmp054_casscf_fcidump.py @@ -5,6 +5,25 @@ from opi.input.structures import Structure +def _naive_eri(two_electron: dict, norb: int) -> np.ndarray: + """Independent, unvectorized reference implementation for differential testing.""" + tensor = np.zeros((norb,) * 4) + for (i, j, k, ll), val in two_electron.items(): + a, b, c, d = i - 1, j - 1, k - 1, ll - 1 + for p, q, r, s in [ + (a, b, c, d), + (b, a, c, d), + (a, b, d, c), + (b, a, d, c), + (c, d, a, b), + (d, c, a, b), + (c, d, b, a), + (d, c, b, a), + ]: + tensor[p, q, r, s] = val + return tensor + + @pytest.mark.examples @pytest.mark.orca def test_exmp054_casscf_fcidump(example_input_file, tmp_path) -> None: @@ -28,5 +47,25 @@ def test_exmp054_casscf_fcidump(example_input_file, tmp_path) -> None: # hcore must be symmetric assert np.allclose(fcidump.hcore_matrix, fcidump.hcore_matrix.T) - # eri must satisfy (ij|kl) == (kl|ij) - assert np.allclose(fcidump.eri_tensor, fcidump.eri_tensor.transpose(2, 3, 0, 1)) + + # eri_tensor must agree with the object's own parsed source at every + # symmetry-equivalent position -- catches an all-zero or misplaced tensor + # that would still pass the transpose check above. + t = fcidump.eri_tensor + for (i, j, k, ll), val in fcidump.two_electron.items(): + a, b, c, d = i - 1, j - 1, k - 1, ll - 1 + for p, q, r, s in [ + (a, b, c, d), + (b, a, c, d), + (a, b, d, c), + (b, a, d, c), + (c, d, a, b), + (d, c, a, b), + (c, d, b, a), + (d, c, b, a), + ]: + assert t[p, q, r, s] == pytest.approx(val) + + # differential test: vectorized eri_tensor must match an independently + # written, unvectorized reference implementation on this same real data + assert np.allclose(fcidump.eri_tensor, _naive_eri(fcidump.two_electron, norb)) From be14cb15a45d8d63ea5b85263cc2841b447ce9b1 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:51:02 +0200 Subject: [PATCH 10/13] test: add unit test for _get_int and _get_int_list helper functions --- tests/unit/test_output_fcidump_parser.py | 47 ++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/unit/test_output_fcidump_parser.py b/tests/unit/test_output_fcidump_parser.py index 3aaa8869..bc77edc5 100644 --- a/tests/unit/test_output_fcidump_parser.py +++ b/tests/unit/test_output_fcidump_parser.py @@ -36,6 +36,53 @@ def test_parse_fcidump_header(tmp_path: Path) -> None: assert pytest.approx(dump.e_nuc) == -1.2345678901 +@pytest.mark.unit +def test_get_int() -> None: + """`_get_int` extracts integer header values, tolerating whitespace and mixed-case keys.""" + header = "&FCI\n NORB= 12, NELEC=8,\n ms2 = 0,\n/" + + assert Fcidump._get_int("NORB", header) == 12 + assert Fcidump._get_int("NELEC", header) == 8 + # key matching is case-insensitive + assert Fcidump._get_int("MS2", header) == 0 + + +@pytest.mark.unit +def test_get_int_missing_key_raises() -> None: + """`_get_int` raises a ValueError naming the key when it is absent from the header.""" + with pytest.raises(ValueError, match="ISYM"): + Fcidump._get_int("ISYM", "&FCI\n NORB= 2,\n/") + + +@pytest.mark.unit +def test_get_int_negative_value_raises() -> None: + """`_get_int` only accepts non-negative integers, so a negative value raises a ValueError.""" + with pytest.raises(ValueError, match="MS2"): + Fcidump._get_int("MS2", "&FCI\n MS2= -2,\n/") + + +@pytest.mark.unit +def test_get_int_list() -> None: + """`_get_int_list` parses comma-separated integers, tolerating whitespace and mixed case.""" + assert Fcidump._get_int_list("ORBSYM", "ORBSYM=1,1,2,1,") == [1, 1, 2, 1] + # tolerates whitespace around the separators and is case-insensitive + assert Fcidump._get_int_list("ORBSYM", "orbsym = 1 , 2 , 3,") == [1, 2, 3] + + +@pytest.mark.unit +def test_get_int_list_missing_key_raises() -> None: + """`_get_int_list` raises a ValueError naming the key when it is absent from the header.""" + with pytest.raises(ValueError, match="ORBSYM"): + Fcidump._get_int_list("ORBSYM", "&FCI\n NORB= 2,\n/") + + +@pytest.mark.unit +def test_get_int_list_negative_value_raises() -> None: + """`_get_int_list` only accepts non-negative integers, so a negative entry raises a ValueError.""" + with pytest.raises(ValueError, match="ORBSYM"): + Fcidump._get_int_list("ORBSYM", "ORBSYM=-1,1,") + + @pytest.mark.unit def test_hcore_matrix_shape_and_symmetry() -> None: dump = Fcidump( From e4167a33ec840488fbd6f48b9a744caef4ac81fa Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:54:05 +0200 Subject: [PATCH 11/13] test: remove dedent from test string --- tests/unit/test_output_fcidump_parser.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_output_fcidump_parser.py b/tests/unit/test_output_fcidump_parser.py index bc77edc5..94e57fdc 100644 --- a/tests/unit/test_output_fcidump_parser.py +++ b/tests/unit/test_output_fcidump_parser.py @@ -1,4 +1,3 @@ -import textwrap from pathlib import Path import pytest @@ -8,7 +7,7 @@ @pytest.mark.unit def test_parse_fcidump_header(tmp_path: Path) -> None: - fcidump_text = textwrap.dedent("""\ + fcidump_text = """ &FCI NORB= 2, NELEC= 2, MS2= 0, ORBSYM=1,1, @@ -22,7 +21,7 @@ def test_parse_fcidump_header(tmp_path: Path) -> None: 0.0987654321 2 1 0 0 0.0500000000 2 2 0 0 -1.2345678901 0 0 0 0 - """) + """ fci_file = tmp_path / "test.fcidump" fci_file.write_text(fcidump_text) From cd36b1be413fcea93472313d998bb7c65d9eab84 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:27:46 +0200 Subject: [PATCH 12/13] fix: Several fixes - get_fcidump would raise a ValueError if the FCIDUMP file could not be parsed. - _get_int_list would not work for a single entry like ORBSYM = 1 which is valid in FCIDUMP. - Added the orbital_energies to Fcidump. Not conventional in FCIDUMP but some programms write them. - improvements to eri_tensor property --- CHANGELOG.md | 1 + examples/exmp054_casscf_fcidump/job.py | 2 +- src/opi/output/core.py | 7 ++- src/opi/output/fcidump.py | 47 ++++++++++--------- tests/examples/test_exmp054_casscf_fcidump.py | 21 +-------- tests/unit/test_output_fcidump_parser.py | 21 +++++++++ 6 files changed, 56 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5e21f08..5e0571a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Added fallback keyword argument for `get_final_energy`, `get_gradient`, and `get_structure` that allows to parse these properties from the `.out` file if no JSON output is available (#237). - Added `get_frequencies`, `get_imaginary_frequencies`, `is_pes_minimum`, and `is_pes_transition_state` to the `Output` class (#247). - `Output.parse()`, `Output.parse_property()`, `Output.parse_gbw()`, and `Output.__init__()` now accept a `strict` parameter (default True). When set to False, output fields that fail Pydantic validation are silently set to None and a UserWarning is emitted instead of raising a ValidationError (#248). +- Added `Output.get_fcidump()` and the `Fcidump` class for parsing FCIDUMP files generated by ORCA (#253). ### Changed - Refactored methods from Runner into BaseRunner (#193) diff --git a/examples/exmp054_casscf_fcidump/job.py b/examples/exmp054_casscf_fcidump/job.py index 8ecaac96..d4b18d90 100755 --- a/examples/exmp054_casscf_fcidump/job.py +++ b/examples/exmp054_casscf_fcidump/job.py @@ -48,7 +48,7 @@ def run_exmp054( print("CASSCF energy") print(output.results_properties.geometries[0].energy[0].totalenergy[0][0]) - # > retrieve the path to the fcidump file + # > parse the fcidump file fcidump = output.get_fcidump() if fcidump is None: diff --git a/src/opi/output/core.py b/src/opi/output/core.py index 922a7721..4daae395 100644 --- a/src/opi/output/core.py +++ b/src/opi/output/core.py @@ -2763,10 +2763,13 @@ def get_fcidump(self) -> Fcidump | None: """ # > Get path to the FCIDUMP file - fci_path = self.get_outfile().with_suffix(".fcidump") + fci_path = self.get_file(".fcidump") # > If there is no file we return None if not fci_path.is_file(): return None - return Fcidump.from_file(fci_path) + try: + return Fcidump.from_file(fci_path) + except ValueError: + return None diff --git a/src/opi/output/fcidump.py b/src/opi/output/fcidump.py index 3a038010..b5a5b13e 100644 --- a/src/opi/output/fcidump.py +++ b/src/opi/output/fcidump.py @@ -21,8 +21,8 @@ class Fcidump: nelec: int Number of active electrons. ms2: int - Electron spin multiplicity. - orbsym: int + Twice the total spin projection, i.e. the difference of alpha and beta electrons. + orbsym: list[int] Symmetry labels of the orbitals. isym: int Overall symmetry of the electronic structure. @@ -30,8 +30,10 @@ class Fcidump: Dictionary that contains the one-electron integrals. two_electron: dict[tuple[int, int, int, int], float] Dictionary that contains the two-electron integrals. + orbital_energies: dict[int, float] + Dictionary that contains the orbital energies, if present in the FCIDUMP file. e_nuc: float - Electronic core contribution. Contains the contracted energy of the inactive space. + Core energy contribution. Contains the contracted energy of the inactive space. path: Path Path to the FCIDUMP file. """ @@ -43,6 +45,7 @@ class Fcidump: isym: int one_electron: dict[tuple[int, int], float] = field(default_factory=dict) two_electron: dict[tuple[int, int, int, int], float] = field(default_factory=dict) + orbital_energies: dict[int, float] = field(default_factory=dict) e_nuc: float = 0.0 path: Path = field(default_factory=Path) @@ -62,14 +65,16 @@ def eri_tensor(self) -> np.ndarray: Uses chemist's notation (ij|kl) with 8-fold permutation symmetry applied. """ tensor = np.zeros((self.norb,) * 4) + if not self.two_electron: + return tensor # > Pull all stored indices/values into arrays once - idx = np.array(list(self.two_electron.keys()), dtype=np.int64) - 1 # (norb, 4) - vals = np.array(list(self.two_electron.values()), dtype=np.float64) # (norb,) + idx = np.array(list(self.two_electron.keys()), dtype=np.int64) - 1 # (n_integrals, 4) + vals = np.array(list(self.two_electron.values()), dtype=np.float64) # (n_integrals,) a, b, c, d = idx[:, 0], idx[:, 1], idx[:, 2], idx[:, 3] - # > Build all 8 permutations as stacked index arrays - perms = [ + # > Vectorized assignment for each of the 8 symmetry-equivalent index permutations + for p, q, r, s in ( (a, b, c, d), (b, a, c, d), (a, b, d, c), @@ -78,17 +83,8 @@ def eri_tensor(self) -> np.ndarray: (d, c, a, b), (c, d, b, a), (d, c, b, a), - ] - # > Concatenate the different permutations of the index arrays - p = np.concatenate([p[0] for p in perms]) - q = np.concatenate([p[1] for p in perms]) - r = np.concatenate([p[2] for p in perms]) - s = np.concatenate([p[3] for p in perms]) - # > Set up a value array with the same 8 x norb dimension - v = np.tile(vals, 8) - - # > Vectorized generation of the eri array - tensor[p, q, r, s] = v + ): + tensor[p, q, r, s] = vals return tensor @classmethod @@ -152,6 +148,9 @@ def from_file(cls, path: Path | str) -> "Fcidump": # > Inactive contribution if i == 0 and j == 0 and k == 0 and ll == 0: dump.e_nuc = val + # > Orbital energies (not written by ORCA, but by some other programs) + elif j == 0 and k == 0 and ll == 0: + dump.orbital_energies[i] = val # > One-electron matrix elif k == 0 and ll == 0: dump.one_electron[(i, j)] = val @@ -171,8 +170,14 @@ def _get_int(cls, key: str, header: str) -> int: @classmethod def _get_int_list(cls, key: str, header: str) -> list[int]: - """Return a list of integers corresponding to the given key.""" - m = re.search(rf"{key}\s*=\s*(\d+(\s*,\s*\d+)+)", header, re.IGNORECASE) + """Return a list of non-negative integers corresponding to the given key.""" + m = re.search(rf"{key}\s*=\s*([-\d\s,]+)", header, re.IGNORECASE) if m is None: raise ValueError(f"{cls.__name__}: Could not parse {key}") - return [int(x) for x in re.split(r"[,\s]+", m.group(1).strip()) if x] + try: + values = [int(x) for x in re.split(r"[,\s]+", m.group(1).strip()) if x] + except ValueError: + raise ValueError(f"{cls.__name__}: Could not parse {key}") + if not values or any(v < 0 for v in values): + raise ValueError(f"{cls.__name__}: Could not parse {key}") + return values diff --git a/tests/examples/test_exmp054_casscf_fcidump.py b/tests/examples/test_exmp054_casscf_fcidump.py index 0ecf6e9e..584eaa14 100644 --- a/tests/examples/test_exmp054_casscf_fcidump.py +++ b/tests/examples/test_exmp054_casscf_fcidump.py @@ -48,24 +48,7 @@ def test_exmp054_casscf_fcidump(example_input_file, tmp_path) -> None: # hcore must be symmetric assert np.allclose(fcidump.hcore_matrix, fcidump.hcore_matrix.T) - # eri_tensor must agree with the object's own parsed source at every - # symmetry-equivalent position -- catches an all-zero or misplaced tensor - # that would still pass the transpose check above. - t = fcidump.eri_tensor - for (i, j, k, ll), val in fcidump.two_electron.items(): - a, b, c, d = i - 1, j - 1, k - 1, ll - 1 - for p, q, r, s in [ - (a, b, c, d), - (b, a, c, d), - (a, b, d, c), - (b, a, d, c), - (c, d, a, b), - (d, c, a, b), - (c, d, b, a), - (d, c, b, a), - ]: - assert t[p, q, r, s] == pytest.approx(val) - # differential test: vectorized eri_tensor must match an independently - # written, unvectorized reference implementation on this same real data + # written, unvectorized reference implementation on this same real data; + # catches an all-zero or misplaced tensor at every symmetry-equivalent position assert np.allclose(fcidump.eri_tensor, _naive_eri(fcidump.two_electron, norb)) diff --git a/tests/unit/test_output_fcidump_parser.py b/tests/unit/test_output_fcidump_parser.py index 94e57fdc..020d811b 100644 --- a/tests/unit/test_output_fcidump_parser.py +++ b/tests/unit/test_output_fcidump_parser.py @@ -6,6 +6,7 @@ @pytest.mark.unit +@pytest.mark.output def test_parse_fcidump_header(tmp_path: Path) -> None: fcidump_text = """ &FCI @@ -36,6 +37,7 @@ def test_parse_fcidump_header(tmp_path: Path) -> None: @pytest.mark.unit +@pytest.mark.output def test_get_int() -> None: """`_get_int` extracts integer header values, tolerating whitespace and mixed-case keys.""" header = "&FCI\n NORB= 12, NELEC=8,\n ms2 = 0,\n/" @@ -47,6 +49,7 @@ def test_get_int() -> None: @pytest.mark.unit +@pytest.mark.output def test_get_int_missing_key_raises() -> None: """`_get_int` raises a ValueError naming the key when it is absent from the header.""" with pytest.raises(ValueError, match="ISYM"): @@ -54,6 +57,7 @@ def test_get_int_missing_key_raises() -> None: @pytest.mark.unit +@pytest.mark.output def test_get_int_negative_value_raises() -> None: """`_get_int` only accepts non-negative integers, so a negative value raises a ValueError.""" with pytest.raises(ValueError, match="MS2"): @@ -61,14 +65,18 @@ def test_get_int_negative_value_raises() -> None: @pytest.mark.unit +@pytest.mark.output def test_get_int_list() -> None: """`_get_int_list` parses comma-separated integers, tolerating whitespace and mixed case.""" assert Fcidump._get_int_list("ORBSYM", "ORBSYM=1,1,2,1,") == [1, 1, 2, 1] # tolerates whitespace around the separators and is case-insensitive assert Fcidump._get_int_list("ORBSYM", "orbsym = 1 , 2 , 3,") == [1, 2, 3] + # a single-entry list (norb=1) must parse as well + assert Fcidump._get_int_list("ORBSYM", "ORBSYM=1,") == [1] @pytest.mark.unit +@pytest.mark.output def test_get_int_list_missing_key_raises() -> None: """`_get_int_list` raises a ValueError naming the key when it is absent from the header.""" with pytest.raises(ValueError, match="ORBSYM"): @@ -76,6 +84,7 @@ def test_get_int_list_missing_key_raises() -> None: @pytest.mark.unit +@pytest.mark.output def test_get_int_list_negative_value_raises() -> None: """`_get_int_list` only accepts non-negative integers, so a negative entry raises a ValueError.""" with pytest.raises(ValueError, match="ORBSYM"): @@ -83,6 +92,7 @@ def test_get_int_list_negative_value_raises() -> None: @pytest.mark.unit +@pytest.mark.output def test_hcore_matrix_shape_and_symmetry() -> None: dump = Fcidump( norb=2, @@ -103,6 +113,17 @@ def test_hcore_matrix_shape_and_symmetry() -> None: @pytest.mark.unit +@pytest.mark.output +def test_eri_tensor_empty_two_electron() -> None: + """Without two-electron integrals, eri_tensor must be all-zero instead of raising.""" + dump = Fcidump(norb=2, nelec=2, ms2=0, orbsym=[1, 1], isym=0) + + assert dump.eri_tensor.shape == (2, 2, 2, 2) + assert not dump.eri_tensor.any() + + +@pytest.mark.unit +@pytest.mark.output def test_eri_tensor_shape_and_symmetry() -> None: dump = Fcidump( norb=2, From 87873241ab2f49e76ba9c2a58c060dc35d2a61c9 Mon Sep 17 00:00:00 2001 From: haneug <38649381+haneug@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:12:56 +0200 Subject: [PATCH 13/13] fix: apply code review fixes --- src/opi/output/fcidump.py | 7 ++++--- tests/unit/test_output_fcidump_parser.py | 12 ++++++------ 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/opi/output/fcidump.py b/src/opi/output/fcidump.py index b5a5b13e..40b7b794 100644 --- a/src/opi/output/fcidump.py +++ b/src/opi/output/fcidump.py @@ -4,6 +4,7 @@ from dataclasses import dataclass, field from functools import cached_property from pathlib import Path +from typing import Self import numpy as np @@ -50,7 +51,7 @@ class Fcidump: path: Path = field(default_factory=Path) @cached_property - def hcore_matrix(self) -> np.ndarray: + def hcore_matrix(self) -> np.ndarray[tuple[int, int], np.dtype[np.float64]]: """Return the one-electron integrals as a symmetric (norb, norb) numpy array.""" mat = np.zeros((self.norb, self.norb)) for (i, j), val in self.one_electron.items(): @@ -59,7 +60,7 @@ def hcore_matrix(self) -> np.ndarray: return mat @cached_property - def eri_tensor(self) -> np.ndarray: + def eri_tensor(self) -> np.ndarray[tuple[int, int, int, int], np.dtype[np.float64]]: """Return the two-electron integrals as a (norb, norb, norb, norb) numpy array. Uses chemist's notation (ij|kl) with 8-fold permutation symmetry applied. @@ -88,7 +89,7 @@ def eri_tensor(self) -> np.ndarray: return tensor @classmethod - def from_file(cls, path: Path | str) -> "Fcidump": + def from_file(cls, path: Path | str) -> Self: """ Parse a FCIDUMP file and return the populated `Fcidump` object. The FCIDUMP file is documented in the paper: diff --git a/tests/unit/test_output_fcidump_parser.py b/tests/unit/test_output_fcidump_parser.py index 020d811b..4c3d6241 100644 --- a/tests/unit/test_output_fcidump_parser.py +++ b/tests/unit/test_output_fcidump_parser.py @@ -39,7 +39,7 @@ def test_parse_fcidump_header(tmp_path: Path) -> None: @pytest.mark.unit @pytest.mark.output def test_get_int() -> None: - """`_get_int` extracts integer header values, tolerating whitespace and mixed-case keys.""" + """`_get_int()` extracts integer header values, tolerating whitespace and mixed-case keys.""" header = "&FCI\n NORB= 12, NELEC=8,\n ms2 = 0,\n/" assert Fcidump._get_int("NORB", header) == 12 @@ -51,7 +51,7 @@ def test_get_int() -> None: @pytest.mark.unit @pytest.mark.output def test_get_int_missing_key_raises() -> None: - """`_get_int` raises a ValueError naming the key when it is absent from the header.""" + """`_get_int()` raises a ValueError naming the key when it is absent from the header.""" with pytest.raises(ValueError, match="ISYM"): Fcidump._get_int("ISYM", "&FCI\n NORB= 2,\n/") @@ -59,7 +59,7 @@ def test_get_int_missing_key_raises() -> None: @pytest.mark.unit @pytest.mark.output def test_get_int_negative_value_raises() -> None: - """`_get_int` only accepts non-negative integers, so a negative value raises a ValueError.""" + """`_get_int()` only accepts non-negative integers, so a negative value raises a ValueError.""" with pytest.raises(ValueError, match="MS2"): Fcidump._get_int("MS2", "&FCI\n MS2= -2,\n/") @@ -67,7 +67,7 @@ def test_get_int_negative_value_raises() -> None: @pytest.mark.unit @pytest.mark.output def test_get_int_list() -> None: - """`_get_int_list` parses comma-separated integers, tolerating whitespace and mixed case.""" + """`_get_int_list()` parses comma-separated integers, tolerating whitespace and mixed case.""" assert Fcidump._get_int_list("ORBSYM", "ORBSYM=1,1,2,1,") == [1, 1, 2, 1] # tolerates whitespace around the separators and is case-insensitive assert Fcidump._get_int_list("ORBSYM", "orbsym = 1 , 2 , 3,") == [1, 2, 3] @@ -78,7 +78,7 @@ def test_get_int_list() -> None: @pytest.mark.unit @pytest.mark.output def test_get_int_list_missing_key_raises() -> None: - """`_get_int_list` raises a ValueError naming the key when it is absent from the header.""" + """`_get_int_list()` raises a ValueError naming the key when it is absent from the header.""" with pytest.raises(ValueError, match="ORBSYM"): Fcidump._get_int_list("ORBSYM", "&FCI\n NORB= 2,\n/") @@ -86,7 +86,7 @@ def test_get_int_list_missing_key_raises() -> None: @pytest.mark.unit @pytest.mark.output def test_get_int_list_negative_value_raises() -> None: - """`_get_int_list` only accepts non-negative integers, so a negative entry raises a ValueError.""" + """`_get_int_list()` only accepts non-negative integers, so a negative entry raises a ValueError.""" with pytest.raises(ValueError, match="ORBSYM"): Fcidump._get_int_list("ORBSYM", "ORBSYM=-1,1,")