diff --git a/analysis/config_types.py b/analysis/config_types.py index b4b10b56..25d966c2 100644 --- a/analysis/config_types.py +++ b/analysis/config_types.py @@ -5,6 +5,8 @@ import tomlkit from tomlkit.toml_file import TOMLFile +from qhat.common.git_utils import get_git_hash + logger = logging.getLogger(__name__) # ------------------------------------------------------------------------------------------------- @@ -342,26 +344,12 @@ def _generate_TOML_table(self): # internal types and support functions # ------------------------------------------------------------------------------------------------- -def _get_git_hash(): - file_path = os.path.realpath(__file__) - dirpath = os.path.dirname(file_path) - commands = ";".join([ - f"pushd {dirpath} > /dev/null", - "if [[ $(git diff --stat) != '' ]]", - "then echo $(git rev-parse HEAD)-dirty", - "else git rev-parse HEAD", - "fi", - "popd > /dev/null" - ]) - output = subprocess.run(commands, shell=True, capture_output=True) - return output.stdout.decode("utf-8")[:-1] - class GeneralConfiguration: def __init__(self, user_config: GeneralConfigurationUser): self.logfile = user_config.logfile self.loglevel = user_config._loglevel self.output_directory = user_config.output_directory - self.git_hash = _get_git_hash() + self.git_hash = get_git_hash(reference_file=__file__) def get_output_path(self, filename): """ diff --git a/analysis/configuration.py b/analysis/configuration.py index 8393d572..af46d856 100644 --- a/analysis/configuration.py +++ b/analysis/configuration.py @@ -9,6 +9,10 @@ State, UnitaryConfiguration, ) +from qhat.common.config_utils import ( + parse_key_value_params, + get_standard_exec_namespace, +) logger = logging.getLogger(__name__) @@ -37,17 +41,7 @@ def load_configuration() -> tuple[State, str]: args = parser.parse_args() # Parse the key=value parameters - config_params = {} - for param in args.params: - if '=' not in param: - raise ValueError(f"Parameter must be in KEY=VALUE format, got: {param}") - key, value = param.split('=', 1) - # Try to evaluate as Python literal (numbers, lists, etc.) - try: - config_params[key] = eval(value) - except: - # If evaluation fails, treat as string - config_params[key] = value + config_params = parse_key_value_params(args.params) # Read the configuration file with open(args.configuration_file, 'r') as fin: @@ -61,19 +55,10 @@ def load_configuration() -> tuple[State, str]: unitary = UnitaryConfiguration() algorithm = AlgorithmConfiguration() analysis = AnalysisConfiguration() - def meV_to_Hartree(meV): - return 3.67493221757e-5 * meV - def string_to_seed(s): - import hashlib - """Convert a string to a deterministic integer seed.""" - # Use SHA-256 hash and convert to integer - hash_bytes = hashlib.sha256(s.encode('utf-8')).digest() - # Take first 8 bytes and convert to integer (fits in 64-bit) - seed = int.from_bytes(hash_bytes[:8], byteorder='big') - return seed - - # Create namespace with config objects and params dictionary - exec_namespace = { + + # Create namespace with config objects, params, and standard utilities + exec_namespace = get_standard_exec_namespace() + exec_namespace.update({ # configuration objects 'general': general, 'hamiltonian': hamiltonian, @@ -82,10 +67,7 @@ def string_to_seed(s): 'analysis': analysis, # command-line parameters 'params': config_params, - # utility functions - 'meV_to_Hartree': meV_to_Hartree, - 'string_to_seed': string_to_seed, - } + }) exec(config_script, exec_namespace) # Build the state (does some post-processing of user configuration) diff --git a/common/__init__.py b/common/__init__.py index 1a688d6d..98d9961c 100644 --- a/common/__init__.py +++ b/common/__init__.py @@ -1 +1 @@ -__all__ = ["dense_paul_exp", "trotter"] +__all__ = ["dense_paul_exp", "trotter", "git_utils", "config_utils"] diff --git a/common/config_utils.py b/common/config_utils.py new file mode 100644 index 00000000..fcf5856b --- /dev/null +++ b/common/config_utils.py @@ -0,0 +1,114 @@ +"""Configuration utilities shared across QHAT tools.""" + +import hashlib + + +def parse_key_value_params(param_strings): + """ + Parse KEY=VALUE parameter strings from command line. + + Parameters + ---------- + param_strings : list of str + List of "KEY=VALUE" strings + + Returns + ------- + dict + Dictionary mapping keys to values, with type conversion applied + + Raises + ------ + ValueError + If a parameter string is not in KEY=VALUE format + + Notes + ----- + - Attempts to evaluate values as Python literals (int, float, list, etc.) + - Falls back to string if evaluation fails + - Example: "distance=1.5" → {"distance": 1.5} + - Example: "name=hello" → {"name": "hello"} + """ + config_params = {} + for param in param_strings: + if '=' not in param: + raise ValueError(f"Parameter must be in KEY=VALUE format, got: {param}") + key, value = param.split('=', 1) + # Try to evaluate as Python literal (numbers, lists, etc.) + try: + config_params[key] = eval(value) + except: + # If evaluation fails, treat as string + config_params[key] = value + return config_params + + +def meV_to_Hartree(meV): + """ + Convert millielectronvolts (meV) to Hartree atomic units. + + Parameters + ---------- + meV : float + Energy in millielectronvolts + + Returns + ------- + float + Energy in Hartree atomic units + + Notes + ----- + Conversion factor: 1 meV = 3.67493221757e-5 Hartree + """ + return 3.67493221757e-5 * meV + + +def string_to_seed(s): + """ + Convert a string to a deterministic integer seed for random number generators. + + Parameters + ---------- + s : str + Input string + + Returns + ------- + int + Deterministic seed derived from the string (fits in 64-bit integer) + + Notes + ----- + - Uses SHA-256 hash for deterministic, uniform distribution + - Same string always produces same seed + - Different strings produce very different seeds + """ + hash_bytes = hashlib.sha256(s.encode('utf-8')).digest() + # Take first 8 bytes and convert to integer (fits in 64-bit) + seed = int.from_bytes(hash_bytes[:8], byteorder='big') + return seed + + +def get_standard_exec_namespace(): + """ + Get a dictionary of standard utility functions for config file execution. + + Returns + ------- + dict + Dictionary of utility function names to functions + + Notes + ----- + This provides the standard utilities available in config files: + - meV_to_Hartree: Energy unit conversion + - string_to_seed: Deterministic seed generation + + Tools should add their own config objects (general, hamiltonian, etc.) + and params dictionary to this namespace before executing config files. + """ + return { + 'meV_to_Hartree': meV_to_Hartree, + 'string_to_seed': string_to_seed, + } diff --git a/common/git_utils.py b/common/git_utils.py new file mode 100644 index 00000000..0a62013e --- /dev/null +++ b/common/git_utils.py @@ -0,0 +1,35 @@ +"""Git utilities for QHAT tools.""" +import os +import subprocess + + +def get_git_hash(reference_file=None): + """ + Get current git commit hash with dirty flag if uncommitted changes exist. + + Parameters + ---------- + reference_file : str, optional + Path to a file in the repository. If provided, uses that file's directory + as the repository root. If None, uses current working directory. + + Returns + ------- + str + Git hash, with "-dirty" suffix if uncommitted changes exist + """ + if reference_file: + dirpath = os.path.dirname(os.path.realpath(reference_file)) + else: + dirpath = os.getcwd() + + commands = ";".join([ + f"pushd {dirpath} > /dev/null", + "if [[ $(git diff --stat) != '' ]]", + "then echo $(git rev-parse HEAD)-dirty", + "else git rev-parse HEAD", + "fi", + "popd > /dev/null" + ]) + output = subprocess.run(commands, shell=True, capture_output=True) + return output.stdout.decode("utf-8")[:-1] diff --git a/hamiltonian_generator/config.py b/hamiltonian_generator/config.py index ce166da0..0f6ff3d1 100644 --- a/hamiltonian_generator/config.py +++ b/hamiltonian_generator/config.py @@ -1,19 +1,29 @@ # _________________________________________________________________________________________________ # General configuration -general.print_verbose() # Additional information printed out -general.file_stub = "diatomic_lithium" # Base name that all filenames are built from -general.file_format = "default" # Use default Pauli string style (not HamLib style) +general.print_verbose() # Additional information printed out +general.file_stub = "diatomic_lithium" # Base name that all filenames are built from +general.file_format = "default" # Default Pauli string style (not HamLib style) + +# Output directory - keeps all generated files organized +# Override with: -p output_directory=custom_dir +# Defaults to current directory (old behavior) +general.output_directory = params.get("output_directory", "") + +# Cache directory - where to look for reusable intermediate files (ham1, ham2) +# If not set, falls back to output_directory +# Override with: -p cache_directory=previous_run +general.cache_directory = params.get("cache_directory", "") # _________________________________________________________________________________________________ # Describe the Hamiltonian -L = 2.0 +L = params.get("L", 2.0) for i in range(2): - hamiltonian.add_atom("Li", i * L, 0, 0) # Add two lithium atoms at (0,0,0) and (L,0,0) + hamiltonian.add_atom("Li", i * L, 0, 0) # Add two lithium atoms at (0,0,0) and (L,0,0) -hamiltonian.basis = "sto-3g" # Select the atomic basis functions +hamiltonian.basis = params.get("basis", "sto-3g") # Select the atomic basis functions -hamiltonian.num_active_occupied = 4 # Specify the active space +hamiltonian.num_active_occupied = 4 # Specify the active space hamiltonian.num_active_vacant = 6 diff --git a/hamiltonian_generator/hamgen.py b/hamiltonian_generator/hamgen.py index 6c82d943..3b1e4214 100644 --- a/hamiltonian_generator/hamgen.py +++ b/hamiltonian_generator/hamgen.py @@ -1,3 +1,12 @@ +""" +Hamiltonian Generator for QHAT + +Generates quantum Hamiltonians from molecular specifications using PySCF. + +Usage: + # Basic usage with default config + python3.11 -m qhat.hamiltonian_generator.hamgen +""" import argparse import logging import math @@ -13,6 +22,10 @@ from openfermionpyscf import PyscfMolecularData from openfermionpyscf._run_pyscf import compute_integrals, compute_scf, prepare_pyscf_molecule +from qhat.common.config_utils import ( + parse_key_value_params, + get_standard_exec_namespace, +) from qhat.common.logging_utils import configure_logging from qhat.hamiltonian_generator.hamgen_types import ( GeneralConfiguration, @@ -35,8 +48,21 @@ def load_configuration() -> State: nargs='?', default=default_config, help=f"Name of the configuration file; defaults to \"{default_config}\"") + + # Add support for arbitrary key=value arguments + parser.add_argument( + '--param', '-p', + action='append', + dest='params', + default=[], + metavar='KEY=VALUE', + help='Parameters to pass to the configuration file (e.g., -p distance=1.5)') + args = parser.parse_args() + # Parse the key=value parameters + config_params = parse_key_value_params(args.params) + # Read the configuration file with open(args.configuration_file, 'r') as fin: config_script = fin.read() @@ -46,15 +72,31 @@ def load_configuration() -> State: # Execute the configuration file general = GeneralConfigurationUser() hamiltonian = HamiltonianConfiguration() - exec(config_script) + + # Create namespace with config objects, params, and standard utilities + exec_namespace = get_standard_exec_namespace() + exec_namespace.update({ + 'general': general, + 'hamiltonian': hamiltonian, + 'params': config_params, + }) + exec(config_script, exec_namespace) # Build the state (does some post-processing of user configuration) state = State(config_script, general, hamiltonian) + # Log configuration file contents state.log("\n".join([ f"Contents of configuration file \"{args.configuration_file}\":", config_script - ])) + ])) + + # Log parameters if any were passed + if config_params: + params_lines = ["Command-line parameters:"] + for key, value in config_params.items(): + params_lines.append(f" {key} = {value!r}") + state.log("\n".join(params_lines)) return state @@ -307,12 +349,14 @@ def write_data(state, pauli_sum, num_qubits): hamlib_str.extend(term + " +\n".encode(encoding)) del hamlib_str[-3:] # TODO: This needs to be saved to the appropriate data file (HDF5) - with open(state.filename_ham3(), 'w') as f: + ham3_filepath = state.config_general.get_output_path(state.filename_ham3()) + with open(ham3_filepath, 'w') as f: for key, value in state.metadata.items(): print(f"# {key} = {value}", file=f) print(hamlib_str.decode(), file=f) else: - with open(state.filename_ham3(), 'w') as f: + ham3_filepath = state.config_general.get_output_path(state.filename_ham3()) + with open(ham3_filepath, 'w') as f: for key, value in state.metadata.items(): print(f"# {key} = {value}", file=f) for string, coefficient in pauli_sum.items(): @@ -333,25 +377,27 @@ def write_initial_state(vec, path, n_qubits): def get_ham1(state): ham1_filename = state.filename_ham1() + ham1_cache_path = state.config_general.get_cache_path(ham1_filename) try: - with open(ham1_filename, 'rb') as file: + with open(ham1_cache_path, 'rb') as file: ham1_HartreeFock = pickle.load(file) # TODO: Python errors get a little weird if you have an exception inside an exception. So # instead the exception clause should _only_ flag that we're going to recompute versus # load, and the actual recomputing should be outside of the except clause. except FileNotFoundError as err: - state.log(f"Could not load \"{ham1_filename}\". Recomputing from the beginning.") - # Compute ham1_ActiveSpace from scratch + state.log(f"Could not load \"{ham1_cache_path}\". Recomputing from the beginning.") + # Compute ham1_HartreeFock from scratch state.log("Perform Hartree-Fock calculation.") ham1_HartreeFock = compute_Hartree_Fock(state) - state.log(f"Pickle to \"{ham1_filename}\" file.") - # Save ham2_ActiveSpace for later re-use - with open(ham1_filename, 'wb') as ham1_file: + ham1_output_path = state.config_general.get_output_path(ham1_filename) + state.log(f"Pickle to \"{ham1_output_path}\" file.") + # Save ham1_HartreeFock for later re-use + with open(ham1_output_path, 'wb') as ham1_file: pickle.dump(ham1_HartreeFock, ham1_file) return ham1_HartreeFock else: state.log(' '.join([ - f"Loaded \"{ham1_filename}\".", + f"Loaded \"{ham1_cache_path}\".", "Continuing from after the Hartree-Fock calculation."])) return ham1_HartreeFock @@ -359,37 +405,40 @@ def get_ham1(state): def get_ham2(state): ham2_filename = state.filename_ham2() - state.log(f"Trying to load \"{ham2_filename}\".") + ham2_cache_path = state.config_general.get_cache_path(ham2_filename) + state.log(f"Trying to load \"{ham2_cache_path}\".") try: - with open(ham2_filename, 'rb') as file: + with open(ham2_cache_path, 'rb') as file: ham2_ActiveSpace = pickle.load(file) # TODO: Python errors get a little weird if you have an exception inside an exception. So # instead the exception clause should _only_ flag that we're going to recompute versus # load, and the actual recomputing should be outside of the except clause. except FileNotFoundError as err: state.log(' '.join([ - f"Could not load \"{ham2_filename}\".", + f"Could not load \"{ham2_cache_path}\".", f"Trying to load \"{state.filename_ham1()}\"."])) # Get ham1_HartreeFock (by loading or by recomputing, depending on data availability) ham1_HartreeFock = get_ham1(state) # Recompute ham2_ActiveSpace from ham1_HartreeFock state.log("Apply active space.") ham2_ActiveSpace = apply_active_space(state, ham1_HartreeFock) - state.log(f"Pickle to \"{ham2_filename}\" file.") + ham2_output_path = state.config_general.get_output_path(ham2_filename) + state.log(f"Pickle to \"{ham2_output_path}\" file.") # Save ham2_ActiveSpace for later re-use - with open(ham2_filename, 'wb') as ham2_file: + with open(ham2_output_path, 'wb') as ham2_file: pickle.dump(ham2_ActiveSpace, ham2_file) # Save the one-body and two-body tensors using numpy's `save` function. These files can be # loaded using numpy's `load` function. - t_filename = ham2_filename[:ham2_filename.rfind('.')] + ".tensors.npz" - np.savez_compressed(t_filename, + t_filename_base = ham2_filename[:ham2_filename.rfind('.')] + ".tensors.npz" + t_filepath = state.config_general.get_output_path(t_filename_base) + np.savez_compressed(t_filepath, constant=ham2_ActiveSpace.constant, one_body=ham2_ActiveSpace.one_body_tensor, two_body=ham2_ActiveSpace.two_body_tensor) return ham2_ActiveSpace else: state.log(' '.join([ - f"Loaded \"{ham2_filename}\".", + f"Loaded \"{ham2_cache_path}\".", "Continuing from after the active space is applied."])) return ham2_ActiveSpace @@ -473,15 +522,16 @@ def run(): state = load_configuration() # Configure logging based on user settings + logfile_path = state.config_general.get_output_path(state.config_general.logfile) configure_logging( level=state.config_general.loglevel, - logfile=state.config_general.logfile + logfile=logfile_path ) logger.info("=" * 80) logger.info("HAMILTONIAN GENERATOR START") logger.info("=" * 80) - logger.info(f"Logfile: {state.config_general.logfile}") + logger.info(f"Logfile: {logfile_path}") logger.info(f"Git hash: {state.config_general.git_hash}") # TODO: If the final result files already exist, exit as no-op @@ -503,12 +553,14 @@ def run(): compute_metadata(state, ham3_Fermion2Qubit) ham3_filename = state.filename_ham3() - state.log(f"Save sum of Pauli strings to data file \"{ham3_filename}\".") + ham3_filepath = state.config_general.get_output_path(ham3_filename) + state.log(f"Save sum of Pauli strings to data file \"{ham3_filepath}\".") write_data(state, pauli_sum, ham2_ActiveSpace.n_qubits) is_filename = ham3_filename[0:-4] + ".npy" - state.log(f"Save initial state to data file \"{is_filename}\".") - write_initial_state(psi0, is_filename, ham2_ActiveSpace.n_qubits) + is_filepath = state.config_general.get_output_path(is_filename) + state.log(f"Save initial state to data file \"{is_filepath}\".") + write_initial_state(psi0, is_filepath, ham2_ActiveSpace.n_qubits) state.log("Hamiltonian generation complete.") diff --git a/hamiltonian_generator/hamgen_types.py b/hamiltonian_generator/hamgen_types.py index 3638463e..a8b2cdd5 100644 --- a/hamiltonian_generator/hamgen_types.py +++ b/hamiltonian_generator/hamgen_types.py @@ -6,6 +6,8 @@ import subprocess import sys +from qhat.common.git_utils import get_git_hash + logger = logging.getLogger(__name__) # ------------------------------------------------------------------------------------------------- @@ -20,6 +22,10 @@ def __init__(self): self.logfile = "hamgen.log" # How much information to print as the script runs self._loglevel = "info" + # Output directory for all generated files (empty string = current directory) + self.output_directory = "" + # Cache directory for reusable intermediate files (empty string = use output_directory) + self.cache_directory = "" def print_default(self): self._loglevel= "info" def print_verbose(self): @@ -82,25 +88,13 @@ def f2q_tag(self): # internal types and support functions # ------------------------------------------------------------------------------------------------- -def _get_git_hash(): - file_path = os.path.realpath(__file__) - dirpath = os.path.dirname(file_path) - commands = ";".join([ - f"pushd {dirpath} > /dev/null", - "if [[ $(git diff --stat) != '' ]]", - "then echo $(git rev-parse HEAD)-dirty", - "else git rev-parse HEAD", - "fi", - "popd > /dev/null" - ]) - output = subprocess.run(commands, shell=True, capture_output=True) - return output.stdout.decode("utf-8")[:-1] - class GeneralConfiguration: def __init__(self, user_config: GeneralConfigurationUser): self.logfile = user_config.logfile self.loglevel = user_config._loglevel - self.git_hash = _get_git_hash() + self.output_directory = user_config.output_directory + self.cache_directory = user_config.cache_directory + self.git_hash = get_git_hash(reference_file=__file__) logger.info(f"Running script with git hash {self.git_hash}") assert user_config.file_stub is not None self.file_stub = user_config.file_stub @@ -109,6 +103,72 @@ def __init__(self, user_config: GeneralConfigurationUser): else: self.file_format = "default" logger.info(f"Writing to file stub \"{self.file_stub}\" in format \"{self.file_format}\".") + + def get_output_path(self, filename): + """ + Get the full output path for a file, respecting output_directory. + + Mirrors the implementation in analysis/config_types.py for consistency. + + Parameters + ---------- + filename : str + The filename or relative path + + Returns + ------- + str + Full path with output_directory prepended (if set) + + Notes + ----- + - If output_directory is empty or None, returns filename unchanged + - Uses os.path.join() for proper path joining + - Absolute paths in filename override output_directory + - Creates parent directories automatically + """ + if not self.output_directory: + output_path = filename + else: + output_path = os.path.join(self.output_directory, filename) + + # Create parent directory if it doesn't exist + parent_dir = os.path.dirname(output_path) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + return output_path + + def get_cache_path(self, filename): + """ + Get the full cache path for a file, respecting cache_directory. + + Used when looking for reusable intermediate files (ham1, ham2). + + Parameters + ---------- + filename : str + The filename or relative path + + Returns + ------- + str + Full path with cache_directory or output_directory prepended (if set) + + Notes + ----- + - If cache_directory is set, uses cache_directory + - Otherwise falls back to output_directory (if set) + - If both are empty, returns filename unchanged (current directory) + - Does NOT create directories (cache is read-only) + """ + if self.cache_directory: + return os.path.join(self.cache_directory, filename) + elif self.output_directory: + return os.path.join(self.output_directory, filename) + else: + return filename + def ham3_ext(self): if self.file_format == "hamlib": return "dat" # TODO: return "hdf5"