diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index ab74dfaa..98d14ec4 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -70,30 +70,31 @@ jobs: directory: analysis script: driver.py config_file: '' + config_params: '' - name: analysis-pauli-lcu directory: analysis script: driver.py - config_file: config-pl.py - config_setup: 'sed -e ''s/^my_method = "Trotter"/my_method = "pauli-lcu"/'' config.py > config-pl.py' + config_file: '' + config_params: '-p method=pauli-lcu' - name: analysis-dbl-factor directory: analysis script: driver.py - config_file: config-df.py - config_setup: 'sed -e ''s/^my_method = "Trotter"/my_method = "double-factorization"/'' config.py > config-df.py' + config_file: '' + config_params: '-p method=double-factorization' - name: common-unit-tests directory: common script: pytest config_file: '' - config_setup: '' + config_params: '' - name: analysis-unit-tests directory: analysis script: pytest config_file: '' - config_setup: '' + config_params: '' name: Test ${{ matrix.test-suite.name }} @@ -117,12 +118,6 @@ jobs: pip install --upgrade pip pip install --find-links=python-wheels . - - name: Setup config - if: matrix.test-suite.config_setup != '' - run: | - cd ${{ matrix.test-suite.directory }} - ${{ matrix.test-suite.config_setup }} - - name: Run test run: | set -euxo pipefail @@ -130,5 +125,5 @@ jobs: if [ "${{ matrix.test-suite.script }}" = "pytest" ]; then python -m pytest tests/ -v else - python ${{ matrix.test-suite.script }} ${{ matrix.test-suite.config_file }} + python ${{ matrix.test-suite.script }} ${{ matrix.test-suite.config_file }} ${{ matrix.test-suite.config_params }} fi diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dd317387..4734b5d3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -52,6 +52,20 @@ run-analysis-trotter: - .venv/ policy: pull +run-analysis-pauli-lcu: + stage: test + script: + - hostname + - source .venv/bin/activate + - module load gcc/13.2.0 + - cd analysis + - python driver.py -p method=pauli-lcu + cache: + key: "${CI_COMMIT_REF_SLUG}" + paths: + - .venv/ + policy: pull + run-analysis-dbl-fact: stage: test script: @@ -59,8 +73,7 @@ run-analysis-dbl-fact: - source .venv/bin/activate - module load gcc/13.2.0 - cd analysis - - sed -e 's/^my_method = "Trotter"/my_method = "double-factorization"/' config.py > config-df.py - - python driver.py config-df.py + - python driver.py -p method=double-factorization cache: key: "${CI_COMMIT_REF_SLUG}" paths: diff --git a/analysis/README.md b/analysis/README.md index 026664d3..b2ee1983 100644 --- a/analysis/README.md +++ b/analysis/README.md @@ -13,6 +13,40 @@ of a configuration file as a command line argument python driver.py my_configuration_file.py ``` +### Command-line Parameters + +Configuration files can access command-line parameters via the `params` dictionary, allowing runtime +customization without modifying the config file itself. Parameters are passed using the `-p` or +`--param` flag with `KEY=VALUE` format: + +```bash +python driver.py config.py -p output_directory=my_run -p timestep=0.1 +``` + +**Parameter evaluation**: +- Values are evaluated as Python literals when possible (numbers, lists, etc.) +- If evaluation fails, the value is treated as a string +- Parameters are accessed in config files via `params.get(key, default)` + +**Example configuration usage**: +```python +# In your config.py file +general.output_directory = params.get("output_directory", "default_output") +timestep = params.get("timestep", 0.01) # Will be converted to float +run_name = params.get("run_name", "unnamed") # String parameter +``` + +**Multiple parameters**: +```bash +python driver.py -p dir=results/run1 -p error=1e-4 -p phases=[0,1,2,3] +``` + +This feature is particularly useful for: +- Running parameter sweeps without editing config files +- Organizing outputs into different directories per run +- Automating analyses with scripts or job schedulers +- Quick testing of different parameter values + ## Configuration Options Configuration files are themselves Python scripts, allowing users to use control logic to build up @@ -20,6 +54,25 @@ complex configuration files. Configuration is broken down by several parts of the processing script. +### Utility Functions + +Configuration files have access to several utility functions: + +- **`meV_to_Hartree(meV)`**: Converts energy from milli-electronvolts (meV) to Hartree atomic units. + Useful for specifying energy errors in more intuitive units. + ```python + energy_error = meV_to_Hartree(1e4) # 0.01 keV + ``` + +- **`string_to_seed(s)`**: Converts a string to a deterministic integer seed for random number + generation. Uses SHA-256 hashing to ensure the same string always produces the same seed. This is + particularly useful in large test suites where reproducible random behavior is needed. + ```python + import random + seed = string_to_seed("my_experiment_name") + random.seed(seed) + ``` + ### General "General" configuration governs the behavior of the resource analysis script itself, including @@ -57,6 +110,9 @@ You can configure the log file that the script will write to by setting **`gener the name of the logfile you want to use. The default is `analysis.log`. If `output_directory` is set, the logfile will be written to that directory. +The logfile automatically records the configuration file contents and any command-line parameters +that were passed via `-p`, making it easy to reproduce analyses. + #### Log Level The log level is set by calling one of the following functions. If you call multiple of these diff --git a/analysis/config.py b/analysis/config.py index 582c1724..1af0a437 100644 --- a/analysis/config.py +++ b/analysis/config.py @@ -1,17 +1,21 @@ """ -QHAT Analysis Configuration: Trotter Method +QHAT Analysis Configuration: basic example -This config demonstrates resource estimation using Trotter decomposition. -For comprehensive analysis including error metrics, see examples/config_full_analysis.py +This configuration demonstrates the use of command-line parameters to select between different +quantum simulation methods without editing the file. This is useful for CI pipelines and automated +testing. -PHASE 2 IMPROVEMENTS (2026-07): - If you enable error analysis, it now uses OperatorRepresentation framework internally - to correctly compare time-evolution operators. No config changes needed! +Usage: + python driver.py # Uses Trotter (default) + python driver.py -p method=pauli-lcu # Uses Pauli-LCU + python driver.py -p method=double-factorization # Uses double-factorization + +Each method automatically outputs to a separate directory (Be-H-trotter/, Be-H-pauli-lcu/, etc.) +to avoid overwriting results. You can override this with -p output_directory=custom_dir """ -my_method = "Trotter" -#my_method = "pauli-lcu" -#my_method = "double-factorization" +# Method selection: override via command-line with -p method= +my_method = params.get("method", "Trotter") # this configuration file assumes equipartition of energy between Trotterization error and phase # estimation error (see usage of energy_error below) @@ -22,10 +26,15 @@ general.print_verbose() -# Set output directory for all generated files (logfiles, matrices, eigendecompositions, etc.) -# If not set or empty, files are written to the current directory -# Example: "Be-H/" will create a Be-H/ subdirectory for all outputs -general.output_directory = "Be-H/" +# Set output directory based on method to keep results separated +# Override with: -p output_directory=custom_dir +default_output_dir = { + "Trotter": "Be-H-trotter", + "pauli-lcu": "Be-H-pauli-lcu", + "double-factorization": "Be-H-double-factorization" +}.get(my_method, "Be-H") + +general.output_directory = params.get("output_directory", default_output_dir) general.logfile = "Be-H.log" diff --git a/analysis/configuration.py b/analysis/configuration.py index fd5c3443..8393d572 100644 --- a/analysis/configuration.py +++ b/analysis/configuration.py @@ -14,7 +14,7 @@ # ------------------------------------------------------------------------------------------------- -def load_configuration() -> State: +def load_configuration() -> tuple[State, str]: # Set up and read command-line arguments parser = argparse.ArgumentParser() @@ -24,8 +24,31 @@ 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 = {} + 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 + # Read the configuration file with open(args.configuration_file, 'r') as fin: config_script = fin.read() @@ -40,15 +63,44 @@ def load_configuration() -> State: analysis = AnalysisConfiguration() def meV_to_Hartree(meV): return 3.67493221757e-5 * meV - exec(config_script) + 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 = { + # configuration objects + 'general': general, + 'hamiltonian': hamiltonian, + 'unitary': unitary, + 'algorithm': algorithm, + '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) state = State(config_script, general, hamiltonian, unitary, algorithm, analysis) - logger.info("\n".join([ - f"Contents of configuration file \"{args.configuration_file}\":", - config_script - ])) + # Prepare log messages with configuration file contents and any parameters passed + # These will be logged after logging is configured + config_file_message = f"Contents of configuration file \"{args.configuration_file}\":\n{config_script}" + + params_message = None + if config_params: + params_lines = ["Command-line parameters:"] + for key, value in config_params.items(): + params_lines.append(f" {key} = {value!r}") + params_message = "\n".join(params_lines) - return state + return state, config_file_message, params_message diff --git a/analysis/driver.py b/analysis/driver.py index 406c6251..68e043b3 100644 --- a/analysis/driver.py +++ b/analysis/driver.py @@ -15,7 +15,7 @@ from qhat.analysis.hamiltonian import get_physical_hamiltonian from qhat.analysis.unitary import encode_as_unitary -logger = logging.getLogger(__name__) +logger = logging.getLogger("qhat.analysis.driver") # ================================================================================================= @@ -23,7 +23,7 @@ def run(): # Configuration _______________________________________________________________________________ - state = load_configuration() + state, config_file_message, params_message = load_configuration() # Configure logging based on user settings logfile_path = state.config_general.get_output_path(state.config_general.logfile) @@ -34,6 +34,9 @@ def run(): logger.info("=" * 99) logger.info(f"Logfile: {logfile_path}") logger.info(f"Git hash: {state.config_general.git_hash}") + logger.info(config_file_message) + if params_message: + logger.info(params_message) # Hamiltonian _________________________________________________________________________________ diff --git a/analysis/tests/test_cmdline_params.py b/analysis/tests/test_cmdline_params.py new file mode 100644 index 00000000..c79fd8ae --- /dev/null +++ b/analysis/tests/test_cmdline_params.py @@ -0,0 +1,177 @@ +"""Tests for command-line parameter functionality in configuration loading.""" + +import pytest +import sys +import tempfile +import os +from pathlib import Path + +from qhat.analysis.configuration import load_configuration + + +def test_cmdline_params_basic(monkeypatch, tmp_path): + """Test basic command-line parameter passing.""" + # Create a simple test config file + config_file = tmp_path / "test_config.py" + config_file.write_text(""" +# Use command-line parameters +test_value = params.get('test_param', 'default') +numeric_value = params.get('number', 42) + +# Use output_directory which is preserved in GeneralConfiguration +general.output_directory = f"test_{test_value}_{numeric_value}" +""") + + # Mock sys.argv to simulate command-line arguments + test_args = [ + 'driver.py', + str(config_file), + '-p', 'test_param=hello', + '-p', 'number=100' + ] + monkeypatch.setattr(sys, 'argv', test_args) + + # Load configuration + state, _, _ = load_configuration() + + # Verify the parameters were used + assert 'hello' in state.config_general.output_directory + assert '100' in state.config_general.output_directory + + +def test_cmdline_params_type_conversion(monkeypatch, tmp_path): + """Test that parameters are properly converted to Python types.""" + config_file = tmp_path / "test_config.py" + config_file.write_text(""" +# Access parameters from params dict +float_val = params['float_param'] +int_val = params['int_param'] +list_val = params['list_param'] + +# Verify types +assert isinstance(float_val, float) +assert isinstance(int_val, int) +assert isinstance(list_val, list) +assert float_val == 3.14 +assert int_val == 42 +assert list_val == [1, 2, 3] + +general.output_directory = "test" +""") + + test_args = [ + 'driver.py', + str(config_file), + '-p', 'float_param=3.14', + '-p', 'int_param=42', + '-p', 'list_param=[1,2,3]' + ] + monkeypatch.setattr(sys, 'argv', test_args) + + state, _, _ = load_configuration() + # If we got here without errors, the types were correctly evaluated + assert True + + +def test_cmdline_params_string_fallback(monkeypatch, tmp_path): + """Test that invalid Python expressions are treated as strings.""" + config_file = tmp_path / "test_config.py" + config_file.write_text(""" +# This should be a string since "hello-world" isn't valid Python +string_val = params.get('weird_string', 'default') + +general.output_directory = string_val +""") + + test_args = [ + 'driver.py', + str(config_file), + '-p', 'weird_string=hello-world' + ] + monkeypatch.setattr(sys, 'argv', test_args) + + state, _, _ = load_configuration() + assert state.config_general.output_directory == 'hello-world' + + +def test_cmdline_params_no_params(monkeypatch, tmp_path): + """Test that configs work without any command-line parameters.""" + config_file = tmp_path / "test_config.py" + config_file.write_text(""" +# Use defaults when no params provided +test_value = params.get('missing_param', 'default_value') + +general.output_directory = test_value +""") + + test_args = ['driver.py', str(config_file)] + monkeypatch.setattr(sys, 'argv', test_args) + + state, _, _ = load_configuration() + assert state.config_general.output_directory == 'default_value' + + +def test_cmdline_params_invalid_format(monkeypatch, tmp_path): + """Test that invalid parameter format raises an error.""" + config_file = tmp_path / "test_config.py" + config_file.write_text("general.file_stub = 'test'") + + # Parameter without '=' should raise ValueError + test_args = [ + 'driver.py', + str(config_file), + '-p', 'invalid_param_no_equals' + ] + monkeypatch.setattr(sys, 'argv', test_args) + + with pytest.raises(ValueError, match="Parameter must be in KEY=VALUE format"): + load_configuration() + + +def test_cmdline_params_multiple_params(monkeypatch, tmp_path): + """Test passing multiple parameters.""" + config_file = tmp_path / "test_config.py" + config_file.write_text(""" +a = params.get('a', 0) +b = params.get('b', 0) +c = params.get('c', 0) + +general.output_directory = f"test_{a}_{b}_{c}" +""") + + test_args = [ + 'driver.py', + str(config_file), + '-p', 'a=1', + '-p', 'b=2', + '-p', 'c=3' + ] + monkeypatch.setattr(sys, 'argv', test_args) + + state, _, _ = load_configuration() + assert state.config_general.output_directory == 'test_1_2_3' + + +def test_cmdline_params_not_in_global_namespace(monkeypatch, tmp_path): + """Test that parameters are NOT available as direct variables.""" + config_file = tmp_path / "test_config.py" + config_file.write_text(""" +# Try to access parameter directly as a variable (should fail) +try: + x = my_param # This should raise NameError + general.output_directory = "SHOULD_NOT_GET_HERE" +except NameError: + # Expected - parameter not in namespace + general.output_directory = "params_not_global" +""") + + test_args = [ + 'driver.py', + str(config_file), + '-p', 'my_param=42' + ] + monkeypatch.setattr(sys, 'argv', test_args) + + state, _, _ = load_configuration() + # Should have caught the NameError, not accessed my_param directly + assert state.config_general.output_directory == 'params_not_global'