diff --git a/CodeEntropy/ConformationFunctions.py b/CodeEntropy/calculations/ConformationFunctions.py similarity index 96% rename from CodeEntropy/ConformationFunctions.py rename to CodeEntropy/calculations/ConformationFunctions.py index 7fe1efd..cd62d25 100644 --- a/CodeEntropy/ConformationFunctions.py +++ b/CodeEntropy/calculations/ConformationFunctions.py @@ -1,5 +1,9 @@ +import logging + import numpy as np +logger = logging.getLogger(__name__) + # from MDAnalysis.analysis.dihedrals import Dihedral @@ -79,4 +83,6 @@ def assign_conformation( distances = [abs(phi[frame] - peak) for peak in peak_values] conformations[frame] = np.argmin(distances) + logger.debug(f"Final conformations: {conformations}") + return conformations diff --git a/CodeEntropy/EntropyFunctions.py b/CodeEntropy/calculations/EntropyFunctions.py similarity index 79% rename from CodeEntropy/EntropyFunctions.py rename to CodeEntropy/calculations/EntropyFunctions.py index e4a6d71..aded08a 100644 --- a/CodeEntropy/EntropyFunctions.py +++ b/CodeEntropy/calculations/EntropyFunctions.py @@ -1,3 +1,4 @@ +import logging import math # import matplotlib.pyplot as plt @@ -7,8 +8,10 @@ # import pandas as pd from numpy import linalg as la -from CodeEntropy import ConformationFunctions as CONF -from CodeEntropy import UnitsAndConversions as UAC +from CodeEntropy.calculations import ConformationFunctions as CONF +from CodeEntropy.calculations import UnitsAndConversions as UAC + +logger = logging.getLogger(__name__) # import sys # from ast import arg @@ -40,15 +43,19 @@ def frequency_calculation(lambdas, temp): pi = np.pi # get kT in Joules from given temperature kT = UAC.get_KT2J(temp) + logger.debug(f"Temperature: {temp}, kT: {kT}") lambdas = np.array(lambdas) # Ensure input is a NumPy array + logger.debug(f"Eigenvalues (lambdas): {lambdas}") # Check for negatives and raise an error if any are found if np.any(lambdas < 0): + logger.error(f"Negative eigenvalues encountered: {lambdas[lambdas < 0]}") raise ValueError(f"Negative eigenvalues encountered: {lambdas[lambdas < 0]}") # Compute frequencies safely frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT) + logger.debug(f"Calculated frequencies: {frequencies}") return frequencies @@ -74,22 +81,31 @@ def vibrational_entropy(matrix, matrix_type, temp, highest_level): # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues # Get eigenvalues of the given matrix and change units to SI units lambdas = la.eigvals(matrix) + logger.debug(f"Eigenvalues (lambdas) before unit change: {lambdas}") lambdas = UAC.change_lambda_units(lambdas) + logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}") # Calculate frequencies from the eigenvalues frequencies = frequency_calculation(lambdas, temp) + logger.debug(f"Calculated frequencies: {frequencies}") # Sort frequencies lowest to highest frequencies = np.sort(frequencies) + logger.debug(f"Sorted frequencies: {frequencies}") kT = UAC.get_KT2J(temp) + logger.debug(f"Temperature: {temp}, kT: {kT}") exponent = UAC.PLANCK_CONST * frequencies / kT + logger.debug(f"Exponent values: {exponent}") power_positive = np.power(np.e, exponent) power_negative = np.power(np.e, -exponent) + logger.debug(f"Power positive values: {power_positive}") + logger.debug(f"Power negative values: {power_negative}") S_components = exponent / (power_positive - 1) - np.log(1 - power_negative) S_components = ( S_components * UAC.GAS_CONST ) # multiply by R - get entropy in J mol^{-1} K^{-1} + logger.debug(f"Entropy components: {S_components}") # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues if matrix_type == "force": # force covariance matrix if highest_level: # whole molecule level - we take all frequencies into account @@ -104,6 +120,8 @@ def vibrational_entropy(matrix, matrix_type, temp, highest_level): else: # torque covariance matrix - we always take all values into account S_vib_total = sum(S_components) + logger.debug(f"Total vibrational entropy: {S_vib_total}") + return S_vib_total @@ -138,17 +156,23 @@ def conformational_entropy( ) index += 1 + logger.debug(f"Conformation matrix: {conformation}") + # For each frame, convert the conformation of all dihedrals into a state string states = ["" for x in range(number_frames)] for frame_index in range(number_frames): for index in range(num_dihedrals): states[frame_index] += str(conformation[index][frame_index]) + logger.debug(f"States: {states}") + # Count how many times each state occurs, then use the probability to get the # entropy # entropy = sum over states p*ln(p) values, counts = np.unique(states, return_counts=True) for state in range(len(values)): + logger.debug(f"Unique states: {values}") + logger.debug(f"Counts: {counts}") count = counts[state] probability = count / number_frames entropy = probability * np.log(probability) @@ -157,6 +181,8 @@ def conformational_entropy( # multiply by gas constant to get the units J/mol/K S_conf_total *= -1 * UAC.GAS_CONST + logger.debug(f"Total conformational entropy: {S_conf_total}") + return S_conf_total @@ -193,12 +219,28 @@ def orientational_entropy(neighbours_dict): else: # the bound ligand is always going to be a neighbour omega = np.sqrt((neighbours_dict[neighbour] ** 3) * math.pi) + logger.debug(f"Omega for neighbour {neighbour}: {omega}") # orientational entropy arising from each neighbouring species # - we know the species is going to be a neighbour S_or_component = math.log(omega) + logger.debug( + f"S_or_component (log(omega)) for neighbour {neighbour}: " + f"{S_or_component}" + ) S_or_component *= UAC.GAS_CONST + logger.debug( + f"S_or_component after multiplying by GAS_CONST for neighbour " + f"{neighbour}: {S_or_component}" + ) S_or_total += S_or_component + logger.debug( + f"S_or_total after adding component for neighbour {neighbour}: " + f"{S_or_total}" + ) # TODO for future releases # implement a case for molecules with hydrogen bonds but to a lesser # extent than water + + logger.debug(f"Final total orientational entropy: {S_or_total}") + return S_or_total diff --git a/CodeEntropy/GeometricFunctions.py b/CodeEntropy/calculations/GeometricFunctions.py similarity index 95% rename from CodeEntropy/GeometricFunctions.py rename to CodeEntropy/calculations/GeometricFunctions.py index eb598d6..b63bf39 100644 --- a/CodeEntropy/GeometricFunctions.py +++ b/CodeEntropy/calculations/GeometricFunctions.py @@ -1,5 +1,9 @@ +import logging + import numpy as np +logger = logging.getLogger(__name__) + def get_beads(data_container, level): """ @@ -40,6 +44,8 @@ def get_beads(data_container, level): ) list_of_beads.append(data_container.select_atoms(atom_group)) + logger.debug(f"List of beads: {list_of_beads}") + return list_of_beads @@ -120,6 +126,9 @@ def get_axes(data_container, level, index=0): # use spherical coordinates function to get rotational axes rot_axes = get_sphCoord_axes(vector) + logger.debug(f"Translational Axes: {trans_axes}") + logger.debug(f"Rotational Axes: {rot_axes}") + return trans_axes, rot_axes @@ -159,6 +168,8 @@ def get_avg_pos(atom_set, center): # transform the average position to a coordinate system with the origin at center avg_position = avg_position - center + logger.debug(f"Average Position: {avg_position}") + return avg_position @@ -231,6 +242,8 @@ def get_sphCoord_axes(arg_r): # Phi^ spherical_basis[2, :] = np.asarray([-sin_phi, cos_phi, 0.0]) + logger.debug(f"Spherical Basis: {spherical_basis}") + return spherical_basis @@ -276,6 +289,8 @@ def get_weighted_forces( weighted_force = forces_trans / np.sqrt(mass) + logger.debug(f"Weighted Force: {weighted_force}") + return weighted_force @@ -361,6 +376,8 @@ def get_weighted_torques(data_container, bead, rot_axes, force_partitioning=0.5) moment_of_inertia[dimension, dimension] ) + logger.debug(f"Weighted Torque: {weighted_torque}") + return weighted_torque @@ -390,6 +407,8 @@ def create_submatrix(data_i, data_j, number_frames): # Divide by the number of frames to get the average submatrix /= number_frames + logger.debug(f"Submatrix: {submatrix}") + return submatrix @@ -432,11 +451,13 @@ def filter_zero_rows_columns(arg_matrix, verbose): # get the final shape final_shape = np.shape(arg_matrix) - if verbose and init_shape != final_shape: - print( - "A shape change has occured ({},{}) -> ({}, {})".format( + if init_shape != final_shape: + logger.debug( + "A shape change has occurred ({},{}) -> ({}, {})".format( *init_shape, *final_shape ) ) + logger.debug(f"arg_matrix: {arg_matrix}") + return arg_matrix diff --git a/CodeEntropy/LevelFunctions.py b/CodeEntropy/calculations/LevelFunctions.py similarity index 94% rename from CodeEntropy/LevelFunctions.py rename to CodeEntropy/calculations/LevelFunctions.py index dcf5079..ac2d089 100644 --- a/CodeEntropy/LevelFunctions.py +++ b/CodeEntropy/calculations/LevelFunctions.py @@ -1,7 +1,11 @@ # import MDAnalysis as mda +import logging + import numpy as np -from CodeEntropy import GeometricFunctions as GF +from CodeEntropy.calculations import GeometricFunctions as GF + +logger = logging.getLogger(__name__) def select_levels(data_container, verbose): @@ -23,7 +27,7 @@ def select_levels(data_container, verbose): # fragments is MDAnalysis terminology for what chemists would call molecules number_molecules = len(data_container.atoms.fragments) - print("The number of molecules is {}.".format(number_molecules)) + logger.debug("The number of molecules is {}.".format(number_molecules)) fragments = data_container.atoms.fragments levels = [[] for _ in range(number_molecules)] @@ -42,8 +46,7 @@ def select_levels(data_container, verbose): if number_residues > 1: levels[molecule].append("polymer") - if verbose: - print(levels) + logger.debug(f"levels {levels}") return number_molecules, levels @@ -142,12 +145,8 @@ def get_matrices( force_matrix = GF.filter_zero_rows_columns(force_matrix, verbose) torque_matrix = GF.filter_zero_rows_columns(torque_matrix, verbose) - if verbose: - with open("matrix.out", "a") as f: - print("force_matrix \n", file=f) - print(force_matrix, file=f) - print("torque_matrix \n", file=f) - print(torque_matrix, file=f) + logger.debug(f"Force Matrix: {force_matrix}") + logger.debug(f"Torque Matrix: {torque_matrix}") return force_matrix, torque_matrix @@ -181,7 +180,7 @@ def get_dihedrals(data_container, level): if level == "residue": num_residues = len(data_container.residues) if num_residues < 4: - print("no residue level dihedrals") + logger.debug("no residue level dihedrals") else: # find bonds between residues N-3:N-2 and N-1:N @@ -224,4 +223,6 @@ def get_dihedrals(data_container, level): atom_group = atom1 + atom2 + atom3 + atom4 dihedrals.append(atom_group.dihedral) + logger.debug(f"Dihedrals: {dihedrals}") + return dihedrals diff --git a/CodeEntropy/MDAUniverseHelper.py b/CodeEntropy/calculations/MDAUniverseHelper.py similarity index 95% rename from CodeEntropy/MDAUniverseHelper.py rename to CodeEntropy/calculations/MDAUniverseHelper.py index e88c2c3..6a75fed 100644 --- a/CodeEntropy/MDAUniverseHelper.py +++ b/CodeEntropy/calculations/MDAUniverseHelper.py @@ -1,9 +1,12 @@ +import logging import pickle import MDAnalysis as mda from MDAnalysis.analysis.base import AnalysisFromFunction from MDAnalysis.coordinates.memory import MemoryReader +logger = logging.getLogger(__name__) + def new_U_select_frame(u, start=None, end=None, step=1): """Create a reduced universe by dropping frames according to user selection @@ -46,6 +49,7 @@ def new_U_select_frame(u, start=None, end=None, step=1): ) u2 = mda.Merge(select_atom) u2.load_new(coordinates, format=MemoryReader, forces=forces, dimensions=dimensions) + logger.debug(f"MDAnalysis.Universe - reduced universe: {u2}") return u2 @@ -83,6 +87,7 @@ def new_U_select_atom(u, select_string="all"): ) u2 = mda.Merge(select_atom) u2.load_new(coordinates, format=MemoryReader, forces=forces, dimensions=dimensions) + logger.debug(f"MDAnalysis.Universe - reduced universe: {u2}") return u2 diff --git a/CodeEntropy/NeighbourFunctions.py b/CodeEntropy/calculations/NeighbourFunctions.py similarity index 95% rename from CodeEntropy/NeighbourFunctions.py rename to CodeEntropy/calculations/NeighbourFunctions.py index c8ce7c0..d612f63 100644 --- a/CodeEntropy/NeighbourFunctions.py +++ b/CodeEntropy/calculations/NeighbourFunctions.py @@ -1,10 +1,14 @@ # import os +import logging import sys # import matplotlib.pyplot as plt import MDAnalysis as mda import numpy as np +logger = logging.getLogger(__name__) + + # from ast import arg @@ -85,4 +89,8 @@ def get_neighbours(molecule_i, reduced_atom): # print(r_ij) neighbours_array.append(molecule_j.atoms.resids[0]) neighbours_dict[molecule_j.atoms.resnames[0]] = 1 + + logger.debug(f"Neighbours dictionary: {neighbours_dict}") + logger.debug(f"Neighbours array: {neighbours_array}") + return neighbours_dict, neighbours_array diff --git a/CodeEntropy/UnitsAndConversions.py b/CodeEntropy/calculations/UnitsAndConversions.py similarity index 100% rename from CodeEntropy/UnitsAndConversions.py rename to CodeEntropy/calculations/UnitsAndConversions.py diff --git a/CodeEntropy/config/arg_config_manager.py b/CodeEntropy/config/arg_config_manager.py new file mode 100644 index 0000000..7ac03c9 --- /dev/null +++ b/CodeEntropy/config/arg_config_manager.py @@ -0,0 +1,132 @@ +import argparse +import logging +import os + +import yaml + +# Set up logger +logger = logging.getLogger(__name__) + +arg_map = { + "top_traj_file": { + "type": str, + "nargs": "+", + "help": "Path to Structure/topology file followed by Trajectory file(s)", + "default": [], + }, + "selection_string": { + "type": str, + "help": "Selection string for CodeEntropy", + "default": "all", + }, + "start": { + "type": int, + "help": "Start analysing the trajectory from this frame index", + "default": 0, + }, + "end": { + "type": int, + "help": "Stop analysing the trajectory at this frame index", + "default": -1, + }, + "step": { + "type": int, + "help": "Interval between two consecutive frames to be read index", + "default": 1, + }, + "bin_width": { + "type": int, + "help": "Bin width in degrees for making the histogram", + "default": 30, + }, + "temperature": { + "type": float, + "help": "Temperature for entropy calculation (K)", + "default": 298.0, + }, + "verbose": { + "action": "store_true", + "help": "Enable verbose output", + }, + "thread": {"type": int, "help": "How many multiprocess to use", "default": 1}, + "outfile": { + "type": str, + "help": "Name of the file where the output will be written", + "default": "outfile.json", + }, + "mout": { + "type": str, + "help": "Name of the file where certain matrices will be written", + "default": None, + }, + "force_partitioning": {"type": float, "help": "Force partitioning", "default": 0.5}, + "waterEntropy": {"type": bool, "help": "Calculate water entropy", "default": False}, +} + + +class ConfigManager: + def __init__(self): + self.arg_map = arg_map + + def load_config(self, file_path): + """Load YAML configuration file.""" + if not os.path.exists(file_path): + raise FileNotFoundError(f"Configuration file '{file_path}' not found.") + + with open(file_path, "r") as file: + config = yaml.safe_load(file) + + # If YAML content is empty, return an empty dictionary + if config is None: + config = {} + + return config + + def setup_argparse(self): + """Setup argument parsing dynamically based on arg_map.""" + parser = argparse.ArgumentParser( + description="CodeEntropy: Entropy calculation with MCC method." + ) + + for arg, properties in self.arg_map.items(): + kwargs = {key: properties[key] for key in properties if key != "help"} + parser.add_argument(f"--{arg}", **kwargs, help=properties.get("help")) + + return parser + + def merge_configs(self, args, run_config): + """Merge CLI arguments with YAML configuration and adjust logging level.""" + if run_config is None: + run_config = {} + + if not isinstance(run_config, dict): + raise TypeError("run_config must be a dictionary or None.") + + # Step 1: Merge YAML configuration into args + for key, value in run_config.items(): + if getattr(args, key, None) is None: + setattr(args, key, value) + + # Step 2: Set default values for any missing arguments from `arg_map` + for key, params in self.arg_map.items(): + if getattr(args, key, None) is None: + setattr(args, key, params.get("default")) + + # Step 3: Override with CLI values if provided + for key in self.arg_map.keys(): + cli_value = getattr(args, key, None) + if cli_value is not None: + run_config[key] = cli_value + + # Adjust logging level based on 'verbose' flag + if getattr(args, "verbose", False): + logger.setLevel(logging.DEBUG) + for handler in logger.handlers: + handler.setLevel(logging.DEBUG) + logger.debug("Verbose mode enabled. Logger set to DEBUG level.") + else: + logger.setLevel(logging.INFO) + for handler in logger.handlers: + handler.setLevel(logging.INFO) + + return args diff --git a/CodeEntropy/config/data_logger.py b/CodeEntropy/config/data_logger.py new file mode 100644 index 0000000..c22e1ae --- /dev/null +++ b/CodeEntropy/config/data_logger.py @@ -0,0 +1,54 @@ +import json +import logging + +from tabulate import tabulate + +# Set up logger +logger = logging.getLogger(__name__) + + +class DataLogger: + def __init__(self): + self.molecule_data = [] + self.residue_data = [] + + def save_dataframes_as_json(self, molecule_df, residue_df, outfile): + """Save multiple DataFrames into a single JSON file with separate keys""" + data = { + "molecule_data": molecule_df.to_dict(orient="records"), + "residue_data": residue_df.to_dict(orient="records"), + } + + # Write JSON data to file + with open(outfile, "w") as out: + json.dump(data, out, indent=4) + + def add_results_data(self, molecule, level, type, S_molecule): + """Add data for molecule-level entries""" + self.molecule_data.append([molecule, level, type, f"{S_molecule}"]) + + def add_residue_data(self, molecule, residue, type, S_trans_residue): + """Add data for residue-level entries""" + self.residue_data.append([molecule, residue, type, f"{S_trans_residue}"]) + + def log_tables(self): + """Log both tables at once""" + # Log molecule data + if self.molecule_data: + logger.info("Molecule Data Table:") + table_str = tabulate( + self.molecule_data, + headers=["Molecule ID", "Level", "Type", "Result (J/mol/K)"], + tablefmt="grid", + ) + logger.info(f"\n{table_str}") + + # Log residue data + if self.residue_data: + logger.info("Residue Data Table:") + table_str = tabulate( + self.residue_data, + headers=["Molecule ID", "Residue", "Type", "Result (J/mol/K)"], + tablefmt="grid", + ) + logger.info(f"\n{table_str}") diff --git a/CodeEntropy/config/logging_config.py b/CodeEntropy/config/logging_config.py new file mode 100644 index 0000000..475fc2e --- /dev/null +++ b/CodeEntropy/config/logging_config.py @@ -0,0 +1,84 @@ +import logging +import logging.config +import os + + +class LoggingConfig: + def __init__(self, folder, default_level=logging.INFO): + log_directory = os.path.join(folder, "logs") + os.makedirs(log_directory, exist_ok=True) + + self.LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "detailed": { + "format": "%(asctime)s " + "- %(levelname)s " + "- %(filename)s:%(lineno)d " + "- %(message)s", + }, + "simple": { + "format": "%(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "simple", + "level": "INFO", + }, + "stdout": { + "class": "logging.FileHandler", + "filename": os.path.join(log_directory, "program.out"), + "formatter": "simple", + "level": "INFO", + }, + "logfile": { + "class": "logging.FileHandler", + "filename": os.path.join(log_directory, "program.log"), + "formatter": "detailed", + "level": "DEBUG", + }, + "errorfile": { + "class": "logging.FileHandler", + "filename": os.path.join(log_directory, "program.err"), + "formatter": "detailed", + "level": "ERROR", + }, + "commandfile": { + "class": "logging.FileHandler", + "filename": os.path.join(log_directory, "program.com"), + "formatter": "simple", + "level": "INFO", + }, + "mdanalysis_log": { + "class": "logging.FileHandler", + "filename": os.path.join(log_directory, "mdanalysis.log"), + "formatter": "detailed", + "level": "DEBUG", + }, + }, + "loggers": { + "": { + "handlers": ["console", "stdout", "logfile", "errorfile"], + "level": default_level, + }, + "MDAnalysis": { + "handlers": ["mdanalysis_log"], + "level": "DEBUG", + "propagate": False, + }, + "commands": { + "handlers": ["commandfile"], + "level": "INFO", + "propagate": False, + }, + }, + } + + def setup_logging(self): + logging.config.dictConfig(self.LOGGING) + logging.getLogger("MDAnalysis") + logging.getLogger("commands") + return logging.getLogger(__name__) diff --git a/CodeEntropy/main_mcc.py b/CodeEntropy/main_mcc.py index 1e57014..e163640 100644 --- a/CodeEntropy/main_mcc.py +++ b/CodeEntropy/main_mcc.py @@ -1,135 +1,50 @@ -import argparse +import logging import math import os +import re +import sys import MDAnalysis as mda - -# import numpy as np import pandas as pd -import yaml - -from CodeEntropy import EntropyFunctions as EF -from CodeEntropy import LevelFunctions as LF -from CodeEntropy import MDAUniverseHelper as MDAHelper - -# from datetime import datetime - -arg_map = { - "top_traj_file": { - "type": str, - "nargs": "+", - "help": "Path to Structure/topology file followed by Trajectory file(s)", - "default": [], - }, - "selection_string": { - "type": str, - "help": "Selection string for CodeEntropy", - "default": "all", - }, - "start": { - "type": int, - "help": "Start analysing the trajectory from this frame index", - "default": 0, - }, - "end": { - "type": int, - "help": "Stop analysing the trajectory at this frame index", - "default": -1, - }, - "step": { - "type": int, - "help": "Interval between two consecutive frames to be read index", - "default": 1, - }, - "bin_width": { - "type": int, - "help": "Bin width in degrees for making the histogram", - "default": 30, - }, - "temperature": { - "type": float, - "help": "Temperature for entropy calculation (K)", - "default": 298.0, - }, - "verbose": { - "type": bool, - "help": "True/False flag for noisy or quiet output", - "default": False, - }, - "thread": {"type": int, "help": "How many multiprocess to use", "default": 1}, - "outfile": { - "type": str, - "help": "Name of the file where the output will be written", - "default": "outfile.out", - }, - "resfile": { - "type": str, - "help": "Name of the file where the residue entropy output will be written", - "default": "res_outfile.out", - }, - "mout": { - "type": str, - "help": "Name of the file where certain matrices will be written", - "default": None, - }, - "force_partitioning": {"type": float, "help": "Force partitioning", "default": 0.5}, - "waterEntropy": {"type": bool, "help": "Calculate water entropy", "default": False}, -} - - -def load_config(file_path): - """Load YAML configuration file.""" - if not os.path.exists(file_path): - raise FileNotFoundError(f"Configuration file '{file_path}' not found.") - - with open(file_path, "r") as file: - config = yaml.safe_load(file) - - # If YAML content is empty, return an empty dictionary - if config is None: - config = {} - - return config - - -def setup_argparse(): - """Setup argument parsing dynamically based on arg_map.""" - parser = argparse.ArgumentParser( - description="CodeEntropy: Entropy calculation with MCC method." - ) - for arg, properties in arg_map.items(): - kwargs = {key: properties[key] for key in properties if key != "help"} - parser.add_argument(f"--{arg}", **kwargs, help=properties.get("help")) +from CodeEntropy.calculations import EntropyFunctions as EF +from CodeEntropy.calculations import LevelFunctions as LF +from CodeEntropy.calculations import MDAUniverseHelper as MDAHelper +from CodeEntropy.config.arg_config_manager import ConfigManager +from CodeEntropy.config.data_logger import DataLogger +from CodeEntropy.config.logging_config import LoggingConfig - return parser +def create_job_folder(): + """ + Create a new job folder with an incremented job number based on existing folders. + """ + # Get the current working directory + base_dir = os.getcwd() -def merge_configs(args, run_config): - """Merge CLI arguments with YAML configuration.""" - if run_config is None: - run_config = {} + # List all folders in the base directory + existing_folders = [ + f for f in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, f)) + ] - if not isinstance(run_config, dict): - raise TypeError("run_config must be a dictionary or None.") + # Filter folders that match the pattern 'jobXXX' + job_folders = [f for f in existing_folders if re.match(r"job\d{3}", f)] - # Step 1: Merge YAML configuration into args - for key, value in run_config.items(): - if getattr(args, key, None) is None: - setattr(args, key, value) + # Determine the next job number + if job_folders: + max_job_number = max([int(re.search(r"\d{3}", f).group()) for f in job_folders]) + next_job_number = max_job_number + 1 + else: + next_job_number = 1 - # Step 2: Set default values for any missing arguments from `arg_map` - for key, params in arg_map.items(): - if getattr(args, key, None) is None: - setattr(args, key, params.get("default")) + # Format the new job folder name + new_job_folder = f"job{next_job_number:03d}" + new_job_folder_path = os.path.join(base_dir, new_job_folder) - # Step 3: Override with CLI values if provided - for key in arg_map.keys(): - cli_value = getattr(args, key, None) - if cli_value is not None: - run_config[key] = cli_value + # Create the new job folder + os.makedirs(new_job_folder_path, exist_ok=True) - return args + return new_job_folder_path def main(): @@ -137,22 +52,38 @@ def main(): Main function for calculating the entropy of a system using the multiscale cell correlation method. """ - try: - config = load_config("config.yaml") + folder = create_job_folder() + data_logger = DataLogger() + arg_config = ConfigManager() + + # Load configuration + config = arg_config.load_config("config.yaml") + if config is None: + raise ValueError( + "No configuration file found, and no CLI arguments were provided." + ) + + parser = arg_config.setup_argparse() + args, unknown = parser.parse_known_args() + args.outfile = os.path.join(folder, args.outfile) + + # Determine logging level + log_level = logging.DEBUG if args.verbose else logging.INFO - if config is None: - raise ValueError( - "No configuration file found, and no CLI arguments were provided." - ) + # Initialize the logging system with the determined log level + logging_config = LoggingConfig(folder, default_level=log_level) + logger = logging_config.setup_logging() - parser = setup_argparse() - args, unknown = parser.parse_known_args() + # Capture and log the command-line invocation + command = " ".join(sys.argv) + logging.getLogger("commands").info(command) + try: # Process each run in the YAML configuration for run_name, run_config in config.items(): if isinstance(run_config, dict): # Merging CLI arguments with YAML configuration - args = merge_configs(args, run_config) + args = arg_config.merge_configs(args, run_config) # Ensure necessary arguments are provided if not getattr(args, "top_traj_file"): @@ -164,18 +95,16 @@ def main(): "The 'selection_string' argument is required but not provided." ) - # REPLACE INPUTS - print(f"Printing all input for {run_name}") + # Log all inputs for the current run + logger.info(f"All input for {run_name}") for arg in vars(args): - print(f" {arg}: {getattr(args, arg) or ''}") + logger.info(f" {arg}: {getattr(args, arg) or ''}") else: - print(f"Run configuration for {run_name} is not a dictionary.") + logger.warning(f"Run configuration for {run_name} is not a dictionary.") except ValueError as e: - print(e) + logger.error(e) raise - # startTime = datetime.now() - # Get topology and trajectory file names and make universe tprfile = args.top_traj_file[0] trrfile = args.top_traj_file[1:] @@ -202,7 +131,7 @@ def main(): number_frames = math.floor((end - start) / step) + 1 else: number_frames = math.floor((end - start) / step) + 1 - print(number_frames) + logger.debug(f"Number of Frames: {number_frames}") # Create pandas data frame for results results_df = pd.DataFrame(columns=["Molecule ID", "Level", "Type", "Result"]) @@ -210,13 +139,6 @@ def main(): columns=["Molecule ID", "Residue", "Type", "Result"] ) - # printing headings for output files - with open(args.outfile, "a") as out: - print("Molecule\tLevel\tType\tResult (J/mol/K)\n", file=out) - - with open(args.resfile, "a") as res: - print("Molecule\tResidue\tType\tResult (J/mol/K)\n", file=res) - # Reduce number of atoms in MDA universe to selection_string arg # (default all atoms included) if args.selection_string == "all": @@ -294,7 +216,7 @@ def main(): force_matrix, "force", args.temperature, highest_level ) S_trans += S_trans_residue - print(f"S_trans_{level}_{residue} = {S_trans_residue}") + logger.debug(f"S_trans_{level}_{residue} = {S_trans_residue}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -306,21 +228,15 @@ def main(): residue_results_df = pd.concat( [residue_results_df, new_row], ignore_index=True ) - with open(args.resfile, "a") as res: - print( - molecule, - "\t", - residue, - "\tTransvibration\t", - S_trans_residue, - file=res, - ) + data_logger.add_residue_data( + molecule, residue, "Transvibrational", S_trans_residue + ) S_rot_residue = EF.vibrational_entropy( torque_matrix, "torque", args.temperature, highest_level ) S_rot += S_rot_residue - print(f"S_rot_{level}_{residue} = {S_rot_residue}") + logger.debug(f"S_rot_{level}_{residue} = {S_rot_residue}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -332,16 +248,9 @@ def main(): residue_results_df = pd.concat( [residue_results_df, new_row], ignore_index=True ) - with open(args.resfile, "a") as res: - # print(new_row, file=res) - print( - molecule, - "\t", - residue, - "\tRovibrational \t", - S_rot_residue, - file=res, - ) + data_logger.add_residue_data( + molecule, residue, "Rovibrational", S_rot_residue + ) # Conformational entropy based on atom dihedral angle distributions # Gives entropy of conformations within each residue @@ -360,7 +269,7 @@ def main(): number_frames, ) S_conf += S_conf_residue - print(f"S_conf_{level}_{residue} = {S_conf_residue}") + logger.debug(f"S_conf_{level}_{residue} = {S_conf_residue}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -372,18 +281,12 @@ def main(): residue_results_df = pd.concat( [residue_results_df, new_row], ignore_index=True ) - with open(args.resfile, "a") as res: - print( - molecule, - "\t", - residue, - "\tConformational\t", - S_conf_residue, - file=res, - ) + data_logger.add_residue_data( + molecule, residue, "Conformational", S_conf_residue + ) # Print united atom level results summed over all residues - print(f"S_trans_{level} = {S_trans}") + logger.debug(f"S_trans_{level} = {S_trans}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -392,14 +295,14 @@ def main(): "Result": [S_trans], } ) - with open(args.outfile, "a") as out: - print( - molecule, "\t", level, "\tTransvibration\t", S_trans, file=out - ) results_df = pd.concat([results_df, new_row], ignore_index=True) - print(f"S_rot_{level} = {S_rot}") + data_logger.add_results_data( + molecule, level, "Transvibrational", S_trans + ) + + logger.debug(f"S_rot_{level} = {S_rot}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -409,10 +312,10 @@ def main(): } ) results_df = pd.concat([results_df, new_row], ignore_index=True) - with open(args.outfile, "a") as out: - print(molecule, "\t", level, "\tRovibrational \t", S_rot, file=out) - print(f"S_conf_{level} = {S_conf}") + data_logger.add_results_data(molecule, level, "Rovibrational", S_rot) + logger.debug(f"S_conf_{level} = {S_conf}") + new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -422,8 +325,8 @@ def main(): } ) results_df = pd.concat([results_df, new_row], ignore_index=True) - with open(args.outfile, "a") as out: - print(molecule, "\t", level, "\tConformational\t", S_conf, file=out) + + data_logger.add_results_data(molecule, level, "Conformational", S_conf) if level in ("polymer", "residue"): # Vibrational entropy at every level @@ -443,8 +346,10 @@ def main(): S_trans = EF.vibrational_entropy( force_matrix, "force", args.temperature, highest_level ) - print(f"S_trans_{level} = {S_trans}") - new_row = pd.DataFrame( + logger.debug(f"S_trans_{level} = {S_trans}") + + # Create new row as a DataFrame for Transvibrational + new_row_trans = pd.DataFrame( { "Molecule ID": [molecule], "Level": [level], @@ -452,17 +357,18 @@ def main(): "Result": [S_trans], } ) - results_df = pd.concat([results_df, new_row], ignore_index=True) - with open(args.outfile, "a") as out: - print( - molecule, "\t", level, "\tTransvibrational\t", S_trans, file=out - ) + # Concatenate the new row to the DataFrame + results_df = pd.concat([results_df, new_row_trans], ignore_index=True) + + # Calculate the entropy for Rovibrational S_rot = EF.vibrational_entropy( torque_matrix, "torque", args.temperature, highest_level ) - print(f"S_rot_{level} = {S_rot}") - new_row = pd.DataFrame( + logger.debug(f"S_rot_{level} = {S_rot}") + + # Create new row as a DataFrame for Rovibrational + new_row_rot = pd.DataFrame( { "Molecule ID": [molecule], "Level": [level], @@ -470,9 +376,14 @@ def main(): "Result": [S_rot], } ) - results_df = pd.concat([results_df, new_row], ignore_index=True) - with open(args.outfile, "a") as out: - print(molecule, "\t", level, "\tRovibrational \t", S_rot, file=out) + + # Concatenate the new row to the DataFrame + results_df = pd.concat([results_df, new_row_rot], ignore_index=True) + + data_logger.add_results_data( + molecule, level, "Transvibrational", S_trans + ) + data_logger.add_results_data(molecule, level, "Rovibrational", S_rot) # Note: conformational entropy is not calculated at the polymer level, # because there is at most one polymer bead per molecule so no dihedral @@ -494,7 +405,7 @@ def main(): step, number_frames, ) - print(f"S_conf_{level} = {S_conf}") + logger.debug(f"S_conf_{level} = {S_conf}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -504,8 +415,7 @@ def main(): } ) results_df = pd.concat([results_df, new_row], ignore_index=True) - with open(args.outfile, "a") as out: - print(molecule, "\t", level, "\tConformational\t", S_conf, file=out) + data_logger.add_results_data(molecule, level, "Conformational", S_conf) # Orientational entropy based on network of neighbouring molecules, # only calculated at the highest level (whole molecule) @@ -529,7 +439,7 @@ def main(): # Report total entropy for the molecule S_molecule = results_df[results_df["Molecule ID"] == molecule]["Result"].sum() - print(f"S_molecule = {S_molecule}") + logger.debug(f"S_molecule = {S_molecule}") new_row = pd.DataFrame( { "Molecule ID": [molecule], @@ -539,8 +449,16 @@ def main(): } ) results_df = pd.concat([results_df, new_row], ignore_index=True) - with open(args.outfile, "a") as out: - print(molecule, "\t Molecule\tTotal Entropy\t", S_molecule, file=out) + + data_logger.add_results_data( + molecule, level, "Molecule Total Entropy", S_molecule + ) + data_logger.save_dataframes_as_json( + results_df, residue_results_df, args.outfile + ) + + logger.info("Molecules:") + data_logger.log_tables() if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 696e1a7..c1fc229 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,8 @@ dependencies = [ "pandas==2.2.3", "psutil==5.9.5", "PyYAML==6.0.2", + "python-json-logger==3.3.0", + "tabulate==0.9.0" ] [project.urls] diff --git a/tests/test_EntropyFunctions/test_frequency_calculation.py b/tests/test_EntropyFunctions/test_frequency_calculation.py index 3b82cdc..ed0a31d 100644 --- a/tests/test_EntropyFunctions/test_frequency_calculation.py +++ b/tests/test_EntropyFunctions/test_frequency_calculation.py @@ -1,7 +1,7 @@ import numpy import pytest -from CodeEntropy import EntropyFunctions as EF +from CodeEntropy.calculations import EntropyFunctions as EF # Test frequency_calculation # calculate vibrational frequencies from eigenvalues of covariance matrix diff --git a/tests/test_EntropyFunctions/test_main_mcc.py b/tests/test_EntropyFunctions/test_main_mcc.py index fa6d023..0ce491c 100644 --- a/tests/test_EntropyFunctions/test_main_mcc.py +++ b/tests/test_EntropyFunctions/test_main_mcc.py @@ -2,13 +2,8 @@ import unittest from unittest.mock import MagicMock, mock_open, patch -from CodeEntropy.main_mcc import ( - arg_map, - load_config, - main, - merge_configs, - setup_argparse, -) +from CodeEntropy.config.arg_config_manager import ConfigManager +from CodeEntropy.main_mcc import main class test_maincc(unittest.TestCase): @@ -58,9 +53,12 @@ def test_load_config(self, mock_exists, mock_file): """ Test loading a valid configuration file. """ + + arg_config = ConfigManager() + self.setup_file(mock_file) - config = load_config(self.config_file) + config = arg_config.load_config(self.config_file) self.assertIn("run1", config) self.assertEqual( @@ -72,34 +70,35 @@ def test_load_config_file_not_found(self, mock_file): """ Test loading a configuration file that does not exist. """ + + arg_config = ConfigManager() + with self.assertRaises(FileNotFoundError): - load_config(self.config_file) + arg_config.load_config(self.config_file) - @patch("CodeEntropy.main_mcc.load_config", return_value=None) + @patch.object(ConfigManager, "load_config", return_value=None) def test_no_cli_no_yaml(self, mock_load_config): - """ - Test behavior when no CLI arguments and no YAML file are provided. - Should raise an exception or use defaults. - """ + """Test behavior when no CLI arguments and no YAML file are provided.""" with self.assertRaises(ValueError) as context: self.code_entropy() - self.assertTrue( - "No configuration file found, and no CLI arguments were provided." - in str(context.exception) + self.assertEqual( + str(context.exception), + "No configuration file found, and no CLI arguments were provided.", ) def test_invalid_run_config_type(self): """ Test that passing an invalid type for run_config raises a TypeError. """ + arg_config = ConfigManager() args = MagicMock() invalid_configs = ["string", 123, 3.14, ["list"], {("tuple_key",): "value"}] for invalid in invalid_configs: with self.assertRaises(TypeError): - merge_configs(args, invalid) + arg_config.merge_configs(args, invalid) @patch( "argparse.ArgumentParser.parse_args", @@ -124,7 +123,8 @@ def test_setup_argparse(self, mock_args): """ Test parsing command-line arguments. """ - parser = setup_argparse() + arg_config = ConfigManager() + parser = arg_config.setup_argparse() args = parser.parse_args() self.assertEqual(args.top_traj_file, ["/path/to/tpr", "/path/to/trr"]) self.assertEqual(args.selection_string, "all") @@ -133,7 +133,8 @@ def test_cli_overrides_defaults(self): """ Test if CLI parameters override default values. """ - parser = setup_argparse() + arg_config = ConfigManager() + parser = arg_config.setup_argparse() args = parser.parse_args( ["--top_traj_file", "/cli/path", "--selection_string", "cli_value"] ) @@ -146,7 +147,8 @@ def test_yaml_overrides_defaults(self): """ run_config = {"top_traj_file": ["/yaml/path"], "selection_string": "yaml_value"} args = argparse.Namespace() - merged_args = merge_configs(args, run_config) + arg_config = ConfigManager() + merged_args = arg_config.merge_configs(args, run_config) self.assertEqual(merged_args.top_traj_file, ["/yaml/path"]) self.assertEqual(merged_args.selection_string, "yaml_value") @@ -154,12 +156,13 @@ def test_cli_overrides_yaml(self): """ Test if CLI parameters override YAML parameters correctly. """ - parser = setup_argparse() + arg_config = ConfigManager() + parser = arg_config.setup_argparse() args = parser.parse_args( ["--top_traj_file", "/cli/path", "--selection_string", "cli_value"] ) run_config = {"top_traj_file": ["/yaml/path"], "selection_string": "yaml_value"} - merged_args = merge_configs(args, run_config) + merged_args = arg_config.merge_configs(args, run_config) self.assertEqual(merged_args.top_traj_file, ["/cli/path"]) self.assertEqual(merged_args.selection_string, "cli_value") @@ -167,6 +170,7 @@ def test_merge_configs(self): """ Test merging default arguments with a run configuration. """ + arg_config = ConfigManager() args = MagicMock( top_traj_file=None, selection_string=None, @@ -199,7 +203,7 @@ def test_merge_configs(self): "force_partitioning": 0.5, "waterEntropy": False, } - merged_args = merge_configs(args, run_config) + merged_args = arg_config.merge_configs(args, run_config) self.assertEqual(merged_args.top_traj_file, ["/path/to/tpr", "/path/to/trr"]) self.assertEqual(merged_args.selection_string, "all") @@ -208,12 +212,26 @@ def test_default_values(self, mock_parse_args): """ Test if argument parser assigns default values correctly. """ - default_args = {arg: params["default"] for arg, params in arg_map.items()} + arg_config = ConfigManager() + + # Ensure every argument gets a sensible default + default_args = { + arg: params.get("default", False if "action" in params else None) + for arg, params in arg_config.arg_map.items() + } + + # Mock argparse to return expected defaults mock_parse_args.return_value = MagicMock(**default_args) - parser = setup_argparse() + + parser = arg_config.setup_argparse() args = parser.parse_args() - for arg, params in arg_map.items(): - self.assertEqual(getattr(args, arg), params["default"]) + + # Compare parsed args with expected defaults + for arg, params in arg_config.arg_map.items(): + expected_default = params.get( + "default", False if "action" in params else None + ) + self.assertEqual(getattr(args, arg), expected_default) @patch( "argparse.ArgumentParser.parse_args", return_value=MagicMock(top_traj_file=None) @@ -222,7 +240,8 @@ def test_missing_required_arguments(self, mock_args): """ Test behavior when required arguments are missing. """ - parser = setup_argparse() + arg_config = ConfigManager() + parser = arg_config.setup_argparse() args = parser.parse_args() with self.assertRaises(ValueError): if not args.top_traj_file: @@ -234,7 +253,8 @@ def test_invalid_argument_type(self): """ Test handling of invalid argument types. """ - parser = setup_argparse() + arg_config = ConfigManager() + parser = arg_config.setup_argparse() with self.assertRaises(SystemExit): parser.parse_args(["--start", "invalid"]) @@ -245,7 +265,8 @@ def test_edge_case_argument_values(self, mock_args): """ Test parsing of edge case values. """ - parser = setup_argparse() + arg_config = ConfigManager() + parser = arg_config.setup_argparse() args = parser.parse_args() self.assertEqual(args.start, -1) self.assertEqual(args.end, -10) @@ -257,7 +278,10 @@ def test_empty_yaml_config(self, mock_exists, mock_file): Test behavior when an empty YAML file is provided. Should use defaults or raise an appropriate error. """ - config = load_config(self.config_file) + + arg_config = ConfigManager() + + config = arg_config.load_config(self.config_file) self.assertIsInstance(config, dict) self.assertEqual(config, {}) diff --git a/tests/test_EntropyFunctions/test_vibrational_entropy.py b/tests/test_EntropyFunctions/test_vibrational_entropy.py index b22c599..d29d432 100644 --- a/tests/test_EntropyFunctions/test_vibrational_entropy.py +++ b/tests/test_EntropyFunctions/test_vibrational_entropy.py @@ -1,7 +1,7 @@ import numpy import pytest -from CodeEntropy import EntropyFunctions as EF +from CodeEntropy.calculations import EntropyFunctions as EF # Test vibrational_entropy # Given a matrix does the code calculate the correct entropy value