OCScore Pipeline Examples

These examples demonstrate how to train and use OCScore models for consensus scoring.

Complete OCScore Pipeline

Complete end-to-end pipeline to obtain OCScore results from scratch:

Complete OCScore pipeline
#!/usr/bin/env python3
"""
Example: Complete OCScore Pipeline

This example demonstrates the complete pipeline to obtain OCScore results from scratch:
1. Receptor and ligand preparation
2. Docking with multiple engines (Vina, PLANTS, GNINA)
3. Pose clustering to find representative poses
4. Rescoring with multiple scoring functions (ODDT, PLANTS, Vina, SMINA, GNINA)
5. Feature extraction (receptor and ligand descriptors)
6. Model inference using trained OCScore model

The rescoring results are automatically mapped to database column names for consistency.

Usage:
    # Update paths in the script to match your system
    python examples/11_python_api_complete_ocscore_pipeline.py
"""

###############################################################################
# USER CONFIGURATION - Update these variables to match your system
###############################################################################

# OCDocker configuration file path
# Set this to the absolute path of your OCDocker.cfg file
# If None, will use OCDOCKER_CONFIG environment variable or search for OCDocker.cfg
OCDOCKER_CONFIG_FILE = "/path/to/OCDocker.cfg"  # Update this path

# Receptor configuration
RECEPTOR_PATH = "/path/to/receptor_directory/receptor.pdb"
RECEPTOR_NAME = "Receptor"
PREPARED_RECEPTOR_PDBQT = "/path/to/receptor_directory/prepared_receptor.pdbqt"
PREPARED_RECEPTOR_MOL2 = "/path/to/receptor_directory/prepared_receptor.mol2"

BOX_CENTER = (0.0, 0.0, 0.0)  # (x, y, z) coordinates of the box center

# Ligand configuration - List of ligand paths (base directories)
# Each path should contain: {ligand_name}.smi, boxes/box0.pdb, and subdirectories for outputs
# Ligand names are automatically extracted from the last folder name in each path
LIGAND_PATHS = [
    ]

# Add more ligand paths here (you can use glob to get all ligand folders):
# "/path/to/ligand2",
# "/path/to/ligand3",

# Model configuration
MODEL_NAME = "OCScore"  # Name of your trained model (without extension)
MODELS_DIR = "OCScore_models"  # Directory containing models
PCA_MODEL_PATH = None  # Path to PCA model if used, e.g., f"{MODELS_DIR}/{MODEL_NAME}_pca.pkl"

# Preprocessing configuration (should match training settings)
SCALER = "standard"  # "standard" or "minmax"
INVERT_CONDITIONALLY = True
NORMALIZE = True
USE_MASK = True
SCORE_COLUMNS_LIST = ["SMINA", "VINA", "ODDT", "PLANTS"]

###############################################################################
# !!! CRITICAL WARNING: SCORING FUNCTION COLUMN ORDER !!!
###############################################################################
# 
# THE FOLLOWING ORDER MUST BE STRICTLY RESPECTED WHEN APPLYING MASKS:
# 
#   1. SMINA_VINA
#   2. SMINA_SCORING_DKOES
#   3. SMINA_VINARDO
#   4. SMINA_OLD_SCORING_DKOES
#   5. SMINA_FAST_DKOES
#   6. SMINA_SCORING_AD4
#   7. VINA_VINA
#   8. VINA_VINARDO
#   9. PLANTS_CHEMPLP
#  10. PLANTS_PLP
#  11. PLANTS_PLP95
#  12. ODDT_RFSCORE_V1
#  13. ODDT_RFSCORE_V2
#  14. ODDT_RFSCORE_V3
#  15. ODDT_PLECRF_P5_L1_S65536
#  16. ODDT_NNSCORE
# 
# !!! WARNING: If you change the order of scoring function columns in the output,
#   the mask will be applied incorrectly, leading to wrong predictions!
# 
# The mask is a 16-element array where each position corresponds to one of the
# scoring functions above in the exact order listed. Position 0 = SMINA_VINA,
# position 1 = SMINA_SCORING_DKOES, etc.
# 
# DO NOT MODIFY THE ORDER OF SCORING FUNCTION COLUMNS WITHOUT UPDATING THE MASK!
# 
###############################################################################

# GPU configuration
USE_GPU = True  # Set to False to force CPU usage (useful if CUDA is not available or to avoid GPU memory issues)

# Output configuration
OUTPUT_FILE = "ocscore_results.csv"  # CSV file to save results (None to skip saving)
SAVE_TO_FILE = True  # Set to False to only store results in memory

# Multiprocessing configuration
N_JOBS = 4                  # Number of parallel jobs (cores) to use. Set to -1 for all available cores
USE_MULTIPROCESSING = True  # Set to False to process ligands sequentially

# Pipeline stage toggles — enable each engine independently
RUN_VINA_DOCKING = True
RUN_PLANTS_DOCKING = True
RUN_GNINA_DOCKING = True

RUN_ODDT_RESCORING = True
RUN_PLANTS_RESCORING = True
RUN_VINA_RESCORING = True
RUN_SMINA_RESCORING = True
RUN_GNINA_RESCORING = True

# When True, partial reruns keep scores from disabled engines (read from disk/cache).
# Use this to add GNINA (or any engine) to an already-completed protocol without losing other scores.
MERGE_EXISTING_RESCORING = True

# Example — GNINA rescoring only on an already-docked/scored ligand folder:
#   RUN_VINA_DOCKING = RUN_PLANTS_DOCKING = RUN_GNINA_DOCKING = False
#   RUN_ODDT_RESCORING = RUN_PLANTS_RESCORING = RUN_VINA_RESCORING = RUN_SMINA_RESCORING = False
#   RUN_GNINA_RESCORING = True
#   MERGE_EXISTING_RESCORING = True
# Existing vina/smina/plants/oddt outputs are left untouched; only gninaFiles/rescoring is written.

# Checkpoint/resume configuration
# When enabled, each ligand writes a per-ligand checkpoint and can resume from the
# last completed stage. This is backward-compatible with runs done before this code:
# if no checkpoint file exists, completion is inferred from existing output artifacts.
ENABLE_LIGAND_CHECKPOINT = True
LIGAND_CHECKPOINT_FILE = ".ocscore_pipeline_checkpoint.json"
LIGAND_FEATURES_CACHE_FILE = ".ocscore_pipeline_features.json"

###############################################################################
# END USER CONFIGURATION
###############################################################################

import argparse
import json
import os
import time
import warnings

import numpy as np
import pandas as pd

from glob import glob
from typing import Any, Dict, Optional, Tuple

# Configure sklearn/joblib to use threading backend for parallel execution
# This allows sklearn models to use multiple threads while main process uses multiprocessing
# The threading backend avoids the "Loky-backed parallel loops cannot be called in multiprocessing" issue
warnings.filterwarnings('ignore', message='.*Loky-backed parallel loops cannot be called in a multiprocessing.*')

# Note: We keep the default multiprocessing start method ('fork' on Linux)
# which is faster and works well with proper tmp directory isolation

try:
    import joblib
    from joblib import parallel_backend, Parallel, delayed
    
    # Set default backend to threading so sklearn can parallelize within multiprocessing workers
    # Threading backend works inside multiprocessing contexts (unlike Loky)
    joblib.parallel.DEFAULT_BACKEND = 'threading'
    JOBLIB_AVAILABLE = True
except (ImportError, AttributeError):
    # If joblib not available, try setting environment variable
    os.environ['JOBLIB_BACKEND'] = 'threading'
    JOBLIB_AVAILABLE = False
    USE_MULTIPROCESSING = False
    print("Warning: joblib not available. Multiprocessing disabled.")

# Explicitly bootstrap OCDocker with the specified config file BEFORE other imports
# This ensures the config is loaded correctly regardless of working directory
# Set OCDOCKER_NO_AUTO_BOOTSTRAP to prevent auto-bootstrap from running first
os.environ['OCDOCKER_NO_AUTO_BOOTSTRAP'] = '1'

if OCDOCKER_CONFIG_FILE and os.path.isfile(OCDOCKER_CONFIG_FILE):
    import OCDocker.Error as ocerror
    import OCDocker.Initialise as init

    print(f"Loading OCDocker configuration from: {OCDOCKER_CONFIG_FILE}")
    bootstrap_ns = argparse.Namespace(
        multiprocess=USE_MULTIPROCESSING,
        update=False,
        config_file=OCDOCKER_CONFIG_FILE,
        output_level=ocerror.ReportLevel.WARNING,
        overwrite=False
    )
    init.bootstrap(bootstrap_ns)
    print("OCDocker configuration loaded successfully.\n")
else:
    # Fall back to auto-bootstrap if config file not specified or not found
    if OCDOCKER_CONFIG_FILE:
        print(f"Warning: Config file not found at {OCDOCKER_CONFIG_FILE}, using auto-bootstrap...")
    # Re-enable auto-bootstrap
    os.environ.pop('OCDOCKER_NO_AUTO_BOOTSTRAP', None)

# Now import other OCDocker modules (they won't trigger auto-bootstrap since we already bootstrapped)
import OCDocker.Docking.Gnina as ocgnina
import OCDocker.Docking.PLANTS as ocplants
import OCDocker.Docking.Smina as ocsmina
import OCDocker.Docking.Vina as ocvina
import OCDocker.Ligand as ocl
import OCDocker.OCScore.Scoring as ocscoring
import OCDocker.OCScore.Utils.Data as ocscoredata
import OCDocker.OCScore.Utils.IO as ocscoreio
import OCDocker.Processing.Preprocessing.RMSDClustering as ocrmsdclust
import OCDocker.Receptor as ocr
import OCDocker.Rescoring.ODDT as ocoddt
import OCDocker.Toolbox.Conversion as occonversion
import OCDocker.Toolbox.MoleculeProcessing as ocmolproc
import OCDocker.Toolbox.Security as ocsec

# Allow unsafe deserialization
ocsec.allow_unsafe_runtime(deserialization=True, script_exec=False)

