diff --git a/qiskit_addon_dice_solver/__init__.py b/qiskit_addon_dice_solver/__init__.py index 151fd41..bcd13d1 100644 --- a/qiskit_addon_dice_solver/__init__.py +++ b/qiskit_addon_dice_solver/__init__.py @@ -25,9 +25,10 @@ solve_dice """ -from .dice_solver import solve_fermion, solve_dice +from .dice_solver import solve_dice, solve_fermion, solve_hci __all__ = [ - "solve_fermion", "solve_dice", + "solve_fermion", + "solve_hci", ] diff --git a/qiskit_addon_dice_solver/dice_solver.py b/qiskit_addon_dice_solver/dice_solver.py index e561b40..369cda2 100644 --- a/qiskit_addon_dice_solver/dice_solver.py +++ b/qiskit_addon_dice_solver/dice_solver.py @@ -14,6 +14,7 @@ from __future__ import annotations +import math import os import shutil import struct @@ -47,20 +48,25 @@ def __init__(self, command, returncode, log_path): super().__init__(message) -def solve_fermion( - bitstring_matrix: np.ndarray, - /, +def solve_hci( hcore: np.ndarray, eri: np.ndarray, *, + norb: int, + nelec: tuple[int, int], + ci_strs: tuple[Sequence[int], Sequence[int]] | None = None, + spin_sq: float = 0.0, + select_cutoff: float = 5e-4, + energy_tol: float = 1e-10, + max_iter: int = 10, mpirun_options: Sequence[str] | str | None = None, temp_dir: str | Path | None = None, clean_temp_dir: bool = True, -) -> tuple[float, np.ndarray, tuple[np.ndarray, np.ndarray]]: +) -> tuple[ + float, np.ndarray, tuple[np.ndarray, np.ndarray], tuple[np.ndarray, np.ndarray] +]: """ - Approximate the ground state of a molecular Hamiltonian given a bitstring matrix defining the Hilbert subspace. - - This solver is designed for compatibility with `qiskit-addon-sqd `_ workflows. + Approximate the ground state of a molecular Hamiltonian using the heat bath configuration interaction method. In order to leverage the multi-processing nature of this tool, the user must specify the CPU resources to use via the `mpirun_options` argument. @@ -74,7 +80,7 @@ def solve_fermion( # OR mpirun_opts = ["-quiet", "-n", "8"] - energy, sci_coeffs, avg_occs = solve_fermion(..., mpirun_options=mpirun_opts) + energy, sci_coeffs, sci_strings, avg_occs = solve_hci(..., mpirun_options=mpirun_opts) For more information on the ``mpirun`` command line options, refer to the `man page `_. @@ -89,14 +95,19 @@ def solve_fermion( of ``40`` or fewer orbitals are supported. Args: - bitstring_matrix: A set of configurations defining the subspace onto which the Hamiltonian - will be projected and diagonalized. This is a 2D array of ``bool`` representations of bit - values such that each row represents a single bitstring. The spin-up configurations - should be specified by column indices in range ``(N, N/2]``, and the spin-down - configurations should be specified by column indices in range ``(N/2, 0]``, where ``N`` - is the number of qubits. - hcore: Core Hamiltonian matrix representing single-electron integrals - eri: Electronic repulsion integrals representing two-electron integrals + hcore: Core Hamiltonian matrix representing single-electron integrals. + eri: Electronic repulsion integrals representing two-electron integrals. + norb: The number of spatial orbitals. + nelec: The numbers of spin up and spin down electrons. + ci_strs: CI strings specifying the subspace to use at the beginning of the first HCI iteration. + Should be specified as a pair of lists, with the first list containing the alpha strings and the + second list containing the beta strings. If not specified, only the Hartree-Fock string will be used. + A CI string is specified as an integer whose binary expansion encodes the string. For example, + the Hartree-Fock string with 3 electrons in 5 orbitals is `0b00111`. + spin_sq: Target value for the total spin squared for the ground state. If ``None``, no spin will be imposed. + select_cutoff: Cutoff threshold for retaining state vector coefficients. + energy_tol: Energy floating point tolerance. + max_iter: The maximum number of HCI iterations to perform. mpirun_options: Options controlling the CPU resource allocation for the ``Dice`` command line application. These command-line options will be passed directly to the ``mpirun`` command line application during invocation of ``Dice``. These may be formatted as a ``Sequence`` of strings or a single string. If a ``Sequence``, @@ -113,16 +124,14 @@ def solve_fermion( Returns: - Minimum energy from SCI calculation - SCI coefficients + - SCI strings - Average orbital occupancy """ - # Hard-code target S^2 until supported - spin_sq = 0.0 + n_alpha, n_beta = nelec - # Convert bitstring matrix to integer determinants for spin-up/down - ci_strs = bitstring_matrix_to_ci_strs(bitstring_matrix) - num_configurations = len(ci_strs[0]) - num_up = format(ci_strs[0][0], "b").count("1") - num_dn = format(ci_strs[1][0], "b").count("1") + if ci_strs is None: + # If CI strings not specified, use the Hartree-Fock bitstring + ci_strs = ([(1 << n_alpha) - 1], [(1 << n_beta) - 1]) # Set up the temp directory temp_dir = temp_dir or tempfile.gettempdir() @@ -130,28 +139,27 @@ def solve_fermion( # Write the integrals out as an FCI dump for Dice command line app active_space_path = dice_dir / "fcidump.txt" - num_orbitals = hcore.shape[0] - tools.fcidump.from_integrals( - active_space_path, hcore, eri, num_orbitals, (num_up + num_dn) - ) + tools.fcidump.from_integrals(active_space_path, hcore, eri, norb, nelec) _write_input_files( addresses=ci_strs, active_space_path=active_space_path, - num_up=num_up, - num_dn=num_dn, - num_configurations=num_configurations, + norb=norb, + num_up=n_alpha, + num_dn=n_beta, dice_dir=dice_dir, spin_sq=spin_sq, - max_iter=1, + select_cutoff=select_cutoff, + energy_tol=energy_tol, + max_iter=max_iter, ) # Navigate to dice dir and call Dice _call_dice(dice_dir, mpirun_options) # Read and convert outputs - e_dice, sci_coefficients, avg_occupancies = _read_dice_outputs( - dice_dir, num_orbitals + e_dice, sci_coefficients, addresses, avg_occupancies = _read_dice_outputs( + dice_dir, norb ) # Clean up the temp directory of intermediate files, if desired @@ -161,10 +169,99 @@ def solve_fermion( return ( e_dice, sci_coefficients, - (avg_occupancies[:num_orbitals], avg_occupancies[num_orbitals:]), + addresses, + (avg_occupancies[:norb], avg_occupancies[norb:]), ) +def solve_fermion( + bitstring_matrix: np.ndarray, + /, + hcore: np.ndarray, + eri: np.ndarray, + *, + mpirun_options: Sequence[str] | str | None = None, + temp_dir: str | Path | None = None, + clean_temp_dir: bool = True, +) -> tuple[float, np.ndarray, tuple[np.ndarray, np.ndarray]]: + """ + Approximate the ground state of a molecular Hamiltonian given a bitstring matrix defining the Hilbert subspace. + + This solver is designed for compatibility with `qiskit-addon-sqd `_ workflows. + + In order to leverage the multi-processing nature of this tool, the user must specify + the CPU resources to use via the `mpirun_options` argument. + + For example, to use 8 CPU slots in parallel in quiet mode: + + .. code-block:: python + + # Run 8 parallel slots in quiet mode + mpirun_opts = "-quiet -n 8" + # OR + mpirun_opts = ["-quiet", "-n", "8"] + + energy, sci_coeffs, avg_occs = solve_fermion(..., mpirun_options=mpirun_opts) + + For more information on the ``mpirun`` command line options, refer to the `man page `_. + + .. note:: + + Only closed-shell systems are supported. The particle number for both + spin-up and spin-down determinants is expected to be equal. + + .. note:: + + Determinants are interpreted by the ``Dice`` command line application as 5-byte unsigned integers; therefore, only systems + of ``40`` or fewer orbitals are supported. + + Args: + bitstring_matrix: A set of configurations defining the subspace onto which the Hamiltonian + will be projected and diagonalized. This is a 2D array of ``bool`` representations of bit + values such that each row represents a single bitstring. The spin-up configurations + should be specified by column indices in range ``(N, N/2]``, and the spin-down + configurations should be specified by column indices in range ``(N/2, 0]``, where ``N`` + is the number of qubits. + hcore: Core Hamiltonian matrix representing single-electron integrals + eri: Electronic repulsion integrals representing two-electron integrals + mpirun_options: Options controlling the CPU resource allocation for the ``Dice`` command line application. + These command-line options will be passed directly to the ``mpirun`` command line application during + invocation of ``Dice``. These may be formatted as a ``Sequence`` of strings or a single string. If a ``Sequence``, + the elements will be combined into a single, space-delimited string and passed to + ``mpirun``. If the input is a single string, it will be passed to ``mpirun`` as-is. If no + ``mpirun_options`` are provided by the user, ``Dice`` will run on a single MPI slot. For more + information on the ``mpirun`` command line options, refer to the `man page `_. + temp_dir: An absolute path to a directory for storing temporary files. If not provided, the + system temporary files directory will be used. + clean_temp_dir: Whether to delete intermediate files generated by the ``Dice`` command line application. + These files will be stored in a directory created inside ``temp_dir``. If ``False``, then + this directory will be preserved. + + Returns: + - Minimum energy from SCI calculation + - SCI coefficients + - Average orbital occupancy + """ + ci_strs = bitstring_matrix_to_ci_strs(bitstring_matrix) + num_up = format(ci_strs[0][0], "b").count("1") + num_dn = format(ci_strs[1][0], "b").count("1") + e_dice, sci_coefficients, _, avg_occupancies = solve_hci( + hcore=hcore, + eri=eri, + norb=hcore.shape[0], + nelec=(num_up, num_dn), + ci_strs=ci_strs, + spin_sq=0.0, # Hard-code target S^2 until supported + select_cutoff=1e12, + energy_tol=1e-10, + max_iter=1, + mpirun_options=mpirun_options, + temp_dir=temp_dir, + clean_temp_dir=clean_temp_dir, + ) + return e_dice, sci_coefficients, avg_occupancies + + def solve_dice( addresses: tuple[Sequence[int], Sequence[int]], active_space_path: str | Path, @@ -228,30 +325,31 @@ def solve_dice( Minimum energy from SCI calculation, SCI coefficients, and average orbital occupancy for spin-up and spin-down orbitals """ # Write Dice inputs to working dir - num_configurations = len(addresses[0]) num_up = bin(addresses[0][0])[2:].count("1") num_dn = bin(addresses[1][0])[2:].count("1") intermediate_dir = Path(tempfile.mkdtemp(prefix="dice_cli_files_", dir=working_dir)) + mf_as = tools.fcidump.to_scf(active_space_path) + num_orbitals = mf_as.get_hcore().shape[0] _write_input_files( - addresses, - active_space_path, - num_up, - num_dn, - num_configurations, - intermediate_dir, - spin_sq, - max_iter, + addresses=addresses, + active_space_path=active_space_path, + norb=num_orbitals, + num_up=num_up, + num_dn=num_dn, + dice_dir=intermediate_dir, + spin_sq=spin_sq, + select_cutoff=1e12, + energy_tol=1e-10, + max_iter=max_iter, ) # Navigate to working dir and call Dice _call_dice(intermediate_dir, mpirun_options) # Read outputs and convert outputs - mf_as = tools.fcidump.to_scf(active_space_path) - num_orbitals = mf_as.get_hcore().shape[0] - e_dice, sci_coefficients, avg_occupancies = _read_dice_outputs( + e_dice, sci_coefficients, _, avg_occupancies = _read_dice_outputs( intermediate_dir, num_orbitals ) e_dice -= mf_as.mol.energy_nuc() @@ -269,7 +367,7 @@ def solve_dice( def _read_dice_outputs( dice_dir: str | Path, num_orbitals: int -) -> tuple[float, np.ndarray, np.ndarray]: +) -> tuple[float, np.ndarray, tuple[np.ndarray, np.ndarray], np.ndarray]: """Calculate the estimated ground state energy and average orbitals occupancies from Dice outputs.""" # Read in the avg orbital occupancies spin1_rdm_dice = np.loadtxt(os.path.join(dice_dir, "spin1RDM.0.0.txt"), skiprows=1) @@ -290,9 +388,11 @@ def _read_dice_outputs( # Construct the SCI wavefunction coefficients from Dice output dets.bin occs, amps = _read_wave_function_magnitudes(os.path.join(dice_dir, "dets.bin")) addresses = _addresses_from_occupancies(occs) - sci_coefficients = _construct_ci_vec_from_addresses_amplitudes(amps, addresses) + sci_coefficients, addresses_a, addresses_b = ( + _construct_ci_vec_from_addresses_amplitudes(amps, addresses) + ) - return energy_dice, sci_coefficients, avg_occupancies + return energy_dice, sci_coefficients, (addresses_a, addresses_b), avg_occupancies def _call_dice(dice_dir: Path, mpirun_options: Sequence[str] | str | None) -> None: @@ -326,11 +426,13 @@ def _call_dice(dice_dir: Path, mpirun_options: Sequence[str] | str | None) -> No def _write_input_files( addresses: tuple[Sequence[int], Sequence[int]], active_space_path: str | Path, + norb: int, num_up: int, num_dn: int, - num_configurations: int, dice_dir: str | Path, spin_sq: float, + select_cutoff: float, + energy_tol: float, max_iter: int, ) -> None: """Prepare the Dice inputs in the specified directory.""" @@ -349,17 +451,18 @@ def _write_input_files( # The Dice/Riken branch this package is built on modifies the normal behavior # of the SHCI application, so we hard-code this value to prevent unintended # behavior due to these modifications. - schedule = "schedule\n0 1.e+12\nend\n" + schedule = f"schedule\n0 {select_cutoff}\nend\n" # Floating point tolerance for Davidson solver davidson_tol = "davidsonTol 1e-5\n" # Energy floating point tolerance - de = "dE 1e-10\n" + de = f"dE {energy_tol}\n" # The maximum number of HCI iterations to perform maxiter = f"maxiter {max_iter}\n" # We don't want Dice to be noisyu for now so we hard code noio noio = "noio\n" # The number of determinants to write as output. We always want all of them. - write_best_determinants = f"writeBestDeterminants {num_configurations}\n" + dim = math.comb(norb, num_up) * math.comb(norb, num_dn) + write_best_determinants = f"writeBestDeterminants {dim}\n" # Number of perturbation theory parameters. Must be 0. n_pt_iter = "nPTiter 0\n" # Requested reduced density matrices @@ -482,11 +585,13 @@ def _addresses_from_occupancies(occupancy_strs: list[str]) -> list[list[int]]: def _construct_ci_vec_from_addresses_amplitudes( amps: list[float], addresses: list[list[int]] -) -> np.ndarray: +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Construct wavefunction amplitudes from determinant addresses and their associated amplitudes.""" uniques = np.unique(np.array(addresses)) num_dets = len(uniques) ci_vec = np.zeros((num_dets, num_dets)) + addresses_a = np.zeros(num_dets, dtype=np.int64) + addresses_b = np.zeros(num_dets, dtype=np.int64) addr_map = {uni_addr: i for i, uni_addr in enumerate(uniques)} @@ -498,4 +603,7 @@ def _construct_ci_vec_from_addresses_amplitudes( j = addr_map[address_b] ci_vec[i, j] = amps[idx] - return ci_vec + addresses_a[i] = uniques[i] + addresses_b[j] = uniques[j] + + return ci_vec, addresses_a, addresses_b