def main():
    '''Main function to process all ligands.'''

    _validate_pipeline_config()
    
    # OCDocker auto-bootstraps on import, so configuration is already loaded
    # If you need to verify bootstrap or use custom settings, you can:
    # 1. Set OCDOCKER_NO_AUTO_BOOTSTRAP=1 environment variable
    # 2. Import OCDocker.Initialise and call bootstrap() explicitly
    
    # Automatically derive ligand names from paths (last folder name)
    ligand_names = [os.path.basename(os.path.normpath(path)) for path in LIGAND_PATHS]
    
    # Create receptor object
    receptor = ocr.Receptor(RECEPTOR_PATH, name=RECEPTOR_NAME)
    
    # Prepare list of (ligand_path, ligand_name) tuples
    ligand_tasks = list(zip(LIGAND_PATHS, ligand_names))
    
    print(f"\n{'='*60}")
    print(f"OCSCORE PIPELINE")
    print(f"{'='*60}")
    print(f"Receptor: {RECEPTOR_NAME}")
    print(f"Number of ligands: {len(ligand_tasks)}")
    print(f"Use mask: {USE_MASK}")
    print(f"Merge existing rescoring: {MERGE_EXISTING_RESCORING}")
    print(f"Docking: vina={RUN_VINA_DOCKING}, plants={RUN_PLANTS_DOCKING}, gnina={RUN_GNINA_DOCKING}")
    print(
        "Rescoring: "
        f"oddt={RUN_ODDT_RESCORING}, plants={RUN_PLANTS_RESCORING}, "
        f"vina={RUN_VINA_RESCORING}, smina={RUN_SMINA_RESCORING}, gnina={RUN_GNINA_RESCORING}"
    )
    print(f"Multiprocessing: {USE_MULTIPROCESSING}")
    if USE_MULTIPROCESSING:
        print(f"Number of jobs: {N_JOBS}")
    print(f"{'='*60}\n")
    
    # Process ligands
    if USE_MULTIPROCESSING and JOBLIB_AVAILABLE and len(ligand_tasks) > 1:
        # Use joblib for parallel processing
        print(f"Processing {len(ligand_tasks)} ligands in parallel using {N_JOBS} cores...")
        results = Parallel(n_jobs=N_JOBS)(
            delayed(process_single_ligand)(ligand_path, ligand_name, receptor)
            for ligand_path, ligand_name in ligand_tasks
        )
    else:
        # Process sequentially
        print(f"Processing {len(ligand_tasks)} ligands sequentially...")
        results = []
        for ligand_path, ligand_name in ligand_tasks:
            print(f"Processing ligand: {ligand_name}")
            result = process_single_ligand(ligand_path, ligand_name, receptor)
            results.append(result)
    
    # Filter out None results (failed processing)
    results = [r for r in results if r is not None]
    
    if not results:
        print("No ligands were successfully processed.")
        return
    
    # Batch all ligands together for model inference
    # This ensures proper normalization (scaler fit on all data, not single rows)
    print(f"\n{'='*60}")
    print(f"BATCH MODEL INFERENCE")
    print(f"{'='*60}")
    
    # Convert all results to a single DataFrame
    if results:
        # Get all unique keys from all dictionaries
        all_keys = set()
        for result in results:
            if result is not None:
                all_keys.update(result.keys())
        
        # Ensure all dictionaries have all keys (fill missing with None)
        normalized_results = []
        for result in results:
            if result is not None:
                normalized_result = {key: result.get(key, None) for key in all_keys}
                normalized_results.append(normalized_result)
        
        # Create DataFrame from normalized dictionaries
        feature_df = pd.DataFrame(normalized_results)
    else:
        feature_df = pd.DataFrame()
    
    if feature_df.empty:
        print("No features to process for model inference.")
        return
    
    # Path to your trained model
    model_path = f"{MODELS_DIR}/{MODEL_NAME}.pt"
    mask_path = f"{MODELS_DIR}/{MODEL_NAME}_mask.pkl"
    scaler_path = f"{MODELS_DIR}/{MODEL_NAME}_scaler.pkl"  # Path to saved scaler
    
    # Load the mask if it exists
    mask = None
    if os.path.isfile(mask_path) and USE_MASK:
        try:
            mask = ocscoreio.load_mask(MODEL_NAME, models_dir=MODELS_DIR)
        except Exception as e:
            print(f"Warning: Could not load mask: {e}")
            mask = None
    
    # Check if scaler exists (required for proper normalization)
    if NORMALIZE and not os.path.isfile(scaler_path):
        print(f"WARNING: Scaler file not found at {scaler_path}")
        print("  This means normalization will use a NEW scaler fitted on prediction data,")
        print("  which is INCORRECT. The scaler should be saved during training.")
        print("  Predictions may be inaccurate!")
        scaler_path = None  # Will create new scaler (incorrect but won't crash)
    elif NORMALIZE:
        print(f"Using saved scaler from: {scaler_path}")
    
    # Get OCScore predictions for all ligands at once
    try:
        print(f"Running model inference on {len(feature_df)} ligands...")
        print(f"Feature DataFrame shape: {feature_df.shape}")
        
        ocscore_predictions = ocscoring.get_score(
            model_path=model_path,
            data=feature_df,
            pca_model=PCA_MODEL_PATH,
            mask=mask,
            score_columns_list=SCORE_COLUMNS_LIST,
            scaler=SCALER,
            scaler_path=scaler_path if NORMALIZE else None,  # Use saved scaler if normalization is enabled
            invert_conditionally=INVERT_CONDITIONALLY,
            normalize=NORMALIZE,
            serialization_method="auto",  # Auto-detect model format
            use_gpu=USE_GPU  # Use GPU if available and USE_GPU=True
        )
        
        if isinstance(ocscore_predictions, pd.DataFrame):
            print(f"Prediction DataFrame shape: {ocscore_predictions.shape}")
            print(f"Prediction DataFrame columns: {list(ocscore_predictions.columns)}")
            if 'predicted_score' in ocscore_predictions.columns:
                print(f"All predicted_score values: {ocscore_predictions['predicted_score'].tolist()}")
                print(f"Unique predicted_score values: {ocscore_predictions['predicted_score'].nunique()}")
        elif isinstance(ocscore_predictions, (pd.Series, np.ndarray)):
            predictions_array = np.asarray(ocscore_predictions)
            print(f"Prediction array shape: {predictions_array.shape}")
            print(f"All prediction values: {predictions_array.tolist()}")
            print(f"Unique prediction values: {len(np.unique(predictions_array))}")
        
        # Add OCScore predictions to results
        if isinstance(ocscore_predictions, pd.DataFrame):
            if 'predicted_score' in ocscore_predictions.columns:
                # Map predictions back to results by index
                for idx, result in enumerate(results):
                    if result is not None and idx < len(ocscore_predictions):
                        result['OCSCORE'] = ocscore_predictions['predicted_score'].iloc[idx]
                        print(f"  Mapped prediction {idx} to ligand {result.get('ligand', 'unknown')}: {result['OCSCORE']}")
            elif len(ocscore_predictions.columns) == 1:
                # Single prediction column
                for idx, result in enumerate(results):
                    if result is not None and idx < len(ocscore_predictions):
                        result['OCSCORE'] = ocscore_predictions.iloc[idx, 0]
            else:
                # Try to find a numeric column
                numeric_cols = ocscore_predictions.select_dtypes(include=[np.number]).columns
                if len(numeric_cols) > 0:
                    for idx, result in enumerate(results):
                        if result is not None and idx < len(ocscore_predictions):
                            result['OCSCORE'] = ocscore_predictions[numeric_cols[0]].iloc[idx]
        elif isinstance(ocscore_predictions, (pd.Series, np.ndarray)):
            # Array/Series of predictions
            predictions_array = np.asarray(ocscore_predictions)
            for idx, result in enumerate(results):
                if result is not None and idx < len(predictions_array):
                    result['OCSCORE'] = float(predictions_array[idx])
        
        print(f"Model inference completed for {len(results)} ligands.")
        
    except FileNotFoundError as e:
        print(f"Warning: Model file not found: {e}")
        for result in results:
            if result is not None:
                result['OCSCORE'] = None
    except Exception as e:
        print(f"Error during model inference: {e}")
        import traceback
        traceback.print_exc()
        for result in results:
            if result is not None:
                result['OCSCORE'] = None
    
    # Convert results to DataFrame
    # Use orient='index' and transpose to preserve order, then convert properly
    # First, ensure all dictionaries have the same keys (fill missing with None)
    if results:
        # Get all unique keys from all dictionaries
        all_keys = set()
        for result in results:
            if result is not None:
                all_keys.update(result.keys())
        
        # Ensure all dictionaries have all keys (fill missing with None)
        normalized_results = []
        for result in results:
            if result is not None:
                normalized_result = {key: result.get(key, None) for key in all_keys}
                normalized_results.append(normalized_result)
            else:
                normalized_results.append({key: None for key in all_keys})
        
        # Create DataFrame from normalized dictionaries
        results_df = pd.DataFrame(normalized_results)
    else:
        results_df = pd.DataFrame()
    
    # Reorder columns to match the data source order (from training data file)
    # !!! CRITICAL: This ensures all columns (especially SFs) are in the exact same order
    # as the training data, which is essential for proper mask application and model inference!
    if not results_df.empty:
        # Get the column order from config (no file path needed)
        # Uses reference_column_order from OCDocker.cfg
        source_order = ocscoredata.get_column_order()  # Uses config by default
        
        # Use the reorder function to match the config column order
        # This handles OCSCORE insertion and extra columns automatically
        results_df = ocscoredata.reorder_columns_to_match_data_order(
            df=results_df,
            data_source=None,  # Uses config.reference_column_order by default
            keep_extra_columns=True,  # Keep OCSCORE and any other extra columns
            fill_missing_columns=False  # Don't add missing columns as NaN
        )
        
        # Manually insert OCSCORE right after 'ligand' if it exists
        if 'OCSCORE' in results_df.columns:
            cols = list(results_df.columns)
            if 'ligand' in cols:
                # Remove OCSCORE from current position
                cols.remove('OCSCORE')
                # Insert after 'ligand'
                ligand_idx = cols.index('ligand')
                cols.insert(ligand_idx + 1, 'OCSCORE')
                results_df = results_df[cols]
    
    # Print summary
    print(f"\n{'='*60}")
    print(f"PROCESSING COMPLETE")
    print(f"{'='*60}")
    print(f"Successfully processed: {len(results)}/{len(ligand_tasks)} ligands")
    if 'OCSCORE' in results_df.columns:
        print(f"OCScore predictions: {results_df['OCSCORE'].notna().sum()}/{len(results_df)}")
        if results_df['OCSCORE'].notna().any():
            print(f"OCScore range: {results_df['OCSCORE'].min():.4f} - {results_df['OCSCORE'].max():.4f}")
    print(f"{'='*60}\n")
    
    # Save to file if requested
    if SAVE_TO_FILE and OUTPUT_FILE:
        output_path = OUTPUT_FILE
        results_df.to_csv(output_path, index=False)
        print(f"Results saved to: {output_path}")
    
    return results_df


# Mapping function to convert rescoring keys to database column names
def map_rescoring_key_to_db_column(key: str, engine: Optional[str] = None) -> str:
    '''Map rescoring result keys to database column names.
    
    Parameters
    ----------
    key : str
        The key from rescoring results (e.g., 'vina_vina_rescoring', 'smina_vinardo_rescoring', etc.)
    engine : str
        The engine name (e.g., 'vina', 'smina')
        If None, the engine will be inferred from the key.
        If provided, the engine will be used to determine the database column name.
        If not provided, the engine will be inferred from the key.

    Returns
    -------
    str
        The database column name (e.g., 'VINA_VINA', 'SMINA_VINARDO', etc.)
    '''

    key_lower = key.lower()
    
    # Mapping dictionary for rescoring keys to database column names
    # !!! CRITICAL: These must match SCORING_FUNCTION_ORDER for mask application !!!
    mapping = {
        # VINA mappings
        'vina_vina_rescoring': 'VINA_VINA',
        'vina_vinardo_rescoring': 'VINA_VINARDO',
        # SMINA mappings - MUST match SCORING_FUNCTION_ORDER
        'smina_vina_rescoring': 'SMINA_VINA',
        'smina_vinardo_rescoring': 'SMINA_VINARDO',
        'smina_dkoes_scoring_rescoring': 'SMINA_SCORING_DKOES',
        'smina_scoring_dkoes_rescoring': 'SMINA_SCORING_DKOES',  # Alternative format
        'smina_old_scoring_dkoes_rescoring': 'SMINA_OLD_SCORING_DKOES',
        'smina_fast_dkoes_rescoring': 'SMINA_FAST_DKOES',
        'smina_ad4_scoring_rescoring': 'SMINA_SCORING_AD4',
        # Handle alternative SMINA key formats (from actual rescoring output)
        'smina_dkoes_fast': 'SMINA_FAST_DKOES',
        'smina_dkoes_scoring_old': 'SMINA_OLD_SCORING_DKOES',
        'smina_dkoes_fast_rescoring': 'SMINA_FAST_DKOES',
        'smina_dkoes_scoring_old_rescoring': 'SMINA_OLD_SCORING_DKOES',
        # PLANTS mappings
        'plants_chemplp': 'PLANTS_CHEMPLP',
        'plants_plp': 'PLANTS_PLP',
        'plants_plp95': 'PLANTS_PLP95',
        # GNINA mappings
        'gnina_vina_rescoring': 'GNINA_VINA',
        'gnina_vinardo_rescoring': 'GNINA_VINARDO',
        'gnina_dkoes_scoring_rescoring': 'GNINA_SCORING_DKOES',
        'gnina_scoring_dkoes_rescoring': 'GNINA_SCORING_DKOES',  # Alternative format
        'gnina_old_scoring_dkoes_rescoring': 'GNINA_OLD_SCORING_DKOES',
        'gnina_fast_dkoes_rescoring': 'GNINA_FAST_DKOES',
        'gnina_ad4_scoring_rescoring': 'GNINA_SCORING_AD4',
        'gnina_dkoes_fast': 'GNINA_FAST_DKOES',
        'gnina_dkoes_scoring_old': 'GNINA_OLD_SCORING_DKOES',
        'gnina_dkoes_fast_rescoring': 'GNINA_FAST_DKOES',
        'gnina_dkoes_scoring_old_rescoring': 'GNINA_OLD_SCORING_DKOES',
        # ODDT mappings (these come from the dataframe columns, already prefixed with oddt_)
        'oddt_rfscore_v1': 'ODDT_RFSCORE_V1',
        'oddt_rfscore_v2': 'ODDT_RFSCORE_V2',
        'oddt_rfscore_v3': 'ODDT_RFSCORE_V3',
        'oddt_plecrf_p5_l1_s65536': 'ODDT_PLECRF_P5_L1_S65536',
        'oddt_plec_p5_l1_s65536': 'ODDT_PLECRF_P5_L1_S65536',  # Alternative naming
        'oddt_nnscore': 'ODDT_NNSCORE',
    }
    
    # Check if exact match exists
    if key_lower in mapping:
        return mapping[key_lower]
    
    # Handle ODDT keys that are already prefixed with oddt_
    if key_lower.startswith('oddt_'):
        # Remove oddt_ prefix
        inner_key = key_lower[5:]  # Remove 'oddt_'
        # Try to match with known ODDT patterns
        if inner_key.startswith('rfscore_v'):
            # Extract version number (handle both 'rfscore_v1' and 'rfscorev1' formats)
            version = inner_key.replace('rfscore_v', '').replace('rfscorev', '')
            return f'ODDT_RFSCORE_V{version.upper()}'
        elif 'plec' in inner_key.lower():
            # Handle PLEC variations (case-insensitive)
            inner_key_lower = inner_key.lower()
            if 'p5_l1_s65536' in inner_key_lower or 'p5l1s65536' in inner_key_lower:
                return 'ODDT_PLECRF_P5_L1_S65536'
        elif inner_key.lower() == 'nnscore':
            return 'ODDT_NNSCORE'
    
    # Handle new format: rescoring_{scoring_function}_{pose_number} or rescoring_{pose_number}
    if key_lower.startswith('rescoring_'):
        # Extract scoring function and pose number
        # Format: rescoring_{scoring_function}_{pose_number} or rescoring_{pose_number}
        parts = key_lower.split('_')
        if len(parts) >= 2:
            # Check if last part is a number (pose number)
            if parts[-1].isdigit():
                pose_number = parts[-1]
                # Remove 'rescoring' and pose number, rest is scoring function
                scoring_function_parts = parts[1:-1]
            else:
                # No pose number, just scoring function after 'rescoring'
                scoring_function_parts = parts[1:]
            
            if scoring_function_parts:
                # Reconstruct scoring function name
                scoring_function = '_'.join(scoring_function_parts)
                
                # Use provided engine if available, otherwise try to detect
                engines_to_try = [engine] if engine else ['vina', 'smina', 'gnina']
                
                # Try to match with known formats
                for eng in engines_to_try:
                    test_key_old = f'{eng}_{scoring_function}_rescoring'
                    test_key_new = f'{eng}_{scoring_function}'
                    if test_key_old in mapping:
                        return mapping[test_key_old]
                    if test_key_new in mapping:
                        return mapping[test_key_new]
                
                # If not found in mapping, construct based on engine or scoring function
                if engine:
                    # Use provided engine
                    if engine == 'vina':
                        return f'VINA_{scoring_function.upper()}'
                    elif engine == 'gnina':
                        sf_mapping = {
                            'dkoes_scoring': 'SCORING_DKOES',
                            'scoring_dkoes': 'SCORING_DKOES',
                            'old_scoring_dkoes': 'OLD_SCORING_DKOES',
                            'dkoes_scoring_old': 'OLD_SCORING_DKOES',
                            'fast_dkoes': 'FAST_DKOES',
                            'ad4_scoring': 'SCORING_AD4',
                        }
                        if scoring_function in sf_mapping:
                            return f'GNINA_{sf_mapping[scoring_function]}'
                        return f'GNINA_{scoring_function.upper()}'
                    elif engine == 'smina':
                        sf_mapping = {
                            'dkoes_scoring': 'SCORING_DKOES',
                            'scoring_dkoes': 'SCORING_DKOES',
                            'old_scoring_dkoes': 'OLD_SCORING_DKOES',
                            'dkoes_scoring_old': 'OLD_SCORING_DKOES',
                            'fast_dkoes': 'FAST_DKOES',
                            'ad4_scoring': 'SCORING_AD4',
                        }
                        if scoring_function in sf_mapping:
                            return f'SMINA_{sf_mapping[scoring_function]}'
                        return f'SMINA_{scoring_function.upper()}'
                else:
                    # Try to detect engine from scoring function
                    if scoring_function in ['vina', 'vinardo']:
                        return f'VINA_{scoring_function.upper()}'
                    else:
                        # Assume smina for other scoring functions
                        sf_mapping = {
                            'dkoes_scoring': 'SCORING_DKOES',
                            'scoring_dkoes': 'SCORING_DKOES',
                            'old_scoring_dkoes': 'OLD_SCORING_DKOES',
                            'dkoes_scoring_old': 'OLD_SCORING_DKOES',
                            'fast_dkoes': 'FAST_DKOES',
                            'ad4_scoring': 'SCORING_AD4',
                        }
                        if scoring_function in sf_mapping:
                            return f'SMINA_{sf_mapping[scoring_function]}'
                        return f'SMINA_{scoring_function.upper()}'
            else:
                # Just 'rescoring_{pose_number}' - no scoring function specified
                # Use engine if provided, otherwise default to vina_vina
                if engine == 'smina':
                    return 'SMINA_VINARDO'  # Default SMINA scoring function
                if engine == 'gnina':
                    return 'GNINA_VINARDO'  # Default GNINA scoring function
                return 'VINA_VINA'  # Default VINA scoring function
    
    # Handle old format: VINA/SMINA rescoring keys (remove _rescoring suffix if present)
    if key_lower.endswith('_rescoring'):
        key_without_suffix = key_lower[:-10]  # Remove '_rescoring'
        if key_without_suffix in mapping:
            return mapping[key_without_suffix]
        # Try to construct the mapping
        if key_without_suffix.startswith('vina_'):
            sf = key_without_suffix.replace('vina_', '')
            return f'VINA_{sf.upper()}'
        elif key_without_suffix.startswith('gnina_'):
            sf = key_without_suffix.replace('gnina_', '')
            sf_mapping = {
                'dkoes_scoring': 'SCORING_DKOES',
                'scoring_dkoes': 'SCORING_DKOES',
                'old_scoring_dkoes': 'OLD_SCORING_DKOES',
                'dkoes_scoring_old': 'OLD_SCORING_DKOES',
                'fast_dkoes': 'FAST_DKOES',
                'dkoes_fast': 'FAST_DKOES',
                'ad4_scoring': 'SCORING_AD4',
                'vina': 'VINA',
                'vinardo': 'VINARDO',
            }
            if sf in sf_mapping:
                return f'GNINA_{sf_mapping[sf]}'
            return f'GNINA_{sf.upper()}'
        elif key_without_suffix.startswith('smina_'):
            sf = key_without_suffix.replace('smina_', '')
            # Handle special SMINA scoring function names
            # !!! CRITICAL: These must match SCORING_FUNCTION_ORDER !!!
            sf_mapping = {
                'dkoes_scoring': 'SCORING_DKOES',
                'scoring_dkoes': 'SCORING_DKOES',  # Alternative format
                'old_scoring_dkoes': 'OLD_SCORING_DKOES',
                'dkoes_scoring_old': 'OLD_SCORING_DKOES',  # Alternative format
                'fast_dkoes': 'FAST_DKOES',
                'dkoes_fast': 'FAST_DKOES',  # Alternative format
                'ad4_scoring': 'SCORING_AD4',
                'vina': 'VINA',
                'vinardo': 'VINARDO',
            }
            if sf in sf_mapping:
                return f'SMINA_{sf_mapping[sf]}'
            else:
                return f'SMINA_{sf.upper()}'
    
    # If no mapping found, return uppercase version of the key
    return key.upper()


CHECKPOINT_STAGE_DOCKING = "docking"
CHECKPOINT_STAGE_RESCORING = "rescoring"
CHECKPOINT_STAGE_FEATURES = "features"


def _enabled_docking_engines() -> Tuple[str, ...]:
    engines = []
    if RUN_VINA_DOCKING:
        engines.append("vina")
    if RUN_PLANTS_DOCKING:
        engines.append("plants")
    if RUN_GNINA_DOCKING:
        engines.append("gnina")
    return tuple(engines)


def _enabled_rescoring_engines() -> Tuple[str, ...]:
    engines = []
    if RUN_ODDT_RESCORING:
        engines.append("oddt")
    if RUN_PLANTS_RESCORING:
        engines.append("plants")
    if RUN_VINA_RESCORING:
        engines.append("vina")
    if RUN_SMINA_RESCORING:
        engines.append("smina")
    if RUN_GNINA_RESCORING:
        engines.append("gnina")
    return tuple(engines)


def _validate_pipeline_config() -> None:
    if not _enabled_rescoring_engines():
        raise ValueError(
            "At least one rescoring engine must be enabled (ODDT, PLANTS, Vina, SMINA, or GNINA)."
        )
    if RUN_ODDT_RESCORING:
        has_prepared_receptor = bool(PREPARED_RECEPTOR_PDBQT and os.path.isfile(PREPARED_RECEPTOR_PDBQT))
        has_pdbqt_preparer = any([
            RUN_VINA_DOCKING,
            RUN_VINA_RESCORING,
            RUN_SMINA_RESCORING,
            RUN_GNINA_DOCKING,
            RUN_GNINA_RESCORING,
        ])
        if not has_prepared_receptor and not has_pdbqt_preparer:
            raise ValueError(
                "ODDT rescoring requires PREPARED_RECEPTOR_PDBQT or a PDBQT-capable engine "
                "(Vina, SMINA, or GNINA docking/rescoring)."
            )


def _docking_outputs_ready(vina_poses: list, plants_poses: list, gnina_poses: list) -> bool:
    if not _enabled_docking_engines():
        return bool(vina_poses or plants_poses or gnina_poses)
    ready = True
    if RUN_VINA_DOCKING:
        ready = ready and bool(vina_poses)
    if RUN_PLANTS_DOCKING:
        ready = ready and bool(plants_poses)
    if RUN_GNINA_DOCKING:
        ready = ready and bool(gnina_poses)
    return ready


def _checkpoint_medoid(checkpoint: Dict[str, Any]) -> Optional[str]:
    stage_data = checkpoint.get("stage_data", {})
    if not isinstance(stage_data, dict):
        return None
    rescoring_data = stage_data.get(CHECKPOINT_STAGE_RESCORING, {})
    if not isinstance(rescoring_data, dict):
        return None
    medoid = rescoring_data.get("medoid")
    if isinstance(medoid, str) and medoid and os.path.isfile(medoid):
        return medoid
    return None


def _rescoring_engine_outputs_present(
    ligand_path: str,
    ligand_name: str,
    engine: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> bool:
    if engine == "oddt":
        return os.path.isfile(os.path.join(ligand_path, "oddt", f"{ligand_name}.csv"))
    if engine == "plants":
        if plants_ligand is None:
            return False
        try:
            return bool(plants_ligand.read_rescore_logs(f"{ligand_path}/plantsFiles"))
        except Exception:
            return False
    if engine == "vina":
        if vina_ligand is None:
            return False
        try:
            return bool(vina_ligand.read_rescore_logs(f"{ligand_path}/vinaFiles/rescoring"))
        except Exception:
            return False
    if engine == "smina":
        if smina_ligand is None:
            return False
        try:
            return bool(smina_ligand.read_rescore_logs(f"{ligand_path}/sminaFiles/rescoring"))
        except Exception:
            return False
    if engine == "gnina":
        if gnina_ligand is None:
            return False
        try:
            return bool(gnina_ligand.read_rescore_logs(f"{ligand_path}/gninaFiles/rescoring"))
        except Exception:
            return False
    return False


def _enabled_rescoring_outputs_present(
    ligand_path: str,
    ligand_name: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> bool:
    enabled = _enabled_rescoring_engines()
    if not enabled:
        return True
    return all(
        _rescoring_engine_outputs_present(
            ligand_path,
            ligand_name,
            engine,
            vina_ligand,
            plants_ligand,
            smina_ligand,
            gnina_ligand,
        )
        for engine in enabled
    )


def _read_preserved_rescoring_outputs(
    ligand_path: str,
    ligand_name: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> Dict[str, Any]:
    """Read on-disk rescoring for engines that are disabled this run."""
    if not MERGE_EXISTING_RESCORING:
        return {}

    preserved_engines = tuple(
        engine for engine in ("oddt", "plants", "vina", "smina", "gnina")
        if engine not in _enabled_rescoring_engines()
    )
    if not preserved_engines:
        return {}

    preserved: Dict[str, Any] = {}
    for engine in preserved_engines:
        if not _rescoring_engine_outputs_present(
            ligand_path,
            ligand_name,
            engine,
            vina_ligand,
            plants_ligand,
            smina_ligand,
            gnina_ligand,
        ):
            continue
        partial = _read_single_engine_rescoring(
            ligand_path,
            ligand_name,
            engine,
            vina_ligand,
            plants_ligand,
            smina_ligand,
            gnina_ligand,
        )
        if partial:
            preserved.update(partial)
    return preserved


def _read_single_engine_rescoring(
    ligand_path: str,
    ligand_name: str,
    engine: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> Dict[str, Any]:
    result: Dict[str, Any] = {}
    if engine == "oddt":
        oddt_csv = os.path.join(ligand_path, "oddt", f"{ligand_name}.csv")
        if not os.path.isfile(oddt_csv):
            return result
        try:
            oddt_df = pd.read_csv(oddt_csv)
            oddt_dict = ocoddt.df_to_dict(oddt_df)
            if not oddt_dict:
                return result
            first_key = list(oddt_dict.keys())[0]
            oddt_scores = oddt_dict[first_key]
            if isinstance(oddt_scores, dict):
                for key, value in oddt_scores.items():
                    result[map_rescoring_key_to_db_column(f"oddt_{key}")] = value
        except Exception:
            return result
    elif engine == "plants" and plants_ligand is not None:
        try:
            plants_rescoring = plants_ligand.read_rescore_logs(f"{ligand_path}/plantsFiles")
            if plants_rescoring:
                _add_plants_scores_to_rescoring_result(plants_rescoring, result)
        except Exception:
            return result
    elif engine == "vina" and vina_ligand is not None:
        try:
            vina_rescoring = vina_ligand.read_rescore_logs(f"{ligand_path}/vinaFiles/rescoring")
            for key, value in (vina_rescoring or {}).items():
                result[map_rescoring_key_to_db_column(key, engine="vina")] = _value_from_rescore_entry(value)
        except Exception:
            return result
    elif engine == "smina" and smina_ligand is not None:
        try:
            smina_rescoring = smina_ligand.read_rescore_logs(f"{ligand_path}/sminaFiles/rescoring")
            for key, value in (smina_rescoring or {}).items():
                result[map_rescoring_key_to_db_column(key, engine="smina")] = _value_from_rescore_entry(value)
        except Exception:
            return result
    elif engine == "gnina" and gnina_ligand is not None:
        try:
            gnina_rescoring = gnina_ligand.read_rescore_logs(f"{ligand_path}/gninaFiles/rescoring")
            for key, value in (gnina_rescoring or {}).items():
                result[map_rescoring_key_to_db_column(key, engine="gnina")] = _value_from_rescore_entry(value)
        except Exception:
            return result
    return _normalize_sf_names(result)


def _merge_rescoring_results(*parts: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    merged: Dict[str, Any] = {}
    for part in parts:
        if isinstance(part, dict):
            merged.update(part)
    return merged


def _engine_output_dir_exists(ligand_path: str, engine: str) -> bool:
    dirs = {
        "vina": f"{ligand_path}/vinaFiles",
        "plants": f"{ligand_path}/plantsFiles",
        "gnina": f"{ligand_path}/gninaFiles",
        "smina": f"{ligand_path}/sminaFiles",
    }
    path = dirs.get(engine, "")
    return bool(path and os.path.isdir(path))


def _prepare_receptor_pdbqt_for_oddt(
    vina_ligand: Optional[ocvina.Vina],
    smina_ligand: Optional[ocsmina.Smina],
    gnina_ligand: Optional[ocgnina.Gnina],
) -> str:
    if PREPARED_RECEPTOR_PDBQT and os.path.isfile(PREPARED_RECEPTOR_PDBQT):
        return PREPARED_RECEPTOR_PDBQT
    for ligand_obj in (vina_ligand, smina_ligand, gnina_ligand):
        if ligand_obj is None:
            continue
        ligand_obj.run_prepare_receptor(overwrite=False)
        prepared = getattr(ligand_obj, "prepared_receptor", "")
        if prepared and os.path.isfile(prepared):
            return prepared
    raise ValueError(
        "ODDT rescoring requires a prepared receptor (PDBQT). "
        "Set PREPARED_RECEPTOR_PDBQT or enable Vina, SMINA, or GNINA rescoring."
    )


def _checkpoint_path(ligand_path: str) -> str:
    return os.path.join(ligand_path, LIGAND_CHECKPOINT_FILE)


def _features_cache_path(ligand_path: str) -> str:
    return os.path.join(ligand_path, LIGAND_FEATURES_CACHE_FILE)


def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
    tmp_path = f"{path}.tmp"
    with open(tmp_path, "w", encoding="utf-8") as handle:
        json.dump(payload, handle, indent=2, sort_keys=True, default=_json_default)
    os.replace(tmp_path, path)


def _json_default(value: Any) -> Any:
    if isinstance(value, np.generic):
        return value.item()
    if isinstance(value, (set, tuple)):
        return list(value)
    return str(value)


def _default_checkpoint(ligand_name: str) -> Dict[str, Any]:
    return {
        "version": 1,
        "ligand": ligand_name,
        "status": "new",
        "completed_stages": [],
        "stage_data": {},
        "failed_stage": "",
        "last_error": "",
        "updated_at": time.time(),
    }


def _load_checkpoint(ligand_path: str, ligand_name: str) -> Dict[str, Any]:
    checkpoint = _default_checkpoint(ligand_name)
    if not ENABLE_LIGAND_CHECKPOINT:
        return checkpoint

    path = _checkpoint_path(ligand_path)
    if not os.path.isfile(path):
        return checkpoint

    try:
        with open(path, "r", encoding="utf-8") as handle:
            loaded = json.load(handle)
    except Exception:
        return checkpoint

    if not isinstance(loaded, dict):
        return checkpoint

    checkpoint["status"] = str(loaded.get("status", checkpoint["status"]))
    checkpoint["failed_stage"] = str(loaded.get("failed_stage", ""))
    checkpoint["last_error"] = str(loaded.get("last_error", ""))
    checkpoint["updated_at"] = loaded.get("updated_at", checkpoint["updated_at"])

    completed = loaded.get("completed_stages", [])
    if isinstance(completed, list):
        checkpoint["completed_stages"] = sorted({str(stage) for stage in completed})

    stage_data = loaded.get("stage_data", {})
    if isinstance(stage_data, dict):
        checkpoint["stage_data"] = stage_data

    return checkpoint


def _save_checkpoint(ligand_path: str, checkpoint: Dict[str, Any]) -> None:
    if not ENABLE_LIGAND_CHECKPOINT:
        return
    checkpoint["updated_at"] = time.time()
    _write_json_atomic(_checkpoint_path(ligand_path), checkpoint)


def _is_stage_complete(checkpoint: Dict[str, Any], stage: str) -> bool:
    completed = checkpoint.get("completed_stages", [])
    return stage in completed if isinstance(completed, list) else False


def _clear_stage(checkpoint: Dict[str, Any], stage: str) -> None:
    completed = checkpoint.get("completed_stages", [])
    if isinstance(completed, list):
        checkpoint["completed_stages"] = sorted([st for st in completed if st != stage])
    stage_data = checkpoint.get("stage_data", {})
    if isinstance(stage_data, dict):
        stage_data.pop(stage, None)
    checkpoint["status"] = "in_progress"


def _mark_stage_complete(checkpoint: Dict[str, Any], stage: str, stage_data: Optional[Dict[str, Any]] = None) -> None:
    completed = checkpoint.get("completed_stages", [])
    completed_set = set(completed if isinstance(completed, list) else [])
    completed_set.add(stage)
    checkpoint["completed_stages"] = sorted(completed_set)
    checkpoint["status"] = "in_progress" if stage != CHECKPOINT_STAGE_FEATURES else "completed"
    checkpoint["failed_stage"] = ""
    checkpoint["last_error"] = ""
    if stage_data:
        checkpoint.setdefault("stage_data", {})[stage] = stage_data


def _mark_stage_failed(checkpoint: Dict[str, Any], stage: str, message: str) -> None:
    checkpoint["status"] = "failed"
    checkpoint["failed_stage"] = stage
    checkpoint["last_error"] = message


def _load_cached_features(ligand_path: str) -> Optional[Dict[str, Any]]:
    path = _features_cache_path(ligand_path)
    if not os.path.isfile(path):
        return None
    try:
        with open(path, "r", encoding="utf-8") as handle:
            payload = json.load(handle)
        if isinstance(payload, dict):
            return payload
    except Exception:
        return None
    return None


def _save_cached_features(ligand_path: str, features: Dict[str, Any]) -> None:
    if not ENABLE_LIGAND_CHECKPOINT:
        return
    _write_json_atomic(_features_cache_path(ligand_path), features)


def _normalize_sf_names(rescoring_result: Dict[str, Any]) -> Dict[str, Any]:
    sf_name_corrections = {
        "SMINA_DKOES_FAST": "SMINA_FAST_DKOES",
        "SMINA_DKOES_SCORING_OLD": "SMINA_OLD_SCORING_DKOES",
        "SMINA_SCORING_DKOES_OLD": "SMINA_OLD_SCORING_DKOES",
        "SMINA_FAST_DKOES_RESCORING": "SMINA_FAST_DKOES",
        "SMINA_OLD_SCORING_DKOES_RESCORING": "SMINA_OLD_SCORING_DKOES",
    }
    for old_name, correct_name in sf_name_corrections.items():
        if old_name in rescoring_result and correct_name not in rescoring_result:
            rescoring_result[correct_name] = rescoring_result.pop(old_name)
    return rescoring_result


def _value_from_rescore_entry(value: Any) -> Any:
    if isinstance(value, list) and len(value) > 0:
        return value[0]
    return value


def _add_plants_scores_to_rescoring_result(plants_rescoring: Dict[str, Any], rescoring_result: Dict[str, Any]) -> None:
    for outer_key, inner_dict in plants_rescoring.items():
        db_column_name = map_rescoring_key_to_db_column(outer_key)

        if isinstance(inner_dict, dict):
            if "PLANTS_TOTAL_SCORE" in inner_dict:
                total_score = inner_dict["PLANTS_TOTAL_SCORE"]
                rescoring_result[db_column_name] = _value_from_rescore_entry(total_score)
            else:
                print(
                    f"Warning: PLANTS_TOTAL_SCORE not found in inner dict for {outer_key}. "
                    f"Available keys: {list(inner_dict.keys())}"
                )
        else:
            rescoring_result[db_column_name] = _value_from_rescore_entry(inner_dict)


def _read_rescoring_outputs(
    ligand_path: str,
    ligand_name: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
    engines: Optional[Tuple[str, ...]] = None,
) -> Optional[Dict[str, Any]]:
    enabled = engines or _enabled_rescoring_engines()
    if not enabled:
        return {}

    rescoring_result: Dict[str, Any] = {}
    for engine in enabled:
        partial = _read_single_engine_rescoring(
            ligand_path,
            ligand_name,
            engine,
            vina_ligand,
            plants_ligand,
            smina_ligand,
            gnina_ligand,
        )
        if not partial:
            return None
        rescoring_result.update(partial)

    if not rescoring_result:
        return None

    return _normalize_sf_names(rescoring_result)


def _run_vina_docking(ligand_path: str, vina_ligand: ocvina.Vina) -> list:
    vina_ligand.run_prepare_receptor(overwrite=True)
    vina_ligand.run_prepare_ligand(overwrite=True)
    vina_ligand.run_docking(overwrite=True)
    vina_ligand.split_poses()

    vina_poses_dir = (
        os.path.dirname(vina_ligand.output_vina)
        if hasattr(vina_ligand, "output_vina")
        else f"{ligand_path}/vinaFiles"
    )
    vina_pattern = f"{vina_poses_dir}/*_split_*.pdbqt"
    pose_files_found = False
    for _ in range(50):
        found_files = glob(vina_pattern)
        if found_files and wait_for_files_ready(found_files, max_wait=2.0):
            pose_files_found = True
            break
        time.sleep(0.2)
    if not pose_files_found:
        print("Warning: No stable pose files found for Vina after waiting, proceeding anyway...")
    time.sleep(0.5)
    vina_poses = vina_ligand.get_docked_poses()
    if vina_poses and not wait_for_files_ready(vina_poses, max_wait=5.0):
        print("Warning: Some Vina pose files may not be fully ready, but proceeding...")
    return vina_poses or []


def _run_plants_docking(ligand_path: str, plants_ligand: ocplants.PLANTS) -> list:
    plants_ligand.run_prepare_receptor(overwrite=True)
    plants_ligand.run_prepare_ligand(overwrite=True)
    plants_ligand.run_docking(overwrite=True)

    plants_output_dir = (
        plants_ligand.output_plants
        if hasattr(plants_ligand, "output_plants")
        else f"{ligand_path}/plantsFiles"
    )
    plants_run_dir = os.path.join(plants_output_dir, "run")
    plants_pattern = f"{plants_run_dir}/*.mol2"
    plants_files_found = False
    for _ in range(100):
        found_files = [
            f for f in glob(plants_pattern)
            if not f.endswith("_protein.mol2") and not f.endswith("_fixed.mol2")
        ]
        if found_files and wait_for_files_ready(found_files, max_wait=2.0):
            plants_files_found = True
            break
        time.sleep(0.2)
    if not plants_files_found:
        print("Warning: No stable PLANTS output files found after waiting, proceeding anyway...")

    time.sleep(0.5)
    plants_poses = plants_ligand.get_docked_poses()
    if plants_poses and not wait_for_files_ready(plants_poses, max_wait=3.0):
        print("Warning: Some PLANTS pose files may not be fully ready, but proceeding...")
    return plants_poses or []


def _run_gnina_docking(ligand_path: str, gnina_ligand: ocgnina.Gnina) -> list:
    gnina_ligand.run_prepare_receptor(overwrite=True)
    gnina_ligand.run_prepare_ligand(overwrite=True)
    gnina_ligand.run_docking(overwrite=True)
    gnina_ligand.split_poses()

    gnina_poses_dir = (
        os.path.dirname(gnina_ligand.output_gnina)
        if hasattr(gnina_ligand, "output_gnina")
        else f"{ligand_path}/gninaFiles"
    )
    gnina_pattern = f"{gnina_poses_dir}/*_split_*.pdbqt"
    pose_files_found = False
    for _ in range(50):
        found_files = glob(gnina_pattern)
        if found_files and wait_for_files_ready(found_files, max_wait=2.0):
            pose_files_found = True
            break
        time.sleep(0.2)
    if not pose_files_found:
        print("Warning: No stable pose files found for GNINA after waiting, proceeding anyway...")
    time.sleep(0.5)
    gnina_poses = gnina_ligand.get_docked_poses()
    if gnina_poses and not wait_for_files_ready(gnina_poses, max_wait=5.0):
        print("Warning: Some GNINA pose files may not be fully ready, but proceeding...")
    return gnina_poses or []


def _run_docking_stage(
    ligand_path: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> Tuple[list, list, list]:
    vina_poses: list = []
    plants_poses: list = []
    gnina_poses: list = []

    if RUN_VINA_DOCKING:
        if vina_ligand is None:
            raise ValueError("Vina docking is enabled but no Vina ligand object was provided.")
        vina_poses = _run_vina_docking(ligand_path, vina_ligand)
        if not vina_poses:
            raise ValueError("Vina docking did not generate valid pose files.")

    if RUN_PLANTS_DOCKING:
        if plants_ligand is None:
            raise ValueError("PLANTS docking is enabled but no PLANTS ligand object was provided.")
        plants_poses = _run_plants_docking(ligand_path, plants_ligand)
        if not plants_poses:
            raise ValueError("PLANTS docking did not generate valid pose files.")

    if RUN_GNINA_DOCKING:
        if gnina_ligand is None:
            raise ValueError("GNINA docking is enabled but no GNINA ligand object was provided.")
        gnina_poses = _run_gnina_docking(ligand_path, gnina_ligand)
        if not gnina_poses:
            raise ValueError("GNINA docking did not generate valid pose files.")

    if not _docking_outputs_ready(vina_poses, plants_poses, gnina_poses):
        raise ValueError(
            f"Docking did not generate valid pose files for enabled engines: {', '.join(_enabled_docking_engines())}."
        )

    return vina_poses, plants_poses, gnina_poses


def _load_existing_docking_poses(
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> Tuple[list, list, list]:
    vina_poses: list = []
    plants_poses: list = []
    gnina_poses: list = []

    if vina_ligand is not None:
        vina_poses = vina_ligand.get_docked_poses() or []
        if vina_poses:
            _ = wait_for_files_ready(vina_poses, max_wait=2.0)
    if plants_ligand is not None:
        plants_poses = plants_ligand.get_docked_poses() or []
        if plants_poses:
            _ = wait_for_files_ready(plants_poses, max_wait=2.0)
    if gnina_ligand is not None:
        gnina_poses = gnina_ligand.get_docked_poses() or []
        if gnina_poses:
            _ = wait_for_files_ready(gnina_poses, max_wait=2.0)

    return vina_poses, plants_poses, gnina_poses


def _select_representative_medoid(
    ligand_path: str,
    ligand_name: str,
    vina_poses: list,
    plants_poses: list,
    vina_ligand: ocvina.Vina,
    plants_ligand: ocplants.PLANTS,
    gnina_poses: Optional[list] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> str:
    gnina_poses = gnina_poses or []
    poses_list = vina_poses + plants_poses + gnina_poses

    valid_poses = []
    max_retries = 5
    retry_delay = 0.3
    for pose_file in poses_list:
        validated = False
        for attempt in range(max_retries):
            if validate_molecule_file(pose_file):
                valid_poses.append(pose_file)
                validated = True
                break
            if attempt < max_retries - 1:
                time.sleep(retry_delay * (attempt + 1))
        if not validated:
            print(f"Warning: Could not validate pose file {pose_file} after {max_retries} attempts, skipping.")

    if not valid_poses:
        raise ValueError(f"No valid pose files found for ligand {ligand_name} after validation")
    if len(valid_poses) == 1:
        return valid_poses[0]
    if len(valid_poses) < 2:
        raise ValueError(f"Need at least 2 valid poses for RMSD calculation, found {len(valid_poses)} for ligand {ligand_name}")

    mol2_poses_dir = f"{ligand_path}/poses_mol2"
    os.makedirs(mol2_poses_dir, exist_ok=True)
    mol2_poses = []
    mol2_to_original_map = {}

    for pose_file in valid_poses:
        pose_ext = os.path.splitext(pose_file)[1].lower()
        pose_basename = os.path.basename(pose_file)

        if pose_ext == ".mol2":
            mol2_poses.append(pose_file)
            mol2_to_original_map[pose_file] = pose_file
        else:
            mol2_path = os.path.join(mol2_poses_dir, f"{os.path.splitext(pose_basename)[0]}.mol2")
            occonversion.convert_mols(pose_file, mol2_path, overwrite=True)
            if wait_for_file_stable(mol2_path, max_wait=3.0):
                mol2_poses.append(mol2_path)
                mol2_to_original_map[mol2_path] = pose_file
            else:
                print(f"Warning: Could not convert {pose_file} to MOL2 format, skipping for RMSD calculation")

    if len(mol2_poses) == 1:
        return mol2_to_original_map.get(mol2_poses[0], mol2_poses[0])
    if len(mol2_poses) < 2:
        raise ValueError(f"Need at least 2 valid MOL2 poses for RMSD calculation, found {len(mol2_poses)} for ligand {ligand_name}")
    if not wait_for_files_ready(mol2_poses, max_wait=5.0):
        print("Warning: Some MOL2 pose files may not be fully ready for RMSD calculation, but proceeding...")
    time.sleep(0.5)

    rmsd_matrix = ocmolproc.get_rmsd_matrix(mol2_poses)

    pose_engine_map = {}
    for mol2_pose in mol2_poses:
        original_pose = mol2_to_original_map.get(mol2_pose, mol2_pose)
        if original_pose in vina_poses:
            pose_engine_map[mol2_pose] = "vina"
        elif original_pose in plants_poses:
            pose_engine_map[mol2_pose] = "plants"
        elif original_pose in gnina_poses:
            pose_engine_map[mol2_pose] = "gnina"

    clusters = ocrmsdclust.cluster_rmsd(
        rmsd_matrix,
        algorithm="agglomerativeClustering",
        outputPlot=f"{ligand_path}/medoids.png",
        pose_engine_map=pose_engine_map,
    )
    medoids_mol2 = ocrmsdclust.get_medoids(rmsd_matrix, clusters, onlyBiggest=True)
    medoids = [mol2_to_original_map.get(medoid_mol2, medoid_mol2) for medoid_mol2 in medoids_mol2]

    medoids_dict = {}
    for medoid in medoids:
        if medoid in vina_poses:
            medoids_dict[medoid] = vina_ligand.read_log(onlyBest=False)[ocvina.get_pose_index_from_file_path(medoid)]
        elif medoid in plants_poses:
            medoids_dict[medoid] = plants_ligand.read_log(onlyBest=False)[ocplants.get_pose_index_from_file_path(medoid)]
        elif medoid in gnina_poses and gnina_ligand is not None:
            medoids_dict[medoid] = gnina_ligand.read_log(onlyBest=False)[ocgnina.get_pose_index_from_file_path(medoid)]

    if not medoids_dict:
        raise ValueError(f"Could not determine medoid for ligand {ligand_name}")

    return list(medoids_dict.keys())[0]


def _run_rescoring_stage(
    ligand_path: str,
    ligand_name: str,
    ligand: ocl.Ligand,
    medoid: str,
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> Dict[str, Any]:
    if not _enabled_rescoring_engines():
        return {}

    if RUN_ODDT_RESCORING:
        prepared_receptor = _prepare_receptor_pdbqt_for_oddt(vina_ligand, smina_ligand, gnina_ligand)
        oddt_outdir = os.path.join(ligand_path, "oddt")
        try:
            from joblib import parallel_backend
            with parallel_backend("threading"):
                _ = ocoddt.run_oddt(
                    prepared_receptor,
                    medoid,
                    ligand.name,
                    oddt_outdir,
                    overwrite=True,
                )
        except ImportError:
            _ = ocoddt.run_oddt(
                prepared_receptor,
                medoid,
                ligand.name,
                oddt_outdir,
                overwrite=True,
            )

    medoid_extension = os.path.splitext(medoid)[1].lower()

    if RUN_PLANTS_RESCORING:
        if plants_ligand is None:
            raise ValueError("PLANTS rescoring is enabled but no PLANTS ligand object was provided.")
        if medoid_extension != ".mol2":
            plants_input = medoid.replace(medoid_extension, ".mol2")
            occonversion.convert_mols(medoid, plants_input, overwrite=True)
        else:
            plants_input = medoid
        plants_pose_list = f"{ligand_path}/plantsFiles/plants_pose_list.txt"
        ocplants.write_pose_list(plants_input, plants_pose_list)
        plants_ligand.run_rescore(plants_pose_list, logFile="", overwrite=True)
        time.sleep(0.3)

    pdbqt_engines = RUN_VINA_RESCORING or RUN_SMINA_RESCORING or RUN_GNINA_RESCORING
    if pdbqt_engines:
        if medoid_extension != ".pdbqt":
            vina_smina_input = medoid.replace(medoid_extension, ".pdbqt")
            occonversion.convert_mols(medoid, vina_smina_input, overwrite=True)
        else:
            vina_smina_input = medoid

        if RUN_VINA_RESCORING:
            if vina_ligand is None:
                raise ValueError("Vina rescoring is enabled but no Vina ligand object was provided.")
            vina_ligand.run_rescore(
                f"{ligand_path}/vinaFiles/rescoring",
                vina_smina_input,
                overwrite=True,
                splitLigand=False,
            )
        if RUN_SMINA_RESCORING:
            if smina_ligand is None:
                raise ValueError("SMINA rescoring is enabled but no SMINA ligand object was provided.")
            smina_ligand.run_rescore(
                f"{ligand_path}/sminaFiles/rescoring",
                vina_smina_input,
                overwrite=True,
                splitLigand=False,
            )
        if RUN_GNINA_RESCORING:
            if gnina_ligand is None:
                raise ValueError("GNINA rescoring is enabled but no GNINA ligand object was provided.")
            gnina_ligand.run_rescore(
                f"{ligand_path}/gninaFiles/rescoring",
                vina_smina_input,
                overwrite=True,
                splitLigand=False,
            )

    rescoring_result = _read_rescoring_outputs(
        ligand_path,
        ligand_name,
        vina_ligand,
        plants_ligand,
        smina_ligand,
        gnina_ligand,
    )
    if rescoring_result is None:
        raise ValueError(
            f"Rescoring outputs are incomplete or invalid for ligand {ligand_name} "
            f"(enabled: {', '.join(_enabled_rescoring_engines())})."
        )
    return rescoring_result


def _infer_completed_stages(
    ligand_path: str,
    ligand_name: str,
    checkpoint: Dict[str, Any],
    vina_ligand: Optional[ocvina.Vina] = None,
    plants_ligand: Optional[ocplants.PLANTS] = None,
    smina_ligand: Optional[ocsmina.Smina] = None,
    gnina_ligand: Optional[ocgnina.Gnina] = None,
) -> None:
    if _is_stage_complete(checkpoint, CHECKPOINT_STAGE_FEATURES) and _is_stage_complete(checkpoint, CHECKPOINT_STAGE_RESCORING):
        return

    if (
        isinstance(_load_cached_features(ligand_path), dict)
        and _enabled_rescoring_outputs_present(
            ligand_path,
            ligand_name,
            vina_ligand,
            plants_ligand,
            smina_ligand,
            gnina_ligand,
        )
        and not _is_stage_complete(checkpoint, CHECKPOINT_STAGE_FEATURES)
    ):
        _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_FEATURES)

    if _enabled_rescoring_outputs_present(
        ligand_path,
        ligand_name,
        vina_ligand,
        plants_ligand,
        smina_ligand,
        gnina_ligand,
    ):
        if not _is_stage_complete(checkpoint, CHECKPOINT_STAGE_RESCORING):
            _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_RESCORING)
        if not _is_stage_complete(checkpoint, CHECKPOINT_STAGE_DOCKING):
            _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_DOCKING)
        return

    vina_poses, plants_poses, gnina_poses = _load_existing_docking_poses(vina_ligand, plants_ligand, gnina_ligand)
    if _docking_outputs_ready(vina_poses, plants_poses, gnina_poses) and not _is_stage_complete(checkpoint, CHECKPOINT_STAGE_DOCKING):
        _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_DOCKING)


def process_single_ligand(ligand_path: str, ligand_name: str, receptor: ocr.Receptor) -> Optional[dict]:
    ''' Process a single ligand through the complete OCScore pipeline with checkpoint/resume support.
    
    Parameters
    ----------
    ligand_path : str
        Base path to the ligand directory
    ligand_name : str
        Name of the ligand (without extension)
    receptor : ocr.Receptor
        Receptor object
    
    Returns
    -------
    dict | None
        Dictionary containing all features and OCScore prediction. None if processing fails.
    '''

    checkpoint: Optional[Dict[str, Any]] = None
    current_stage = "initialization"

    try:
        ligand = ocl.Ligand(f"{ligand_path}/{ligand_name}.smi", name=ligand_name)
        ligand.create_box(centroid=BOX_CENTER, save_path=f"{ligand_path}/boxes/")

        vina_ligand: Optional[ocvina.Vina] = None
        if (
            RUN_VINA_DOCKING
            or RUN_VINA_RESCORING
            or (MERGE_EXISTING_RESCORING and _engine_output_dir_exists(ligand_path, "vina"))
        ):
            os.makedirs(f"{ligand_path}/vinaFiles", exist_ok=True)
            vina_ligand = ocvina.Vina(
                f"{ligand_path}/vinaFiles/conf_vina.txt",
                f"{ligand_path}/boxes/box0.pdb",
                receptor, PREPARED_RECEPTOR_PDBQT,
                ligand, f"{ligand_path}/prepared_ligand.pdbqt",
                f"{ligand_path}/vinaFiles/vina.log", f"{ligand_path}/vinaFiles/vina.pdbqt",
                name=f"Vina {receptor.name}-{ligand_name}",
            )

        plants_ligand: Optional[ocplants.PLANTS] = None
        if (
            RUN_PLANTS_DOCKING
            or RUN_PLANTS_RESCORING
            or (MERGE_EXISTING_RESCORING and _engine_output_dir_exists(ligand_path, "plants"))
        ):
            os.makedirs(f"{ligand_path}/plantsFiles", exist_ok=True)
            plants_ligand = ocplants.PLANTS(
                f"{ligand_path}/plantsFiles/conf_plants.txt",
                f"{ligand_path}/boxes/box0.pdb",
                receptor, PREPARED_RECEPTOR_MOL2,
                ligand, f"{ligand_path}/prepared_ligand.mol2",
                f"{ligand_path}/plantsFiles/plants.log", f"{ligand_path}/plantsFiles",
                name=f"Plants {receptor.name}-{ligand_name}",
            )

        smina_ligand: Optional[ocsmina.Smina] = None
        if RUN_SMINA_RESCORING or (
            MERGE_EXISTING_RESCORING and _engine_output_dir_exists(ligand_path, "smina")
        ):
            os.makedirs(f"{ligand_path}/sminaFiles", exist_ok=True)
            smina_ligand = ocsmina.Smina(
                f"{ligand_path}/sminaFiles/conf_smina.txt",
                f"{ligand_path}/boxes/box0.pdb",
                receptor, PREPARED_RECEPTOR_PDBQT,
                ligand, f"{ligand_path}/prepared_ligand.pdbqt",
                f"{ligand_path}/sminaFiles/smina.log", f"{ligand_path}/sminaFiles/smina.pdbqt",
                name=f"Smina {receptor.name}-{ligand_name}",
            )

        gnina_ligand: Optional[ocgnina.Gnina] = None
        if RUN_GNINA_DOCKING or RUN_GNINA_RESCORING:
            os.makedirs(f"{ligand_path}/gninaFiles", exist_ok=True)
            gnina_ligand = ocgnina.Gnina(
                f"{ligand_path}/gninaFiles/conf_gnina.txt",
                f"{ligand_path}/boxes/box0.pdb",
                receptor, PREPARED_RECEPTOR_PDBQT,
                ligand, f"{ligand_path}/prepared_ligand.pdbqt",
                f"{ligand_path}/gninaFiles/gnina.log", f"{ligand_path}/gninaFiles/gnina.pdbqt",
                name=f"Gnina {receptor.name}-{ligand_name}",
            )

        # ODDT-only: create a minimal PDBQT preparer when no prepared receptor path is configured
        if (
            RUN_ODDT_RESCORING
            and not (PREPARED_RECEPTOR_PDBQT and os.path.isfile(PREPARED_RECEPTOR_PDBQT))
            and vina_ligand is None
            and smina_ligand is None
            and gnina_ligand is None
        ):
            os.makedirs(f"{ligand_path}/vinaFiles", exist_ok=True)
            vina_ligand = ocvina.Vina(
                f"{ligand_path}/vinaFiles/conf_vina.txt",
                f"{ligand_path}/boxes/box0.pdb",
                receptor, PREPARED_RECEPTOR_PDBQT,
                ligand, f"{ligand_path}/prepared_ligand.pdbqt",
                f"{ligand_path}/vinaFiles/vina.log", f"{ligand_path}/vinaFiles/vina.pdbqt",
                name=f"Vina {receptor.name}-{ligand_name}",
            )

        current_stage = "checkpoint"
        checkpoint = _load_checkpoint(ligand_path, ligand_name)
        resume_enabled = ENABLE_LIGAND_CHECKPOINT
        if resume_enabled:
            _infer_completed_stages(
                ligand_path,
                ligand_name,
                checkpoint,
                vina_ligand,
                plants_ligand,
                smina_ligand,
                gnina_ligand,
            )
            _save_checkpoint(ligand_path, checkpoint)

        if resume_enabled and _is_stage_complete(checkpoint, CHECKPOINT_STAGE_FEATURES):
            cached_features = _load_cached_features(ligand_path)
            if (
                isinstance(cached_features, dict)
                and _enabled_rescoring_outputs_present(
                    ligand_path,
                    ligand_name,
                    vina_ligand,
                    plants_ligand,
                    smina_ligand,
                    gnina_ligand,
                )
            ):
                print(f"[{ligand_name}] Resume: using cached features.")
                return cached_features
            print(f"[{ligand_name}] Cached features are stale or missing newly enabled rescoring outputs. Refreshing.")
            _clear_stage(checkpoint, CHECKPOINT_STAGE_FEATURES)
            _save_checkpoint(ligand_path, checkpoint)

        rescoring_result: Optional[Dict[str, Any]] = None
        if resume_enabled and _is_stage_complete(checkpoint, CHECKPOINT_STAGE_RESCORING):
            if _enabled_rescoring_outputs_present(
                ligand_path,
                ligand_name,
                vina_ligand,
                plants_ligand,
                smina_ligand,
                gnina_ligand,
            ):
                rescoring_result = _read_rescoring_outputs(
                    ligand_path,
                    ligand_name,
                    vina_ligand,
                    plants_ligand,
                    smina_ligand,
                    gnina_ligand,
                )
                if rescoring_result is None:
                    print(f"[{ligand_name}] Checkpoint says rescoring complete, but outputs are missing/corrupted. Recomputing.")
                    _clear_stage(checkpoint, CHECKPOINT_STAGE_RESCORING)
                    _clear_stage(checkpoint, CHECKPOINT_STAGE_FEATURES)
                    _save_checkpoint(ligand_path, checkpoint)
                else:
                    print(f"[{ligand_name}] Resume: using existing rescoring outputs for enabled engines.")
            else:
                print(f"[{ligand_name}] Newly enabled rescoring engine(s) missing outputs. Running rescoring.")
                _clear_stage(checkpoint, CHECKPOINT_STAGE_RESCORING)
                _clear_stage(checkpoint, CHECKPOINT_STAGE_FEATURES)
                _save_checkpoint(ligand_path, checkpoint)

        if rescoring_result is None:
            vina_poses: list = []
            plants_poses: list = []
            gnina_poses: list = []
            medoid = _checkpoint_medoid(checkpoint)

            if medoid is None:
                if resume_enabled and _is_stage_complete(checkpoint, CHECKPOINT_STAGE_DOCKING):
                    vina_poses, plants_poses, gnina_poses = _load_existing_docking_poses(
                        vina_ligand,
                        plants_ligand,
                        gnina_ligand,
                    )
                    if not _docking_outputs_ready(vina_poses, plants_poses, gnina_poses):
                        print(f"[{ligand_name}] Checkpoint says docking complete, but poses are missing. Re-running docking.")
                        _clear_stage(checkpoint, CHECKPOINT_STAGE_DOCKING)
                        _save_checkpoint(ligand_path, checkpoint)
                    else:
                        print(f"[{ligand_name}] Resume: using existing docking outputs.")

                if not _docking_outputs_ready(vina_poses, plants_poses, gnina_poses):
                    if not _enabled_docking_engines():
                        vina_poses, plants_poses, gnina_poses = _load_existing_docking_poses(
                            vina_ligand,
                            plants_ligand,
                            gnina_ligand,
                        )
                    if not _docking_outputs_ready(vina_poses, plants_poses, gnina_poses):
                        if not _enabled_docking_engines():
                            raise ValueError(
                                f"No docking poses found for {ligand_name}. "
                                "Enable a docking engine or run docking first."
                            )
                        current_stage = CHECKPOINT_STAGE_DOCKING
                        vina_poses, plants_poses, gnina_poses = _run_docking_stage(
                            ligand_path,
                            vina_ligand,
                            plants_ligand,
                            gnina_ligand,
                        )
                        _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_DOCKING)
                        _save_checkpoint(ligand_path, checkpoint)

                current_stage = CHECKPOINT_STAGE_RESCORING
                medoid = _select_representative_medoid(
                    ligand_path,
                    ligand_name,
                    vina_poses,
                    plants_poses,
                    vina_ligand,
                    plants_ligand,
                    gnina_poses=gnina_poses,
                    gnina_ligand=gnina_ligand,
                )
            else:
                current_stage = CHECKPOINT_STAGE_RESCORING
                print(f"[{ligand_name}] Resume: using checkpoint medoid {medoid}.")

            rescoring_result = _run_rescoring_stage(
                ligand_path,
                ligand_name,
                ligand,
                medoid,
                vina_ligand,
                plants_ligand,
                smina_ligand,
                gnina_ligand,
            )
            _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_RESCORING, stage_data={"medoid": medoid})
            _save_checkpoint(ligand_path, checkpoint)

        preserved_rescoring = _read_preserved_rescoring_outputs(
            ligand_path,
            ligand_name,
            vina_ligand,
            plants_ligand,
            smina_ligand,
            gnina_ligand,
        )
        rescoring_result = _merge_rescoring_results(preserved_rescoring, rescoring_result)

        current_stage = CHECKPOINT_STAGE_FEATURES
        cached_features = _load_cached_features(ligand_path) if MERGE_EXISTING_RESCORING else None
        receptor_descriptors = receptor.get_descriptors()
        ligand_descriptors = ligand.get_descriptors()

        all_features: Dict[str, Any] = {}
        if isinstance(cached_features, dict):
            all_features.update(cached_features)
        all_features.update(rescoring_result if rescoring_result is not None else {})
        all_features.update(receptor_descriptors)
        all_features.update(ligand_descriptors)
        all_features["name"] = f"{receptor.name}_{ligand.name}"
        all_features["receptor"] = receptor.name
        all_features["ligand"] = ligand.name

        _save_cached_features(ligand_path, all_features)
        _mark_stage_complete(checkpoint, CHECKPOINT_STAGE_FEATURES, stage_data={"feature_count": len(all_features)})
        _save_checkpoint(ligand_path, checkpoint)
        return all_features

    except Exception as e:
        if checkpoint is not None:
            try:
                _mark_stage_failed(checkpoint, current_stage, str(e))
                _save_checkpoint(ligand_path, checkpoint)
            except Exception:
                pass
        print(f"Error processing ligand {ligand_name}: {e}")
        import traceback
        traceback.print_exc()
        return None


def validate_molecule_file(file_path: str) -> bool:
    '''Validate that a molecule file can be loaded and is complete.
    
    Parameters
    ----------
    file_path : str
        Path to the molecule file
    
    Returns
    -------
    bool
        True if file is valid and can be loaded
    '''

    from OCDocker.Toolbox import Validation as ocvalidation
    
    # First check if file is stable (not being written)
    if not wait_for_file_stable(file_path, max_wait=2.0):
        return False
    
    # Then validate the molecule structure
    try:
        return ocvalidation.is_molecule_valid(file_path)
    except Exception:
        return False


def wait_for_file_stable(file_path: str, max_wait: float = 5.0, check_interval: float = 0.1) -> bool:
    '''Wait for a file to stabilize (size stops changing).
    
    Parameters
    ----------
    file_path : str
        Path to the file to check
    max_wait : float
        Maximum time to wait in seconds
    check_interval : float
        Time between checks in seconds
    
    Returns
    -------
    bool
        True if file stabilized, False if timeout
    '''

    if not os.path.isfile(file_path):
        return False
    
    start_time = time.time()
    last_size = -1
    stable_count = 0
    required_stable_checks = 3  # File must be stable for 3 consecutive checks
    
    while time.time() - start_time < max_wait:
        try:
            current_size = os.path.getsize(file_path)
            
            if current_size == last_size:
                stable_count += 1
                if stable_count >= required_stable_checks:
                    return True
            else:
                stable_count = 0
                last_size = current_size
            
            time.sleep(check_interval)
        except (OSError, IOError):
            # File might be locked or deleted
            time.sleep(check_interval)
            continue
    
    return False


def wait_for_files_ready(file_paths: list, max_wait: float = 8.0, check_interval: float = 0.2) -> bool:
    '''Wait for all files in a list to exist, be stable, and be readable.
    
    Parameters
    ----------
    file_paths : list
        List of file paths to wait for
    max_wait : float
        Maximum time to wait in seconds
    check_interval : float
        Time between checks in seconds
    
    Returns
    -------
    bool
        True if all files are ready, False if timeout
    '''
    
    if not file_paths:
        return True
    
    start_time = time.time()
    ready_files = set()
    
    while time.time() - start_time < max_wait:
        all_ready = True
        for file_path in file_paths:
            if file_path in ready_files:
                continue
                
            if wait_for_file_stable(file_path, max_wait=check_interval * 2, check_interval=check_interval / 2):
                ready_files.add(file_path)
            else:
                all_ready = False
        
        if all_ready and len(ready_files) == len(file_paths):
            return True
        
        time.sleep(check_interval)
    
    return len(ready_files) == len(file_paths)


if __name__ == "__main__":
    results = main()

This script demonstrates:

  • Receptor and ligand preparation

  • Multi-engine docking (Vina, PLANTS)

  • Pose clustering to find representative poses

  • Rescoring with multiple scoring functions (ODDT, PLANTS, Vina, SMINA)

  • Feature extraction (receptor and ligand descriptors)

  • Model inference using trained OCScore model

  • Automatic mapping of rescoring results to database column names

  • Multiprocessing support for processing multiple ligands

Inference from CSV

Example of OCScore inference loading features directly from a CSV file:

OCScore inference from CSV
#!/usr/bin/env python3
"""
Example: OCScore Inference from CSV

This example demonstrates how to run OCScore model inference using feature data
loaded from a CSV file. The script:

1. Loads OCDocker configuration to enforce `reference_column_order`
2. Loads input CSV data and keeps the original table for output
3. Loads model artifacts (model, optional mask, optional scaler)
4. Runs `ocscoring.get_score(...)` for inference
5. Exports an output CSV preserving original rows/columns plus `OCSCORE`

Usage:
    # Recommended: pass config path explicitly
    python examples/13_python_api_inference_from_csv.py \
        --csv-path /path/to/features.csv \
        --model-name OCScore \
        --config-path /path/to/OCDocker.cfg \
        --output-csv /path/to/scored.csv

    # Alternative: use OCDOCKER_CONFIG environment variable
    export OCDOCKER_CONFIG=/path/to/OCDocker.cfg
    python examples/13_python_api_inference_from_csv.py \
        --csv-path /path/to/features.csv \
        --model-name OCScore
"""

import argparse
import os
import sys

import numpy as np
import pandas as pd

# Allow running the example directly from source checkout without installation.
_script_dir = os.path.dirname(os.path.abspath(__file__))
_parent_dir = os.path.dirname(_script_dir)
if _parent_dir not in sys.path:
    sys.path.insert(0, _parent_dir)

import OCDocker.Config as occonfig
import OCDocker.OCScore.Scoring as ocscoring
import OCDocker.OCScore.Utils.IO as ocscoreio


def _resolve_model_path(model_name: str, models_dir: str, model_path: str | None) -> str:
    """Resolve the model path from an explicit path or common model-name patterns."""

    if model_path:
        if not os.path.isfile(model_path):
            raise FileNotFoundError(f"Model file not found: {model_path}")
        return model_path

    candidates = [
        os.path.join(models_dir, f"{model_name}.pt"),
        os.path.join(models_dir, f"{model_name}.pth"),
        os.path.join(models_dir, f"{model_name}.pkl"),
    ]

    for candidate in candidates:
        if os.path.isfile(candidate):
            return candidate

    raise FileNotFoundError(
        f"Could not find model for '{model_name}' in {models_dir}. "
        "Tried .pt, .pth, and .pkl."
    )


def _resolve_config_path(config_path: str | None) -> str | None:
    """Resolve config path from argument/env/common local locations."""

    if config_path:
        candidate = os.path.abspath(config_path)
        if not os.path.isfile(candidate):
            raise FileNotFoundError(f"Config file not found: {candidate}")
        return candidate

    env_cfg = os.getenv("OCDOCKER_CONFIG", "").strip()
    if env_cfg:
        env_candidate = os.path.abspath(env_cfg)
        if os.path.isfile(env_candidate):
            return env_candidate

    repo_cfg = os.path.join(_parent_dir, "OCDocker.cfg")
    if os.path.isfile(repo_cfg):
        return os.path.abspath(repo_cfg)

    cwd_cfg = os.path.abspath("OCDocker.cfg")
    if os.path.isfile(cwd_cfg):
        return cwd_cfg

    return None


def _ensure_reference_order_config(config_path: str | None) -> str | None:
    """Load config into OCDocker singleton so get_score can enforce column order."""

    resolved = _resolve_config_path(config_path)
    if resolved is None:
        return None

    os.environ["OCDOCKER_CONFIG"] = resolved
    loaded = occonfig.OCDockerConfig.from_config_file(resolved)
    occonfig.set_config(loaded)
    return resolved


def main() -> None:
    '''Run OCScore inference on a feature CSV and print the resulting scores.'''

    parser = argparse.ArgumentParser(description="Run OCScore inference from a CSV file.")
    parser.add_argument(
        "--csv-path",
        required=True,
        help="Path to input CSV file containing features for inference.",
    )
    parser.add_argument(
        "--model-name",
        default="OCScore",
        help="Model name used to locate model/mask/scaler files (default: OCScore).",
    )
    parser.add_argument(
        "--models-dir",
        default=None,
        help="Directory containing model artifacts. Defaults to OCScore_models.",
    )
    parser.add_argument(
        "--model-path",
        default=None,
        help="Explicit model path. If omitted, inferred from --model-name in --models-dir.",
    )
    parser.add_argument(
        "--config-path",
        default=None,
        help="Path to OCDocker config file (.cfg/.yml). Needed to enforce reference_column_order.",
    )
    parser.add_argument(
        "--scaler-path",
        default=None,
        help="Optional scaler path. If omitted, tries <model_name>_scaler.pkl in models dir.",
    )
    parser.add_argument(
        "--no-mask",
        action="store_true",
        help="Disable mask loading and run inference without a mask.",
    )
    parser.add_argument(
        "--use-gpu",
        action="store_true",
        help="Use GPU when available (PyTorch models).",
    )
    parser.add_argument(
        "--output-csv",
        default="predictions_from_csv.csv",
        help="Output CSV file for predictions (default: predictions_from_csv.csv).",
    )
    parser.add_argument(
        "--score-column",
        default="OCSCORE",
        help="Column name to store predictions in the output CSV (default: OCSCORE).",
    )
    parser.add_argument(
        "--disable-reference-order",
        action="store_true",
        help="Disable enforcement of reference_column_order (not recommended).",
    )
    parser.add_argument(
        "--invert-conditionally",
        dest="invert_conditionally",
        action="store_true",
        default=False,
        help="Invert VINA/SMINA/PLANTS-like columns before inference (default: enabled).",
    )
    args = parser.parse_args()

    if not os.path.isfile(args.csv_path):
        raise FileNotFoundError(f"Input CSV file not found: {args.csv_path}")

    enforce_reference_order = not args.disable_reference_order
    loaded_config_path = _ensure_reference_order_config(args.config_path)

    if enforce_reference_order:
        cfg = occonfig.get_config()
        if not cfg.paths.reference_column_order:
            raise ValueError(
                "reference_column_order is not set in the active config. "
                "Pass --config-path /path/to/OCDocker.cfg (or set OCDOCKER_CONFIG)."
            )
        print(f"Using OCDocker config: {loaded_config_path if loaded_config_path else 'active runtime config'}")

    # Keep a full copy for output (same input rows/columns + OCScore column).
    input_df = pd.read_csv(args.csv_path)
    # Use OCScore loader for inference input (drops rows with invalid NaNs).
    scoring_df = ocscoreio.load_data(args.csv_path)

    if scoring_df.empty:
        raise ValueError("No valid rows found for inference after CSV preprocessing.")

    models_dir = os.path.abspath(args.models_dir) if args.models_dir else ocscoreio.get_models_dir()
    model_path = _resolve_model_path(args.model_name, models_dir, args.model_path)

    mask = None
    if not args.no_mask:
        try:
            mask = ocscoreio.load_mask(args.model_name, models_dir=models_dir)
            print(f"Loaded mask for model '{args.model_name}'.")
        except FileNotFoundError:
            print(f"Mask file not found for model '{args.model_name}'. Continuing without mask.")

    scaler_path = args.scaler_path
    if scaler_path is None:
        candidate_scaler = os.path.join(models_dir, f"{args.model_name}_scaler.pkl")
        if os.path.isfile(candidate_scaler):
            scaler_path = candidate_scaler

    if scaler_path:
        print(f"Using scaler: {scaler_path}")
    else:
        print("No scaler provided/found. Inference will fit a scaler on the prediction data.")

    predictions = ocscoring.get_score(
        model_path=model_path,
        data=scoring_df,
        mask=mask,
        scaler_path=scaler_path,
        use_gpu=args.use_gpu,
        enforce_reference_column_order=enforce_reference_order,
        invert_conditionally=args.invert_conditionally,
    )

    if "predicted_score" not in predictions.columns:
        raise ValueError("Prediction output does not contain 'predicted_score' column.")

    # Build output with the same rows/columns as input plus an OCScore column.
    # Rows removed during preprocessing keep NaN in the score column.
    output_df = input_df.copy()
    output_df[args.score_column] = np.nan
    output_df.loc[scoring_df.index, args.score_column] = predictions["predicted_score"].to_numpy()

    print(f"Input shape: {input_df.shape}")
    print(f"Output shape: {output_df.shape}")
    print(f"Predictions assigned to {predictions['predicted_score'].notna().sum()} rows.")
    print(output_df[[args.score_column]].head())

    if args.output_csv:
        output_df.to_csv(args.output_csv, index=False)
        print(f"Saved output CSV to: {args.output_csv}")


if __name__ == "__main__":
    main()

This script demonstrates:

  • Loading input features from a .csv file

  • Loading OCDocker config so reference_column_order is enforced

  • Resolving model artifacts from OCScore_models (or a custom directory)

  • Optional mask and scaler loading

  • Running model inference and exporting an output CSV with original rows/columns plus OCSCORE

Example command:

python examples/13_python_api_inference_from_csv.py \
    --csv-path /path/to/features.csv \
    --model-name OCScore \
    --config-path /path/to/OCDocker.cfg \
    --output-csv /path/to/scored.csv

Staged pipeline CLI

Examples 14–16 are also available as ocdocker ocscore subcommands (requires pip install "ocdocker[ml]"):

ocdocker ocscore reduce --pdbbind-archive ... --dudez-archive ... --output-dir ...
ocdocker ocscore train --protocol development --raw-input-dir ... --output-dir ...
ocdocker ocscore score --export-dir ... --raw-archive ... --output-csv ...

See Usage for the full command list.

Reusable SHAP Plots

The SHAP plotting utilities are reusable and policy-agnostic. Use OCDocker.OCScore.Analysis.SHAP.Plots.save_shap_plot_suite with in-memory arrays, or save_shap_plot_suite_from_paths with explicit SHAP, feature, metadata, and label paths.

The default feature-family suggestion is available as an editable example:

Default suggested SHAP feature families
families:
  PMI:
    - ligand_PMI*
  AUTOCORR2D:
    - ligand_AUTOCORR2D_*
  VSA/EState:
    - ligand_EState_VSA*
    - ligand_PEOE_VSA*
    - ligand_SMR_VSA*
    - ligand_SlogP_VSA*
    - ligand_VSA_EState*
  shape/size/topology:
    - ligand_NPR*
    - ligand_Asphericity
    - ligand_Eccentricity
    - ligand_InertialShapeFactor
    - ligand_RadiusOfGyration
    - ligand_SpherocityIndex
    - ligand_BertzCT
    - ligand_MolWt
    - ligand_ExactMolWt
    - ligand_HeavyAtomMolWt
    - ligand_HeavyAtomCount
    - ligand_TPSA
    - ligand_MolLogP
    - ligand_MolMR
    - ligand_RingCount
    - ligand_Num*Ring*
    - ligand_Num*Cycle*
    - ligand_NumHAcceptors
    - ligand_NumHDonors
    - ligand_NumHeteroatoms
    - ligand_NumValenceElectrons
    - ligand_FractionCSP3
  fragments:
    - ligand_fr_*
  BCUT2D:
    - ligand_BCUT2D_*
  Chi/Kappa/topological indices:
    - ligand_Chi*
    - ligand_Kappa*
    - ligand_BalabanJ
    - ligand_HallKierAlpha
  partial-charge / scalar EState:
    - ligand_*PartialCharge
    - ligand_MaxEStateIndex
    - ligand_MinEStateIndex
    - ligand_MaxAbsEStateIndex
    - ligand_MinAbsEStateIndex
  PLANTS:
    - plants_*
  Vina/Smina:
    - vina_*
    - smina_*
  GNINA:
    - gnina_*
  ODDT:
    - oddt_*
  receptor:
    - receptor_*
  other ligand:
    - ligand_*
  other:
    - "*"

Example CLI usage:

ocdocker ocscore shap \
    --export-dir /path/to/best_model \
    --reduction-archive /path/to/reduction.tar.gz \
    --output-dir /path/to/shap \
    --policy full \
    --eval-split validation \
    --family-spec examples/ocscore_shap_feature_families.yml \
    --dependence-features ligand_PMI1 plants_plp

Configuration

The complete pipeline script includes a configuration section at the top where you can customize:

  • Receptor and ligand paths

  • Model paths and names

  • Preprocessing settings

  • Output file paths

  • Multiprocessing settings

Example configuration:

# Receptor configuration
RECEPTOR_PATH = "/path/to/receptor.pdb"
RECEPTOR_NAME = "Receptor"

# Ligand configuration
LIGAND_PATHS = [
    "/path/to/ligand1",
    "/path/to/ligand2",
]

# Model configuration
MODEL_NAME = "OCScore"
MODELS_DIR = "OCScore_models"

# Multiprocessing
N_JOBS = 4  # Number of parallel jobs
USE_MULTIPROCESSING = True