From 8ef0ac13c90a3254d77476ef224d33a6f00d81a4 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 31 Oct 2025 09:06:05 +0000 Subject: [PATCH 001/101] restructure current `levels` file into abstracted and defined new classes and files --- CodeEntropy/{ => cli}/main.py | 0 CodeEntropy/{ => config}/run.py | 0 CodeEntropy/config/utils/__init__.py | 0 CodeEntropy/config/utils/io_utils.py | 0 CodeEntropy/config/utils/math_utils.py | 0 .../entropy_manager.py} | 0 .../{ => group_molecules}/group_molecules.py | 0 CodeEntropy/levels.py | 1173 ----------------- CodeEntropy/levels/coordinate_system.py | 227 ++++ CodeEntropy/levels/force_torque_manager.py | 420 ++++++ CodeEntropy/levels/level_hierarchy.py | 101 ++ CodeEntropy/levels/level_manager.py | 26 + CodeEntropy/levels/matrix_operations.py | 194 +++ CodeEntropy/levels/structual_analysis.py | 289 ++++ 14 files changed, 1257 insertions(+), 1173 deletions(-) rename CodeEntropy/{ => cli}/main.py (100%) rename CodeEntropy/{ => config}/run.py (100%) create mode 100644 CodeEntropy/config/utils/__init__.py create mode 100644 CodeEntropy/config/utils/io_utils.py create mode 100644 CodeEntropy/config/utils/math_utils.py rename CodeEntropy/{entropy.py => entropy/entropy_manager.py} (100%) rename CodeEntropy/{ => group_molecules}/group_molecules.py (100%) delete mode 100644 CodeEntropy/levels.py create mode 100644 CodeEntropy/levels/coordinate_system.py create mode 100644 CodeEntropy/levels/force_torque_manager.py create mode 100644 CodeEntropy/levels/level_hierarchy.py create mode 100644 CodeEntropy/levels/level_manager.py create mode 100644 CodeEntropy/levels/matrix_operations.py create mode 100644 CodeEntropy/levels/structual_analysis.py diff --git a/CodeEntropy/main.py b/CodeEntropy/cli/main.py similarity index 100% rename from CodeEntropy/main.py rename to CodeEntropy/cli/main.py diff --git a/CodeEntropy/run.py b/CodeEntropy/config/run.py similarity index 100% rename from CodeEntropy/run.py rename to CodeEntropy/config/run.py diff --git a/CodeEntropy/config/utils/__init__.py b/CodeEntropy/config/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/config/utils/io_utils.py b/CodeEntropy/config/utils/io_utils.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/config/utils/math_utils.py b/CodeEntropy/config/utils/math_utils.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/entropy.py b/CodeEntropy/entropy/entropy_manager.py similarity index 100% rename from CodeEntropy/entropy.py rename to CodeEntropy/entropy/entropy_manager.py diff --git a/CodeEntropy/group_molecules.py b/CodeEntropy/group_molecules/group_molecules.py similarity index 100% rename from CodeEntropy/group_molecules.py rename to CodeEntropy/group_molecules/group_molecules.py diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py deleted file mode 100644 index 62523e65..00000000 --- a/CodeEntropy/levels.py +++ /dev/null @@ -1,1173 +0,0 @@ -import logging - -import numpy as np -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) - -logger = logging.getLogger(__name__) - - -class LevelManager: - """ - Manages the structural and dynamic levels involved in entropy calculations. This - includes selecting relevant levels, computing axes for translation and rotation, - and handling bead-based representations of molecular systems. Provides utility - methods to extract averaged positions, convert coordinates to spherical systems, - compute weighted forces and torques, and manipulate matrices used in entropy - analysis. - """ - - def __init__(self): - """ - Initializes the LevelManager with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ - self.data_container = None - self._levels = None - self._trans_axes = None - self._rot_axes = None - self._number_of_beads = None - - def select_levels(self, data_container): - """ - Function to read input system and identify the number of molecules and - the levels (i.e. united atom, residue and/or polymer) that should be used. - The level refers to the size of the bead (atom or collection of atoms) - that will be used in the entropy calculations. - - Args: - arg_DataContainer: MDAnalysis universe object containing the system of - interest - - Returns: - number_molecules (int): Number of molecules in the system. - levels (array): Strings describing the length scales for each molecule. - """ - - # fragments is MDAnalysis terminology for what chemists would call molecules - number_molecules = len(data_container.atoms.fragments) - logger.debug(f"The number of molecules is {number_molecules}.") - - fragments = data_container.atoms.fragments - levels = [[] for _ in range(number_molecules)] - - for molecule in range(number_molecules): - levels[molecule].append( - "united_atom" - ) # every molecule has at least one atom - - atoms_in_fragment = fragments[molecule].select_atoms("prop mass > 1.1") - number_residues = len(atoms_in_fragment.residues) - - if len(atoms_in_fragment) > 1: - levels[molecule].append("residue") - - if number_residues > 1: - levels[molecule].append("polymer") - - logger.debug(f"levels {levels}") - - return number_molecules, levels - - def get_matrices( - self, - data_container, - level, - number_frames, - highest_level, - force_matrix, - torque_matrix, - ): - """ - Compute and accumulate force/torque covariance matrices for a given level. - - Parameters: - data_container (MDAnalysis.Universe): Data for a molecule or residue. - level (str): 'polymer', 'residue', or 'united_atom'. - number_frames (int): Number of frames being processed. - highest_level (bool): Whether this is the top (largest bead size) level. - force_matrix, torque_matrix (np.ndarray or None): Accumulated matrices to add - to. - - Returns: - force_matrix (np.ndarray): Accumulated force covariance matrix. - torque_matrix (np.ndarray): Accumulated torque covariance matrix. - """ - - # Make beads - list_of_beads = self.get_beads(data_container, level) - - # number of beads and frames in trajectory - number_beads = len(list_of_beads) - - # initialize force and torque arrays - weighted_forces = [None for _ in range(number_beads)] - weighted_torques = [None for _ in range(number_beads)] - - # Calculate forces/torques for each bead - for bead_index in range(number_beads): - # Set up axes - # translation and rotation use different axes - # how the axes are defined depends on the level - trans_axes, rot_axes = self.get_axes(data_container, level, bead_index) - - # Sort out coordinates, forces, and torques for each atom in the bead - weighted_forces[bead_index] = self.get_weighted_forces( - data_container, list_of_beads[bead_index], trans_axes, highest_level - ) - weighted_torques[bead_index] = self.get_weighted_torques( - data_container, list_of_beads[bead_index], rot_axes - ) - - # Create covariance submatrices - force_submatrix = [ - [0 for _ in range(number_beads)] for _ in range(number_beads) - ] - torque_submatrix = [ - [0 for _ in range(number_beads)] for _ in range(number_beads) - ] - - for i in range(number_beads): - for j in range(i, number_beads): - f_sub = self.create_submatrix(weighted_forces[i], weighted_forces[j]) - t_sub = self.create_submatrix(weighted_torques[i], weighted_torques[j]) - force_submatrix[i][j] = f_sub - force_submatrix[j][i] = f_sub.T - torque_submatrix[i][j] = t_sub - torque_submatrix[j][i] = t_sub.T - - # Convert block matrices to full matrix - force_block = np.block( - [ - [force_submatrix[i][j] for j in range(number_beads)] - for i in range(number_beads) - ] - ) - torque_block = np.block( - [ - [torque_submatrix[i][j] for j in range(number_beads)] - for i in range(number_beads) - ] - ) - - # Enforce consistent shape before accumulation - if force_matrix is None: - force_matrix = np.zeros_like(force_block) - elif force_matrix.shape != force_block.shape: - raise ValueError( - f"Inconsistent force matrix shape: existing " - f"{force_matrix.shape}, new {force_block.shape}" - ) - else: - force_matrix = force_block - - if torque_matrix is None: - torque_matrix = np.zeros_like(torque_block) - elif torque_matrix.shape != torque_block.shape: - raise ValueError( - f"Inconsistent torque matrix shape: existing " - f"{torque_matrix.shape}, new {torque_block.shape}" - ) - else: - torque_matrix = torque_block - - return force_matrix, torque_matrix - - def get_dihedrals(self, data_container, level): - """ - Define the set of dihedrals for use in the conformational entropy function. - If united atom level, the dihedrals are defined from the heavy atoms - (4 bonded atoms for 1 dihedral). - If residue level, use the bonds between residues to cast dihedrals. - Note: not using improper dihedrals only ones with 4 atoms/residues - in a linear arrangement. - - Args: - data_container (MDAnalysis.Universe): system information - level (str): level of the hierarchy (should be residue or polymer) - - Returns: - dihedrals (array): set of dihedrals - """ - # Start with empty array - dihedrals = [] - - # if united atom level, read dihedrals from MDAnalysis universe - if level == "united_atom": - dihedrals = data_container.dihedrals - - # if residue level, looking for dihedrals involving residues - if level == "residue": - num_residues = len(data_container.residues) - logger.debug(f"Number Residues: {num_residues}") - if num_residues < 4: - logger.debug("no residue level dihedrals") - - else: - # find bonds between residues N-3:N-2 and N-1:N - for residue in range(4, num_residues + 1): - # Using MDAnalysis selection, - # assuming only one covalent bond between neighbouring residues - # TODO not written for branched polymers - atom_string = ( - "resindex " - + str(residue - 4) - + " and bonded resindex " - + str(residue - 3) - ) - atom1 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 3) - + " and bonded resindex " - + str(residue - 4) - ) - atom2 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 2) - + " and bonded resindex " - + str(residue - 1) - ) - atom3 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 1) - + " and bonded resindex " - + str(residue - 2) - ) - atom4 = data_container.select_atoms(atom_string) - - atom_group = atom1 + atom2 + atom3 + atom4 - dihedrals.append(atom_group.dihedral) - - logger.debug(f"Level: {level}, Dihedrals: {dihedrals}") - - return dihedrals - - def compute_dihedral_conformations( - self, - selector, - level, - number_frames, - bin_width, - start, - end, - step, - ce, - ): - """ - Compute dihedral conformations for a given selector and entropy level. - - Parameters: - selector (AtomGroup): Atom selection to compute dihedrals for. - level (str): Entropy level ("united_atom" or "residue"). - number_frames (int): Number of frames to process. - bin_width (float): Bin width for dihedral angle discretization. - start (int): Start frame index. - end (int): End frame index. - step (int): Step size for frame iteration. - ce : Conformational Entropy class - - Returns: - states (list): List of conformation strings per frame. - """ - # Identify the dihedral angles in the residue/molecule - dihedrals = self.get_dihedrals(selector, level) - - # When there are no dihedrals, there is only one possible conformation - # so the conformational states are not relevant - if len(dihedrals) == 0: - logger.debug("No dihedrals found; skipping conformation assignment.") - states = [] - else: - # Identify the conformational label for each dihedral at each frame - num_dihedrals = len(dihedrals) - conformation = np.zeros((num_dihedrals, number_frames)) - - for i, dihedral in enumerate(dihedrals): - conformation[i] = ce.assign_conformation( - selector, dihedral, number_frames, bin_width, start, end, step - ) - - # for all the dihedrals available concatenate the label of each - # dihedral into the state for that frame - states = [ - state - for state in ( - "".join(str(int(conformation[d][f])) for d in range(num_dihedrals)) - for f in range(number_frames) - ) - if state - ] - - logger.debug(f"level: {level}, states: {states}") - return states - - def get_beads(self, data_container, level): - """ - Function to define beads depending on the level in the hierarchy. - - Args: - data_container (MDAnalysis.Universe): the molecule data - level (str): the heirarchy level (polymer, residue, or united atom) - - Returns: - list_of_beads : the relevent beads - """ - - if level == "polymer": - list_of_beads = [] - atom_group = "all" - list_of_beads.append(data_container.select_atoms(atom_group)) - - if level == "residue": - list_of_beads = [] - num_residues = len(data_container.residues) - for residue in range(num_residues): - atom_group = "resindex " + str(residue) - list_of_beads.append(data_container.select_atoms(atom_group)) - - if level == "united_atom": - list_of_beads = [] - heavy_atoms = data_container.select_atoms("prop mass > 1.1") - if len(heavy_atoms) == 0: - # molecule without heavy atoms would be a hydrogen molecule - list_of_beads.append(data_container.select_atoms("all")) - else: - # Select one heavy atom and all light atoms bonded to it - for atom in heavy_atoms: - atom_group = ( - "index " - + str(atom.index) - + " or ((prop mass <= 1.1) and bonded index " - + str(atom.index) - + ")" - ) - list_of_beads.append(data_container.select_atoms(atom_group)) - - logger.debug(f"List of beads: {list_of_beads}") - - return list_of_beads - - def get_axes(self, data_container, level, index=0): - """ - Function to set the translational and rotational axes. - The translational axes are based on the principal axes of the unit - one level larger than the level we are interested in (except for - the polymer level where there is no larger unit). The rotational - axes use the covalent links between residues or atoms where possible - to define the axes, or if the unit is not bonded to others of the - same level the prinicpal axes of the unit are used. - - Args: - data_container (MDAnalysis.Universe): the molecule and trajectory data - level (str): the level (united atom, residue, or polymer) of interest - index (int): residue index - - Returns: - trans_axes : translational axes - rot_axes : rotational axes - """ - index = int(index) - - if level == "polymer": - # for polymer use principle axis for both translation and rotation - trans_axes = data_container.atoms.principal_axes() - rot_axes = data_container.atoms.principal_axes() - - elif level == "residue": - # Translation - # for residues use principal axes of whole molecule for translation - trans_axes = data_container.atoms.principal_axes() - - # Rotation - # find bonds between atoms in residue of interest and other residues - # we are assuming bonds only exist between adjacent residues - # (linear chains of residues) - # TODO refine selection so that it will work for branched polymers - index_prev = index - 1 - index_next = index + 1 - atom_set = data_container.select_atoms( - f"(resindex {index_prev} or resindex {index_next}) " - f"and bonded resid {index}" - ) - residue = data_container.select_atoms(f"resindex {index}") - - if len(atom_set) == 0: - # if no bonds to other residues use pricipal axes of residue - rot_axes = residue.atoms.principal_axes() - - else: - # set center of rotation to center of mass of the residue - center = residue.atoms.center_of_mass() - - # get vector for average position of bonded atoms - vector = self.get_avg_pos(atom_set, center) - - # use spherical coordinates function to get rotational axes - rot_axes = self.get_sphCoord_axes(vector) - - elif level == "united_atom": - # Translation - # for united atoms use principal axes of residue for translation - trans_axes = data_container.residues.principal_axes() - - # Rotation - # for united atoms use heavy atoms bonded to the heavy atom - atom_set = data_container.select_atoms( - f"(prop mass > 1.1) and bonded index {index}" - ) - - if len(atom_set) == 0: - # if no bonds to other residues use pricipal axes of residue - rot_axes = data_container.residues.principal_axes() - else: - # center at position of heavy atom - atom_group = data_container.select_atoms(f"index {index}") - center = atom_group.positions[0] - - # get vector for average position of bonded atoms - vector = self.get_avg_pos(atom_set, center) - - # use spherical coordinates function to get rotational axes - rot_axes = self.get_sphCoord_axes(vector) - - logger.debug(f"Translational Axes: {trans_axes}") - logger.debug(f"Rotational Axes: {rot_axes}") - - return trans_axes, rot_axes - - def get_avg_pos(self, atom_set, center): - """ - Function to get the average position of a set of atoms. - - Args: - atom_set : MDAnalysis atom group - center : position for center of rotation - - Returns: - avg_position : three dimensional vector - """ - # start with an empty vector - avg_position = np.zeros((3)) - - # get number of atoms - number_atoms = len(atom_set.names) - - if number_atoms != 0: - # sum positions for all atoms in the given set - for atom_index in range(number_atoms): - atom_position = atom_set.atoms[atom_index].position - - avg_position += atom_position - - avg_position /= number_atoms # divide by number of atoms to get average - - else: - # if no atoms in set the unit has no bonds to restrict its rotational - # motion, so we can use a random vector to get spherical - # coordinate axes - avg_position = np.random.random(3) - - # 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 - - def get_sphCoord_axes(self, arg_r): - """ - For a given vector in space, treat it is a radial vector rooted at - 0,0,0 and derive a curvilinear coordinate system according to the - rules of polar spherical coordinates - - Args: - arg_r: 3 dimensional vector - - Returns: - spherical_basis: axes set (3 vectors) - """ - - x2y2 = arg_r[0] ** 2 + arg_r[1] ** 2 - r2 = x2y2 + arg_r[2] ** 2 - - # Check for division by zero - if r2 == 0.0: - raise ValueError("r2 is zero, cannot compute spherical coordinates.") - - if x2y2 == 0.0: - raise ValueError("x2y2 is zero, cannot compute sin_phi and cos_phi.") - - # These conditions are mathematically unreachable for real-valued vectors. - # Marked as no cover to avoid false negatives in coverage reports. - - # Check for non-negative values inside the square root - if x2y2 / r2 < 0: # pragma: no cover - raise ValueError( - f"Negative value encountered for sin_theta calculation: {x2y2 / r2}. " - f"Cannot take square root." - ) - - if x2y2 < 0: # pragma: no cover - raise ValueError( - f"Negative value encountered for sin_phi and cos_phi " - f"calculation: {x2y2}. " - f"Cannot take square root." - ) - - if x2y2 != 0.0: - sin_theta = np.sqrt(x2y2 / r2) - cos_theta = arg_r[2] / np.sqrt(r2) - - sin_phi = arg_r[1] / np.sqrt(x2y2) - cos_phi = arg_r[0] / np.sqrt(x2y2) - - else: # pragma: no cover - sin_theta = 0.0 - cos_theta = 1 - - sin_phi = 0.0 - cos_phi = 1 - - # if abs(sin_theta) > 1 or abs(sin_phi) > 1: - # print('Bad sine : T {} , P {}'.format(sin_theta, sin_phi)) - - # cos_theta = np.sqrt(1 - sin_theta*sin_theta) - # cos_phi = np.sqrt(1 - sin_phi*sin_phi) - - # print('{} {} {}'.format(*arg_r)) - # print('Sin T : {}, cos T : {}'.format(sin_theta, cos_theta)) - # print('Sin P : {}, cos P : {}'.format(sin_phi, cos_phi)) - - spherical_basis = np.zeros((3, 3)) - - # r^ - spherical_basis[0, :] = np.asarray( - [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta] - ) - - # Theta^ - spherical_basis[1, :] = np.asarray( - [cos_theta * cos_phi, cos_theta * sin_phi, -sin_theta] - ) - - # Phi^ - spherical_basis[2, :] = np.asarray([-sin_phi, cos_phi, 0.0]) - - logger.debug(f"Spherical Basis: {spherical_basis}") - - return spherical_basis - - def get_weighted_forces( - self, data_container, bead, trans_axes, highest_level, force_partitioning=0.5 - ): - """ - Function to calculate the mass weighted forces for a given bead. - - Args: - data_container (MDAnalysis.Universe): Contains atomic positions and forces. - bead : The part of the molecule to be considered. - trans_axes (np.ndarray): The axes relative to which the forces are located. - highest_level (bool): Is this the largest level of the length scale hierarchy - force_partitioning (float): Factor to adjust force contributions to avoid - over counting correlated forces, default is 0.5. - - Returns: - weighted_force (np.ndarray): The mass-weighted sum of the forces in the - bead. - """ - - forces_trans = np.zeros((3,)) - - # Sum forces from all atoms in the bead - for atom in bead.atoms: - # update local forces in translational axes - forces_local = np.matmul(trans_axes, data_container.atoms[atom.index].force) - forces_trans += forces_local - - if highest_level: - # multiply by the force_partitioning parameter to avoid double counting - # of the forces on weakly correlated atoms - # the default value of force_partitioning is 0.5 (dividing by two) - forces_trans = force_partitioning * forces_trans - - # divide the sum of forces by the mass of the bead to get the weighted forces - mass = bead.total_mass() - - # Check that mass is positive to avoid division by 0 or negative values inside - # sqrt - if mass <= 0: - raise ValueError( - f"Invalid mass value: {mass}. Mass must be positive to compute the " - f"square root." - ) - - weighted_force = forces_trans / np.sqrt(mass) - - logger.debug(f"Weighted Force: {weighted_force}") - - return weighted_force - - def get_weighted_torques( - self, data_container, bead, rot_axes, force_partitioning=0.5 - ): - """ - Function to calculate the moment of inertia weighted torques for a given bead. - - This function computes torques in a rotated frame and then weights them using - the moment of inertia tensor. To prevent numerical instability, it treats - extremely small diagonal elements of the moment of inertia tensor as zero - (since values below machine precision are effectively zero). This avoids - unnecessary use of extended precision (e.g., float128). - - Additionally, if the computed torque is already zero, the function skips - the division step, reducing unnecessary computations and potential errors. - - Parameters - ---------- - data_container : object - Contains atomic positions and forces. - bead : object - The part of the molecule to be considered. - rot_axes : np.ndarray - The axes relative to which the forces and coordinates are located. - force_partitioning : float, optional - Factor to adjust force contributions, default is 0.5. - - Returns - ------- - weighted_torque : np.ndarray - The mass-weighted sum of the torques in the bead. - """ - - torques = np.zeros((3,)) - weighted_torque = np.zeros((3,)) - - for atom in bead.atoms: - - # update local coordinates in rotational axes - coords_rot = ( - data_container.atoms[atom.index].position - bead.center_of_mass() - ) - coords_rot = np.matmul(rot_axes, coords_rot) - # update local forces in rotational frame - forces_rot = np.matmul(rot_axes, data_container.atoms[atom.index].force) - - # multiply by the force_partitioning parameter to avoid double counting - # of the forces on weakly correlated atoms - # the default value of force_partitioning is 0.5 (dividing by two) - forces_rot = force_partitioning * forces_rot - - # define torques (cross product of coordinates and forces) in rotational - # axes - torques_local = np.cross(coords_rot, forces_rot) - torques += torques_local - - # divide by moment of inertia to get weighted torques - # moment of inertia is a 3x3 tensor - # the weighting is done in each dimension (x,y,z) using the diagonal - # elements of the moment of inertia tensor - moment_of_inertia = bead.moment_of_inertia() - - for dimension in range(3): - # Skip calculation if torque is already zero - if np.isclose(torques[dimension], 0): - weighted_torque[dimension] = 0 - continue - - # Check for zero moment of inertia - if np.isclose(moment_of_inertia[dimension, dimension], 0): - raise ZeroDivisionError( - f"Attempted to divide by zero moment of inertia in dimension " - f"{dimension}." - ) - - # Check for negative moment of inertia - if moment_of_inertia[dimension, dimension] < 0: - raise ValueError( - f"Negative value encountered for moment of inertia: " - f"{moment_of_inertia[dimension, dimension]} " - f"Cannot compute weighted torque." - ) - - # Compute weighted torque - weighted_torque[dimension] = torques[dimension] / np.sqrt( - moment_of_inertia[dimension, dimension] - ) - - logger.debug(f"Weighted Torque: {weighted_torque}") - - return weighted_torque - - def create_submatrix(self, data_i, data_j): - """ - Function for making covariance matrices. - - Args - ----- - data_i : values for bead i - data_j : values for bead j - - Returns - ------ - submatrix : 3x3 matrix for the covariance between i and j - """ - - # Start with 3 by 3 matrix of zeros - submatrix = np.zeros((3, 3)) - - # For each frame calculate the outer product (cross product) of the data from - # the two beads and add the result to the submatrix - outer_product_matrix = np.outer(data_i, data_j) - submatrix = np.add(submatrix, outer_product_matrix) - - logger.debug(f"Submatrix: {submatrix}") - - return submatrix - - def build_covariance_matrices( - self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - ): - """ - Construct average force and torque covariance matrices for all molecules and - entropy levels. - - Parameters - ---------- - entropy_manager : EntropyManager - Instance of the EntropyManager. - reduced_atom : Universe - The reduced atom selection. - levels : dict - Dictionary mapping molecule IDs to lists of entropy levels. - groups : dict - Dictionary mapping group IDs to lists of molecule IDs. - start : int - Start frame index. - end : int - End frame index. - step : int - Step size for frame iteration. - number_frames : int - Total number of frames to process. - - Returns - ------- - tuple - force_avg : dict - Averaged force covariance matrices by entropy level. - torque_avg : dict - Averaged torque covariance matrices by entropy level. - """ - number_groups = len(groups) - - force_avg = { - "ua": {}, - "res": [None] * number_groups, - "poly": [None] * number_groups, - } - torque_avg = { - "ua": {}, - "res": [None] * number_groups, - "poly": [None] * number_groups, - } - - total_steps = len(reduced_atom.trajectory[start:end:step]) - total_items = ( - sum(len(levels[mol_id]) for mols in groups.values() for mol_id in mols) - * total_steps - ) - - frame_counts = { - "ua": {}, - "res": np.zeros(number_groups, dtype=int), - "poly": np.zeros(number_groups, dtype=int), - } - - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) as progress: - - task = progress.add_task( - "[green]Processing...", - total=total_items, - title="Starting...", - ) - - indices = list(range(number_frames)) - for time_index, _ in zip(indices, reduced_atom.trajectory[start:end:step]): - for group_id, molecules in groups.items(): - for mol_id in molecules: - mol = entropy_manager._get_molecule_container( - reduced_atom, mol_id - ) - for level in levels[mol_id]: - mol = entropy_manager._get_molecule_container( - reduced_atom, mol_id - ) - - resname = mol.atoms[0].resname - resid = mol.atoms[0].resid - segid = mol.atoms[0].segid - - mol_label = f"{resname}_{resid} (segid {segid})" - - progress.update( - task, - title=f"Building covariance matrices | " - f"Timestep {time_index} | " - f"Molecule: {mol_label} | " - f"Level: {level}", - ) - - self.update_force_torque_matrices( - entropy_manager, - mol, - group_id, - level, - levels[mol_id], - time_index, - number_frames, - force_avg, - torque_avg, - frame_counts, - ) - - progress.advance(task) - - return force_avg, torque_avg, frame_counts - - def update_force_torque_matrices( - self, - entropy_manager, - mol, - group_id, - level, - level_list, - time_index, - num_frames, - force_avg, - torque_avg, - frame_counts, - ): - """ - Update the running averages of force and torque covariance matrices - for a given molecule and entropy level. - - This function computes the force and torque covariance matrices for the - current frame and updates the existing averages in-place using the incremental - mean formula: - - new_avg = old_avg + (value - old_avg) / n - - where n is the number of frames processed so far for that molecule/level - combination. This ensures that the averages are maintained without storing - all previous frame data. - - Parameters - ---------- - entropy_manager : EntropyManager - Instance of the EntropyManager. - mol : AtomGroup - The molecule to process. - group_id : int - Index of the group to which the molecule belongs. - level : str - Current entropy level ("united_atom", "residue", or "polymer"). - level_list : list - List of entropy levels for the molecule. - time_index : int - Index of the current frame relative to the start of the trajectory slice. - num_frames : int - Total number of frames to process. - force_avg : dict - Dictionary holding the running average force matrices, keyed by entropy - level. - torque_avg : dict - Dictionary holding the running average torque matrices, keyed by entropy - level. - frame_counts : dict - Dictionary holding the count of frames processed for each molecule/level - combination. - - Returns - ------- - None - Updates are performed in-place on `force_avg`, `torque_avg`, and - `frame_counts`. - """ - highest = level == level_list[-1] - - # United atom level calculations are done separately for each residue - # This allows information per residue to be output and keeps the - # matrices from becoming too large - if level == "united_atom": - for res_id, residue in enumerate(mol.residues): - key = (group_id, res_id) - res = entropy_manager._run_manager.new_U_select_atom( - mol, f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}" - ) - - # This is to get MDAnalysis to get the information from the - # correct frame of the trajectory - res.trajectory[time_index] - - # Build the matrices, adding data from each timestep - # Being careful for the first timestep when data has not yet - # been added to the matrices - f_mat, t_mat = self.get_matrices( - res, - level, - num_frames, - highest, - None if key not in force_avg["ua"] else force_avg["ua"][key], - None if key not in torque_avg["ua"] else torque_avg["ua"][key], - ) - - if key not in force_avg["ua"]: - force_avg["ua"][key] = f_mat.copy() - torque_avg["ua"][key] = t_mat.copy() - frame_counts["ua"][key] = 1 - else: - frame_counts["ua"][key] += 1 - n = frame_counts["ua"][key] - force_avg["ua"][key] += (f_mat - force_avg["ua"][key]) / n - torque_avg["ua"][key] += (t_mat - torque_avg["ua"][key]) / n - - elif level in ["residue", "polymer"]: - # This is to get MDAnalysis to get the information from the - # correct frame of the trajectory - mol.trajectory[time_index] - - key = "res" if level == "residue" else "poly" - - # Build the matrices, adding data from each timestep - # Being careful for the first timestep when data has not yet - # been added to the matrices - f_mat, t_mat = self.get_matrices( - mol, - level, - num_frames, - highest, - None if force_avg[key][group_id] is None else force_avg[key][group_id], - ( - None - if torque_avg[key][group_id] is None - else torque_avg[key][group_id] - ), - ) - - if force_avg[key][group_id] is None: - force_avg[key][group_id] = f_mat.copy() - torque_avg[key][group_id] = t_mat.copy() - frame_counts[key][group_id] = 1 - else: - frame_counts[key][group_id] += 1 - n = frame_counts[key][group_id] - force_avg[key][group_id] += (f_mat - force_avg[key][group_id]) / n - torque_avg[key][group_id] += (t_mat - torque_avg[key][group_id]) / n - - return frame_counts - - def filter_zero_rows_columns(self, arg_matrix): - """ - function for removing rows and columns that contain only zeros from a matrix - - Args: - arg_matrix : matrix - - Returns: - arg_matrix : the reduced size matrix - """ - - # record the initial size - init_shape = np.shape(arg_matrix) - - zero_indices = list( - filter( - lambda row: np.all(np.isclose(arg_matrix[row, :], 0.0)), - np.arange(np.shape(arg_matrix)[0]), - ) - ) - all_indices = np.ones((np.shape(arg_matrix)[0]), dtype=bool) - all_indices[zero_indices] = False - arg_matrix = arg_matrix[all_indices, :] - - all_indices = np.ones((np.shape(arg_matrix)[1]), dtype=bool) - zero_indices = list( - filter( - lambda col: np.all(np.isclose(arg_matrix[:, col], 0.0)), - np.arange(np.shape(arg_matrix)[1]), - ) - ) - all_indices[zero_indices] = False - arg_matrix = arg_matrix[:, all_indices] - - # get the final shape - final_shape = np.shape(arg_matrix) - - 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 - - def build_conformational_states( - self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - bin_width, - ce, - ): - """ - Construct the conformational states for each molecule at - relevant levels. - - Parameters: - entropy_manager (EntropyManager): Instance of the EntropyManager - reduced_atom (Universe): The reduced atom selection. - levels (list): List of entropy levels per molecule. - groups (dict): Groups for averaging over molecules. - start (int): Start frame index. - end (int): End frame index. - step (int): Step size for frame iteration. - number_frames (int): Total number of frames to process. - bin_width (int): Width of histogram bins. - ce: Conformational Entropy object - - Returns: - tuple: A tuple containing: - - states_ua (dict): Conformational states at the united-atom level. - - states_res (list): Conformational states at the residue level. - """ - number_groups = len(groups) - states_ua = {} - states_res = [None] * number_groups - - total_items = sum( - len(levels[mol_id]) for mols in groups.values() for mol_id in mols - ) - - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) as progress: - - task = progress.add_task( - "[green]Building Conformational States...", - total=total_items, - title="Starting...", - ) - - for group_id in groups.keys(): - molecules = groups[group_id] - for mol_id in molecules: - mol = entropy_manager._get_molecule_container(reduced_atom, mol_id) - - resname = mol.atoms[0].resname - resid = mol.atoms[0].resid - segid = mol.atoms[0].segid - - mol_label = f"{resname}_{resid} (segid {segid})" - - for level in levels[mol_id]: - progress.update( - task, - title=f"Building conformational states | " - f"Molecule: {mol_label} | " - f"Level: {level}", - ) - - if level == "united_atom": - for res_id, residue in enumerate(mol.residues): - key = (group_id, res_id) - - res_container = ( - entropy_manager._run_manager.new_U_select_atom( - mol, - f"index {residue.atoms.indices[0]}:" - f"{residue.atoms.indices[-1]}", - ) - ) - heavy_res = ( - entropy_manager._run_manager.new_U_select_atom( - res_container, "prop mass > 1.1" - ) - ) - states = self.compute_dihedral_conformations( - heavy_res, - level, - number_frames, - bin_width, - start, - end, - step, - ce, - ) - - if key in states_ua: - states_ua[key].extend(states) - else: - states_ua[key] = states - - elif level == "residue": - states = self.compute_dihedral_conformations( - mol, - level, - number_frames, - bin_width, - start, - end, - step, - ce, - ) - - if states_res[group_id] is None: - states_res[group_id] = states - else: - states_res[group_id].extend(states) - - progress.advance(task) - - logger.debug(f"states_ua {states_ua}") - logger.debug(f"states_res {states_res}") - - return states_ua, states_res diff --git a/CodeEntropy/levels/coordinate_system.py b/CodeEntropy/levels/coordinate_system.py new file mode 100644 index 00000000..c25682f7 --- /dev/null +++ b/CodeEntropy/levels/coordinate_system.py @@ -0,0 +1,227 @@ +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + + +class CoordinateSystem: + """ """ + + def __init__(self): + """ + Initializes the CoordinateSystem with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + + def get_axes(self, data_container, level, index=0): + """ + Function to set the translational and rotational axes. + The translational axes are based on the principal axes of the unit + one level larger than the level we are interested in (except for + the polymer level where there is no larger unit). The rotational + axes use the covalent links between residues or atoms where possible + to define the axes, or if the unit is not bonded to others of the + same level the prinicpal axes of the unit are used. + + Args: + data_container (MDAnalysis.Universe): the molecule and trajectory data + level (str): the level (united atom, residue, or polymer) of interest + index (int): residue index + + Returns: + trans_axes : translational axes + rot_axes : rotational axes + """ + index = int(index) + + if level == "polymer": + # for polymer use principle axis for both translation and rotation + trans_axes = data_container.atoms.principal_axes() + rot_axes = data_container.atoms.principal_axes() + + elif level == "residue": + # Translation + # for residues use principal axes of whole molecule for translation + trans_axes = data_container.atoms.principal_axes() + + # Rotation + # find bonds between atoms in residue of interest and other residues + # we are assuming bonds only exist between adjacent residues + # (linear chains of residues) + # TODO refine selection so that it will work for branched polymers + index_prev = index - 1 + index_next = index + 1 + atom_set = data_container.select_atoms( + f"(resindex {index_prev} or resindex {index_next}) " + f"and bonded resid {index}" + ) + residue = data_container.select_atoms(f"resindex {index}") + + if len(atom_set) == 0: + # if no bonds to other residues use pricipal axes of residue + rot_axes = residue.atoms.principal_axes() + + else: + # set center of rotation to center of mass of the residue + center = residue.atoms.center_of_mass() + + # get vector for average position of bonded atoms + vector = self.get_avg_pos(atom_set, center) + + # use spherical coordinates function to get rotational axes + rot_axes = self.get_sphCoord_axes(vector) + + elif level == "united_atom": + # Translation + # for united atoms use principal axes of residue for translation + trans_axes = data_container.residues.principal_axes() + + # Rotation + # for united atoms use heavy atoms bonded to the heavy atom + atom_set = data_container.select_atoms( + f"(prop mass > 1.1) and bonded index {index}" + ) + + if len(atom_set) == 0: + # if no bonds to other residues use pricipal axes of residue + rot_axes = data_container.residues.principal_axes() + else: + # center at position of heavy atom + atom_group = data_container.select_atoms(f"index {index}") + center = atom_group.positions[0] + + # get vector for average position of bonded atoms + vector = self.get_avg_pos(atom_set, center) + + # use spherical coordinates function to get rotational axes + rot_axes = self.get_sphCoord_axes(vector) + + logger.debug(f"Translational Axes: {trans_axes}") + logger.debug(f"Rotational Axes: {rot_axes}") + + return trans_axes, rot_axes + + def get_avg_pos(self, atom_set, center): + """ + Function to get the average position of a set of atoms. + + Args: + atom_set : MDAnalysis atom group + center : position for center of rotation + + Returns: + avg_position : three dimensional vector + """ + # start with an empty vector + avg_position = np.zeros((3)) + + # get number of atoms + number_atoms = len(atom_set.names) + + if number_atoms != 0: + # sum positions for all atoms in the given set + for atom_index in range(number_atoms): + atom_position = atom_set.atoms[atom_index].position + + avg_position += atom_position + + avg_position /= number_atoms # divide by number of atoms to get average + + else: + # if no atoms in set the unit has no bonds to restrict its rotational + # motion, so we can use a random vector to get spherical + # coordinate axes + avg_position = np.random.random(3) + + # 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 + + def get_sphCoord_axes(self, arg_r): + """ + For a given vector in space, treat it is a radial vector rooted at + 0,0,0 and derive a curvilinear coordinate system according to the + rules of polar spherical coordinates + + Args: + arg_r: 3 dimensional vector + + Returns: + spherical_basis: axes set (3 vectors) + """ + + x2y2 = arg_r[0] ** 2 + arg_r[1] ** 2 + r2 = x2y2 + arg_r[2] ** 2 + + # Check for division by zero + if r2 == 0.0: + raise ValueError("r2 is zero, cannot compute spherical coordinates.") + + if x2y2 == 0.0: + raise ValueError("x2y2 is zero, cannot compute sin_phi and cos_phi.") + + # These conditions are mathematically unreachable for real-valued vectors. + # Marked as no cover to avoid false negatives in coverage reports. + + # Check for non-negative values inside the square root + if x2y2 / r2 < 0: # pragma: no cover + raise ValueError( + f"Negative value encountered for sin_theta calculation: {x2y2 / r2}. " + f"Cannot take square root." + ) + + if x2y2 < 0: # pragma: no cover + raise ValueError( + f"Negative value encountered for sin_phi and cos_phi " + f"calculation: {x2y2}. " + f"Cannot take square root." + ) + + if x2y2 != 0.0: + sin_theta = np.sqrt(x2y2 / r2) + cos_theta = arg_r[2] / np.sqrt(r2) + + sin_phi = arg_r[1] / np.sqrt(x2y2) + cos_phi = arg_r[0] / np.sqrt(x2y2) + + else: # pragma: no cover + sin_theta = 0.0 + cos_theta = 1 + + sin_phi = 0.0 + cos_phi = 1 + + # if abs(sin_theta) > 1 or abs(sin_phi) > 1: + # print('Bad sine : T {} , P {}'.format(sin_theta, sin_phi)) + + # cos_theta = np.sqrt(1 - sin_theta*sin_theta) + # cos_phi = np.sqrt(1 - sin_phi*sin_phi) + + # print('{} {} {}'.format(*arg_r)) + # print('Sin T : {}, cos T : {}'.format(sin_theta, cos_theta)) + # print('Sin P : {}, cos P : {}'.format(sin_phi, cos_phi)) + + spherical_basis = np.zeros((3, 3)) + + # r^ + spherical_basis[0, :] = np.asarray( + [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta] + ) + + # Theta^ + spherical_basis[1, :] = np.asarray( + [cos_theta * cos_phi, cos_theta * sin_phi, -sin_theta] + ) + + # Phi^ + spherical_basis[2, :] = np.asarray([-sin_phi, cos_phi, 0.0]) + + logger.debug(f"Spherical Basis: {spherical_basis}") + + return spherical_basis diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py new file mode 100644 index 00000000..db7a0c6b --- /dev/null +++ b/CodeEntropy/levels/force_torque_manager.py @@ -0,0 +1,420 @@ +import logging + +import numpy as np +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, +) + +logger = logging.getLogger(__name__) + + +class ForceTorqueManager: + """ """ + + def __init__(self): + """ + Initializes the ForceTorqueManager with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + + def get_weighted_forces( + self, data_container, bead, trans_axes, highest_level, force_partitioning=0.5 + ): + """ + Function to calculate the mass weighted forces for a given bead. + + Args: + data_container (MDAnalysis.Universe): Contains atomic positions and forces. + bead : The part of the molecule to be considered. + trans_axes (np.ndarray): The axes relative to which the forces are located. + highest_level (bool): Is this the largest level of the length scale hierarchy + force_partitioning (float): Factor to adjust force contributions to avoid + over counting correlated forces, default is 0.5. + + Returns: + weighted_force (np.ndarray): The mass-weighted sum of the forces in the + bead. + """ + + forces_trans = np.zeros((3,)) + + # Sum forces from all atoms in the bead + for atom in bead.atoms: + # update local forces in translational axes + forces_local = np.matmul(trans_axes, data_container.atoms[atom.index].force) + forces_trans += forces_local + + if highest_level: + # multiply by the force_partitioning parameter to avoid double counting + # of the forces on weakly correlated atoms + # the default value of force_partitioning is 0.5 (dividing by two) + forces_trans = force_partitioning * forces_trans + + # divide the sum of forces by the mass of the bead to get the weighted forces + mass = bead.total_mass() + + # Check that mass is positive to avoid division by 0 or negative values inside + # sqrt + if mass <= 0: + raise ValueError( + f"Invalid mass value: {mass}. Mass must be positive to compute the " + f"square root." + ) + + weighted_force = forces_trans / np.sqrt(mass) + + logger.debug(f"Weighted Force: {weighted_force}") + + return weighted_force + + def get_weighted_torques( + self, data_container, bead, rot_axes, force_partitioning=0.5 + ): + """ + Function to calculate the moment of inertia weighted torques for a given bead. + + This function computes torques in a rotated frame and then weights them using + the moment of inertia tensor. To prevent numerical instability, it treats + extremely small diagonal elements of the moment of inertia tensor as zero + (since values below machine precision are effectively zero). This avoids + unnecessary use of extended precision (e.g., float128). + + Additionally, if the computed torque is already zero, the function skips + the division step, reducing unnecessary computations and potential errors. + + Parameters + ---------- + data_container : object + Contains atomic positions and forces. + bead : object + The part of the molecule to be considered. + rot_axes : np.ndarray + The axes relative to which the forces and coordinates are located. + force_partitioning : float, optional + Factor to adjust force contributions, default is 0.5. + + Returns + ------- + weighted_torque : np.ndarray + The mass-weighted sum of the torques in the bead. + """ + + torques = np.zeros((3,)) + weighted_torque = np.zeros((3,)) + + for atom in bead.atoms: + + # update local coordinates in rotational axes + coords_rot = ( + data_container.atoms[atom.index].position - bead.center_of_mass() + ) + coords_rot = np.matmul(rot_axes, coords_rot) + # update local forces in rotational frame + forces_rot = np.matmul(rot_axes, data_container.atoms[atom.index].force) + + # multiply by the force_partitioning parameter to avoid double counting + # of the forces on weakly correlated atoms + # the default value of force_partitioning is 0.5 (dividing by two) + forces_rot = force_partitioning * forces_rot + + # define torques (cross product of coordinates and forces) in rotational + # axes + torques_local = np.cross(coords_rot, forces_rot) + torques += torques_local + + # divide by moment of inertia to get weighted torques + # moment of inertia is a 3x3 tensor + # the weighting is done in each dimension (x,y,z) using the diagonal + # elements of the moment of inertia tensor + moment_of_inertia = bead.moment_of_inertia() + + for dimension in range(3): + # Skip calculation if torque is already zero + if np.isclose(torques[dimension], 0): + weighted_torque[dimension] = 0 + continue + + # Check for zero moment of inertia + if np.isclose(moment_of_inertia[dimension, dimension], 0): + raise ZeroDivisionError( + f"Attempted to divide by zero moment of inertia in dimension " + f"{dimension}." + ) + + # Check for negative moment of inertia + if moment_of_inertia[dimension, dimension] < 0: + raise ValueError( + f"Negative value encountered for moment of inertia: " + f"{moment_of_inertia[dimension, dimension]} " + f"Cannot compute weighted torque." + ) + + # Compute weighted torque + weighted_torque[dimension] = torques[dimension] / np.sqrt( + moment_of_inertia[dimension, dimension] + ) + + logger.debug(f"Weighted Torque: {weighted_torque}") + + return weighted_torque + + def build_covariance_matrices( + self, + entropy_manager, + reduced_atom, + levels, + groups, + start, + end, + step, + number_frames, + ): + """ + Construct average force and torque covariance matrices for all molecules and + entropy levels. + + Parameters + ---------- + entropy_manager : EntropyManager + Instance of the EntropyManager. + reduced_atom : Universe + The reduced atom selection. + levels : dict + Dictionary mapping molecule IDs to lists of entropy levels. + groups : dict + Dictionary mapping group IDs to lists of molecule IDs. + start : int + Start frame index. + end : int + End frame index. + step : int + Step size for frame iteration. + number_frames : int + Total number of frames to process. + + Returns + ------- + tuple + force_avg : dict + Averaged force covariance matrices by entropy level. + torque_avg : dict + Averaged torque covariance matrices by entropy level. + """ + number_groups = len(groups) + + force_avg = { + "ua": {}, + "res": [None] * number_groups, + "poly": [None] * number_groups, + } + torque_avg = { + "ua": {}, + "res": [None] * number_groups, + "poly": [None] * number_groups, + } + + total_steps = len(reduced_atom.trajectory[start:end:step]) + total_items = ( + sum(len(levels[mol_id]) for mols in groups.values() for mol_id in mols) + * total_steps + ) + + frame_counts = { + "ua": {}, + "res": np.zeros(number_groups, dtype=int), + "poly": np.zeros(number_groups, dtype=int), + } + + with Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.fields[title]}", justify="right"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), + TimeElapsedColumn(), + ) as progress: + + task = progress.add_task( + "[green]Processing...", + total=total_items, + title="Starting...", + ) + + indices = list(range(number_frames)) + for time_index, _ in zip(indices, reduced_atom.trajectory[start:end:step]): + for group_id, molecules in groups.items(): + for mol_id in molecules: + mol = entropy_manager._get_molecule_container( + reduced_atom, mol_id + ) + for level in levels[mol_id]: + mol = entropy_manager._get_molecule_container( + reduced_atom, mol_id + ) + + resname = mol.atoms[0].resname + resid = mol.atoms[0].resid + segid = mol.atoms[0].segid + + mol_label = f"{resname}_{resid} (segid {segid})" + + progress.update( + task, + title=f"Building covariance matrices | " + f"Timestep {time_index} | " + f"Molecule: {mol_label} | " + f"Level: {level}", + ) + + self.update_force_torque_matrices( + entropy_manager, + mol, + group_id, + level, + levels[mol_id], + time_index, + number_frames, + force_avg, + torque_avg, + frame_counts, + ) + + progress.advance(task) + + return force_avg, torque_avg, frame_counts + + def update_force_torque_matrices( + self, + entropy_manager, + mol, + group_id, + level, + level_list, + time_index, + num_frames, + force_avg, + torque_avg, + frame_counts, + ): + """ + Update the running averages of force and torque covariance matrices + for a given molecule and entropy level. + + This function computes the force and torque covariance matrices for the + current frame and updates the existing averages in-place using the incremental + mean formula: + + new_avg = old_avg + (value - old_avg) / n + + where n is the number of frames processed so far for that molecule/level + combination. This ensures that the averages are maintained without storing + all previous frame data. + + Parameters + ---------- + entropy_manager : EntropyManager + Instance of the EntropyManager. + mol : AtomGroup + The molecule to process. + group_id : int + Index of the group to which the molecule belongs. + level : str + Current entropy level ("united_atom", "residue", or "polymer"). + level_list : list + List of entropy levels for the molecule. + time_index : int + Index of the current frame relative to the start of the trajectory slice. + num_frames : int + Total number of frames to process. + force_avg : dict + Dictionary holding the running average force matrices, keyed by entropy + level. + torque_avg : dict + Dictionary holding the running average torque matrices, keyed by entropy + level. + frame_counts : dict + Dictionary holding the count of frames processed for each molecule/level + combination. + + Returns + ------- + None + Updates are performed in-place on `force_avg`, `torque_avg`, and + `frame_counts`. + """ + highest = level == level_list[-1] + + # United atom level calculations are done separately for each residue + # This allows information per residue to be output and keeps the + # matrices from becoming too large + if level == "united_atom": + for res_id, residue in enumerate(mol.residues): + key = (group_id, res_id) + res = entropy_manager._run_manager.new_U_select_atom( + mol, f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}" + ) + + # This is to get MDAnalysis to get the information from the + # correct frame of the trajectory + res.trajectory[time_index] + + # Build the matrices, adding data from each timestep + # Being careful for the first timestep when data has not yet + # been added to the matrices + f_mat, t_mat = self.get_matrices( + res, + level, + num_frames, + highest, + None if key not in force_avg["ua"] else force_avg["ua"][key], + None if key not in torque_avg["ua"] else torque_avg["ua"][key], + ) + + if key not in force_avg["ua"]: + force_avg["ua"][key] = f_mat.copy() + torque_avg["ua"][key] = t_mat.copy() + frame_counts["ua"][key] = 1 + else: + frame_counts["ua"][key] += 1 + n = frame_counts["ua"][key] + force_avg["ua"][key] += (f_mat - force_avg["ua"][key]) / n + torque_avg["ua"][key] += (t_mat - torque_avg["ua"][key]) / n + + elif level in ["residue", "polymer"]: + # This is to get MDAnalysis to get the information from the + # correct frame of the trajectory + mol.trajectory[time_index] + + key = "res" if level == "residue" else "poly" + + # Build the matrices, adding data from each timestep + # Being careful for the first timestep when data has not yet + # been added to the matrices + f_mat, t_mat = self.get_matrices( + mol, + level, + num_frames, + highest, + None if force_avg[key][group_id] is None else force_avg[key][group_id], + ( + None + if torque_avg[key][group_id] is None + else torque_avg[key][group_id] + ), + ) + + if force_avg[key][group_id] is None: + force_avg[key][group_id] = f_mat.copy() + torque_avg[key][group_id] = t_mat.copy() + frame_counts[key][group_id] = 1 + else: + frame_counts[key][group_id] += 1 + n = frame_counts[key][group_id] + force_avg[key][group_id] += (f_mat - force_avg[key][group_id]) / n + torque_avg[key][group_id] += (t_mat - torque_avg[key][group_id]) / n + + return frame_counts diff --git a/CodeEntropy/levels/level_hierarchy.py b/CodeEntropy/levels/level_hierarchy.py new file mode 100644 index 00000000..eefa706d --- /dev/null +++ b/CodeEntropy/levels/level_hierarchy.py @@ -0,0 +1,101 @@ +import logging + +logger = logging.getLogger(__name__) + + +class LevelHierarchy: + """ """ + + def __init__(self): + """ + Initializes the LevelHierarchy with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + + def select_levels(self, data_container): + """ + Function to read input system and identify the number of molecules and + the levels (i.e. united atom, residue and/or polymer) that should be used. + The level refers to the size of the bead (atom or collection of atoms) + that will be used in the entropy calculations. + + Args: + arg_DataContainer: MDAnalysis universe object containing the system of + interest + + Returns: + number_molecules (int): Number of molecules in the system. + levels (array): Strings describing the length scales for each molecule. + """ + + # fragments is MDAnalysis terminology for what chemists would call molecules + number_molecules = len(data_container.atoms.fragments) + logger.debug(f"The number of molecules is {number_molecules}.") + + fragments = data_container.atoms.fragments + levels = [[] for _ in range(number_molecules)] + + for molecule in range(number_molecules): + levels[molecule].append( + "united_atom" + ) # every molecule has at least one atom + + atoms_in_fragment = fragments[molecule].select_atoms("prop mass > 1.1") + number_residues = len(atoms_in_fragment.residues) + + if len(atoms_in_fragment) > 1: + levels[molecule].append("residue") + + if number_residues > 1: + levels[molecule].append("polymer") + + logger.debug(f"levels {levels}") + + return number_molecules, levels + + def get_beads(self, data_container, level): + """ + Function to define beads depending on the level in the hierarchy. + + Args: + data_container (MDAnalysis.Universe): the molecule data + level (str): the heirarchy level (polymer, residue, or united atom) + + Returns: + list_of_beads : the relevent beads + """ + + if level == "polymer": + list_of_beads = [] + atom_group = "all" + list_of_beads.append(data_container.select_atoms(atom_group)) + + if level == "residue": + list_of_beads = [] + num_residues = len(data_container.residues) + for residue in range(num_residues): + atom_group = "resindex " + str(residue) + list_of_beads.append(data_container.select_atoms(atom_group)) + + if level == "united_atom": + list_of_beads = [] + heavy_atoms = data_container.select_atoms("prop mass > 1.1") + if len(heavy_atoms) == 0: + # molecule without heavy atoms would be a hydrogen molecule + list_of_beads.append(data_container.select_atoms("all")) + else: + # Select one heavy atom and all light atoms bonded to it + for atom in heavy_atoms: + atom_group = ( + "index " + + str(atom.index) + + " or ((prop mass <= 1.1) and bonded index " + + str(atom.index) + + ")" + ) + list_of_beads.append(data_container.select_atoms(atom_group)) + + logger.debug(f"List of beads: {list_of_beads}") + + return list_of_beads diff --git a/CodeEntropy/levels/level_manager.py b/CodeEntropy/levels/level_manager.py new file mode 100644 index 00000000..55fba8e3 --- /dev/null +++ b/CodeEntropy/levels/level_manager.py @@ -0,0 +1,26 @@ +import logging + +logger = logging.getLogger(__name__) + + +class LevelManager: + """ + Manages the structural and dynamic levels involved in entropy calculations. This + includes selecting relevant levels, computing axes for translation and rotation, + and handling bead-based representations of molecular systems. Provides utility + methods to extract averaged positions, convert coordinates to spherical systems, + compute weighted forces and torques, and manipulate matrices used in entropy + analysis. + """ + + def __init__(self): + """ + Initializes the LevelManager with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + self.data_container = None + self._levels = None + self._trans_axes = None + self._rot_axes = None + self._number_of_beads = None diff --git a/CodeEntropy/levels/matrix_operations.py b/CodeEntropy/levels/matrix_operations.py new file mode 100644 index 00000000..404ecc9f --- /dev/null +++ b/CodeEntropy/levels/matrix_operations.py @@ -0,0 +1,194 @@ +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + + +class MatrixOperations: + """ """ + + def __init__(self): + """ + Initializes the MatrixOperations with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + + def create_submatrix(self, data_i, data_j): + """ + Function for making covariance matrices. + + Args + ----- + data_i : values for bead i + data_j : values for bead j + + Returns + ------ + submatrix : 3x3 matrix for the covariance between i and j + """ + + # Start with 3 by 3 matrix of zeros + submatrix = np.zeros((3, 3)) + + # For each frame calculate the outer product (cross product) of the data from + # the two beads and add the result to the submatrix + outer_product_matrix = np.outer(data_i, data_j) + submatrix = np.add(submatrix, outer_product_matrix) + + logger.debug(f"Submatrix: {submatrix}") + + return submatrix + + def filter_zero_rows_columns(self, arg_matrix): + """ + function for removing rows and columns that contain only zeros from a matrix + + Args: + arg_matrix : matrix + + Returns: + arg_matrix : the reduced size matrix + """ + + # record the initial size + init_shape = np.shape(arg_matrix) + + zero_indices = list( + filter( + lambda row: np.all(np.isclose(arg_matrix[row, :], 0.0)), + np.arange(np.shape(arg_matrix)[0]), + ) + ) + all_indices = np.ones((np.shape(arg_matrix)[0]), dtype=bool) + all_indices[zero_indices] = False + arg_matrix = arg_matrix[all_indices, :] + + all_indices = np.ones((np.shape(arg_matrix)[1]), dtype=bool) + zero_indices = list( + filter( + lambda col: np.all(np.isclose(arg_matrix[:, col], 0.0)), + np.arange(np.shape(arg_matrix)[1]), + ) + ) + all_indices[zero_indices] = False + arg_matrix = arg_matrix[:, all_indices] + + # get the final shape + final_shape = np.shape(arg_matrix) + + 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 + + def get_matrices( + self, + data_container, + level, + number_frames, + highest_level, + force_matrix, + torque_matrix, + ): + """ + Compute and accumulate force/torque covariance matrices for a given level. + + Parameters: + data_container (MDAnalysis.Universe): Data for a molecule or residue. + level (str): 'polymer', 'residue', or 'united_atom'. + number_frames (int): Number of frames being processed. + highest_level (bool): Whether this is the top (largest bead size) level. + force_matrix, torque_matrix (np.ndarray or None): Accumulated matrices to add + to. + + Returns: + force_matrix (np.ndarray): Accumulated force covariance matrix. + torque_matrix (np.ndarray): Accumulated torque covariance matrix. + """ + + # Make beads + list_of_beads = self.get_beads(data_container, level) + + # number of beads and frames in trajectory + number_beads = len(list_of_beads) + + # initialize force and torque arrays + weighted_forces = [None for _ in range(number_beads)] + weighted_torques = [None for _ in range(number_beads)] + + # Calculate forces/torques for each bead + for bead_index in range(number_beads): + # Set up axes + # translation and rotation use different axes + # how the axes are defined depends on the level + trans_axes, rot_axes = self.get_axes(data_container, level, bead_index) + + # Sort out coordinates, forces, and torques for each atom in the bead + weighted_forces[bead_index] = self.get_weighted_forces( + data_container, list_of_beads[bead_index], trans_axes, highest_level + ) + weighted_torques[bead_index] = self.get_weighted_torques( + data_container, list_of_beads[bead_index], rot_axes + ) + + # Create covariance submatrices + force_submatrix = [ + [0 for _ in range(number_beads)] for _ in range(number_beads) + ] + torque_submatrix = [ + [0 for _ in range(number_beads)] for _ in range(number_beads) + ] + + for i in range(number_beads): + for j in range(i, number_beads): + f_sub = self.create_submatrix(weighted_forces[i], weighted_forces[j]) + t_sub = self.create_submatrix(weighted_torques[i], weighted_torques[j]) + force_submatrix[i][j] = f_sub + force_submatrix[j][i] = f_sub.T + torque_submatrix[i][j] = t_sub + torque_submatrix[j][i] = t_sub.T + + # Convert block matrices to full matrix + force_block = np.block( + [ + [force_submatrix[i][j] for j in range(number_beads)] + for i in range(number_beads) + ] + ) + torque_block = np.block( + [ + [torque_submatrix[i][j] for j in range(number_beads)] + for i in range(number_beads) + ] + ) + + # Enforce consistent shape before accumulation + if force_matrix is None: + force_matrix = np.zeros_like(force_block) + elif force_matrix.shape != force_block.shape: + raise ValueError( + f"Inconsistent force matrix shape: existing " + f"{force_matrix.shape}, new {force_block.shape}" + ) + else: + force_matrix = force_block + + if torque_matrix is None: + torque_matrix = np.zeros_like(torque_block) + elif torque_matrix.shape != torque_block.shape: + raise ValueError( + f"Inconsistent torque matrix shape: existing " + f"{torque_matrix.shape}, new {torque_block.shape}" + ) + else: + torque_matrix = torque_block + + return force_matrix, torque_matrix diff --git a/CodeEntropy/levels/structual_analysis.py b/CodeEntropy/levels/structual_analysis.py new file mode 100644 index 00000000..573372b3 --- /dev/null +++ b/CodeEntropy/levels/structual_analysis.py @@ -0,0 +1,289 @@ +import logging + +import numpy as np +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, +) + +logger = logging.getLogger(__name__) + + +class StructuralAnalysis: + """ """ + + def __init__(self): + """ + Initializes the StructuralAnalysis with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + + def get_dihedrals(self, data_container, level): + """ + Define the set of dihedrals for use in the conformational entropy function. + If united atom level, the dihedrals are defined from the heavy atoms + (4 bonded atoms for 1 dihedral). + If residue level, use the bonds between residues to cast dihedrals. + Note: not using improper dihedrals only ones with 4 atoms/residues + in a linear arrangement. + + Args: + data_container (MDAnalysis.Universe): system information + level (str): level of the hierarchy (should be residue or polymer) + + Returns: + dihedrals (array): set of dihedrals + """ + # Start with empty array + dihedrals = [] + + # if united atom level, read dihedrals from MDAnalysis universe + if level == "united_atom": + dihedrals = data_container.dihedrals + + # if residue level, looking for dihedrals involving residues + if level == "residue": + num_residues = len(data_container.residues) + logger.debug(f"Number Residues: {num_residues}") + if num_residues < 4: + logger.debug("no residue level dihedrals") + + else: + # find bonds between residues N-3:N-2 and N-1:N + for residue in range(4, num_residues + 1): + # Using MDAnalysis selection, + # assuming only one covalent bond between neighbouring residues + # TODO not written for branched polymers + atom_string = ( + "resindex " + + str(residue - 4) + + " and bonded resindex " + + str(residue - 3) + ) + atom1 = data_container.select_atoms(atom_string) + + atom_string = ( + "resindex " + + str(residue - 3) + + " and bonded resindex " + + str(residue - 4) + ) + atom2 = data_container.select_atoms(atom_string) + + atom_string = ( + "resindex " + + str(residue - 2) + + " and bonded resindex " + + str(residue - 1) + ) + atom3 = data_container.select_atoms(atom_string) + + atom_string = ( + "resindex " + + str(residue - 1) + + " and bonded resindex " + + str(residue - 2) + ) + atom4 = data_container.select_atoms(atom_string) + + atom_group = atom1 + atom2 + atom3 + atom4 + dihedrals.append(atom_group.dihedral) + + logger.debug(f"Level: {level}, Dihedrals: {dihedrals}") + + return dihedrals + + def compute_dihedral_conformations( + self, + selector, + level, + number_frames, + bin_width, + start, + end, + step, + ce, + ): + """ + Compute dihedral conformations for a given selector and entropy level. + + Parameters: + selector (AtomGroup): Atom selection to compute dihedrals for. + level (str): Entropy level ("united_atom" or "residue"). + number_frames (int): Number of frames to process. + bin_width (float): Bin width for dihedral angle discretization. + start (int): Start frame index. + end (int): End frame index. + step (int): Step size for frame iteration. + ce : Conformational Entropy class + + Returns: + states (list): List of conformation strings per frame. + """ + # Identify the dihedral angles in the residue/molecule + dihedrals = self.get_dihedrals(selector, level) + + # When there are no dihedrals, there is only one possible conformation + # so the conformational states are not relevant + if len(dihedrals) == 0: + logger.debug("No dihedrals found; skipping conformation assignment.") + states = [] + else: + # Identify the conformational label for each dihedral at each frame + num_dihedrals = len(dihedrals) + conformation = np.zeros((num_dihedrals, number_frames)) + + for i, dihedral in enumerate(dihedrals): + conformation[i] = ce.assign_conformation( + selector, dihedral, number_frames, bin_width, start, end, step + ) + + # for all the dihedrals available concatenate the label of each + # dihedral into the state for that frame + states = [ + state + for state in ( + "".join(str(int(conformation[d][f])) for d in range(num_dihedrals)) + for f in range(number_frames) + ) + if state + ] + + logger.debug(f"level: {level}, states: {states}") + + return states + + def build_conformational_states( + self, + entropy_manager, + reduced_atom, + levels, + groups, + start, + end, + step, + number_frames, + bin_width, + ce, + ): + """ + Construct the conformational states for each molecule at + relevant levels. + + Parameters: + entropy_manager (EntropyManager): Instance of the EntropyManager + reduced_atom (Universe): The reduced atom selection. + levels (list): List of entropy levels per molecule. + groups (dict): Groups for averaging over molecules. + start (int): Start frame index. + end (int): End frame index. + step (int): Step size for frame iteration. + number_frames (int): Total number of frames to process. + bin_width (int): Width of histogram bins. + ce: Conformational Entropy object + + Returns: + tuple: A tuple containing: + - states_ua (dict): Conformational states at the united-atom level. + - states_res (list): Conformational states at the residue level. + """ + number_groups = len(groups) + states_ua = {} + states_res = [None] * number_groups + + total_items = sum( + len(levels[mol_id]) for mols in groups.values() for mol_id in mols + ) + + with Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.fields[title]}", justify="right"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), + TimeElapsedColumn(), + ) as progress: + + task = progress.add_task( + "[green]Building Conformational States...", + total=total_items, + title="Starting...", + ) + + for group_id in groups.keys(): + molecules = groups[group_id] + for mol_id in molecules: + mol = entropy_manager._get_molecule_container(reduced_atom, mol_id) + + resname = mol.atoms[0].resname + resid = mol.atoms[0].resid + segid = mol.atoms[0].segid + + mol_label = f"{resname}_{resid} (segid {segid})" + + for level in levels[mol_id]: + progress.update( + task, + title=f"Building conformational states | " + f"Molecule: {mol_label} | " + f"Level: {level}", + ) + + if level == "united_atom": + for res_id, residue in enumerate(mol.residues): + key = (group_id, res_id) + + res_container = ( + entropy_manager._run_manager.new_U_select_atom( + mol, + f"index {residue.atoms.indices[0]}:" + f"{residue.atoms.indices[-1]}", + ) + ) + heavy_res = ( + entropy_manager._run_manager.new_U_select_atom( + res_container, "prop mass > 1.1" + ) + ) + states = self.compute_dihedral_conformations( + heavy_res, + level, + number_frames, + bin_width, + start, + end, + step, + ce, + ) + + if key in states_ua: + states_ua[key].extend(states) + else: + states_ua[key] = states + + elif level == "residue": + states = self.compute_dihedral_conformations( + mol, + level, + number_frames, + bin_width, + start, + end, + step, + ce, + ) + + if states_res[group_id] is None: + states_res[group_id] = states + else: + states_res[group_id].extend(states) + + progress.advance(task) + + logger.debug(f"states_ua {states_ua}") + logger.debug(f"states_res {states_res}") + + return states_ua, states_res From 2f943e5639fcb33d68fb9b0834ec5ab3ed768372 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 11 Nov 2025 10:53:04 +0000 Subject: [PATCH 002/101] Refinements to the layout of the levels module: - Renamed `CodeEntropy/levels/structual_analysis.py` -> `CodeEntropy/levels/dihedral_analysis.py` to more accuatly define what this Class does - Introduced a new class within the module `CodeEntropy/levels/neighbours.py` --- .../levels/{structual_analysis.py => dihedral_analysis.py} | 4 ++-- CodeEntropy/levels/neighbours.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) rename CodeEntropy/levels/{structual_analysis.py => dihedral_analysis.py} (98%) create mode 100644 CodeEntropy/levels/neighbours.py diff --git a/CodeEntropy/levels/structual_analysis.py b/CodeEntropy/levels/dihedral_analysis.py similarity index 98% rename from CodeEntropy/levels/structual_analysis.py rename to CodeEntropy/levels/dihedral_analysis.py index 573372b3..999d52be 100644 --- a/CodeEntropy/levels/structual_analysis.py +++ b/CodeEntropy/levels/dihedral_analysis.py @@ -12,12 +12,12 @@ logger = logging.getLogger(__name__) -class StructuralAnalysis: +class DihedralAnalysis: """ """ def __init__(self): """ - Initializes the StructuralAnalysis with placeholders for level-related data, + Initializes the DihedralAnalysis with placeholders for level-related data, including translational and rotational axes, number of beads, and a general-purpose data container. """ diff --git a/CodeEntropy/levels/neighbours.py b/CodeEntropy/levels/neighbours.py new file mode 100644 index 00000000..96464885 --- /dev/null +++ b/CodeEntropy/levels/neighbours.py @@ -0,0 +1,7 @@ +class Neighbours: + """ """ + + def __init__(self): + """ + Initializes the Neighbours with placeholders + """ From 236ebf6b165e9088e09aec0585d64ef448b3477f Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 11 Nov 2025 12:13:21 +0000 Subject: [PATCH 003/101] Restructure `entropy.py` to more abstracted files and classes: - `VibrationalEntropy` class -> own dedicated file within `CodeEntropy/entropy/nodes/vibrational_entropy.py` - `ConformationalEntropy` class -> own dedicated file within `CodeEntropy/entropy/nodes/configurational_entropy.py` - `OrientationalEntropy` class -> own dedicated file within `CodeEntropy/entropy/nodes/orientational_entropy.py` - Created a placeholder for the new graph builder `CodeEntropy/entropy/entropy_graph.py` --- CodeEntropy/entropy/entropy_graph.py | 0 CodeEntropy/entropy/entropy_manager.py | 448 ++---------------- .../entropy/nodes/configurational_entropy.py | 144 ++++++ .../entropy/nodes/orientational_entropy.py | 79 +++ .../entropy/nodes/vibrational_entropy.py | 136 ++++++ 5 files changed, 404 insertions(+), 403 deletions(-) create mode 100644 CodeEntropy/entropy/entropy_graph.py create mode 100644 CodeEntropy/entropy/nodes/configurational_entropy.py create mode 100644 CodeEntropy/entropy/nodes/orientational_entropy.py create mode 100644 CodeEntropy/entropy/nodes/vibrational_entropy.py diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 75acfe53..ffbf0e13 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -5,7 +5,6 @@ import numpy as np import pandas as pd import waterEntropy.recipes.interfacial_solvent as GetSolvent -from numpy import linalg as la from rich.progress import ( BarColumn, Progress, @@ -68,22 +67,22 @@ def execute(self): f"Analyzing a total of {number_frames} frames in this calculation." ) - ve = VibrationalEntropy( - self._run_manager, - self._args, - self._universe, - self._data_logger, - self._level_manager, - self._group_molecules, - ) - ce = ConformationalEntropy( - self._run_manager, - self._args, - self._universe, - self._data_logger, - self._level_manager, - self._group_molecules, - ) + # ve = VibrationalEntropy( + # self._run_manager, + # self._args, + # self._universe, + # self._data_logger, + # self._level_manager, + # self._group_molecules, + # ) + # ce = ConformationalEntropy( + # self._run_manager, + # self._args, + # self._universe, + # self._data_logger, + # self._level_manager, + # self._group_molecules, + # ) reduced_atom, number_molecules, levels, groups = self._initialize_molecules() logger.debug(f"Universe 3: {reduced_atom}") @@ -121,35 +120,35 @@ def execute(self): ) ) - # Identify the conformational states from dihedral angles for the - # conformational entropy calculations - states_ua, states_res = self._level_manager.build_conformational_states( - self, - reduced_atom, - levels, - nonwater_groups, - start, - end, - step, - number_frames, - self._args.bin_width, - ce, - ) - - # Complete the entropy calculations - self._compute_entropies( - reduced_atom, - levels, - nonwater_groups, - force_matrices, - torque_matrices, - states_ua, - states_res, - frame_counts, - number_frames, - ve, - ce, - ) + # # Identify the conformational states from dihedral angles for the + # # conformational entropy calculations + # states_ua, states_res = self._level_manager.build_conformational_states( + # self, + # reduced_atom, + # levels, + # nonwater_groups, + # start, + # end, + # step, + # number_frames, + # self._args.bin_width, + # ce, + # ) + + # # Complete the entropy calculations + # self._compute_entropies( + # reduced_atom, + # levels, + # nonwater_groups, + # force_matrices, + # torque_matrices, + # states_ua, + # states_res, + # frame_counts, + # number_frames, + # ve, + # ce, + # ) # Print the results in a nicely formated way self._finalize_molecule_results() @@ -795,360 +794,3 @@ def _calculate_water_vibrational_rotational_entropy( self._data_logger.add_residue_data( group_id, resname, "Water", "Rovibrational", count, entropy ) - - -class VibrationalEntropy(EntropyManager): - """ - Performs vibrational entropy calculations using molecular trajectory data. - Extends the base EntropyManager with constants and logic specific to - vibrational modes and thermodynamic properties. - """ - - def __init__( - self, run_manager, args, universe, data_logger, level_manager, group_molecules - ): - """ - Initializes the VibrationalEntropy manager with all required components and - defines physical constants used in vibrational entropy calculations. - """ - super().__init__( - run_manager, args, universe, data_logger, level_manager, group_molecules - ) - self._PLANCK_CONST = 6.62607004081818e-34 - - def frequency_calculation(self, lambdas, temp): - """ - Function to calculate an array of vibrational frequencies from the eigenvalues - of the covariance matrix. - - Calculated from eq. (3) in Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, - Molecular Physics, 2018, 116, 1965–1976//eq. (3) in A. Chakravorty, J. Higham - and R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551 - - frequency=sqrt(λ/kT)/2π - - Args: - lambdas : array of floats - eigenvalues of the covariance matrix - temp: float - temperature - - Returns: - frequencies : array of floats - corresponding vibrational frequencies - """ - pi = np.pi - # get kT in Joules from given temperature - kT = self._run_manager.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}") - - # Filter out lambda values that are negative or imaginary numbers - # As these will produce supurious entropy results that can crash - # the calculation - lambdas = np.real_if_close(lambdas, tol=1000) - valid_mask = ( - np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-07)) - ) - - # If any lambdas were removed by the filter, warn the user - # as this will suggest insufficient sampling in the simulation data - if len(lambdas) > np.count_nonzero(valid_mask): - logger.warning( - f"{len(lambdas) - np.count_nonzero(valid_mask)} " - f"invalid eigenvalues excluded (complex, non-positive, or near-zero)." - ) - - lambdas = lambdas[valid_mask].real - - # Compute frequencies safely - frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT) - logger.debug(f"Calculated frequencies: {frequencies}") - - return frequencies - - def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_level): - """ - Function to calculate the vibrational entropy for each level calculated from - eq. (4) in J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular - Physics, 2018, 116, 1965–1976 / eq. (2) in A. Chakravorty, J. Higham and - R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551. - - Args: - matrix : matrix - force/torque covariance matrix - matrix_type: string - temp: float - temperature - highest_level: bool - is this the highest level of the heirarchy - - Returns: - S_vib_total : float - transvibrational/rovibrational entropy - """ - # 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 = self._run_manager.change_lambda_units(lambdas) - logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}") - - # Calculate frequencies from the eigenvalues - frequencies = self.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 = self._run_manager.get_KT2J(temp) - logger.debug(f"Temperature: {temp}, kT: {kT}") - exponent = self._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 * self._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 - S_vib_total = sum(S_components) - - # discard the 6 lowest frequencies to discard translation and rotation of - # the whole unit the overall translation and rotation of a unit is an - # internal motion of the level above - else: - S_vib_total = sum(S_components[6:]) - - 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 - - -class ConformationalEntropy(EntropyManager): - """ - Performs conformational entropy calculations based on molecular dynamics data. - Inherits from EntropyManager and includes constants specific to conformational - analysis using statistical mechanics principles. - """ - - def __init__( - self, run_manager, args, universe, data_logger, level_manager, group_molecules - ): - """ - Initializes the ConformationalEntropy manager with all required components and - sets the gas constant used in conformational entropy calculations. - """ - super().__init__( - run_manager, args, universe, data_logger, level_manager, group_molecules - ) - - def assign_conformation( - self, data_container, dihedral, number_frames, bin_width, start, end, step - ): - """ - Create a state vector, showing the state in which the input dihedral is - as a function of time. The function creates a histogram from the timeseries of - the dihedral angle values and identifies points of dominant occupancy - (called CONVEX TURNING POINTS). - Based on the identified TPs, states are assigned to each configuration of the - dihedral. - - Args: - data_container (MDAnalysis Universe): data for the molecule/residue unit - dihedral (array): The dihedral angles in the unit - number_frames (int): number of frames in the trajectory - bin_width (int): the width of the histogram bit, default 30 degrees - start (int): starting frame, will default to 0 - end (int): ending frame, will default to -1 (last frame in trajectory) - step (int): spacing between frames, will default to 1 - - Returns: - conformations (array): A timeseries with integer labels describing the - state at each point in time. - - """ - conformations = np.zeros(number_frames) - phi = np.zeros(number_frames) - - # get the values of the angle for the dihedral - # dihedral angle values have a range from -180 to 180 - indices = list(range(number_frames)) - for timestep_index, _ in zip( - indices, data_container.trajectory[start:end:step] - ): - timestep_index = timestep_index - value = dihedral.value() - # we want postive values in range 0 to 360 to make the peak assignment - # works using the fact that dihedrals have circular symetry - # (i.e. -15 degrees = +345 degrees) - if value < 0: - value += 360 - phi[timestep_index] = value - - # create a histogram using numpy - number_bins = int(360 / bin_width) - popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360)) - bin_value = [ - 0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul)) - ] - - # identify "convex turning-points" and populate a list of peaks - # peak : a bin whose neighboring bins have smaller population - # NOTE might have problems if the peak is wide with a flat or sawtooth - # top in which case check you have a sensible bin width - peak_values = [] - - for bin_index in range(number_bins): - # if there is no dihedrals in a bin then it cannot be a peak - if popul[bin_index] == 0: - pass - # being careful of the last bin - # (dihedrals have circular symmetry, the histogram does not) - elif ( - bin_index == number_bins - 1 - ): # the -1 is because the index starts with 0 not 1 - if ( - popul[bin_index] >= popul[bin_index - 1] - and popul[bin_index] >= popul[0] - ): - peak_values.append(bin_value[bin_index]) - else: - if ( - popul[bin_index] >= popul[bin_index - 1] - and popul[bin_index] >= popul[bin_index + 1] - ): - peak_values.append(bin_value[bin_index]) - - # go through each frame again and assign conformation state - for frame in range(number_frames): - # find the TP that the snapshot is least distant from - distances = [abs(phi[frame] - peak) for peak in peak_values] - conformations[frame] = np.argmin(distances) - - logger.debug(f"Final conformations: {conformations}") - - return conformations - - def conformational_entropy_calculation(self, states, number_frames): - """ - Function to calculate conformational entropies using eq. (7) in Higham, - S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116, - 1965–1976 / eq. (4) in A. Chakravorty, J. Higham and R. H. Henchman, - J. Chem. Inf. Model., 2020, 60, 5540–5551. - - Uses the adaptive enumeration method (AEM). - - Args: - states (array): Conformational states in the molecule - number_frames (int): The number of frames analysed - - Returns: - S_conf_total (float) : conformational entropy - """ - - S_conf_total = 0 - - # 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) - S_conf_total += entropy - - # multiply by gas constant to get the units J/mol/K - S_conf_total *= -1 * self._GAS_CONST - - logger.debug(f"Total conformational entropy: {S_conf_total}") - - return S_conf_total - - -class OrientationalEntropy(EntropyManager): - """ - Performs orientational entropy calculations using molecular dynamics data. - Inherits from EntropyManager and includes constants relevant to rotational - and orientational degrees of freedom. - """ - - def __init__( - self, run_manager, args, universe, data_logger, level_manager, group_molecules - ): - """ - Initializes the OrientationalEntropy manager with all required components and - sets the gas constant used in orientational entropy calculations. - """ - super().__init__( - run_manager, args, universe, data_logger, level_manager, group_molecules - ) - - def orientational_entropy_calculation(self, neighbours_dict): - """ - Function to calculate orientational entropies from eq. (10) in J. Higham, - S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116, - 3 1965–1976. Number of orientations, Ω, is calculated using eq. (8) in - J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, - 2018, 116, 3 1965–1976. - - σ is assumed to be 1 for the molecules we're concerned with and hence, - max {1, (Nc^3*π)^(1/2)} will always be (Nc^3*π)^(1/2). - - TODO future release - function for determing symmetry and symmetry numbers - maybe? - - Input - ----- - neighbours_dict : dictionary - dictionary of neighbours for the molecule - - should contain the type of neighbour molecule and the number of neighbour - molecules of that species - - Returns - ------- - S_or_total : float - orientational entropy - """ - - # Replaced molecule with neighbour as this is what the for loop uses - S_or_total = 0 - for neighbour in neighbours_dict: # we are going through neighbours - if neighbour in ["H2O"]: # water molecules - call POSEIDON functions - pass # TODO temporary until function is written - 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 *= self._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/entropy/nodes/configurational_entropy.py b/CodeEntropy/entropy/nodes/configurational_entropy.py new file mode 100644 index 00000000..70f308fa --- /dev/null +++ b/CodeEntropy/entropy/nodes/configurational_entropy.py @@ -0,0 +1,144 @@ +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + + +class ConformationalEntropy: + """ + Performs conformational entropy calculations based on molecular dynamics data. + """ + + def __init__( + self, run_manager, args, universe, data_logger, level_manager, group_molecules + ): + """ + Initializes the ConformationalEntropy manager with all required components and + sets the gas constant used in conformational entropy calculations. + """ + + def assign_conformation( + self, data_container, dihedral, number_frames, bin_width, start, end, step + ): + """ + Create a state vector, showing the state in which the input dihedral is + as a function of time. The function creates a histogram from the timeseries of + the dihedral angle values and identifies points of dominant occupancy + (called CONVEX TURNING POINTS). + Based on the identified TPs, states are assigned to each configuration of the + dihedral. + + Args: + data_container (MDAnalysis Universe): data for the molecule/residue unit + dihedral (array): The dihedral angles in the unit + number_frames (int): number of frames in the trajectory + bin_width (int): the width of the histogram bit, default 30 degrees + start (int): starting frame, will default to 0 + end (int): ending frame, will default to -1 (last frame in trajectory) + step (int): spacing between frames, will default to 1 + + Returns: + conformations (array): A timeseries with integer labels describing the + state at each point in time. + + """ + conformations = np.zeros(number_frames) + phi = np.zeros(number_frames) + + # get the values of the angle for the dihedral + # dihedral angle values have a range from -180 to 180 + indices = list(range(number_frames)) + for timestep_index, _ in zip( + indices, data_container.trajectory[start:end:step] + ): + timestep_index = timestep_index + value = dihedral.value() + # we want postive values in range 0 to 360 to make the peak assignment + # works using the fact that dihedrals have circular symetry + # (i.e. -15 degrees = +345 degrees) + if value < 0: + value += 360 + phi[timestep_index] = value + + # create a histogram using numpy + number_bins = int(360 / bin_width) + popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360)) + bin_value = [ + 0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul)) + ] + + # identify "convex turning-points" and populate a list of peaks + # peak : a bin whose neighboring bins have smaller population + # NOTE might have problems if the peak is wide with a flat or sawtooth + # top in which case check you have a sensible bin width + peak_values = [] + + for bin_index in range(number_bins): + # if there is no dihedrals in a bin then it cannot be a peak + if popul[bin_index] == 0: + pass + # being careful of the last bin + # (dihedrals have circular symmetry, the histogram does not) + elif ( + bin_index == number_bins - 1 + ): # the -1 is because the index starts with 0 not 1 + if ( + popul[bin_index] >= popul[bin_index - 1] + and popul[bin_index] >= popul[0] + ): + peak_values.append(bin_value[bin_index]) + else: + if ( + popul[bin_index] >= popul[bin_index - 1] + and popul[bin_index] >= popul[bin_index + 1] + ): + peak_values.append(bin_value[bin_index]) + + # go through each frame again and assign conformation state + for frame in range(number_frames): + # find the TP that the snapshot is least distant from + distances = [abs(phi[frame] - peak) for peak in peak_values] + conformations[frame] = np.argmin(distances) + + logger.debug(f"Final conformations: {conformations}") + + return conformations + + def conformational_entropy_calculation(self, states, number_frames): + """ + Function to calculate conformational entropies using eq. (7) in Higham, + S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116, + 1965–1976 / eq. (4) in A. Chakravorty, J. Higham and R. H. Henchman, + J. Chem. Inf. Model., 2020, 60, 5540–5551. + + Uses the adaptive enumeration method (AEM). + + Args: + states (array): Conformational states in the molecule + number_frames (int): The number of frames analysed + + Returns: + S_conf_total (float) : conformational entropy + """ + + S_conf_total = 0 + + # 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) + S_conf_total += entropy + + # multiply by gas constant to get the units J/mol/K + S_conf_total *= -1 * self._GAS_CONST + + logger.debug(f"Total conformational entropy: {S_conf_total}") + + return S_conf_total diff --git a/CodeEntropy/entropy/nodes/orientational_entropy.py b/CodeEntropy/entropy/nodes/orientational_entropy.py new file mode 100644 index 00000000..2d8d20c2 --- /dev/null +++ b/CodeEntropy/entropy/nodes/orientational_entropy.py @@ -0,0 +1,79 @@ +import logging +import math + +import numpy as np + +logger = logging.getLogger(__name__) + + +class OrientationalEntropy: + """ + Performs orientational entropy calculations using molecular dynamics data. + """ + + def __init__( + self, run_manager, args, universe, data_logger, level_manager, group_molecules + ): + """ + Initializes the OrientationalEntropy manager with all required components and + sets the gas constant used in orientational entropy calculations. + """ + + def orientational_entropy_calculation(self, neighbours_dict): + """ + Function to calculate orientational entropies from eq. (10) in J. Higham, + S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116, + 3 1965–1976. Number of orientations, Ω, is calculated using eq. (8) in + J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, + 2018, 116, 3 1965–1976. + + σ is assumed to be 1 for the molecules we're concerned with and hence, + max {1, (Nc^3*π)^(1/2)} will always be (Nc^3*π)^(1/2). + + TODO future release - function for determing symmetry and symmetry numbers + maybe? + + Input + ----- + neighbours_dict : dictionary - dictionary of neighbours for the molecule - + should contain the type of neighbour molecule and the number of neighbour + molecules of that species + + Returns + ------- + S_or_total : float - orientational entropy + """ + + # Replaced molecule with neighbour as this is what the for loop uses + S_or_total = 0 + for neighbour in neighbours_dict: # we are going through neighbours + if neighbour in ["H2O"]: # water molecules - call POSEIDON functions + pass # TODO temporary until function is written + 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 *= self._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/entropy/nodes/vibrational_entropy.py b/CodeEntropy/entropy/nodes/vibrational_entropy.py new file mode 100644 index 00000000..4731dba0 --- /dev/null +++ b/CodeEntropy/entropy/nodes/vibrational_entropy.py @@ -0,0 +1,136 @@ +import logging + +import numpy as np +from numpy import linalg as la + +logger = logging.getLogger(__name__) + + +class VibrationalEntropy: + """ + Performs vibrational entropy calculations using molecular trajectory data. + """ + + def __init__( + self, run_manager, args, universe, data_logger, level_manager, group_molecules + ): + """ + Initializes the VibrationalEntropy manager with all required components and + defines physical constants used in vibrational entropy calculations. + """ + self._PLANCK_CONST = 6.62607004081818e-34 + + def frequency_calculation(self, lambdas, temp): + """ + Function to calculate an array of vibrational frequencies from the eigenvalues + of the covariance matrix. + + Calculated from eq. (3) in Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, + Molecular Physics, 2018, 116, 1965–1976//eq. (3) in A. Chakravorty, J. Higham + and R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551 + + frequency=sqrt(λ/kT)/2π + + Args: + lambdas : array of floats - eigenvalues of the covariance matrix + temp: float - temperature + + Returns: + frequencies : array of floats - corresponding vibrational frequencies + """ + pi = np.pi + # get kT in Joules from given temperature + kT = self._run_manager.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}") + + # Filter out lambda values that are negative or imaginary numbers + # As these will produce supurious entropy results that can crash + # the calculation + lambdas = np.real_if_close(lambdas, tol=1000) + valid_mask = ( + np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-07)) + ) + + # If any lambdas were removed by the filter, warn the user + # as this will suggest insufficient sampling in the simulation data + if len(lambdas) > np.count_nonzero(valid_mask): + logger.warning( + f"{len(lambdas) - np.count_nonzero(valid_mask)} " + f"invalid eigenvalues excluded (complex, non-positive, or near-zero)." + ) + + lambdas = lambdas[valid_mask].real + + # Compute frequencies safely + frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT) + logger.debug(f"Calculated frequencies: {frequencies}") + + return frequencies + + def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_level): + """ + Function to calculate the vibrational entropy for each level calculated from + eq. (4) in J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular + Physics, 2018, 116, 1965–1976 / eq. (2) in A. Chakravorty, J. Higham and + R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551. + + Args: + matrix : matrix - force/torque covariance matrix + matrix_type: string + temp: float - temperature + highest_level: bool - is this the highest level of the heirarchy + + Returns: + S_vib_total : float - transvibrational/rovibrational entropy + """ + # 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 = self._run_manager.change_lambda_units(lambdas) + logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}") + + # Calculate frequencies from the eigenvalues + frequencies = self.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 = self._run_manager.get_KT2J(temp) + logger.debug(f"Temperature: {temp}, kT: {kT}") + exponent = self._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 * self._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 + S_vib_total = sum(S_components) + + # discard the 6 lowest frequencies to discard translation and rotation of + # the whole unit the overall translation and rotation of a unit is an + # internal motion of the level above + else: + S_vib_total = sum(S_components[6:]) + + 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 From 955abd3a892a9fd55a159e139a1dd41aa75bd176 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 11 Nov 2025 13:14:15 +0000 Subject: [PATCH 004/101] Abstract out water entropy calculations into dedicated file and class --- CodeEntropy/entropy/entropy_manager.py | 124 ------------------- CodeEntropy/entropy/nodes/water_entropy.py | 135 +++++++++++++++++++++ 2 files changed, 135 insertions(+), 124 deletions(-) create mode 100644 CodeEntropy/entropy/nodes/water_entropy.py diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index ffbf0e13..64340b15 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -4,7 +4,6 @@ import numpy as np import pandas as pd -import waterEntropy.recipes.interfacial_solvent as GetSolvent from rich.progress import ( BarColumn, Progress, @@ -671,126 +670,3 @@ def _finalize_molecule_results(self): ), self._args.output_file, ) - - def _calculate_water_entropy(self, universe, start, end, step, group_id=None): - """ - Calculate and aggregate the entropy of water molecules in a simulation. - - This function computes orientational, translational, and rotational - entropy components for all water molecules, aggregates them per residue, - and maps all waters to a single group ID. It also logs the total results - and labels the water group in the data logger. - - Parameters - ---------- - universe : MDAnalysis.Universe - The simulation universe containing water molecules. - start : int - The starting frame for analysis. - end : int - The ending frame for analysis. - step : int - Frame interval for analysis. - group_id : int or str, optional - The group ID to which all water molecules will be assigned. - """ - Sorient_dict, covariances, vibrations, _, water_count = ( - GetSolvent.get_interfacial_water_orient_entropy( - universe, start, end, step, self._args.temperature, parallel=True - ) - ) - - self._calculate_water_orientational_entropy(Sorient_dict, group_id) - self._calculate_water_vibrational_translational_entropy( - vibrations, group_id, covariances - ) - self._calculate_water_vibrational_rotational_entropy( - vibrations, group_id, covariances - ) - - water_selection = universe.select_atoms("resname WAT") - actual_water_residues = len(water_selection.residues) - residue_names = { - resname - for res_dict in Sorient_dict.values() - for resname in res_dict.keys() - if resname.upper() in water_selection.residues.resnames - } - - residue_group = "_".join(sorted(residue_names)) if residue_names else "WAT" - self._data_logger.add_group_label( - group_id, residue_group, actual_water_residues, len(water_selection.atoms) - ) - - def _calculate_water_orientational_entropy(self, Sorient_dict, group_id): - """ - Aggregate orientational entropy for all water molecules into a single group. - - Parameters - ---------- - Sorient_dict : dict - Dictionary containing orientational entropy values per residue. - group_id : int or str - The group ID to which the water residues belong. - covariances : object - Covariance object. - """ - for resid, resname_dict in Sorient_dict.items(): - for resname, values in resname_dict.items(): - if isinstance(values, list) and len(values) == 2: - Sor, count = values - self._data_logger.add_residue_data( - group_id, resname, "Water", "Orientational", count, Sor - ) - - def _calculate_water_vibrational_translational_entropy( - self, vibrations, group_id, covariances - ): - """ - Aggregate translational vibrational entropy for all water molecules. - - Parameters - ---------- - vibrations : object - Object containing translational entropy data (vibrations.translational_S). - group_id : int or str - The group ID for the water residues. - covariances : object - Covariance object. - """ - - for (solute_id, _), entropy in vibrations.translational_S.items(): - if isinstance(entropy, (list, np.ndarray)): - entropy = float(np.sum(entropy)) - - count = covariances.counts.get((solute_id, "WAT"), 1) - resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id - self._data_logger.add_residue_data( - group_id, resname, "Water", "Transvibrational", count, entropy - ) - - def _calculate_water_vibrational_rotational_entropy( - self, vibrations, group_id, covariances - ): - """ - Aggregate rotational vibrational entropy for all water molecules. - - Parameters - ---------- - vibrations : object - Object containing rotational entropy data (vibrations.rotational_S). - group_id : int or str - The group ID for the water residues. - covariances : object - Covariance object. - """ - for (solute_id, _), entropy in vibrations.rotational_S.items(): - if isinstance(entropy, (list, np.ndarray)): - entropy = float(np.sum(entropy)) - - count = covariances.counts.get((solute_id, "WAT"), 1) - - resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id - self._data_logger.add_residue_data( - group_id, resname, "Water", "Rovibrational", count, entropy - ) diff --git a/CodeEntropy/entropy/nodes/water_entropy.py b/CodeEntropy/entropy/nodes/water_entropy.py new file mode 100644 index 00000000..7682ba31 --- /dev/null +++ b/CodeEntropy/entropy/nodes/water_entropy.py @@ -0,0 +1,135 @@ +import logging + +import numpy as np +import waterEntropy.recipes.interfacial_solvent as GetSolvent + +logger = logging.getLogger(__name__) + + +class WaterEntropy: + + def __init__(self): + """""" + + def _calculate_water_entropy(self, universe, start, end, step, group_id=None): + """ + Calculate and aggregate the entropy of water molecules in a simulation. + + This function computes orientational, translational, and rotational + entropy components for all water molecules, aggregates them per residue, + and maps all waters to a single group ID. It also logs the total results + and labels the water group in the data logger. + + Parameters + ---------- + universe : MDAnalysis.Universe + The simulation universe containing water molecules. + start : int + The starting frame for analysis. + end : int + The ending frame for analysis. + step : int + Frame interval for analysis. + group_id : int or str, optional + The group ID to which all water molecules will be assigned. + """ + Sorient_dict, covariances, vibrations, _, water_count = ( + GetSolvent.get_interfacial_water_orient_entropy( + universe, start, end, step, self._args.temperature, parallel=True + ) + ) + + self._calculate_water_orientational_entropy(Sorient_dict, group_id) + self._calculate_water_vibrational_translational_entropy( + vibrations, group_id, covariances + ) + self._calculate_water_vibrational_rotational_entropy( + vibrations, group_id, covariances + ) + + water_selection = universe.select_atoms("resname WAT") + actual_water_residues = len(water_selection.residues) + residue_names = { + resname + for res_dict in Sorient_dict.values() + for resname in res_dict.keys() + if resname.upper() in water_selection.residues.resnames + } + + residue_group = "_".join(sorted(residue_names)) if residue_names else "WAT" + self._data_logger.add_group_label( + group_id, residue_group, actual_water_residues, len(water_selection.atoms) + ) + + def _calculate_water_orientational_entropy(self, Sorient_dict, group_id): + """ + Aggregate orientational entropy for all water molecules into a single group. + + Parameters + ---------- + Sorient_dict : dict + Dictionary containing orientational entropy values per residue. + group_id : int or str + The group ID to which the water residues belong. + covariances : object + Covariance object. + """ + for resid, resname_dict in Sorient_dict.items(): + for resname, values in resname_dict.items(): + if isinstance(values, list) and len(values) == 2: + Sor, count = values + self._data_logger.add_residue_data( + group_id, resname, "Water", "Orientational", count, Sor + ) + + def _calculate_water_vibrational_translational_entropy( + self, vibrations, group_id, covariances + ): + """ + Aggregate translational vibrational entropy for all water molecules. + + Parameters + ---------- + vibrations : object + Object containing translational entropy data (vibrations.translational_S). + group_id : int or str + The group ID for the water residues. + covariances : object + Covariance object. + """ + + for (solute_id, _), entropy in vibrations.translational_S.items(): + if isinstance(entropy, (list, np.ndarray)): + entropy = float(np.sum(entropy)) + + count = covariances.counts.get((solute_id, "WAT"), 1) + resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id + self._data_logger.add_residue_data( + group_id, resname, "Water", "Transvibrational", count, entropy + ) + + def _calculate_water_vibrational_rotational_entropy( + self, vibrations, group_id, covariances + ): + """ + Aggregate rotational vibrational entropy for all water molecules. + + Parameters + ---------- + vibrations : object + Object containing rotational entropy data (vibrations.rotational_S). + group_id : int or str + The group ID for the water residues. + covariances : object + Covariance object. + """ + for (solute_id, _), entropy in vibrations.rotational_S.items(): + if isinstance(entropy, (list, np.ndarray)): + entropy = float(np.sum(entropy)) + + count = covariances.counts.get((solute_id, "WAT"), 1) + + resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id + self._data_logger.add_residue_data( + group_id, resname, "Water", "Rovibrational", count, entropy + ) From 61c6f26ce6dfc7b932c217f6bd9c4d9f340e7ba0 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 11 Nov 2025 13:29:11 +0000 Subject: [PATCH 005/101] setup components for the `entropy_graph` which will orcanstrate the execution of entropy calculations --- CodeEntropy/entropy/entropy_graph.py | 80 ++++++++++++++++++++++++++++ pyproject.toml | 2 + 2 files changed, 82 insertions(+) diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index e69de29b..a944caa7 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -0,0 +1,80 @@ +import logging +from typing import Any, Dict + +import matplotlib.pyplot as plt +import networkx as nx + +logger = logging.getLogger(__name__) + + +class EntropyGraph: + """ + A Directed Acyclic Graph (DAG) for managing entropy calculation nodes. + + Each node must implement: + run(shared_data: dict, **kwargs) -> dict + """ + + def __init__(self): + self.graph = nx.DiGraph() + self.nodes = {} + + def add_node(self, name: str, obj: Any, depends_on=None): + """Add a computational node to the DAG.""" + if not hasattr(obj, "run"): + raise TypeError( + f"Node '{name}' must implement a `run(shared_data, **kwargs)` method." + ) + + self.nodes[name] = obj + self.graph.add_node(name) + + if depends_on: + for dep in depends_on: + if dep not in self.graph: + raise ValueError(f"Dependency '{dep}' not found for node '{name}'.") + self.graph.add_edge(dep, name) + + logger.debug(f"Added node '{name}' with dependencies: {depends_on}") + + def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Execute nodes in topological order.""" + logger.info("Executing EntropyGraph DAG...") + results: Dict[str, Dict[str, Any]] = {} + + for node in nx.topological_sort(self.graph): + preds = list(self.graph.predecessors(node)) + kwargs = {p: results[p] for p in preds} + + node_obj = self.nodes[node] + logger.info(f"Running node: {node} (depends on: {preds})") + + output = node_obj.run(shared_data, **kwargs) + + if not isinstance(output, dict): + raise TypeError( + f"Node '{node}' returned {type(output)}; must return dict." + ) + + results[node] = output + + logger.info("DAG execution complete.") + return results + + def visualize(self, show=True, figsize=(8, 6)): + """Visualize the DAG using matplotlib.""" + + pos = nx.spring_layout(self.graph, seed=42) + plt.figure(figsize=figsize) + nx.draw( + self.graph, + pos, + with_labels=True, + node_color="lightblue", + node_size=2800, + arrows=True, + font_weight="bold", + ) + plt.title("Entropy Computation Graph", fontsize=14) + if show: + plt.show() diff --git a/pyproject.toml b/pyproject.toml index a864344c..872c0eea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ dependencies = [ "art==6.5", "waterEntropy==1.2.2", "requests>=2.32.5", + "networkx==3.5", + "matplotlib==3.10.7", ] [project.urls] From c9a0a1efb2788dec5d5b5bfb04c26a107e752e1a Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 19 Nov 2025 17:36:22 +0000 Subject: [PATCH 006/101] refine `entropy_graph.py` to setup for DAG execution structure --- CodeEntropy/entropy/entropy_graph.py | 55 ++++++---------------------- 1 file changed, 12 insertions(+), 43 deletions(-) diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index a944caa7..839d2a2a 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,7 +1,6 @@ import logging from typing import Any, Dict -import matplotlib.pyplot as plt import networkx as nx logger = logging.getLogger(__name__) @@ -17,64 +16,34 @@ class EntropyGraph: def __init__(self): self.graph = nx.DiGraph() - self.nodes = {} + self.nodes: Dict[str, Any] = {} - def add_node(self, name: str, obj: Any, depends_on=None): - """Add a computational node to the DAG.""" - if not hasattr(obj, "run"): - raise TypeError( - f"Node '{name}' must implement a `run(shared_data, **kwargs)` method." - ) + def add_node(self, name: str, node_obj: Any, depends_on=None): + if not hasattr(node_obj, "run"): + raise TypeError(f"Node '{name}' must implement run(shared_data, **kwargs)") - self.nodes[name] = obj + self.nodes[name] = node_obj self.graph.add_node(name) - if depends_on: for dep in depends_on: - if dep not in self.graph: - raise ValueError(f"Dependency '{dep}' not found for node '{name}'.") self.graph.add_edge(dep, name) - logger.debug(f"Added node '{name}' with dependencies: {depends_on}") - def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: - """Execute nodes in topological order.""" - logger.info("Executing EntropyGraph DAG...") results: Dict[str, Dict[str, Any]] = {} - for node in nx.topological_sort(self.graph): - preds = list(self.graph.predecessors(node)) + for node_name in nx.topological_sort(self.graph): + preds = list(self.graph.predecessors(node_name)) kwargs = {p: results[p] for p in preds} - node_obj = self.nodes[node] - logger.info(f"Running node: {node} (depends on: {preds})") - - output = node_obj.run(shared_data, **kwargs) + node = self.nodes[node_name] + output = node.run(shared_data, **kwargs) if not isinstance(output, dict): raise TypeError( - f"Node '{node}' returned {type(output)}; must return dict." + f"Node '{node_name}' returned {type(output)} (expected dict)" ) - results[node] = output + results[node_name] = output + shared_data.update(output) - logger.info("DAG execution complete.") return results - - def visualize(self, show=True, figsize=(8, 6)): - """Visualize the DAG using matplotlib.""" - - pos = nx.spring_layout(self.graph, seed=42) - plt.figure(figsize=figsize) - nx.draw( - self.graph, - pos, - with_labels=True, - node_color="lightblue", - node_size=2800, - arrows=True, - font_weight="bold", - ) - plt.title("Entropy Computation Graph", fontsize=14) - if show: - plt.show() From b5461c519ed2d9e537bdefda1fcf7029d70146b9 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 19 Nov 2025 17:39:16 +0000 Subject: [PATCH 007/101] Create `levels/nodes/detect_levels.py` node that wraps `LevelHierarchy.select_levels()` --- CodeEntropy/levels/nodes/detect_levels.py | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CodeEntropy/levels/nodes/detect_levels.py diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py new file mode 100644 index 00000000..8b50da19 --- /dev/null +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -0,0 +1,27 @@ +import logging +from typing import Any, Dict + +from CodeEntropy.levels.level_hierarchy import LevelHierarchy + +logger = logging.getLogger(__name__) + + +class DetectLevelsNode: + """ + Node to detect molecule count and assign levels. + """ + + def __init__(self): + self._hier = LevelHierarchy() + + def run(self, shared_data: Dict[str, Any], **kwargs) -> Dict[str, Any]: + universe = shared_data["universe"] + number_molecules, levels = self._hier.select_levels(universe) + + logger.debug(f"[DetectLevelsNode] number_molecules={number_molecules}") + logger.debug(f"[DetectLevelsNode] levels={levels}") + + return { + "number_molecules": number_molecules, + "levels": levels, + } From c088fd4fa8ac3b5a61aa359475036ea571273a3a Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 19 Nov 2025 17:44:50 +0000 Subject: [PATCH 008/101] Create `levels/nodes/build_beads.py` node that wraps `LevelHierarchy.get_beads()` --- CodeEntropy/levels/nodes/build_beads.py | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 CodeEntropy/levels/nodes/build_beads.py diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py new file mode 100644 index 00000000..744cb6ca --- /dev/null +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -0,0 +1,41 @@ +import logging +from typing import Any, Dict, List, Tuple + +from CodeEntropy.config.run import RunManager +from CodeEntropy.levels.level_hierarchy import LevelHierarchy + +logger = logging.getLogger(__name__) + + +class BuildBeadsNode: + """ + Node to build collections of beads for each molecule and level. + """ + + def __init__(self, run_manager: RunManager): + self._hier = LevelHierarchy() + self._run_manager = run_manager + + def run( + self, shared_data: Dict[str, Any], detect_levels: Dict[str, Any], **kwargs + ) -> Dict[str, Any]: + universe = shared_data["universe"] + levels = detect_levels["levels"] + beads_by_mol_level: Dict[Tuple[int, str], List[Any]] = {} + + for mol_id, level_list in enumerate(levels): + # Create universal molecule container if needed + mol_container = self._run_manager.new_U_select_atom( + universe, + f"index {universe.atoms.fragments[mol_id].indices[0]}:" + f"{universe.atoms.fragments[mol_id].indices[-1]}", + ) + for level in level_list: + beads = self._hier.get_beads(mol_container, level) + beads_by_mol_level[(mol_id, level)] = beads + + logger.debug( + f"[BuildBeadsNode] built beads for {len(beads_by_mol_level)} combinations" + ) + + return {"beads_by_mol_level": beads_by_mol_level} From 6ceb4a09ac7e077fc3cfdf4a1e2d80bb7bae45fa Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 19 Nov 2025 17:46:54 +0000 Subject: [PATCH 009/101] Create `hierarchy_graph.py` with a generic DAG engine --- CodeEntropy/levels/hierarchy_graph.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 CodeEntropy/levels/hierarchy_graph.py diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py new file mode 100644 index 00000000..c1242ceb --- /dev/null +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -0,0 +1,19 @@ +from CodeEntropy.entropy.entropy_graph import EntropyGraph +from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode +from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode + + +class HierarchyGraph: + """ + DAG for level / bead / structural preparation. + """ + + def __init__(self): + self.graph = EntropyGraph() + + def build(self, run_manager): + self.graph.add_node("detect_levels", DetectLevelsNode()) + self.graph.add_node( + "build_beads", BuildBeadsNode(run_manager), depends_on=["detect_levels"] + ) + return self.graph From c01e7851db39bceea0e00b865d2372abd86e8f02 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 19 Nov 2025 17:58:04 +0000 Subject: [PATCH 010/101] update `levels/level_manager.py` to coordinate the DAG approach --- CodeEntropy/levels/level_manager.py | 66 ++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 15 deletions(-) diff --git a/CodeEntropy/levels/level_manager.py b/CodeEntropy/levels/level_manager.py index 55fba8e3..5fbc7059 100644 --- a/CodeEntropy/levels/level_manager.py +++ b/CodeEntropy/levels/level_manager.py @@ -1,26 +1,62 @@ import logging +from typing import Any, Dict + +from CodeEntropy.levels.hierarchy_graph import HierarchyGraph logger = logging.getLogger(__name__) class LevelManager: """ - Manages the structural and dynamic levels involved in entropy calculations. This - includes selecting relevant levels, computing axes for translation and rotation, - and handling bead-based representations of molecular systems. Provides utility - methods to extract averaged positions, convert coordinates to spherical systems, - compute weighted forces and torques, and manipulate matrices used in entropy - analysis. + Coordinates the DAG-based computation of molecular levels and beads. + All physics/maths are delegated to lower-level classes and DAG nodes. """ - def __init__(self): + def __init__(self, universe, run_manager, args): + """ + Parameters + ---------- + universe : MDAnalysis.Universe + The MD system being analysed. + run_manager : RunManager + Provides selection helpers and unit conversions. + args : Namespace + Parsed CLI arguments. + """ + self.universe = universe + self.run_manager = run_manager + self.args = args + + def run_hierarchy(self) -> Dict[str, Any]: + """ + Execute the structural hierarchy DAG (levels → beads). + + Returns + ------- + dict + Contains: + - number_molecules + - levels + - beads_by_mol_level + """ + + shared_data = { + "universe": self.universe, + "args": self.args, + "run_manager": self.run_manager, + } + + graph = HierarchyGraph().build(self.run_manager) + results = graph.execute(shared_data) + + logger.debug("[LevelManager] Hierarchy DAG results:") + logger.debug(results) + + return results + + def run(self): """ - Initializes the LevelManager with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. + Placeholder: eventually will run all DAGs (hierarchy, matrices, entropy). + For now, only run the hierarchy graph. """ - self.data_container = None - self._levels = None - self._trans_axes = None - self._rot_axes = None - self._number_of_beads = None + return self.run_hierarchy() From a2760a77e5debee130199e0ab6bd4b128e0b1ae3 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 27 Nov 2025 16:30:17 +0000 Subject: [PATCH 011/101] restructure files from main branch merge --- CodeEntropy/levels/dihedral_analysis.py | 289 ------------------ CodeEntropy/{ => levels}/dihedral_tools.py | 0 .../{ => levels}/mda_universe_operations.py | 0 3 files changed, 289 deletions(-) delete mode 100644 CodeEntropy/levels/dihedral_analysis.py rename CodeEntropy/{ => levels}/dihedral_tools.py (100%) rename CodeEntropy/{ => levels}/mda_universe_operations.py (100%) diff --git a/CodeEntropy/levels/dihedral_analysis.py b/CodeEntropy/levels/dihedral_analysis.py deleted file mode 100644 index 999d52be..00000000 --- a/CodeEntropy/levels/dihedral_analysis.py +++ /dev/null @@ -1,289 +0,0 @@ -import logging - -import numpy as np -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) - -logger = logging.getLogger(__name__) - - -class DihedralAnalysis: - """ """ - - def __init__(self): - """ - Initializes the DihedralAnalysis with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ - - def get_dihedrals(self, data_container, level): - """ - Define the set of dihedrals for use in the conformational entropy function. - If united atom level, the dihedrals are defined from the heavy atoms - (4 bonded atoms for 1 dihedral). - If residue level, use the bonds between residues to cast dihedrals. - Note: not using improper dihedrals only ones with 4 atoms/residues - in a linear arrangement. - - Args: - data_container (MDAnalysis.Universe): system information - level (str): level of the hierarchy (should be residue or polymer) - - Returns: - dihedrals (array): set of dihedrals - """ - # Start with empty array - dihedrals = [] - - # if united atom level, read dihedrals from MDAnalysis universe - if level == "united_atom": - dihedrals = data_container.dihedrals - - # if residue level, looking for dihedrals involving residues - if level == "residue": - num_residues = len(data_container.residues) - logger.debug(f"Number Residues: {num_residues}") - if num_residues < 4: - logger.debug("no residue level dihedrals") - - else: - # find bonds between residues N-3:N-2 and N-1:N - for residue in range(4, num_residues + 1): - # Using MDAnalysis selection, - # assuming only one covalent bond between neighbouring residues - # TODO not written for branched polymers - atom_string = ( - "resindex " - + str(residue - 4) - + " and bonded resindex " - + str(residue - 3) - ) - atom1 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 3) - + " and bonded resindex " - + str(residue - 4) - ) - atom2 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 2) - + " and bonded resindex " - + str(residue - 1) - ) - atom3 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 1) - + " and bonded resindex " - + str(residue - 2) - ) - atom4 = data_container.select_atoms(atom_string) - - atom_group = atom1 + atom2 + atom3 + atom4 - dihedrals.append(atom_group.dihedral) - - logger.debug(f"Level: {level}, Dihedrals: {dihedrals}") - - return dihedrals - - def compute_dihedral_conformations( - self, - selector, - level, - number_frames, - bin_width, - start, - end, - step, - ce, - ): - """ - Compute dihedral conformations for a given selector and entropy level. - - Parameters: - selector (AtomGroup): Atom selection to compute dihedrals for. - level (str): Entropy level ("united_atom" or "residue"). - number_frames (int): Number of frames to process. - bin_width (float): Bin width for dihedral angle discretization. - start (int): Start frame index. - end (int): End frame index. - step (int): Step size for frame iteration. - ce : Conformational Entropy class - - Returns: - states (list): List of conformation strings per frame. - """ - # Identify the dihedral angles in the residue/molecule - dihedrals = self.get_dihedrals(selector, level) - - # When there are no dihedrals, there is only one possible conformation - # so the conformational states are not relevant - if len(dihedrals) == 0: - logger.debug("No dihedrals found; skipping conformation assignment.") - states = [] - else: - # Identify the conformational label for each dihedral at each frame - num_dihedrals = len(dihedrals) - conformation = np.zeros((num_dihedrals, number_frames)) - - for i, dihedral in enumerate(dihedrals): - conformation[i] = ce.assign_conformation( - selector, dihedral, number_frames, bin_width, start, end, step - ) - - # for all the dihedrals available concatenate the label of each - # dihedral into the state for that frame - states = [ - state - for state in ( - "".join(str(int(conformation[d][f])) for d in range(num_dihedrals)) - for f in range(number_frames) - ) - if state - ] - - logger.debug(f"level: {level}, states: {states}") - - return states - - def build_conformational_states( - self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - bin_width, - ce, - ): - """ - Construct the conformational states for each molecule at - relevant levels. - - Parameters: - entropy_manager (EntropyManager): Instance of the EntropyManager - reduced_atom (Universe): The reduced atom selection. - levels (list): List of entropy levels per molecule. - groups (dict): Groups for averaging over molecules. - start (int): Start frame index. - end (int): End frame index. - step (int): Step size for frame iteration. - number_frames (int): Total number of frames to process. - bin_width (int): Width of histogram bins. - ce: Conformational Entropy object - - Returns: - tuple: A tuple containing: - - states_ua (dict): Conformational states at the united-atom level. - - states_res (list): Conformational states at the residue level. - """ - number_groups = len(groups) - states_ua = {} - states_res = [None] * number_groups - - total_items = sum( - len(levels[mol_id]) for mols in groups.values() for mol_id in mols - ) - - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) as progress: - - task = progress.add_task( - "[green]Building Conformational States...", - total=total_items, - title="Starting...", - ) - - for group_id in groups.keys(): - molecules = groups[group_id] - for mol_id in molecules: - mol = entropy_manager._get_molecule_container(reduced_atom, mol_id) - - resname = mol.atoms[0].resname - resid = mol.atoms[0].resid - segid = mol.atoms[0].segid - - mol_label = f"{resname}_{resid} (segid {segid})" - - for level in levels[mol_id]: - progress.update( - task, - title=f"Building conformational states | " - f"Molecule: {mol_label} | " - f"Level: {level}", - ) - - if level == "united_atom": - for res_id, residue in enumerate(mol.residues): - key = (group_id, res_id) - - res_container = ( - entropy_manager._run_manager.new_U_select_atom( - mol, - f"index {residue.atoms.indices[0]}:" - f"{residue.atoms.indices[-1]}", - ) - ) - heavy_res = ( - entropy_manager._run_manager.new_U_select_atom( - res_container, "prop mass > 1.1" - ) - ) - states = self.compute_dihedral_conformations( - heavy_res, - level, - number_frames, - bin_width, - start, - end, - step, - ce, - ) - - if key in states_ua: - states_ua[key].extend(states) - else: - states_ua[key] = states - - elif level == "residue": - states = self.compute_dihedral_conformations( - mol, - level, - number_frames, - bin_width, - start, - end, - step, - ce, - ) - - if states_res[group_id] is None: - states_res[group_id] = states - else: - states_res[group_id].extend(states) - - progress.advance(task) - - logger.debug(f"states_ua {states_ua}") - logger.debug(f"states_res {states_res}") - - return states_ua, states_res diff --git a/CodeEntropy/dihedral_tools.py b/CodeEntropy/levels/dihedral_tools.py similarity index 100% rename from CodeEntropy/dihedral_tools.py rename to CodeEntropy/levels/dihedral_tools.py diff --git a/CodeEntropy/mda_universe_operations.py b/CodeEntropy/levels/mda_universe_operations.py similarity index 100% rename from CodeEntropy/mda_universe_operations.py rename to CodeEntropy/levels/mda_universe_operations.py From e058a9d5f6926f4b756ad129eae70819dc2d784e Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 27 Nov 2025 16:47:46 +0000 Subject: [PATCH 012/101] move `main.py` and remove `CodeEntropy/cli` folder --- CodeEntropy/{cli => }/main.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CodeEntropy/{cli => }/main.py (100%) diff --git a/CodeEntropy/cli/main.py b/CodeEntropy/main.py similarity index 100% rename from CodeEntropy/cli/main.py rename to CodeEntropy/main.py From 1818b3f70761651730cad65bf621af4c2de06b1a Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 1 Dec 2025 14:11:55 +0000 Subject: [PATCH 013/101] updated `level_manager.py` to reflect graph implementation --- CodeEntropy/levels/level_manager.py | 195 +++++++++++++++++++++++----- 1 file changed, 160 insertions(+), 35 deletions(-) diff --git a/CodeEntropy/levels/level_manager.py b/CodeEntropy/levels/level_manager.py index 5fbc7059..41707bfc 100644 --- a/CodeEntropy/levels/level_manager.py +++ b/CodeEntropy/levels/level_manager.py @@ -1,62 +1,187 @@ import logging -from typing import Any, Dict -from CodeEntropy.levels.hierarchy_graph import HierarchyGraph +from CodeEntropy.levels.coordinate_system import CoordinateSystem +from CodeEntropy.levels.dihedral_tools import DihedralAnalysis +from CodeEntropy.levels.force_torque_manager import ForceTorqueManager +from CodeEntropy.levels.level_hierarchy import LevelHierarchy +from CodeEntropy.levels.matrix_operations import MatrixOperations +from CodeEntropy.levels.neighbours import Neighbours logger = logging.getLogger(__name__) class LevelManager: """ - Coordinates the DAG-based computation of molecular levels and beads. - All physics/maths are delegated to lower-level classes and DAG nodes. + High–level orchestrator for all 'level' computations. + + It does not implement physics itself. Instead it: + - delegates to LevelHierarchy to decide which levels exist + - delegates to ForceTorqueManager to build covariance matrices + - delegates to DihedralAnalysis to build conformational states + - delegates to MatrixOperations for matrix cleanup / utilities """ - def __init__(self, universe, run_manager, args): + def __init__(self): + """ + Construct modular helpers and keep shared references. + """ + self._hierarchy = LevelHierarchy() + self._coords = CoordinateSystem() + self._dihedrals = DihedralAnalysis() + self._mat_ops = MatrixOperations() + self._force_torque = ForceTorqueManager() + self._neighbours = Neighbours() + + def select_levels(self, data_container): """ + Wrapper around LevelHierarchy.select_levels + Parameters ---------- - universe : MDAnalysis.Universe - The MD system being analysed. - run_manager : RunManager - Provides selection helpers and unit conversions. - args : Namespace - Parsed CLI arguments. + data_container : MDAnalysis.Universe + Reduced universe / selection. + + Returns + ------- + number_molecules : int + levels : list[list[str]] + e.g. [["united_atom", "residue", "polymer"], ["united_atom"], ...] """ - self.universe = universe - self.run_manager = run_manager - self.args = args + number_molecules, levels = self._hierarchy.select_levels(data_container) + logger.debug(f"[LevelManager] number_molecules={number_molecules}") + logger.debug(f"[LevelManager] levels={levels}") + return number_molecules, levels - def run_hierarchy(self) -> Dict[str, Any]: + def get_beads(self, data_container, level): """ - Execute the structural hierarchy DAG (levels → beads). + Simple pass-through to LevelHierarchy.get_beads + + Kept here in case you later want the DAG to ask LevelManager for bead + sets without touching LevelHierarchy directly. + """ + return self._hierarchy.get_beads(data_container, level) + + def build_covariance_matrices( + self, + entropy_manager, + reduced_atom, + levels, + groups, + start, + end, + step, + number_frames, + ): + """ + Thin wrapper around ForceTorqueManager.build_covariance_matrices. + + All the heavy lifting (loops over frames, groups, levels, beads, + physics of forces/torques, incremental averaging) stays inside + ForceTorqueManager. + + Parameters + ---------- + entropy_manager : EntropyManager + Needed because ForceTorqueManager currently calls + entropy_manager._get_molecule_container(...) + reduced_atom : MDAnalysis.Universe + levels : list[list[str]] + groups : dict[int, list[int]] + start, end, step : int + number_frames : int + + Returns + ------- + force_matrices : dict + torque_matrices : dict + frame_counts : dict + """ + logger.debug( + "[LevelManager] Delegating to ForceTorqueManager.build_covariance_matrices" + ) + return self._force_torque.build_covariance_matrices( + entropy_manager=entropy_manager, + reduced_atom=reduced_atom, + levels=levels, + groups=groups, + start=start, + end=end, + step=step, + number_frames=number_frames, + ) + + def build_conformational_states( + self, + entropy_manager, + reduced_atom, + levels, + groups, + start, + end, + step, + number_frames, + bin_width, + conformational_entropy_obj, + ): + """ + Wrapper around DihedralAnalysis.build_conformational_states. + + Parameters + ---------- + entropy_manager : EntropyManager + reduced_atom : MDAnalysis.Universe + levels : list[list[str]] + groups : dict[int, list[int]] + start, end, step : int + number_frames : int + bin_width : int + Histogram bin width (degrees) for dihedral distributions. + conformational_entropy_obj : ConformationalEntropy + The CE object, passed through because your dihedral code + may call its methods. Returns ------- - dict - Contains: - - number_molecules - - levels - - beads_by_mol_level + states_ua : dict + e.g. {(group_id, residue_id): states_array} + states_res : list + e.g. [states_for_group0, states_for_group1, ...] """ + logger.debug( + "[LevelManager] Delegating to DihedralAnalysis.build_conformational_states" + ) + return self._dihedrals.build_conformational_states( + entropy_manager=entropy_manager, + reduced_atom=reduced_atom, + levels=levels, + groups=groups, + start=start, + end=end, + step=step, + number_frames=number_frames, + bin_width=bin_width, + conformational_entropy_obj=conformational_entropy_obj, + ) - shared_data = { - "universe": self.universe, - "args": self.args, - "run_manager": self.run_manager, - } + def filter_zero_rows_columns(self, matrix): + """ + Wrapper around MatrixOperations.filter_zero_rows_columns. - graph = HierarchyGraph().build(self.run_manager) - results = graph.execute(shared_data) - logger.debug("[LevelManager] Hierarchy DAG results:") - logger.debug(results) + Physics and numerical behaviour are unchanged – MatrixOperations + contains the original implementation. + """ + return self._mat_ops.filter_zero_rows_columns(matrix) - return results + def get_axes(self, bead): + """ + Convenience forwarder to CoordinateSystem.get_axes (if/when needed). + Not used by EntropyManager right now but handy for future DAG nodes. + """ + return self._coords.get_axes(bead) - def run(self): + def find_neighbours(self, data_container, bead, cutoff): """ - Placeholder: eventually will run all DAGs (hierarchy, matrices, entropy). - For now, only run the hierarchy graph. + Convenience wrapper around Neighbours. """ - return self.run_hierarchy() + return self._neighbours.find_neighbours(data_container, bead, cutoff) From 287e23989e166cf27f250fb0c76d706b91d5aeb7 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 5 Dec 2025 13:17:11 +0000 Subject: [PATCH 014/101] update `networkx` and `matplotlib` to dependency range within `pyproject.toml` --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25ae707c..96015d2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,8 +46,8 @@ dependencies = [ "python-json-logger>=4.0,<5.0", "rich>=14.2,<15.0", "art>=6.5,<7.0", - "networkx==3.5", - "matplotlib==3.10.7", + "networkx>=3.6,<3.7", + "matplotlib>=3.10,<3.11", "waterEntropy>=1.2,<2.0", "requests>=2.32,<3.0", ] From 5750327242095ebf5e6207a5c72c9da0e9d571e9 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 9 Dec 2025 11:17:58 +0000 Subject: [PATCH 015/101] build minimal graph nodes for the levels functionality --- CodeEntropy/levels/nodes/build_beads.py | 39 +++++-------------- .../levels/nodes/build_conformations.py | 9 +++++ .../levels/nodes/build_covariance_matrices.py | 11 ++++++ CodeEntropy/levels/nodes/compute_axes.py | 20 ++++++++++ CodeEntropy/levels/nodes/compute_dihedrals.py | 9 +++++ .../levels/nodes/compute_neighbours.py | 9 +++++ .../levels/nodes/compute_weighted_forces.py | 16 ++++++++ .../levels/nodes/compute_weighted_torques.py | 16 ++++++++ CodeEntropy/levels/nodes/detect_levels.py | 21 +++------- CodeEntropy/levels/nodes/detect_molecules.py | 15 +++++++ 10 files changed, 119 insertions(+), 46 deletions(-) create mode 100644 CodeEntropy/levels/nodes/build_conformations.py create mode 100644 CodeEntropy/levels/nodes/build_covariance_matrices.py create mode 100644 CodeEntropy/levels/nodes/compute_axes.py create mode 100644 CodeEntropy/levels/nodes/compute_dihedrals.py create mode 100644 CodeEntropy/levels/nodes/compute_neighbours.py create mode 100644 CodeEntropy/levels/nodes/compute_weighted_forces.py create mode 100644 CodeEntropy/levels/nodes/compute_weighted_torques.py create mode 100644 CodeEntropy/levels/nodes/detect_molecules.py diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py index 744cb6ca..2ba7e8d7 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -1,41 +1,20 @@ -import logging -from typing import Any, Dict, List, Tuple - -from CodeEntropy.config.run import RunManager from CodeEntropy.levels.level_hierarchy import LevelHierarchy - -logger = logging.getLogger(__name__) +from CodeEntropy.levels.mda_universe_operations import UniverseOperations class BuildBeadsNode: - """ - Node to build collections of beads for each molecule and level. - """ - - def __init__(self, run_manager: RunManager): + def __init__(self): self._hier = LevelHierarchy() - self._run_manager = run_manager + self._mda = UniverseOperations() - def run( - self, shared_data: Dict[str, Any], detect_levels: Dict[str, Any], **kwargs - ) -> Dict[str, Any]: - universe = shared_data["universe"] + def run(self, shared_data, detect_levels): + u = shared_data["universe"] levels = detect_levels["levels"] - beads_by_mol_level: Dict[Tuple[int, str], List[Any]] = {} + beads = {} for mol_id, level_list in enumerate(levels): - # Create universal molecule container if needed - mol_container = self._run_manager.new_U_select_atom( - universe, - f"index {universe.atoms.fragments[mol_id].indices[0]}:" - f"{universe.atoms.fragments[mol_id].indices[-1]}", - ) + mol_u = self._mda.get_molecule_container(u, mol_id) for level in level_list: - beads = self._hier.get_beads(mol_container, level) - beads_by_mol_level[(mol_id, level)] = beads - - logger.debug( - f"[BuildBeadsNode] built beads for {len(beads_by_mol_level)} combinations" - ) + beads[(mol_id, level)] = self._hier.get_beads(mol_u, level) - return {"beads_by_mol_level": beads_by_mol_level} + return {"beads": beads} diff --git a/CodeEntropy/levels/nodes/build_conformations.py b/CodeEntropy/levels/nodes/build_conformations.py new file mode 100644 index 00000000..ba23fe29 --- /dev/null +++ b/CodeEntropy/levels/nodes/build_conformations.py @@ -0,0 +1,9 @@ +from CodeEntropy.levels.dihedral_tools import DihedralTools + + +class BuildConformationsNode: + def __init__(self): + self._dih = DihedralTools() + + def run(self, compute_dihedrals): + return self._dih.build_conformational_states(compute_dihedrals["dihedrals"]) diff --git a/CodeEntropy/levels/nodes/build_covariance_matrices.py b/CodeEntropy/levels/nodes/build_covariance_matrices.py new file mode 100644 index 00000000..a953ed33 --- /dev/null +++ b/CodeEntropy/levels/nodes/build_covariance_matrices.py @@ -0,0 +1,11 @@ +from CodeEntropy.levels.force_torque_manager import ForceTorqueManager + + +class BuildCovarianceMatricesNode: + def __init__(self): + self._ft = ForceTorqueManager() + + def run(self, shared_data, compute_weighted_forces, compute_weighted_torques): + return self._ft.build_covariance_matrices( + compute_weighted_forces["forces"], compute_weighted_torques["torques"] + ) diff --git a/CodeEntropy/levels/nodes/compute_axes.py b/CodeEntropy/levels/nodes/compute_axes.py new file mode 100644 index 00000000..23022c07 --- /dev/null +++ b/CodeEntropy/levels/nodes/compute_axes.py @@ -0,0 +1,20 @@ +from CodeEntropy.levels.coordinate_system import CoordinateSystem + + +class ComputeAxesNode: + def __init__(self): + self._coord = CoordinateSystem() + + def run(self, shared_data, build_beads): + axes = {} + avg_pos = {} + + for key, bead_list in build_beads["beads"].items(): + axes[key] = [] + avg_pos[key] = [] + + for bead in bead_list: + avg_pos[key].append(self._coord.get_avg_pos(bead)) + axes[key].append(self._coord.get_axes(bead)) + + return {"axes": axes, "avg_pos": avg_pos} diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py new file mode 100644 index 00000000..9a386658 --- /dev/null +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -0,0 +1,9 @@ +from CodeEntropy.levels.dihedral_tools import DihedralTools + + +class ComputeDihedralsNode: + def __init__(self): + self._dih = DihedralTools() + + def run(self, build_beads): + return {"dihedrals": self._dih.get_dihedrals(build_beads["beads"])} diff --git a/CodeEntropy/levels/nodes/compute_neighbours.py b/CodeEntropy/levels/nodes/compute_neighbours.py new file mode 100644 index 00000000..4b868f82 --- /dev/null +++ b/CodeEntropy/levels/nodes/compute_neighbours.py @@ -0,0 +1,9 @@ +from CodeEntropy.levels.neighbours import NeighbourList + + +class ComputeNeighboursNode: + def __init__(self): + self._nb = NeighbourList() + + def run(self, build_beads): + return {"neighbours": self._nb.compute(build_beads["beads"])} diff --git a/CodeEntropy/levels/nodes/compute_weighted_forces.py b/CodeEntropy/levels/nodes/compute_weighted_forces.py new file mode 100644 index 00000000..731da4a0 --- /dev/null +++ b/CodeEntropy/levels/nodes/compute_weighted_forces.py @@ -0,0 +1,16 @@ +from CodeEntropy.levels.force_torque_manager import ForceTorqueManager + + +class ComputeWeightedForcesNode: + def __init__(self): + self._ft = ForceTorqueManager() + + def run(self, shared_data, compute_axes, build_beads): + u = shared_data["universe"] + forces = {} + + for key, bead_list in build_beads["beads"].items(): + forces[key] = [] + for bead, ax in zip(bead_list, compute_axes["axes"][key]): + forces[key].append(self._ft.get_weighted_forces(u, bead, ax, False)) + return {"forces": forces} diff --git a/CodeEntropy/levels/nodes/compute_weighted_torques.py b/CodeEntropy/levels/nodes/compute_weighted_torques.py new file mode 100644 index 00000000..26924298 --- /dev/null +++ b/CodeEntropy/levels/nodes/compute_weighted_torques.py @@ -0,0 +1,16 @@ +from CodeEntropy.levels.force_torque_manager import ForceTorqueManager + + +class ComputeWeightedTorquesNode: + def __init__(self): + self._ft = ForceTorqueManager() + + def run(self, shared_data, compute_axes, build_beads): + u = shared_data["universe"] + torques = {} + + for key, bead_list in build_beads["beads"].items(): + torques[key] = [] + for bead, ax in zip(bead_list, compute_axes["axes"][key]): + torques[key].append(self._ft.get_weighted_torques(u, bead, ax)) + return {"torques": torques} diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index 8b50da19..c1db6242 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -1,27 +1,16 @@ -import logging -from typing import Any, Dict - from CodeEntropy.levels.level_hierarchy import LevelHierarchy -logger = logging.getLogger(__name__) - class DetectLevelsNode: - """ - Node to detect molecule count and assign levels. - """ - def __init__(self): self._hier = LevelHierarchy() - def run(self, shared_data: Dict[str, Any], **kwargs) -> Dict[str, Any]: - universe = shared_data["universe"] - number_molecules, levels = self._hier.select_levels(universe) - - logger.debug(f"[DetectLevelsNode] number_molecules={number_molecules}") - logger.debug(f"[DetectLevelsNode] levels={levels}") + def run(self, shared_data, detect_molecules): + u = shared_data["universe"] + num_mol, levels = self._hier.select_levels(u) return { - "number_molecules": number_molecules, + "number_molecules": num_mol, "levels": levels, + "fragments": detect_molecules["fragments"], } diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py new file mode 100644 index 00000000..b19a203e --- /dev/null +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -0,0 +1,15 @@ +import logging +from typing import Any, Dict + +logger = logging.getLogger(__name__) + + +class DetectMoleculesNode: + def run(self, shared_data: Dict[str, Any], **_): + u = shared_data["universe"] + fragments = u.atoms.fragments + num_mol = len(fragments) + + logger.info(f"[DetectMoleculesNode] {num_mol} molecules detected") + + return {"number_molecules": num_mol, "fragments": fragments} From 7d5b8610e628eb5896084dd840670c5fb4733ecc Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 9 Dec 2025 12:04:34 +0000 Subject: [PATCH 016/101] update `hierarchy_graph` to match `levels/nodes` --- CodeEntropy/levels/hierarchy_graph.py | 61 ++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index c1242ceb..b583efe4 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,19 +1,58 @@ -from CodeEntropy.entropy.entropy_graph import EntropyGraph +import networkx as nx + from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode +from CodeEntropy.levels.nodes.build_conformations import BuildConformationsNode +from CodeEntropy.levels.nodes.build_covariance_matrices import ( + BuildCovarianceMatricesNode, +) +from CodeEntropy.levels.nodes.compute_axes import ComputeAxesNode +from CodeEntropy.levels.nodes.compute_dihedrals import ComputeDihedralsNode +from CodeEntropy.levels.nodes.compute_neighbours import ComputeNeighboursNode +from CodeEntropy.levels.nodes.compute_weighted_forces import ComputeWeightedForcesNode +from CodeEntropy.levels.nodes.compute_weighted_torques import ComputeWeightedTorquesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode +from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode -class HierarchyGraph: - """ - DAG for level / bead / structural preparation. - """ +class LevelDAG: def __init__(self): - self.graph = EntropyGraph() + self.graph = nx.DiGraph() + self.nodes = {} - def build(self, run_manager): - self.graph.add_node("detect_levels", DetectLevelsNode()) - self.graph.add_node( - "build_beads", BuildBeadsNode(run_manager), depends_on=["detect_levels"] + def build(self): + self.add("detect_molecules", DetectMoleculesNode()) + self.add("detect_levels", DetectLevelsNode(), ["detect_molecules"]) + self.add("build_beads", BuildBeadsNode(), ["detect_levels"]) + self.add("compute_axes", ComputeAxesNode(), ["build_beads"]) + self.add( + "compute_weighted_forces", ComputeWeightedForcesNode(), ["compute_axes"] + ) + self.add( + "compute_weighted_torques", ComputeWeightedTorquesNode(), ["compute_axes"] + ) + self.add( + "build_covariance", + BuildCovarianceMatricesNode(), + ["compute_weighted_forces", "compute_weighted_torques"], ) - return self.graph + self.add("compute_dihedrals", ComputeDihedralsNode(), ["build_beads"]) + self.add("build_conformations", BuildConformationsNode(), ["compute_dihedrals"]) + self.add("compute_neighbours", ComputeNeighboursNode(), ["build_beads"]) + + return self + + def add(self, name, obj, deps=None): + self.nodes[name] = obj + self.graph.add_node(name) + if deps: + for d in deps: + self.graph.add_edge(d, name) + + def execute(self, shared_data): + results = {} + for node in nx.topological_sort(self.graph): + deps = {d: results[d] for d in self.graph.predecessors(node)} + output = self.nodes[node].run(shared_data, **deps) + results[node] = output + return results From 7500f800624753c83f643afe59e271b0ab173c90 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 9 Dec 2025 12:51:14 +0000 Subject: [PATCH 017/101] update `level_manager.py` to match changes within `hierarchy_graph.py` --- CodeEntropy/levels/level_manager.py | 197 +++++----------------------- 1 file changed, 31 insertions(+), 166 deletions(-) diff --git a/CodeEntropy/levels/level_manager.py b/CodeEntropy/levels/level_manager.py index 41707bfc..dfb5f94e 100644 --- a/CodeEntropy/levels/level_manager.py +++ b/CodeEntropy/levels/level_manager.py @@ -1,187 +1,52 @@ import logging +from typing import Any, Dict -from CodeEntropy.levels.coordinate_system import CoordinateSystem -from CodeEntropy.levels.dihedral_tools import DihedralAnalysis -from CodeEntropy.levels.force_torque_manager import ForceTorqueManager -from CodeEntropy.levels.level_hierarchy import LevelHierarchy -from CodeEntropy.levels.matrix_operations import MatrixOperations -from CodeEntropy.levels.neighbours import Neighbours +from CodeEntropy.levels.hierarchy_graph import LevelDAG logger = logging.getLogger(__name__) class LevelManager: """ - High–level orchestrator for all 'level' computations. + High-level coordinator that runs the Level DAG and returns results required + later for entropy nodes. - It does not implement physics itself. Instead it: - - delegates to LevelHierarchy to decide which levels exist - - delegates to ForceTorqueManager to build covariance matrices - - delegates to DihedralAnalysis to build conformational states - - delegates to MatrixOperations for matrix cleanup / utilities + Output from this class is forwarded into EntropyGraph. """ - def __init__(self): - """ - Construct modular helpers and keep shared references. - """ - self._hierarchy = LevelHierarchy() - self._coords = CoordinateSystem() - self._dihedrals = DihedralAnalysis() - self._mat_ops = MatrixOperations() - self._force_torque = ForceTorqueManager() - self._neighbours = Neighbours() - - def select_levels(self, data_container): - """ - Wrapper around LevelHierarchy.select_levels - - Parameters - ---------- - data_container : MDAnalysis.Universe - Reduced universe / selection. - - Returns - ------- - number_molecules : int - levels : list[list[str]] - e.g. [["united_atom", "residue", "polymer"], ["united_atom"], ...] - """ - number_molecules, levels = self._hierarchy.select_levels(data_container) - logger.debug(f"[LevelManager] number_molecules={number_molecules}") - logger.debug(f"[LevelManager] levels={levels}") - return number_molecules, levels + def __init__(self, run_manager=None): + self.run_manager = run_manager + self.level_results = None - def get_beads(self, data_container, level): + def run(self, universe) -> Dict[str, Any]: """ - Simple pass-through to LevelHierarchy.get_beads + Execute the level-processing DAG and return all structural results. - Kept here in case you later want the DAG to ask LevelManager for bead - sets without touching LevelHierarchy directly. - """ - return self._hierarchy.get_beads(data_container, level) + Input: + universe (MDAnalysis.Universe) - def build_covariance_matrices( - self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - ): + Output dictionary feeds forward into the entropy pipeline. """ - Thin wrapper around ForceTorqueManager.build_covariance_matrices. - - All the heavy lifting (loops over frames, groups, levels, beads, - physics of forces/torques, incremental averaging) stays inside - ForceTorqueManager. + dag = LevelDAG().build() - Parameters - ---------- - entropy_manager : EntropyManager - Needed because ForceTorqueManager currently calls - entropy_manager._get_molecule_container(...) - reduced_atom : MDAnalysis.Universe - levels : list[list[str]] - groups : dict[int, list[int]] - start, end, step : int - number_frames : int + shared_data = {"universe": universe, "run_manager": self.run_manager} - Returns - ------- - force_matrices : dict - torque_matrices : dict - frame_counts : dict - """ - logger.debug( - "[LevelManager] Delegating to ForceTorqueManager.build_covariance_matrices" - ) - return self._force_torque.build_covariance_matrices( - entropy_manager=entropy_manager, - reduced_atom=reduced_atom, - levels=levels, - groups=groups, - start=start, - end=end, - step=step, - number_frames=number_frames, - ) + results = dag.execute(shared_data) - def build_conformational_states( - self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - bin_width, - conformational_entropy_obj, - ): - """ - Wrapper around DihedralAnalysis.build_conformational_states. + self.level_results = { + "levels": results["detect_levels"]["levels"], + "molecule_count": results["detect_molecules"]["molecule_count"], + "beads": results["build_beads"]["beads_by_mol_level"], + "axes": results["compute_axes"]["axes"], + "forces": results["compute_weighted_forces"]["forces"], + "torques": results["compute_weighted_torques"]["torques"], + "cov_matrices": results["build_covariance"]["covariance"], + "dihedrals": results["compute_dihedrials"]["dihedrals"], + "conformations": results["build_conformations"]["states"], + "neighbours": results["compute_neighbours"]["neighbours"], + } - Parameters - ---------- - entropy_manager : EntropyManager - reduced_atom : MDAnalysis.Universe - levels : list[list[str]] - groups : dict[int, list[int]] - start, end, step : int - number_frames : int - bin_width : int - Histogram bin width (degrees) for dihedral distributions. - conformational_entropy_obj : ConformationalEntropy - The CE object, passed through because your dihedral code - may call its methods. + return self.level_results - Returns - ------- - states_ua : dict - e.g. {(group_id, residue_id): states_array} - states_res : list - e.g. [states_for_group0, states_for_group1, ...] - """ - logger.debug( - "[LevelManager] Delegating to DihedralAnalysis.build_conformational_states" - ) - return self._dihedrals.build_conformational_states( - entropy_manager=entropy_manager, - reduced_atom=reduced_atom, - levels=levels, - groups=groups, - start=start, - end=end, - step=step, - number_frames=number_frames, - bin_width=bin_width, - conformational_entropy_obj=conformational_entropy_obj, - ) - - def filter_zero_rows_columns(self, matrix): - """ - Wrapper around MatrixOperations.filter_zero_rows_columns. - - - Physics and numerical behaviour are unchanged – MatrixOperations - contains the original implementation. - """ - return self._mat_ops.filter_zero_rows_columns(matrix) - - def get_axes(self, bead): - """ - Convenience forwarder to CoordinateSystem.get_axes (if/when needed). - Not used by EntropyManager right now but handy for future DAG nodes. - """ - return self._coords.get_axes(bead) - - def find_neighbours(self, data_container, bead, cutoff): - """ - Convenience wrapper around Neighbours. - """ - return self._neighbours.find_neighbours(data_container, bead, cutoff) + def get(self, key): + return self.level_results.get(key) From 5010a8c175e50a3f806a00ddcc0dc2bf562761d8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 12 Dec 2025 13:12:11 +0000 Subject: [PATCH 018/101] update `execute()` within `entropy_manager.py` to use DAG system --- CodeEntropy/entropy/entropy_manager.py | 102 +++++++------------------ 1 file changed, 29 insertions(+), 73 deletions(-) diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index fe45ae05..934a3e5f 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -13,6 +13,8 @@ ) from CodeEntropy.config.logging_config import LoggingConfig +from CodeEntropy.entropy.entropy_graph import EntropyGraph +from CodeEntropy.levels.hierarchy_graph import LevelGraph logger = logging.getLogger(__name__) console = LoggingConfig.get_console() @@ -59,16 +61,16 @@ def __init__( def execute(self): """ - Run the full entropy computation workflow. - - This method orchestrates the entire entropy analysis pipeline, including: - - Handling water entropy if present. - - Initializing molecular structures and levels. - - Building force and torque covariance matrices. - - Computing vibrational and conformational entropies. - - Finalizing and logging results. + Run the full entropy computation workflow using the DAG system. + + Workflow: + 1. Parse trajectory frame bounds + 2. Build reduced universe + 3. Detect molecules + levels + 4. Run LEVEL DAG + 5. Run ENTROPY DAG + 6. Log and store results """ - # Set up initial information start, end, step = self._get_trajectory_bounds() number_frames = self._get_number_frames(start, end, step) @@ -76,27 +78,11 @@ def execute(self): f"Analyzing a total of {number_frames} frames in this calculation." ) - # ve = VibrationalEntropy( - # self._run_manager, - # self._args, - # self._universe, - # self._data_logger, - # self._level_manager, - # self._group_molecules, - # ) - # ce = ConformationalEntropy( - # self._run_manager, - # self._args, - # self._universe, - # self._data_logger, - # self._level_manager, - # self._group_molecules, - # ) - reduced_atom, number_molecules, levels, groups = self._initialize_molecules() - logger.debug(f"Universe 3: {reduced_atom}") + logger.debug(f"[EntropyManager] Reduced universe loaded: {reduced_atom}") + water_atoms = self._universe.select_atoms("water") - water_resids = set(res.resid for res in water_atoms.residues) + water_resids = {res.resid for res in water_atoms.residues} water_groups = { gid: g @@ -116,52 +102,22 @@ def execute(self): else: nonwater_groups.update(water_groups) - force_matrices, torque_matrices, frame_counts = ( - self._level_manager.build_covariance_matrices( - self, - reduced_atom, - levels, - nonwater_groups, - start, - end, - step, - number_frames, - self._args.force_partitioning, - ) - ) + shared_data = { + "universe": self._universe, + "reduced_universe": reduced_atom, + "levels": levels, + "groups": nonwater_groups, + "args": self._args, + "start": start, + "end": end, + "step": step, + "n_frames": number_frames, + } + + level_results = LevelGraph().build().execute(shared_data) + entropy_results = EntropyGraph().build().execute(level_results) - # # Identify the conformational states from dihedral angles for the - # # conformational entropy calculations - # states_ua, states_res = self._level_manager.build_conformational_states( - # self, - # reduced_atom, - # levels, - # nonwater_groups, - # start, - # end, - # step, - # number_frames, - # self._args.bin_width, - # ce, - # ) - - # # Complete the entropy calculations - # self._compute_entropies( - # reduced_atom, - # levels, - # nonwater_groups, - # force_matrices, - # torque_matrices, - # states_ua, - # states_res, - # frame_counts, - # number_frames, - # ve, - # ce, - # ) - - # Print the results in a nicely formated way - self._finalize_molecule_results() + self._finalize_outputs(entropy_results) self._data_logger.log_tables() def _handle_water_entropy(self, start, end, step, water_groups): From edc3d86b667788e93dcaee9d7d0e018d6a645aef Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 12 Dec 2025 13:15:20 +0000 Subject: [PATCH 019/101] change `execute()` within `entropy_manager.py` to use `LevelDAG` rather than `LevelGraph` --- CodeEntropy/entropy/entropy_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 934a3e5f..4bf6ba4e 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -14,7 +14,7 @@ from CodeEntropy.config.logging_config import LoggingConfig from CodeEntropy.entropy.entropy_graph import EntropyGraph -from CodeEntropy.levels.hierarchy_graph import LevelGraph +from CodeEntropy.levels.hierarchy_graph import LevelDAG logger = logging.getLogger(__name__) console = LoggingConfig.get_console() @@ -114,7 +114,7 @@ def execute(self): "n_frames": number_frames, } - level_results = LevelGraph().build().execute(shared_data) + level_results = LevelDAG().build().execute(shared_data) entropy_results = EntropyGraph().build().execute(level_results) self._finalize_outputs(entropy_results) From af816e077cf4cc64b2b991c6edafa5d9357b41e3 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 7 Jan 2026 09:44:30 +0000 Subject: [PATCH 020/101] refined `entropy_graph.py` --- CodeEntropy/entropy/entropy_graph.py | 66 +++++++++++++++++----------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index 839d2a2a..f97bdeae 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,49 +1,63 @@ -import logging -from typing import Any, Dict - import networkx as nx -logger = logging.getLogger(__name__) +from CodeEntropy.entropy.nodes.configurational_entropy import ConfigurationalEntropyNode +from CodeEntropy.entropy.nodes.entropy_aggregator import EntropyAggregatorNode +from CodeEntropy.entropy.nodes.orientational_entropy import OrientationalEntropyNode +from CodeEntropy.entropy.nodes.vibrational_entropy import VibrationalEntropyNode class EntropyGraph: """ - A Directed Acyclic Graph (DAG) for managing entropy calculation nodes. + DAG representing the entropy computation pipeline: - Each node must implement: - run(shared_data: dict, **kwargs) -> dict + 1. Vibrational entropy + 2. Rotational (orientational) entropy + 3. Conformational entropy + 4. Aggregate entropy across levels and groups """ def __init__(self): self.graph = nx.DiGraph() - self.nodes: Dict[str, Any] = {} + self.nodes = {} + + def build(self): + self.add("vibrational_entropy", VibrationalEntropyNode()) + + self.add( + "orientational_entropy", + OrientationalEntropyNode(), + depends_on=["vibrational_entropy"], + ) + + self.add( + "configurational_entropy", + ConfigurationalEntropyNode(), + depends_on=["orientational_entropy"], + ) - def add_node(self, name: str, node_obj: Any, depends_on=None): - if not hasattr(node_obj, "run"): - raise TypeError(f"Node '{name}' must implement run(shared_data, **kwargs)") + self.add( + "aggregate_entropy", + EntropyAggregatorNode(), + depends_on=["configurational_entropy"], + ) - self.nodes[name] = node_obj + return self + + def add(self, name, obj, depends_on=None): + self.nodes[name] = obj self.graph.add_node(name) if depends_on: for dep in depends_on: self.graph.add_edge(dep, name) - def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: - results: Dict[str, Dict[str, Any]] = {} + def execute(self, shared_data): + results = {} - for node_name in nx.topological_sort(self.graph): - preds = list(self.graph.predecessors(node_name)) + for node in nx.topological_sort(self.graph): + preds = list(self.graph.predecessors(node)) kwargs = {p: results[p] for p in preds} - node = self.nodes[node_name] - output = node.run(shared_data, **kwargs) - - if not isinstance(output, dict): - raise TypeError( - f"Node '{node_name}' returned {type(output)} (expected dict)" - ) - - results[node_name] = output - shared_data.update(output) + output = self.nodes[node].run(shared_data, **kwargs) + results[node] = output return results From 24ef756d3cc68861430afdb995e4978d91d70f84 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 9 Jan 2026 10:14:57 +0000 Subject: [PATCH 021/101] ensure `shared_data` is not been replaced within `EntropyManager.execute()` --- CodeEntropy/entropy/entropy_manager.py | 44 ++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 4bf6ba4e..53009ecd 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -66,10 +66,11 @@ def execute(self): Workflow: 1. Parse trajectory frame bounds 2. Build reduced universe - 3. Detect molecules + levels - 4. Run LEVEL DAG - 5. Run ENTROPY DAG - 6. Log and store results + 3. Detect molecules + groups + 4. Handle water entropy (if enabled) + 5. Run LEVEL DAG (structure + mechanics) + 6. Run ENTROPY DAG (entropy calculations) + 7. Finalize and log results """ start, end, step = self._get_trajectory_bounds() number_frames = self._get_number_frames(start, end, step) @@ -78,8 +79,11 @@ def execute(self): f"Analyzing a total of {number_frames} frames in this calculation." ) - reduced_atom, number_molecules, levels, groups = self._initialize_molecules() - logger.debug(f"[EntropyManager] Reduced universe loaded: {reduced_atom}") + reduced_universe, number_molecules, levels, groups = ( + self._initialize_molecules() + ) + + logger.debug("[EntropyManager] Reduced universe initialised") water_atoms = self._universe.select_atoms("water") water_resids = {res.resid for res in water_atoms.residues} @@ -104,20 +108,34 @@ def execute(self): shared_data = { "universe": self._universe, - "reduced_universe": reduced_atom, - "levels": levels, - "groups": nonwater_groups, + "reduced_universe": reduced_universe, + "run_manager": self._run_manager, + "universe_operations": self._universe_operations, "args": self._args, "start": start, "end": end, "step": step, - "n_frames": number_frames, + "number_frames": number_frames, + "groups": nonwater_groups, } - level_results = LevelDAG().build().execute(shared_data) - entropy_results = EntropyGraph().build().execute(level_results) + level_dag = LevelDAG().build() + level_dag.execute(shared_data) + + logger.debug( + f"[EntropyManager] Level DAG complete. " + f"Shared data keys: {list(shared_data.keys())}" + ) + + entropy_dag = EntropyGraph().build() + entropy_dag.execute(shared_data) + + logger.debug( + f"[EntropyManager] Entropy DAG complete. " + f"Shared data keys: {list(shared_data.keys())}" + ) - self._finalize_outputs(entropy_results) + self._finalize_outputs(shared_data) self._data_logger.log_tables() def _handle_water_entropy(self, start, end, step, water_groups): From c13531e20e0e41f64eedc9307363c732fc60cd42 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 9 Jan 2026 15:03:05 +0000 Subject: [PATCH 022/101] updated `LevelDAG.execute()` to use `shared_data` functionality correctly --- CodeEntropy/levels/hierarchy_graph.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index b583efe4..9cf3af82 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -50,9 +50,5 @@ def add(self, name, obj, deps=None): self.graph.add_edge(d, name) def execute(self, shared_data): - results = {} for node in nx.topological_sort(self.graph): - deps = {d: results[d] for d in self.graph.predecessors(node)} - output = self.nodes[node].run(shared_data, **deps) - results[node] = output - return results + self.nodes[node].run(shared_data) From 052aa57b1ee1585e9ce7ccd2fc5b81ce00ef009b Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 9 Jan 2026 15:38:26 +0000 Subject: [PATCH 023/101] ensure `LevelsDAG` is a pure dataflow and not coupled --- CodeEntropy/levels/nodes/build_beads.py | 7 ++++--- .../levels/nodes/build_conformations.py | 7 +++++-- .../levels/nodes/build_covariance_matrices.py | 18 +++++++++++++++--- CodeEntropy/levels/nodes/compute_axes.py | 9 ++++++--- CodeEntropy/levels/nodes/compute_dihedrals.py | 7 +++++-- CodeEntropy/levels/nodes/compute_neighbours.py | 7 +++++-- .../levels/nodes/compute_weighted_forces.py | 16 ++++++++++++---- .../levels/nodes/compute_weighted_torques.py | 16 ++++++++++++---- CodeEntropy/levels/nodes/detect_levels.py | 10 ++++------ CodeEntropy/levels/nodes/detect_molecules.py | 7 ++++--- 10 files changed, 72 insertions(+), 32 deletions(-) diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py index 2ba7e8d7..a6876000 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -7,9 +7,10 @@ def __init__(self): self._hier = LevelHierarchy() self._mda = UniverseOperations() - def run(self, shared_data, detect_levels): + def run(self, shared_data): u = shared_data["universe"] - levels = detect_levels["levels"] + levels = shared_data["levels"] + beads = {} for mol_id, level_list in enumerate(levels): @@ -17,4 +18,4 @@ def run(self, shared_data, detect_levels): for level in level_list: beads[(mol_id, level)] = self._hier.get_beads(mol_u, level) - return {"beads": beads} + shared_data["beads"] = beads diff --git a/CodeEntropy/levels/nodes/build_conformations.py b/CodeEntropy/levels/nodes/build_conformations.py index ba23fe29..6abddc82 100644 --- a/CodeEntropy/levels/nodes/build_conformations.py +++ b/CodeEntropy/levels/nodes/build_conformations.py @@ -5,5 +5,8 @@ class BuildConformationsNode: def __init__(self): self._dih = DihedralTools() - def run(self, compute_dihedrals): - return self._dih.build_conformational_states(compute_dihedrals["dihedrals"]) + def run(self, shared_data): + dihedrals = shared_data["dihedrals"] + + states = self._dih.build_conformational_states(dihedrals) + shared_data["conformational_states"] = states diff --git a/CodeEntropy/levels/nodes/build_covariance_matrices.py b/CodeEntropy/levels/nodes/build_covariance_matrices.py index a953ed33..29b02e70 100644 --- a/CodeEntropy/levels/nodes/build_covariance_matrices.py +++ b/CodeEntropy/levels/nodes/build_covariance_matrices.py @@ -5,7 +5,19 @@ class BuildCovarianceMatricesNode: def __init__(self): self._ft = ForceTorqueManager() - def run(self, shared_data, compute_weighted_forces, compute_weighted_torques): - return self._ft.build_covariance_matrices( - compute_weighted_forces["forces"], compute_weighted_torques["torques"] + def run(self, shared_data): + """ + Build force and torque covariance matrices from weighted forces/torques + already stored in shared_data. + """ + + forces = shared_data["weighted_forces"] + torques = shared_data["weighted_torques"] + + force_cov, torque_cov, frame_counts = self._ft.build_covariance_matrices( + forces, torques ) + + shared_data["force_covariance_matrices"] = force_cov + shared_data["torque_covariance_matrices"] = torque_cov + shared_data["frame_counts"] = frame_counts diff --git a/CodeEntropy/levels/nodes/compute_axes.py b/CodeEntropy/levels/nodes/compute_axes.py index 23022c07..5b34c926 100644 --- a/CodeEntropy/levels/nodes/compute_axes.py +++ b/CodeEntropy/levels/nodes/compute_axes.py @@ -5,11 +5,13 @@ class ComputeAxesNode: def __init__(self): self._coord = CoordinateSystem() - def run(self, shared_data, build_beads): + def run(self, shared_data): + beads = shared_data["beads"] + axes = {} avg_pos = {} - for key, bead_list in build_beads["beads"].items(): + for key, bead_list in beads.items(): axes[key] = [] avg_pos[key] = [] @@ -17,4 +19,5 @@ def run(self, shared_data, build_beads): avg_pos[key].append(self._coord.get_avg_pos(bead)) axes[key].append(self._coord.get_axes(bead)) - return {"axes": axes, "avg_pos": avg_pos} + shared_data["axes"] = axes + shared_data["avg_pos"] = avg_pos diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py index 9a386658..fa937c14 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -5,5 +5,8 @@ class ComputeDihedralsNode: def __init__(self): self._dih = DihedralTools() - def run(self, build_beads): - return {"dihedrals": self._dih.get_dihedrals(build_beads["beads"])} + def run(self, shared_data): + beads = shared_data["beads"] + + dihedrals = self._dih.get_dihedrals(beads) + shared_data["dihedrals"] = dihedrals diff --git a/CodeEntropy/levels/nodes/compute_neighbours.py b/CodeEntropy/levels/nodes/compute_neighbours.py index 4b868f82..e1d8866a 100644 --- a/CodeEntropy/levels/nodes/compute_neighbours.py +++ b/CodeEntropy/levels/nodes/compute_neighbours.py @@ -5,5 +5,8 @@ class ComputeNeighboursNode: def __init__(self): self._nb = NeighbourList() - def run(self, build_beads): - return {"neighbours": self._nb.compute(build_beads["beads"])} + def run(self, shared_data): + beads = shared_data["beads"] + + neighbours = self._nb.compute(beads) + shared_data["neighbours"] = neighbours diff --git a/CodeEntropy/levels/nodes/compute_weighted_forces.py b/CodeEntropy/levels/nodes/compute_weighted_forces.py index 731da4a0..7ca6012a 100644 --- a/CodeEntropy/levels/nodes/compute_weighted_forces.py +++ b/CodeEntropy/levels/nodes/compute_weighted_forces.py @@ -5,12 +5,20 @@ class ComputeWeightedForcesNode: def __init__(self): self._ft = ForceTorqueManager() - def run(self, shared_data, compute_axes, build_beads): + def run(self, shared_data): + """ + Compute weighted forces for each bead using precomputed axes. + """ + u = shared_data["universe"] + beads_by_key = shared_data["beads"] + axes_by_key = shared_data["axes"] + forces = {} - for key, bead_list in build_beads["beads"].items(): + for key, bead_list in beads_by_key.items(): forces[key] = [] - for bead, ax in zip(bead_list, compute_axes["axes"][key]): + for bead, ax in zip(bead_list, axes_by_key[key]): forces[key].append(self._ft.get_weighted_forces(u, bead, ax, False)) - return {"forces": forces} + + shared_data["weighted_forces"] = forces diff --git a/CodeEntropy/levels/nodes/compute_weighted_torques.py b/CodeEntropy/levels/nodes/compute_weighted_torques.py index 26924298..c314b2cb 100644 --- a/CodeEntropy/levels/nodes/compute_weighted_torques.py +++ b/CodeEntropy/levels/nodes/compute_weighted_torques.py @@ -5,12 +5,20 @@ class ComputeWeightedTorquesNode: def __init__(self): self._ft = ForceTorqueManager() - def run(self, shared_data, compute_axes, build_beads): + def run(self, shared_data): + """ + Compute weighted torques for each bead using precomputed axes. + """ + u = shared_data["universe"] + beads_by_key = shared_data["beads"] + axes_by_key = shared_data["axes"] + torques = {} - for key, bead_list in build_beads["beads"].items(): + for key, bead_list in beads_by_key.items(): torques[key] = [] - for bead, ax in zip(bead_list, compute_axes["axes"][key]): + for bead, ax in zip(bead_list, axes_by_key[key]): torques[key].append(self._ft.get_weighted_torques(u, bead, ax)) - return {"torques": torques} + + shared_data["weighted_torques"] = torques diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index c1db6242..83203849 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -5,12 +5,10 @@ class DetectLevelsNode: def __init__(self): self._hier = LevelHierarchy() - def run(self, shared_data, detect_molecules): + def run(self, shared_data): u = shared_data["universe"] + num_mol, levels = self._hier.select_levels(u) - return { - "number_molecules": num_mol, - "levels": levels, - "fragments": detect_molecules["fragments"], - } + shared_data["levels"] = levels + shared_data["number_molecules"] = num_mol diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index b19a203e..fec1ec47 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -1,15 +1,16 @@ import logging -from typing import Any, Dict logger = logging.getLogger(__name__) class DetectMoleculesNode: - def run(self, shared_data: Dict[str, Any], **_): + def run(self, shared_data): u = shared_data["universe"] + fragments = u.atoms.fragments num_mol = len(fragments) logger.info(f"[DetectMoleculesNode] {num_mol} molecules detected") - return {"number_molecules": num_mol, "fragments": fragments} + shared_data["fragments"] = fragments + shared_data["number_molecules"] = num_mol From 4a76e5589c215514617411135517a079f9a0d331 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 9 Jan 2026 15:50:50 +0000 Subject: [PATCH 024/101] udpate to `LevelDAG` to use weighted forces and torques --- .../levels/nodes/build_covariance_matrices.py | 14 +++++++------- .../levels/nodes/compute_weighted_forces.py | 12 ++++-------- .../levels/nodes/compute_weighted_torques.py | 12 ++++-------- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/CodeEntropy/levels/nodes/build_covariance_matrices.py b/CodeEntropy/levels/nodes/build_covariance_matrices.py index 29b02e70..3cd5eed0 100644 --- a/CodeEntropy/levels/nodes/build_covariance_matrices.py +++ b/CodeEntropy/levels/nodes/build_covariance_matrices.py @@ -7,17 +7,17 @@ def __init__(self): def run(self, shared_data): """ - Build force and torque covariance matrices from weighted forces/torques - already stored in shared_data. + Build force and torque covariance matrices from weighted forces/torques. """ - forces = shared_data["weighted_forces"] - torques = shared_data["weighted_torques"] + weighted_forces = shared_data["weighted_forces"] + weighted_torques = shared_data["weighted_torques"] force_cov, torque_cov, frame_counts = self._ft.build_covariance_matrices( - forces, torques + weighted_forces, + weighted_torques, ) - shared_data["force_covariance_matrices"] = force_cov - shared_data["torque_covariance_matrices"] = torque_cov + shared_data["force_covariance"] = force_cov + shared_data["torque_covariance"] = torque_cov shared_data["frame_counts"] = frame_counts diff --git a/CodeEntropy/levels/nodes/compute_weighted_forces.py b/CodeEntropy/levels/nodes/compute_weighted_forces.py index 7ca6012a..7061d794 100644 --- a/CodeEntropy/levels/nodes/compute_weighted_forces.py +++ b/CodeEntropy/levels/nodes/compute_weighted_forces.py @@ -6,19 +6,15 @@ def __init__(self): self._ft = ForceTorqueManager() def run(self, shared_data): - """ - Compute weighted forces for each bead using precomputed axes. - """ - u = shared_data["universe"] - beads_by_key = shared_data["beads"] - axes_by_key = shared_data["axes"] + beads = shared_data["beads"] + axes = shared_data["axes"] forces = {} - for key, bead_list in beads_by_key.items(): + for key, bead_list in beads.items(): forces[key] = [] - for bead, ax in zip(bead_list, axes_by_key[key]): + for bead, ax in zip(bead_list, axes[key]): forces[key].append(self._ft.get_weighted_forces(u, bead, ax, False)) shared_data["weighted_forces"] = forces diff --git a/CodeEntropy/levels/nodes/compute_weighted_torques.py b/CodeEntropy/levels/nodes/compute_weighted_torques.py index c314b2cb..3040ebba 100644 --- a/CodeEntropy/levels/nodes/compute_weighted_torques.py +++ b/CodeEntropy/levels/nodes/compute_weighted_torques.py @@ -6,19 +6,15 @@ def __init__(self): self._ft = ForceTorqueManager() def run(self, shared_data): - """ - Compute weighted torques for each bead using precomputed axes. - """ - u = shared_data["universe"] - beads_by_key = shared_data["beads"] - axes_by_key = shared_data["axes"] + beads = shared_data["beads"] + axes = shared_data["axes"] torques = {} - for key, bead_list in beads_by_key.items(): + for key, bead_list in beads.items(): torques[key] = [] - for bead, ax in zip(bead_list, axes_by_key[key]): + for bead, ax in zip(bead_list, axes[key]): torques[key].append(self._ft.get_weighted_torques(u, bead, ax)) shared_data["weighted_torques"] = torques From 6e7d81d13e1f5aa426797b48c824137a6c641cfd Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 9 Jan 2026 16:05:00 +0000 Subject: [PATCH 025/101] tweaks to level nodes to ensure they match to `shared_data` --- CodeEntropy/levels/nodes/build_conformations.py | 4 +--- CodeEntropy/levels/nodes/compute_axes.py | 2 +- CodeEntropy/levels/nodes/compute_dihedrals.py | 4 +--- CodeEntropy/levels/nodes/compute_neighbours.py | 12 +++++------- CodeEntropy/levels/nodes/detect_levels.py | 3 +-- 5 files changed, 9 insertions(+), 16 deletions(-) diff --git a/CodeEntropy/levels/nodes/build_conformations.py b/CodeEntropy/levels/nodes/build_conformations.py index 6abddc82..07fc50df 100644 --- a/CodeEntropy/levels/nodes/build_conformations.py +++ b/CodeEntropy/levels/nodes/build_conformations.py @@ -7,6 +7,4 @@ def __init__(self): def run(self, shared_data): dihedrals = shared_data["dihedrals"] - - states = self._dih.build_conformational_states(dihedrals) - shared_data["conformational_states"] = states + shared_data["conformations"] = self._dih.build_conformational_states(dihedrals) diff --git a/CodeEntropy/levels/nodes/compute_axes.py b/CodeEntropy/levels/nodes/compute_axes.py index 5b34c926..a379943a 100644 --- a/CodeEntropy/levels/nodes/compute_axes.py +++ b/CodeEntropy/levels/nodes/compute_axes.py @@ -20,4 +20,4 @@ def run(self, shared_data): axes[key].append(self._coord.get_axes(bead)) shared_data["axes"] = axes - shared_data["avg_pos"] = avg_pos + shared_data["avg_positions"] = avg_pos diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py index fa937c14..1b85b811 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -7,6 +7,4 @@ def __init__(self): def run(self, shared_data): beads = shared_data["beads"] - - dihedrals = self._dih.get_dihedrals(beads) - shared_data["dihedrals"] = dihedrals + shared_data["dihedrals"] = self._dih.get_dihedrals(beads) diff --git a/CodeEntropy/levels/nodes/compute_neighbours.py b/CodeEntropy/levels/nodes/compute_neighbours.py index e1d8866a..07fc50df 100644 --- a/CodeEntropy/levels/nodes/compute_neighbours.py +++ b/CodeEntropy/levels/nodes/compute_neighbours.py @@ -1,12 +1,10 @@ -from CodeEntropy.levels.neighbours import NeighbourList +from CodeEntropy.levels.dihedral_tools import DihedralTools -class ComputeNeighboursNode: +class BuildConformationsNode: def __init__(self): - self._nb = NeighbourList() + self._dih = DihedralTools() def run(self, shared_data): - beads = shared_data["beads"] - - neighbours = self._nb.compute(beads) - shared_data["neighbours"] = neighbours + dihedrals = shared_data["dihedrals"] + shared_data["conformations"] = self._dih.build_conformational_states(dihedrals) diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index 83203849..b07cb86d 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -7,8 +7,7 @@ def __init__(self): def run(self, shared_data): u = shared_data["universe"] - num_mol, levels = self._hier.select_levels(u) - shared_data["levels"] = levels shared_data["number_molecules"] = num_mol + shared_data["levels"] = levels From 138db4f44ab2f01082f9c9490f918d82cdf66b42 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 9 Jan 2026 17:00:18 +0000 Subject: [PATCH 026/101] move entropy calculations out of `entropy/node` folder back into `entropy` folder --- CodeEntropy/entropy/{nodes => }/configurational_entropy.py | 0 CodeEntropy/entropy/{nodes => }/orientational_entropy.py | 0 CodeEntropy/entropy/{nodes => }/vibrational_entropy.py | 0 CodeEntropy/entropy/{nodes => }/water_entropy.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename CodeEntropy/entropy/{nodes => }/configurational_entropy.py (100%) rename CodeEntropy/entropy/{nodes => }/orientational_entropy.py (100%) rename CodeEntropy/entropy/{nodes => }/vibrational_entropy.py (100%) rename CodeEntropy/entropy/{nodes => }/water_entropy.py (100%) diff --git a/CodeEntropy/entropy/nodes/configurational_entropy.py b/CodeEntropy/entropy/configurational_entropy.py similarity index 100% rename from CodeEntropy/entropy/nodes/configurational_entropy.py rename to CodeEntropy/entropy/configurational_entropy.py diff --git a/CodeEntropy/entropy/nodes/orientational_entropy.py b/CodeEntropy/entropy/orientational_entropy.py similarity index 100% rename from CodeEntropy/entropy/nodes/orientational_entropy.py rename to CodeEntropy/entropy/orientational_entropy.py diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py similarity index 100% rename from CodeEntropy/entropy/nodes/vibrational_entropy.py rename to CodeEntropy/entropy/vibrational_entropy.py diff --git a/CodeEntropy/entropy/nodes/water_entropy.py b/CodeEntropy/entropy/water_entropy.py similarity index 100% rename from CodeEntropy/entropy/nodes/water_entropy.py rename to CodeEntropy/entropy/water_entropy.py From cf0134db3a01581f9f66b38b7160b10ac61ed6c6 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 12 Jan 2026 12:45:13 +0000 Subject: [PATCH 027/101] refine entropy calculations and move current structure into DAG based graph nodes --- CodeEntropy/entropy/entropy_manager.py | 417 ++---------------- .../nodes/configurational_entropy_node.py | 82 ++++ .../entropy/nodes/vibrational_entropy_node.py | 122 +++++ 3 files changed, 230 insertions(+), 391 deletions(-) create mode 100644 CodeEntropy/entropy/nodes/configurational_entropy_node.py create mode 100644 CodeEntropy/entropy/nodes/vibrational_entropy_node.py diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 53009ecd..ff191cce 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -2,15 +2,7 @@ import math from collections import defaultdict -import numpy as np import pandas as pd -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) from CodeEntropy.config.logging_config import LoggingConfig from CodeEntropy.entropy.entropy_graph import EntropyGraph @@ -61,29 +53,29 @@ def __init__( def execute(self): """ - Run the full entropy computation workflow using the DAG system. + Run the full entropy computation workflow using DAGs. - Workflow: - 1. Parse trajectory frame bounds + Responsibilities: + 1. Prepare trajectory bounds 2. Build reduced universe - 3. Detect molecules + groups - 4. Handle water entropy (if enabled) - 5. Run LEVEL DAG (structure + mechanics) - 6. Run ENTROPY DAG (entropy calculations) - 7. Finalize and log results + 3. Prepare shared_data + 4. Execute LEVEL DAG (structure & physics) + 5. Execute ENTROPY DAG (thermodynamics) + 6. Finalize and log results """ + start, end, step = self._get_trajectory_bounds() - number_frames = self._get_number_frames(start, end, step) + n_frames = self._get_number_frames(start, end, step) - console.print( - f"Analyzing a total of {number_frames} frames in this calculation." - ) + console.print(f"Analyzing a total of {n_frames} frames in this calculation.") - reduced_universe, number_molecules, levels, groups = ( - self._initialize_molecules() - ) + reduced_universe = self._get_reduced_universe() - logger.debug("[EntropyManager] Reduced universe initialised") + number_molecules, levels = self._level_manager.select_levels(reduced_universe) + + groups = self._group_molecules.grouping_molecules( + reduced_universe, self._args.grouping + ) water_atoms = self._universe.select_atoms("water") water_resids = {res.resid for res in water_atoms.residues} @@ -109,33 +101,26 @@ def execute(self): shared_data = { "universe": self._universe, "reduced_universe": reduced_universe, - "run_manager": self._run_manager, - "universe_operations": self._universe_operations, + "levels": levels, + "groups": nonwater_groups, "args": self._args, "start": start, "end": end, "step": step, - "number_frames": number_frames, - "groups": nonwater_groups, + "n_frames": n_frames, + "data_logger": self._data_logger, + "run_manager": self._run_manager, } - level_dag = LevelDAG().build() - level_dag.execute(shared_data) + level_results = LevelDAG().build().execute(shared_data) - logger.debug( - f"[EntropyManager] Level DAG complete. " - f"Shared data keys: {list(shared_data.keys())}" - ) + shared_data.update(level_results) - entropy_dag = EntropyGraph().build() - entropy_dag.execute(shared_data) + entropy_results = EntropyGraph().build().execute(shared_data) - logger.debug( - f"[EntropyManager] Entropy DAG complete. " - f"Shared data keys: {list(shared_data.keys())}" - ) + shared_data.update(entropy_results) - self._finalize_outputs(shared_data) + self._finalize_molecule_results() self._data_logger.log_tables() def _handle_water_entropy(self, start, end, step, water_groups): @@ -194,143 +179,6 @@ def _initialize_molecules(self): return reduced_atom, number_molecules, levels, groups - def _compute_entropies( - self, - reduced_atom, - levels, - groups, - force_matrices, - torque_matrices, - states_ua, - states_res, - frame_counts, - number_frames, - ve, - ce, - ): - """ - Compute vibrational and conformational entropies for all molecules and levels. - - This method iterates over each molecule and its associated entropy levels - (united_atom, residue, polymer), computing the corresponding entropy - contributions using force/torque matrices and dihedral conformations. - - For each level: - - "united_atom": Computes per-residue conformational states and entropy. - - "residue": Computes molecule-level conformational and vibrational entropy. - - "polymer": Computes only vibrational entropy. - - Parameters: - reduced_atom (Universe): The reduced atom selection from the trajectory. - levels (list): List of entropy levels per molecule. - groups (dict): Groups for averaging over molecules. - force_matrices (dict): Precomputed force covariance matrices. - torque_matrices (dict): Precomputed torque covariance matrices. - states_ua (dict): Dictionary to store united-atom conformational states. - states_res (list): List to store residue-level conformational states. - frames_count (dict): Dictionary to store the frame counts - number_frames (int): Total number of trajectory frames to process. - ve: Vibrational Entropy object - ce: Conformational Entropy object - """ - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - "[progress.percentage]{task.percentage:>3.1f}%", - TimeElapsedColumn(), - ) as progress: - - task = progress.add_task( - "[green]Calculating Entropy...", - total=len(groups), - title="Starting...", - ) - - for group_id in groups.keys(): - mol = self._universe_operations.get_molecule_container( - reduced_atom, groups[group_id][0] - ) - - residue_group = "_".join( - sorted(set(res.resname for res in mol.residues)) - ) - group_residue_count = len(groups[group_id]) - group_atom_count = 0 - for mol_id in groups[group_id]: - each_mol = self._universe_operations.get_molecule_container( - reduced_atom, mol_id - ) - group_atom_count += len(each_mol.atoms) - self._data_logger.add_group_label( - group_id, residue_group, group_residue_count, group_atom_count - ) - - resname = mol.atoms[0].resname - resid = mol.atoms[0].resid - segid = mol.atoms[0].segid - - mol_label = f"{resname}_{resid} (segid {segid})" - - for level in levels[groups[group_id][0]]: - progress.update( - task, - title=f"Calculating entropy values | " - f"Molecule: {mol_label} | " - f"Level: {level}", - ) - highest = level == levels[groups[group_id][0]][-1] - - if level == "united_atom": - self._process_united_atom_entropy( - group_id, - mol, - ve, - ce, - level, - force_matrices["ua"], - torque_matrices["ua"], - states_ua, - frame_counts["ua"], - highest, - number_frames, - ) - - elif level == "residue": - self._process_vibrational_entropy( - group_id, - mol, - number_frames, - ve, - level, - force_matrices["res"][group_id], - torque_matrices["res"][group_id], - highest, - ) - - self._process_conformational_entropy( - group_id, - mol, - ce, - level, - states_res, - number_frames, - ) - - elif level == "polymer": - self._process_vibrational_entropy( - group_id, - mol, - number_frames, - ve, - level, - force_matrices["poly"][group_id], - torque_matrices["poly"][group_id], - highest, - ) - - progress.advance(task) - def _get_trajectory_bounds(self): """ Returns the start, end, and step frame indices based on input arguments. @@ -378,219 +226,6 @@ def _get_reduced_universe(self): return reduced - def _process_united_atom_entropy( - self, - group_id, - mol_container, - ve, - ce, - level, - force_matrix, - torque_matrix, - states, - frame_counts, - highest, - number_frames, - ): - """ - Calculates translational, rotational, and conformational entropy at the - united-atom level. - - Args: - group_id (int): ID of the group. - mol_container (Universe): Universe for the selected molecule. - ve: VibrationalEntropy object. - ce: ConformationalEntropy object. - level (str): Granularity level (should be 'united_atom'). - start, end, step (int): Trajectory frame parameters. - n_frames (int): Number of trajectory frames. - frame_counts: Number of frames counted - highest (bool): Whether this is the highest level of resolution for - the molecule. - number_frames (int): The number of frames analysed. - """ - S_trans, S_rot, S_conf = 0, 0, 0 - - # The united atom entropy is calculated separately for each residue - # This is to allow residue by residue information - # and prevents the matrices from becoming too large - for residue_id, residue in enumerate(mol_container.residues): - - key = (group_id, residue_id) - - # Find the relevant force and torque matrices and tidy them up - # by removing rows and columns that are all zeros - f_matrix = force_matrix[key] - f_matrix = self._level_manager.filter_zero_rows_columns(f_matrix) - - t_matrix = torque_matrix[key] - t_matrix = self._level_manager.filter_zero_rows_columns(t_matrix) - - # Calculate the vibrational entropy - S_trans_res = ve.vibrational_entropy_calculation( - f_matrix, "force", self._args.temperature, highest - ) - S_rot_res = ve.vibrational_entropy_calculation( - t_matrix, "torque", self._args.temperature, highest - ) - - # Get the relevant conformational states - values = states[key] - # Check if there is information in the states array - contains_non_empty_states = ( - np.any(values) if isinstance(values, np.ndarray) else any(values) - ) - - # Calculate the conformational entropy - # If there are no conformational states (i.e. no dihedrals) - # then the conformational entropy is zero - S_conf_res = ( - ce.conformational_entropy_calculation(values) - if contains_non_empty_states - else 0 - ) - - # Add the data to the united atom level entropy - S_trans += S_trans_res - S_rot += S_rot_res - S_conf += S_conf_res - - # Print out the data for each residue - self._data_logger.add_residue_data( - group_id, - residue.resname, - level, - "Transvibrational", - frame_counts[key], - S_trans_res, - ) - self._data_logger.add_residue_data( - group_id, - residue.resname, - level, - "Rovibrational", - frame_counts[key], - S_rot_res, - ) - self._data_logger.add_residue_data( - group_id, - residue.resname, - level, - "Conformational", - frame_counts[key], - S_conf_res, - ) - - # Print the total united atom level data for the molecule group - self._data_logger.add_results_data(group_id, level, "Transvibrational", S_trans) - self._data_logger.add_results_data(group_id, level, "Rovibrational", S_rot) - self._data_logger.add_results_data(group_id, level, "Conformational", S_conf) - - residue_group = "_".join( - sorted(set(res.resname for res in mol_container.residues)) - ) - - logger.debug(f"residue_group {residue_group}") - - def _process_vibrational_entropy( - self, - group_id, - mol_container, - number_frames, - ve, - level, - force_matrix, - torque_matrix, - highest, - ): - """ - Calculates vibrational entropy. - - Args: - group_id (int): Group ID. - ve: VibrationalEntropy object. - level (str): Current granularity level. - force_matrix : Force covariance matrix - torque_matrix : Torque covariance matrix - frame_count: - highest (bool): Flag indicating if this is the highest granularity - level. - """ - # Find the relevant force and torque matrices and tidy them up - # by removing rows and columns that are all zeros - - force_matrix = self._level_manager.filter_zero_rows_columns(force_matrix) - - torque_matrix = self._level_manager.filter_zero_rows_columns(torque_matrix) - - # Calculate the vibrational entropy - S_trans = ve.vibrational_entropy_calculation( - force_matrix, "force", self._args.temperature, highest - ) - S_rot = ve.vibrational_entropy_calculation( - torque_matrix, "torque", self._args.temperature, highest - ) - - # Print the vibrational entropy for the molecule group - self._data_logger.add_results_data(group_id, level, "Transvibrational", S_trans) - self._data_logger.add_results_data(group_id, level, "Rovibrational", S_rot) - - residue_group = "_".join( - sorted(set(res.resname for res in mol_container.residues)) - ) - residue_count = len(mol_container.residues) - atom_count = len(mol_container.atoms) - self._data_logger.add_group_label( - group_id, residue_group, residue_count, atom_count - ) - - def _process_conformational_entropy( - self, group_id, mol_container, ce, level, states, number_frames - ): - """ - Computes conformational entropy at the residue level (whole-molecule dihedral - analysis). - - Args: - mol_id (int): ID of the molecule. - mol_container (Universe): Selected molecule's universe. - ce: ConformationalEntropy object. - level (str): Level name (should be 'residue'). - states (array): The conformational states. - number_frames (int): Number of frames used. - """ - # Get the relevant conformational states - # Check if there is information in the states array - group_states = states[group_id] if group_id < len(states) else None - - if group_states is not None: - contains_state_data = ( - group_states.any() - if isinstance(group_states, np.ndarray) - else any(group_states) - ) - else: - contains_state_data = False - - # Calculate the conformational entropy - # If there are no conformational states (i.e. no dihedrals) - # then the conformational entropy is zero - S_conf = ( - ce.conformational_entropy_calculation(group_states) - if contains_state_data - else 0 - ) - self._data_logger.add_results_data(group_id, level, "Conformational", S_conf) - - residue_group = "_".join( - sorted(set(res.resname for res in mol_container.residues)) - ) - residue_count = len(mol_container.residues) - atom_count = len(mol_container.atoms) - self._data_logger.add_group_label( - group_id, residue_group, residue_count, atom_count - ) - def _finalize_molecule_results(self): """ Aggregates and logs total entropy and frame counts per molecule. diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py new file mode 100644 index 00000000..f36dc1a4 --- /dev/null +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -0,0 +1,82 @@ +import logging + +import numpy as np + +from CodeEntropy.entropy.configurational_entropy import ConfigurationalEntropy + +logger = logging.getLogger(__name__) + + +class ConfigurationalEntropyNode: + """ + DAG node responsible for computing configurational entropy + from precomputed dihedral conformational states. + """ + + def __init__(self, data_logger): + self._ce = ConfigurationalEntropy() + self._data_logger = data_logger + + def run(self, shared_data, **_): + levels = shared_data["levels"] + groups = shared_data["groups"] + + states_ua = shared_data["conformational_states"]["ua"] + states_res = shared_data["conformational_states"]["res"] + + conf_results = {} + + for group_id, mol_ids in groups.items(): + mol_index = mol_ids[0] + conf_results[group_id] = {} + + for level in levels[mol_index]: + + if level == "united_atom": + S_conf = self._ua_entropy(group_id, states_ua) + + elif level == "residue": + S_conf = self._residue_entropy(group_id, states_res) + + else: + continue # polymer has no conformational entropy + + conf_results[group_id][level] = S_conf + + self._data_logger.add_results_data( + group_id, level, "Conformational", S_conf + ) + + shared_data["configurational_entropy"] = conf_results + return {"configurational_entropy": conf_results} + + def _ua_entropy(self, group_id, states): + S_total = 0.0 + + for key, values in states.items(): + if key[0] != group_id: + continue + + if self._has_states(values): + S_total += self._ce.conformational_entropy_calculation(values) + + return S_total + + def _residue_entropy(self, group_id, states): + if group_id >= len(states): + return 0.0 + + values = states[group_id] + + if self._has_states(values): + return self._ce.conformational_entropy_calculation(values) + + return 0.0 + + @staticmethod + def _has_states(values): + if values is None: + return False + if isinstance(values, np.ndarray): + return np.any(values) + return any(values) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py new file mode 100644 index 00000000..8e50d862 --- /dev/null +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -0,0 +1,122 @@ +import logging + +import numpy as np + +from CodeEntropy.entropy.vibrational_entropy import VibrationalEntropy + +logger = logging.getLogger(__name__) + + +class VibrationalEntropyNode: + """ + DAG node responsible for computing vibrational entropy + from precomputed force and torque covariance matrices. + """ + + def __init__(self, run_manager, data_logger): + self._ve = VibrationalEntropy(run_manager) + self._data_logger = data_logger + + def run(self, shared_data, **_): + levels = shared_data["levels"] + groups = shared_data["groups"] + args = shared_data["args"] + + force_cov = shared_data["force_covariance"] + torque_cov = shared_data["torque_covariance"] + frame_counts = shared_data["frame_counts"] + + vibrational_results = {} + + for group_id, mol_ids in groups.items(): + mol_index = mol_ids[0] + vibrational_results[group_id] = {} + + for level in levels[mol_index]: + highest = level == levels[mol_index][-1] + + if level == "united_atom": + S_trans, S_rot = self._ua_entropy( + group_id, + force_cov["ua"], + torque_cov["ua"], + frame_counts["ua"], + args.temperature, + highest, + ) + + else: + S_trans, S_rot = self._level_entropy( + group_id, + level, + force_cov[level][group_id], + torque_cov[level][group_id], + args.temperature, + highest, + ) + + vibrational_results[group_id][level] = { + "trans": S_trans, + "rot": S_rot, + } + + self._data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + self._data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot + ) + + shared_data["vibrational_entropy"] = vibrational_results + return {"vibrational_entropy": vibrational_results} + + def _ua_entropy( + self, + group_id, + force_matrices, + torque_matrices, + frame_counts, + temperature, + highest, + ): + S_trans = 0.0 + S_rot = 0.0 + + for key, fmat in force_matrices.items(): + fmat = self._filter_matrix(fmat) + tmat = self._filter_matrix(torque_matrices[key]) + + S_trans += self._ve.vibrational_entropy_calculation( + fmat, "force", temperature, highest + ) + S_rot += self._ve.vibrational_entropy_calculation( + tmat, "torque", temperature, highest + ) + + return S_trans, S_rot + + def _level_entropy( + self, + group_id, + level, + force_matrix, + torque_matrix, + temperature, + highest, + ): + fmat = self._filter_matrix(force_matrix) + tmat = self._filter_matrix(torque_matrix) + + S_trans = self._ve.vibrational_entropy_calculation( + fmat, "force", temperature, highest + ) + S_rot = self._ve.vibrational_entropy_calculation( + tmat, "torque", temperature, highest + ) + + return S_trans, S_rot + + @staticmethod + def _filter_matrix(matrix): + mask = ~(np.all(matrix == 0, axis=0)) + return matrix[np.ix_(mask, mask)] From cda45563e33803a866acd677062c8bbe2d0d7620 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 15 Jan 2026 14:58:36 +0000 Subject: [PATCH 028/101] begin the process of wiring the DAG for both entropy and levels --- CodeEntropy/config/run.py | 10 ++-- CodeEntropy/entropy/entropy_graph.py | 28 +++++------ CodeEntropy/entropy/entropy_manager.py | 9 +++- .../nodes/configurational_entropy_node.py | 4 +- CodeEntropy/levels/hierarchy_graph.py | 49 ++++++++++++++----- .../levels/nodes/build_conformations.py | 4 +- .../levels/nodes/build_covariance_matrices.py | 15 ++---- CodeEntropy/levels/nodes/compute_axes.py | 24 +++++++-- CodeEntropy/levels/nodes/compute_dihedrals.py | 36 +++++++++++--- .../levels/nodes/compute_neighbours.py | 10 ++-- .../levels/nodes/compute_weighted_forces.py | 9 ++-- .../levels/nodes/compute_weighted_torques.py | 9 ++-- CodeEntropy/levels/nodes/detect_levels.py | 1 + CodeEntropy/main.py | 2 +- 14 files changed, 138 insertions(+), 72 deletions(-) diff --git a/CodeEntropy/config/run.py b/CodeEntropy/config/run.py index 20cbae6d..9d7d97bd 100644 --- a/CodeEntropy/config/run.py +++ b/CodeEntropy/config/run.py @@ -17,11 +17,11 @@ from CodeEntropy.config.arg_config_manager import ConfigManager from CodeEntropy.config.data_logger import DataLogger from CodeEntropy.config.logging_config import LoggingConfig -from CodeEntropy.dihedral_tools import DihedralAnalysis -from CodeEntropy.entropy import EntropyManager -from CodeEntropy.group_molecules import GroupMolecules -from CodeEntropy.levels import LevelManager -from CodeEntropy.mda_universe_operations import UniverseOperations +from CodeEntropy.entropy.entropy_manager import EntropyManager +from CodeEntropy.group_molecules.group_molecules import GroupMolecules +from CodeEntropy.levels.dihedral_tools import DihedralAnalysis +from CodeEntropy.levels.level_manager import LevelManager +from CodeEntropy.levels.mda_universe_operations import UniverseOperations logger = logging.getLogger(__name__) console = LoggingConfig.get_console() diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index f97bdeae..580b4cf7 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,9 +1,9 @@ import networkx as nx -from CodeEntropy.entropy.nodes.configurational_entropy import ConfigurationalEntropyNode -from CodeEntropy.entropy.nodes.entropy_aggregator import EntropyAggregatorNode -from CodeEntropy.entropy.nodes.orientational_entropy import OrientationalEntropyNode -from CodeEntropy.entropy.nodes.vibrational_entropy import VibrationalEntropyNode +from CodeEntropy.entropy.nodes.configurational_entropy_node import ( + ConfigurationalEntropyNode, +) +from CodeEntropy.entropy.nodes.vibrational_entropy_node import VibrationalEntropyNode class EntropyGraph: @@ -23,11 +23,11 @@ def __init__(self): def build(self): self.add("vibrational_entropy", VibrationalEntropyNode()) - self.add( - "orientational_entropy", - OrientationalEntropyNode(), - depends_on=["vibrational_entropy"], - ) + # self.add( + # "orientational_entropy", + # OrientationalEntropyNode(), + # depends_on=["vibrational_entropy"], + # ) self.add( "configurational_entropy", @@ -35,11 +35,11 @@ def build(self): depends_on=["orientational_entropy"], ) - self.add( - "aggregate_entropy", - EntropyAggregatorNode(), - depends_on=["configurational_entropy"], - ) + # self.add( + # "aggregate_entropy", + # EntropyAggregatorNode(), + # depends_on=["configurational_entropy"], + # ) return self diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index ff191cce..20eda4ec 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -7,6 +7,7 @@ from CodeEntropy.config.logging_config import LoggingConfig from CodeEntropy.entropy.entropy_graph import EntropyGraph from CodeEntropy.levels.hierarchy_graph import LevelDAG +from CodeEntropy.levels.level_hierarchy import LevelHierarchy logger = logging.getLogger(__name__) console = LoggingConfig.get_console() @@ -71,7 +72,9 @@ def execute(self): reduced_universe = self._get_reduced_universe() - number_molecules, levels = self._level_manager.select_levels(reduced_universe) + level_hierarchy = LevelHierarchy() + + number_molecules, levels = level_hierarchy.select_levels(reduced_universe) groups = self._group_molecules.grouping_molecules( reduced_universe, self._args.grouping @@ -112,7 +115,9 @@ def execute(self): "run_manager": self._run_manager, } - level_results = LevelDAG().build().execute(shared_data) + logger.info(f"shared_data: {shared_data}") + + level_results = LevelDAG(self._universe_operations).build().execute(shared_data) shared_data.update(level_results) diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index f36dc1a4..9fb9352f 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -2,7 +2,7 @@ import numpy as np -from CodeEntropy.entropy.configurational_entropy import ConfigurationalEntropy +from CodeEntropy.entropy.configurational_entropy import ConformationalEntropy logger = logging.getLogger(__name__) @@ -14,7 +14,7 @@ class ConfigurationalEntropyNode: """ def __init__(self, data_logger): - self._ce = ConfigurationalEntropy() + self._ce = ConformationalEntropy() self._data_logger = data_logger def run(self, shared_data, **_): diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 9cf3af82..7907805d 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,44 +1,65 @@ +import logging + import networkx as nx from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode -from CodeEntropy.levels.nodes.build_conformations import BuildConformationsNode from CodeEntropy.levels.nodes.build_covariance_matrices import ( BuildCovarianceMatricesNode, ) from CodeEntropy.levels.nodes.compute_axes import ComputeAxesNode -from CodeEntropy.levels.nodes.compute_dihedrals import ComputeDihedralsNode -from CodeEntropy.levels.nodes.compute_neighbours import ComputeNeighboursNode +from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.compute_weighted_forces import ComputeWeightedForcesNode from CodeEntropy.levels.nodes.compute_weighted_torques import ComputeWeightedTorquesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode +logger = logging.getLogger(__name__) + class LevelDAG: + """ + DAG for computing level-resolved structural quantities. + Uses shared_data as the single state container. + """ - def __init__(self): + def __init__(self, universe_operations): self.graph = nx.DiGraph() self.nodes = {} + self._universe_operations = universe_operations def build(self): self.add("detect_molecules", DetectMoleculesNode()) self.add("detect_levels", DetectLevelsNode(), ["detect_molecules"]) + self.add("build_beads", BuildBeadsNode(), ["detect_levels"]) + self.add("compute_axes", ComputeAxesNode(), ["build_beads"]) + self.add( - "compute_weighted_forces", ComputeWeightedForcesNode(), ["compute_axes"] + "compute_weighted_forces", + ComputeWeightedForcesNode(), + ["compute_axes"], ) self.add( - "compute_weighted_torques", ComputeWeightedTorquesNode(), ["compute_axes"] + "compute_weighted_torques", + ComputeWeightedTorquesNode(), + ["compute_axes"], ) + self.add( "build_covariance", BuildCovarianceMatricesNode(), - ["compute_weighted_forces", "compute_weighted_torques"], + [ + "compute_weighted_forces", + "compute_weighted_torques", + ], + ) + + self.add( + "compute_conformational_states", + ComputeConformationalStatesNode(self._universe_operations), + ["detect_levels"], ) - self.add("compute_dihedrals", ComputeDihedralsNode(), ["build_beads"]) - self.add("build_conformations", BuildConformationsNode(), ["compute_dihedrals"]) - self.add("compute_neighbours", ComputeNeighboursNode(), ["build_beads"]) return self @@ -50,5 +71,9 @@ def add(self, name, obj, deps=None): self.graph.add_edge(d, name) def execute(self, shared_data): - for node in nx.topological_sort(self.graph): - self.nodes[node].run(shared_data) + """ + Execute DAG in topological order. + Nodes mutate shared_data in-place. + """ + for node_name in nx.topological_sort(self.graph): + self.nodes[node_name].run(shared_data) diff --git a/CodeEntropy/levels/nodes/build_conformations.py b/CodeEntropy/levels/nodes/build_conformations.py index 07fc50df..e8702141 100644 --- a/CodeEntropy/levels/nodes/build_conformations.py +++ b/CodeEntropy/levels/nodes/build_conformations.py @@ -1,9 +1,9 @@ -from CodeEntropy.levels.dihedral_tools import DihedralTools +from CodeEntropy.levels.dihedral_tools import DihedralAnalysis class BuildConformationsNode: def __init__(self): - self._dih = DihedralTools() + self._dih = DihedralAnalysis() def run(self, shared_data): dihedrals = shared_data["dihedrals"] diff --git a/CodeEntropy/levels/nodes/build_covariance_matrices.py b/CodeEntropy/levels/nodes/build_covariance_matrices.py index 3cd5eed0..b06d7bcd 100644 --- a/CodeEntropy/levels/nodes/build_covariance_matrices.py +++ b/CodeEntropy/levels/nodes/build_covariance_matrices.py @@ -6,18 +6,13 @@ def __init__(self): self._ft = ForceTorqueManager() def run(self, shared_data): - """ - Build force and torque covariance matrices from weighted forces/torques. - """ - - weighted_forces = shared_data["weighted_forces"] - weighted_torques = shared_data["weighted_torques"] + forces = shared_data["weighted_forces"] + torques = shared_data["weighted_torques"] force_cov, torque_cov, frame_counts = self._ft.build_covariance_matrices( - weighted_forces, - weighted_torques, + forces, torques ) - shared_data["force_covariance"] = force_cov - shared_data["torque_covariance"] = torque_cov + shared_data["force_covariances"] = force_cov + shared_data["torque_covariances"] = torque_cov shared_data["frame_counts"] = frame_counts diff --git a/CodeEntropy/levels/nodes/compute_axes.py b/CodeEntropy/levels/nodes/compute_axes.py index a379943a..29bf11fa 100644 --- a/CodeEntropy/levels/nodes/compute_axes.py +++ b/CodeEntropy/levels/nodes/compute_axes.py @@ -1,5 +1,9 @@ +import logging + from CodeEntropy.levels.coordinate_system import CoordinateSystem +logger = logging.getLogger(__name__) + class ComputeAxesNode: def __init__(self): @@ -8,16 +12,26 @@ def __init__(self): def run(self, shared_data): beads = shared_data["beads"] - axes = {} + trans_axes = {} + rot_axes = {} avg_pos = {} for key, bead_list in beads.items(): - axes[key] = [] + trans_axes[key] = [] + rot_axes[key] = [] avg_pos[key] = [] for bead in bead_list: - avg_pos[key].append(self._coord.get_avg_pos(bead)) - axes[key].append(self._coord.get_axes(bead)) + t_ax, r_ax = self._coord.get_axes( + bead.data_container, bead.level, bead.index + ) + + trans_axes[key].append(t_ax) + rot_axes[key].append(r_ax) + avg_pos[key].append( + self._coord.get_avg_pos(bead.atoms, bead.atoms.center_of_mass()) + ) - shared_data["axes"] = axes + shared_data["trans_axes"] = trans_axes + shared_data["rot_axes"] = rot_axes shared_data["avg_positions"] = avg_pos diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py index 1b85b811..6b50581d 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -1,10 +1,34 @@ -from CodeEntropy.levels.dihedral_tools import DihedralTools +from CodeEntropy.levels.dihedral_tools import DihedralAnalysis -class ComputeDihedralsNode: - def __init__(self): - self._dih = DihedralTools() +class ComputeConformationalStatesNode: + def __init__(self, universe_operations): + self._dih = DihedralAnalysis(universe_operations) def run(self, shared_data): - beads = shared_data["beads"] - shared_data["dihedrals"] = self._dih.get_dihedrals(beads) + u = shared_data["universe"] + levels = shared_data["levels"] + groups = shared_data["groups"] + + start = shared_data["start"] + end = shared_data["end"] + step = shared_data["step"] + bin_width = shared_data["args"].bin_width + + states_ua, states_res = self._dih.build_conformational_states( + data_container=u, + levels=levels, + groups=groups, + start=start, + end=end, + step=step, + bin_width=bin_width, + ) + + shared_data["states_united_atom"] = states_ua + shared_data["states_residue"] = states_res + + return { + "states_united_atom": states_ua, + "states_residue": states_res, + } diff --git a/CodeEntropy/levels/nodes/compute_neighbours.py b/CodeEntropy/levels/nodes/compute_neighbours.py index 07fc50df..c47abf51 100644 --- a/CodeEntropy/levels/nodes/compute_neighbours.py +++ b/CodeEntropy/levels/nodes/compute_neighbours.py @@ -1,10 +1,10 @@ -from CodeEntropy.levels.dihedral_tools import DihedralTools +from CodeEntropy.levels.neighbours import Neighbours -class BuildConformationsNode: +class ComputeNeighboursNode: def __init__(self): - self._dih = DihedralTools() + self._nb = Neighbours() def run(self, shared_data): - dihedrals = shared_data["dihedrals"] - shared_data["conformations"] = self._dih.build_conformational_states(dihedrals) + beads = shared_data["beads"] + shared_data["neighbours"] = self._nb.compute(beads) diff --git a/CodeEntropy/levels/nodes/compute_weighted_forces.py b/CodeEntropy/levels/nodes/compute_weighted_forces.py index 7061d794..83f0c39a 100644 --- a/CodeEntropy/levels/nodes/compute_weighted_forces.py +++ b/CodeEntropy/levels/nodes/compute_weighted_forces.py @@ -8,13 +8,14 @@ def __init__(self): def run(self, shared_data): u = shared_data["universe"] beads = shared_data["beads"] - axes = shared_data["axes"] + trans_axes = shared_data["trans_axes"] forces = {} for key, bead_list in beads.items(): - forces[key] = [] - for bead, ax in zip(bead_list, axes[key]): - forces[key].append(self._ft.get_weighted_forces(u, bead, ax, False)) + forces[key] = [ + self._ft.get_weighted_forces(u, bead, t_ax, False) + for bead, t_ax in zip(bead_list, trans_axes[key]) + ] shared_data["weighted_forces"] = forces diff --git a/CodeEntropy/levels/nodes/compute_weighted_torques.py b/CodeEntropy/levels/nodes/compute_weighted_torques.py index 3040ebba..6f839f80 100644 --- a/CodeEntropy/levels/nodes/compute_weighted_torques.py +++ b/CodeEntropy/levels/nodes/compute_weighted_torques.py @@ -8,13 +8,14 @@ def __init__(self): def run(self, shared_data): u = shared_data["universe"] beads = shared_data["beads"] - axes = shared_data["axes"] + rot_axes = shared_data["rot_axes"] torques = {} for key, bead_list in beads.items(): - torques[key] = [] - for bead, ax in zip(bead_list, axes[key]): - torques[key].append(self._ft.get_weighted_torques(u, bead, ax)) + torques[key] = [ + self._ft.get_weighted_torques(u, bead, r_ax) + for bead, r_ax in zip(bead_list, rot_axes[key]) + ] shared_data["weighted_torques"] = torques diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index b07cb86d..ee74d49e 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -7,6 +7,7 @@ def __init__(self): def run(self, shared_data): u = shared_data["universe"] + num_mol, levels = self._hier.select_levels(u) shared_data["number_molecules"] = num_mol diff --git a/CodeEntropy/main.py b/CodeEntropy/main.py index 3bf24ee6..99e0d9ec 100644 --- a/CodeEntropy/main.py +++ b/CodeEntropy/main.py @@ -1,7 +1,7 @@ import logging import sys -from CodeEntropy.run import RunManager +from CodeEntropy.config.run import RunManager logger = logging.getLogger(__name__) From 94fbc611e7f5db9cd91cf6e9910744b34d11e82b Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 15 Jan 2026 15:30:03 +0000 Subject: [PATCH 029/101] update level nodes with changes from PR #246 --- CodeEntropy/levels.py | 715 --------------------- CodeEntropy/levels/force_torque_manager.py | 160 ++--- CodeEntropy/levels/matrix_operations.py | 17 +- 3 files changed, 97 insertions(+), 795 deletions(-) delete mode 100644 CodeEntropy/levels.py diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py deleted file mode 100644 index 9d43670c..00000000 --- a/CodeEntropy/levels.py +++ /dev/null @@ -1,715 +0,0 @@ -import logging - -import numpy as np -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) - -logger = logging.getLogger(__name__) - - -class LevelManager: - """ - Manages the structural and dynamic levels involved in entropy calculations. This - includes selecting relevant levels, computing axes for translation and rotation, - and handling bead-based representations of molecular systems. Provides utility - methods to extract averaged positions, convert coordinates to spherical systems, - compute weighted forces and torques, and manipulate matrices used in entropy - analysis. - """ - - def __init__(self, universe_operations): - """ - Initializes the LevelManager with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ - self.data_container = None - self._levels = None - self._trans_axes = None - self._rot_axes = None - self._number_of_beads = None - self._universe_operations = universe_operations - - def select_levels(self, data_container): - """ - Function to read input system and identify the number of molecules and - the levels (i.e. united atom, residue and/or polymer) that should be used. - The level refers to the size of the bead (atom or collection of atoms) - that will be used in the entropy calculations. - - Args: - arg_DataContainer: MDAnalysis universe object containing the system of - interest - - Returns: - number_molecules (int): Number of molecules in the system. - levels (array): Strings describing the length scales for each molecule. - """ - - # fragments is MDAnalysis terminology for what chemists would call molecules - number_molecules = len(data_container.atoms.fragments) - logger.debug(f"The number of molecules is {number_molecules}.") - - fragments = data_container.atoms.fragments - levels = [[] for _ in range(number_molecules)] - - for molecule in range(number_molecules): - levels[molecule].append( - "united_atom" - ) # every molecule has at least one atom - - atoms_in_fragment = fragments[molecule].select_atoms("prop mass > 1.1") - number_residues = len(atoms_in_fragment.residues) - - if len(atoms_in_fragment) > 1: - levels[molecule].append("residue") - - if number_residues > 1: - levels[molecule].append("polymer") - - logger.debug(f"levels {levels}") - - return number_molecules, levels - - def get_matrices( - self, - data_container, - level, - highest_level, - force_matrix, - torque_matrix, - force_partitioning, - ): - """ - Compute and accumulate force/torque covariance matrices for a given level. - - Parameters: - data_container (MDAnalysis.Universe): Data for a molecule or residue. - level (str): 'polymer', 'residue', or 'united_atom'. - highest_level (bool): Whether this is the top (largest bead size) level. - force_matrix, torque_matrix (np.ndarray or None): Accumulated matrices to add - to. - force_partitioning (float): Factor to adjust force contributions, - default is 0.5. - - Returns: - force_matrix (np.ndarray): Accumulated force covariance matrix. - torque_matrix (np.ndarray): Accumulated torque covariance matrix. - """ - - # Make beads - list_of_beads = self.get_beads(data_container, level) - - # number of beads and frames in trajectory - number_beads = len(list_of_beads) - - # initialize force and torque arrays - weighted_forces = [None for _ in range(number_beads)] - weighted_torques = [None for _ in range(number_beads)] - - # Calculate forces/torques for each bead - for bead_index in range(number_beads): - bead = list_of_beads[bead_index] - # Set up axes - # translation and rotation use different axes - # how the axes are defined depends on the level - trans_axes = data_container.atoms.principal_axes() - rot_axes = np.real(bead.principal_axes()) - - # Sort out coordinates, forces, and torques for each atom in the bead - weighted_forces[bead_index] = self.get_weighted_forces( - data_container, - bead, - trans_axes, - highest_level, - force_partitioning, - ) - weighted_torques[bead_index] = self.get_weighted_torques( - data_container, bead, rot_axes, force_partitioning - ) - - # Create covariance submatrices - force_submatrix = [ - [0 for _ in range(number_beads)] for _ in range(number_beads) - ] - torque_submatrix = [ - [0 for _ in range(number_beads)] for _ in range(number_beads) - ] - - for i in range(number_beads): - for j in range(i, number_beads): - f_sub = self.create_submatrix(weighted_forces[i], weighted_forces[j]) - t_sub = self.create_submatrix(weighted_torques[i], weighted_torques[j]) - force_submatrix[i][j] = f_sub - force_submatrix[j][i] = f_sub.T - torque_submatrix[i][j] = t_sub - torque_submatrix[j][i] = t_sub.T - - # Convert block matrices to full matrix - force_block = np.block( - [ - [force_submatrix[i][j] for j in range(number_beads)] - for i in range(number_beads) - ] - ) - torque_block = np.block( - [ - [torque_submatrix[i][j] for j in range(number_beads)] - for i in range(number_beads) - ] - ) - - # Enforce consistent shape before accumulation - if force_matrix is None: - force_matrix = np.zeros_like(force_block) - elif force_matrix.shape != force_block.shape: - raise ValueError( - f"Inconsistent force matrix shape: existing " - f"{force_matrix.shape}, new {force_block.shape}" - ) - else: - force_matrix = force_block - - if torque_matrix is None: - torque_matrix = np.zeros_like(torque_block) - elif torque_matrix.shape != torque_block.shape: - raise ValueError( - f"Inconsistent torque matrix shape: existing " - f"{torque_matrix.shape}, new {torque_block.shape}" - ) - else: - torque_matrix = torque_block - - return force_matrix, torque_matrix - - def get_beads(self, data_container, level): - """ - Function to define beads depending on the level in the hierarchy. - - Args: - data_container (MDAnalysis.Universe): the molecule data - level (str): the heirarchy level (polymer, residue, or united atom) - - Returns: - list_of_beads : the relevent beads - """ - - if level == "polymer": - list_of_beads = [] - atom_group = "all" - list_of_beads.append(data_container.select_atoms(atom_group)) - - if level == "residue": - list_of_beads = [] - num_residues = len(data_container.residues) - for residue in range(num_residues): - atom_group = "resindex " + str(residue) - list_of_beads.append(data_container.select_atoms(atom_group)) - - if level == "united_atom": - list_of_beads = [] - heavy_atoms = data_container.select_atoms("prop mass > 1.1") - if len(heavy_atoms) == 0: - # molecule without heavy atoms would be a hydrogen molecule - list_of_beads.append(data_container.select_atoms("all")) - else: - # Select one heavy atom and all light atoms bonded to it - for atom in heavy_atoms: - atom_group = ( - "index " - + str(atom.index) - + " or ((prop mass <= 1.1) and bonded index " - + str(atom.index) - + ")" - ) - list_of_beads.append(data_container.select_atoms(atom_group)) - - logger.debug(f"List of beads: {list_of_beads}") - - return list_of_beads - - def get_weighted_forces( - self, data_container, bead, trans_axes, highest_level, force_partitioning - ): - """ - Compute mass-weighted translational forces for a bead. - - The forces acting on all atoms belonging to the bead are first transformed - into the provided translational reference frame and summed. If this bead - corresponds to the highest level of a hierarchical coarse-graining scheme, - the total force is scaled by a force-partitioning factor to avoid double - counting forces from weakly correlated atoms. - - The resulting force vector is then normalized by the square root of the - bead's total mass. - - Parameters - ---------- - data_container : MDAnalysis.Universe - Container holding atomic positions and forces. - bead : object - Molecular subunit whose atoms contribute to the force. - trans_axes : np.ndarray - Transformation matrix defining the translational reference frame. - highest_level : bool - Whether this bead is the highest level in the length-scale hierarchy. - If True, force partitioning is applied. - force_partitioning : float - Scaling factor applied to forces to avoid over-counting correlated - contributions (typically 0.5). - - Returns - ------- - weighted_force : np.ndarray - Mass-weighted translational force acting on the bead. - - Raises - ------ - ValueError - If the bead mass is zero or negative. - """ - forces_trans = np.zeros((3,)) - - for atom in bead.atoms: - forces_local = np.matmul(trans_axes, data_container.atoms[atom.index].force) - forces_trans += forces_local - - if highest_level: - forces_trans = force_partitioning * forces_trans - - mass = bead.total_mass() - - if mass <= 0: - raise ValueError( - f"Invalid mass value: {mass}. Mass must be positive to compute the " - f"square root." - ) - - weighted_force = forces_trans / np.sqrt(mass) - - logger.debug(f"Weighted Force: {weighted_force}") - - return weighted_force - - def get_weighted_torques(self, data_container, bead, rot_axes, force_partitioning): - """ - Compute moment-of-inertia weighted torques for a bead. - - Atomic coordinates and forces are transformed into the provided rotational - reference frame. Torques are computed as the cross product of position - vectors (relative to the bead center of mass) and forces, with a - force-partitioning factor applied to reduce over-counting of correlated - atomic contributions. - - The total torque vector is then weighted by the square root of the bead's - principal moments of inertia. Weighting is performed component-wise using - the sorted eigenvalues of the moment of inertia tensor. - - To ensure numerical stability: - - Torque components that are effectively zero are skipped. - - Zero moments of inertia result in zero weighted torque with a warning. - - Negative moments of inertia raise an error. - - Parameters - ---------- - data_container : object - Container holding atomic positions and forces. - bead : object - Molecular subunit whose atoms contribute to the torque. - rot_axes : np.ndarray - Transformation matrix defining the rotational reference frame. - force_partitioning : float - Scaling factor applied to forces to avoid over-counting correlated - contributions (typically 0.5). - - Returns - ------- - weighted_torque : np.ndarray - Moment-of-inertia weighted torque acting on the bead. - - Raises - ------ - ValueError - If a negative principal moment of inertia is encountered. - """ - torques = np.zeros((3,)) - weighted_torque = np.zeros((3,)) - moment_of_inertia = np.zeros(3) - - for atom in bead.atoms: - coords_rot = ( - data_container.atoms[atom.index].position - bead.center_of_mass() - ) - coords_rot = np.matmul(rot_axes, coords_rot) - forces_rot = np.matmul(rot_axes, data_container.atoms[atom.index].force) - - forces_rot = force_partitioning * forces_rot - - torques_local = np.cross(coords_rot, forces_rot) - torques += torques_local - - eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) - moments_of_inertia = sorted(eigenvalues, reverse=True) - - for dimension in range(3): - if np.isclose(torques[dimension], 0): - weighted_torque[dimension] = 0 - continue - - if np.isclose(moments_of_inertia[dimension], 0): - weighted_torque[dimension] = 0 - logger.warning("Zero moment of inertia. Setting torque to 0") - continue - - if moments_of_inertia[dimension] < 0: - raise ValueError( - f"Negative value encountered for moment of inertia: " - f"{moment_of_inertia[dimension]} " - f"Cannot compute weighted torque." - ) - - weighted_torque[dimension] = torques[dimension] / np.sqrt( - moments_of_inertia[dimension] - ) - - logger.debug(f"Weighted Torque: {weighted_torque}") - - return weighted_torque - - def create_submatrix(self, data_i, data_j): - """ - Function for making covariance matrices. - - Args - ----- - data_i : values for bead i - data_j : values for bead j - - Returns - ------ - submatrix : 3x3 matrix for the covariance between i and j - """ - - # Start with 3 by 3 matrix of zeros - submatrix = np.zeros((3, 3)) - - # For each frame calculate the outer product (cross product) of the data from - # the two beads and add the result to the submatrix - outer_product_matrix = np.outer(data_i, data_j) - submatrix = np.add(submatrix, outer_product_matrix) - - logger.debug(f"Submatrix: {submatrix}") - - return submatrix - - def build_covariance_matrices( - self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - force_partitioning, - ): - """ - Construct average force and torque covariance matrices for all molecules and - entropy levels. - - Parameters - ---------- - entropy_manager : EntropyManager - Instance of the EntropyManager. - reduced_atom : Universe - The reduced atom selection. - levels : dict - Dictionary mapping molecule IDs to lists of entropy levels. - groups : dict - Dictionary mapping group IDs to lists of molecule IDs. - start : int - Start frame index. - end : int - End frame index. - step : int - Step size for frame iteration. - number_frames : int - Total number of frames to process. - force_partitioning : float - Factor to adjust force contributions, default is 0.5. - - - Returns - ------- - tuple - force_avg : dict - Averaged force covariance matrices by entropy level. - torque_avg : dict - Averaged torque covariance matrices by entropy level. - """ - number_groups = len(groups) - - force_avg = { - "ua": {}, - "res": [None] * number_groups, - "poly": [None] * number_groups, - } - torque_avg = { - "ua": {}, - "res": [None] * number_groups, - "poly": [None] * number_groups, - } - - total_steps = len(reduced_atom.trajectory[start:end:step]) - total_items = ( - sum(len(levels[mol_id]) for mols in groups.values() for mol_id in mols) - * total_steps - ) - - frame_counts = { - "ua": {}, - "res": np.zeros(number_groups, dtype=int), - "poly": np.zeros(number_groups, dtype=int), - } - - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) as progress: - - task = progress.add_task( - "[green]Processing...", - total=total_items, - title="Starting...", - ) - - indices = list(range(number_frames)) - for time_index, _ in zip(indices, reduced_atom.trajectory[start:end:step]): - for group_id, molecules in groups.items(): - for mol_id in molecules: - mol = self._universe_operations.get_molecule_container( - reduced_atom, mol_id - ) - for level in levels[mol_id]: - resname = mol.atoms[0].resname - resid = mol.atoms[0].resid - segid = mol.atoms[0].segid - - mol_label = f"{resname}_{resid} (segid {segid})" - - progress.update( - task, - title=f"Building covariance matrices | " - f"Timestep {time_index} | " - f"Molecule: {mol_label} | " - f"Level: {level}", - ) - - self.update_force_torque_matrices( - entropy_manager, - mol, - group_id, - level, - levels[mol_id], - time_index, - number_frames, - force_avg, - torque_avg, - frame_counts, - force_partitioning, - ) - - progress.advance(task) - - return force_avg, torque_avg, frame_counts - - def update_force_torque_matrices( - self, - entropy_manager, - mol, - group_id, - level, - level_list, - time_index, - num_frames, - force_avg, - torque_avg, - frame_counts, - force_partitioning, - ): - """ - Update the running averages of force and torque covariance matrices - for a given molecule and entropy level. - - This function computes the force and torque covariance matrices for the - current frame and updates the existing averages in-place using the incremental - mean formula: - - new_avg = old_avg + (value - old_avg) / n - - where n is the number of frames processed so far for that molecule/level - combination. This ensures that the averages are maintained without storing - all previous frame data. - - Parameters - ---------- - entropy_manager : EntropyManager - Instance of the EntropyManager. - mol : AtomGroup - The molecule to process. - group_id : int - Index of the group to which the molecule belongs. - level : str - Current entropy level ("united_atom", "residue", or "polymer"). - level_list : list - List of entropy levels for the molecule. - time_index : int - Index of the current frame relative to the start of the trajectory slice. - num_frames : int - Total number of frames to process. - force_avg : dict - Dictionary holding the running average force matrices, keyed by entropy - level. - torque_avg : dict - Dictionary holding the running average torque matrices, keyed by entropy - level. - frame_counts : dict - Dictionary holding the count of frames processed for each molecule/level - combination. - force_partitioning : float - Factor to adjust force contributions, default is 0.5. - Returns - ------- - None - Updates are performed in-place on `force_avg`, `torque_avg`, and - `frame_counts`. - """ - highest = level == level_list[-1] - - # United atom level calculations are done separately for each residue - # This allows information per residue to be output and keeps the - # matrices from becoming too large - if level == "united_atom": - for res_id, residue in enumerate(mol.residues): - key = (group_id, res_id) - res = self._universe_operations.new_U_select_atom( - mol, f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}" - ) - - # This is to get MDAnalysis to get the information from the - # correct frame of the trajectory - res.trajectory[time_index] - - # Build the matrices, adding data from each timestep - # Being careful for the first timestep when data has not yet - # been added to the matrices - f_mat, t_mat = self.get_matrices( - res, - level, - highest, - None if key not in force_avg["ua"] else force_avg["ua"][key], - None if key not in torque_avg["ua"] else torque_avg["ua"][key], - force_partitioning, - ) - - if key not in force_avg["ua"]: - force_avg["ua"][key] = f_mat.copy() - torque_avg["ua"][key] = t_mat.copy() - frame_counts["ua"][key] = 1 - else: - frame_counts["ua"][key] += 1 - n = frame_counts["ua"][key] - force_avg["ua"][key] += (f_mat - force_avg["ua"][key]) / n - torque_avg["ua"][key] += (t_mat - torque_avg["ua"][key]) / n - - elif level in ["residue", "polymer"]: - # This is to get MDAnalysis to get the information from the - # correct frame of the trajectory - mol.trajectory[time_index] - - key = "res" if level == "residue" else "poly" - - # Build the matrices, adding data from each timestep - # Being careful for the first timestep when data has not yet - # been added to the matrices - f_mat, t_mat = self.get_matrices( - mol, - level, - highest, - None if force_avg[key][group_id] is None else force_avg[key][group_id], - ( - None - if torque_avg[key][group_id] is None - else torque_avg[key][group_id] - ), - force_partitioning, - ) - - if force_avg[key][group_id] is None: - force_avg[key][group_id] = f_mat.copy() - torque_avg[key][group_id] = t_mat.copy() - frame_counts[key][group_id] = 1 - else: - frame_counts[key][group_id] += 1 - n = frame_counts[key][group_id] - force_avg[key][group_id] += (f_mat - force_avg[key][group_id]) / n - torque_avg[key][group_id] += (t_mat - torque_avg[key][group_id]) / n - - return frame_counts - - def filter_zero_rows_columns(self, arg_matrix): - """ - function for removing rows and columns that contain only zeros from a matrix - - Args: - arg_matrix : matrix - - Returns: - arg_matrix : the reduced size matrix - """ - - # record the initial size - init_shape = np.shape(arg_matrix) - - zero_indices = list( - filter( - lambda row: np.all(np.isclose(arg_matrix[row, :], 0.0)), - np.arange(np.shape(arg_matrix)[0]), - ) - ) - all_indices = np.ones((np.shape(arg_matrix)[0]), dtype=bool) - all_indices[zero_indices] = False - arg_matrix = arg_matrix[all_indices, :] - - all_indices = np.ones((np.shape(arg_matrix)[1]), dtype=bool) - zero_indices = list( - filter( - lambda col: np.all(np.isclose(arg_matrix[:, col], 0.0)), - np.arange(np.shape(arg_matrix)[1]), - ) - ) - all_indices[zero_indices] = False - arg_matrix = arg_matrix[:, all_indices] - - # get the final shape - final_shape = np.shape(arg_matrix) - - 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/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py index db7a0c6b..5b3f1158 100644 --- a/CodeEntropy/levels/force_torque_manager.py +++ b/CodeEntropy/levels/force_torque_manager.py @@ -23,43 +23,56 @@ def __init__(self): """ def get_weighted_forces( - self, data_container, bead, trans_axes, highest_level, force_partitioning=0.5 + self, data_container, bead, trans_axes, highest_level, force_partitioning ): """ - Function to calculate the mass weighted forces for a given bead. - - Args: - data_container (MDAnalysis.Universe): Contains atomic positions and forces. - bead : The part of the molecule to be considered. - trans_axes (np.ndarray): The axes relative to which the forces are located. - highest_level (bool): Is this the largest level of the length scale hierarchy - force_partitioning (float): Factor to adjust force contributions to avoid - over counting correlated forces, default is 0.5. - - Returns: - weighted_force (np.ndarray): The mass-weighted sum of the forces in the - bead. - """ + Compute mass-weighted translational forces for a bead. + + The forces acting on all atoms belonging to the bead are first transformed + into the provided translational reference frame and summed. If this bead + corresponds to the highest level of a hierarchical coarse-graining scheme, + the total force is scaled by a force-partitioning factor to avoid double + counting forces from weakly correlated atoms. + + The resulting force vector is then normalized by the square root of the + bead's total mass. + + Parameters + ---------- + data_container : MDAnalysis.Universe + Container holding atomic positions and forces. + bead : object + Molecular subunit whose atoms contribute to the force. + trans_axes : np.ndarray + Transformation matrix defining the translational reference frame. + highest_level : bool + Whether this bead is the highest level in the length-scale hierarchy. + If True, force partitioning is applied. + force_partitioning : float + Scaling factor applied to forces to avoid over-counting correlated + contributions (typically 0.5). + Returns + ------- + weighted_force : np.ndarray + Mass-weighted translational force acting on the bead. + + Raises + ------ + ValueError + If the bead mass is zero or negative. + """ forces_trans = np.zeros((3,)) - # Sum forces from all atoms in the bead for atom in bead.atoms: - # update local forces in translational axes forces_local = np.matmul(trans_axes, data_container.atoms[atom.index].force) forces_trans += forces_local if highest_level: - # multiply by the force_partitioning parameter to avoid double counting - # of the forces on weakly correlated atoms - # the default value of force_partitioning is 0.5 (dividing by two) forces_trans = force_partitioning * forces_trans - # divide the sum of forces by the mass of the bead to get the weighted forces mass = bead.total_mass() - # Check that mass is positive to avoid division by 0 or negative values inside - # sqrt if mass <= 0: raise ValueError( f"Invalid mass value: {mass}. Mass must be positive to compute the " @@ -72,91 +85,85 @@ def get_weighted_forces( return weighted_force - def get_weighted_torques( - self, data_container, bead, rot_axes, force_partitioning=0.5 - ): + def get_weighted_torques(self, data_container, bead, rot_axes, force_partitioning): """ - Function to calculate the moment of inertia weighted torques for a given bead. + Compute moment-of-inertia weighted torques for a bead. + + Atomic coordinates and forces are transformed into the provided rotational + reference frame. Torques are computed as the cross product of position + vectors (relative to the bead center of mass) and forces, with a + force-partitioning factor applied to reduce over-counting of correlated + atomic contributions. - This function computes torques in a rotated frame and then weights them using - the moment of inertia tensor. To prevent numerical instability, it treats - extremely small diagonal elements of the moment of inertia tensor as zero - (since values below machine precision are effectively zero). This avoids - unnecessary use of extended precision (e.g., float128). + The total torque vector is then weighted by the square root of the bead's + principal moments of inertia. Weighting is performed component-wise using + the sorted eigenvalues of the moment of inertia tensor. - Additionally, if the computed torque is already zero, the function skips - the division step, reducing unnecessary computations and potential errors. + To ensure numerical stability: + - Torque components that are effectively zero are skipped. + - Zero moments of inertia result in zero weighted torque with a warning. + - Negative moments of inertia raise an error. Parameters ---------- data_container : object - Contains atomic positions and forces. + Container holding atomic positions and forces. bead : object - The part of the molecule to be considered. + Molecular subunit whose atoms contribute to the torque. rot_axes : np.ndarray - The axes relative to which the forces and coordinates are located. - force_partitioning : float, optional - Factor to adjust force contributions, default is 0.5. + Transformation matrix defining the rotational reference frame. + force_partitioning : float + Scaling factor applied to forces to avoid over-counting correlated + contributions (typically 0.5). Returns ------- weighted_torque : np.ndarray - The mass-weighted sum of the torques in the bead. - """ + Moment-of-inertia weighted torque acting on the bead. + Raises + ------ + ValueError + If a negative principal moment of inertia is encountered. + """ torques = np.zeros((3,)) weighted_torque = np.zeros((3,)) + moment_of_inertia = np.zeros(3) for atom in bead.atoms: - - # update local coordinates in rotational axes coords_rot = ( data_container.atoms[atom.index].position - bead.center_of_mass() ) coords_rot = np.matmul(rot_axes, coords_rot) - # update local forces in rotational frame forces_rot = np.matmul(rot_axes, data_container.atoms[atom.index].force) - # multiply by the force_partitioning parameter to avoid double counting - # of the forces on weakly correlated atoms - # the default value of force_partitioning is 0.5 (dividing by two) forces_rot = force_partitioning * forces_rot - # define torques (cross product of coordinates and forces) in rotational - # axes torques_local = np.cross(coords_rot, forces_rot) torques += torques_local - # divide by moment of inertia to get weighted torques - # moment of inertia is a 3x3 tensor - # the weighting is done in each dimension (x,y,z) using the diagonal - # elements of the moment of inertia tensor - moment_of_inertia = bead.moment_of_inertia() + eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) + moments_of_inertia = sorted(eigenvalues, reverse=True) for dimension in range(3): - # Skip calculation if torque is already zero if np.isclose(torques[dimension], 0): weighted_torque[dimension] = 0 continue - # Check for zero moment of inertia - if np.isclose(moment_of_inertia[dimension, dimension], 0): - raise ZeroDivisionError( - f"Attempted to divide by zero moment of inertia in dimension " - f"{dimension}." - ) + if np.isclose(moments_of_inertia[dimension], 0): + weighted_torque[dimension] = 0 + logger.warning("Zero moment of inertia. Setting torque to 0") + continue - # Check for negative moment of inertia - if moment_of_inertia[dimension, dimension] < 0: + if moments_of_inertia[dimension] < 0: raise ValueError( f"Negative value encountered for moment of inertia: " - f"{moment_of_inertia[dimension, dimension]} " + f"{moment_of_inertia[dimension]} " f"Cannot compute weighted torque." ) - # Compute weighted torque weighted_torque[dimension] = torques[dimension] / np.sqrt( - moment_of_inertia[dimension, dimension] + moments_of_inertia[dimension] ) logger.debug(f"Weighted Torque: {weighted_torque}") @@ -173,6 +180,7 @@ def build_covariance_matrices( end, step, number_frames, + force_partitioning, ): """ Construct average force and torque covariance matrices for all molecules and @@ -196,6 +204,9 @@ def build_covariance_matrices( Step size for frame iteration. number_frames : int Total number of frames to process. + force_partitioning : float + Factor to adjust force contributions, default is 0.5. + Returns ------- @@ -248,14 +259,10 @@ def build_covariance_matrices( for time_index, _ in zip(indices, reduced_atom.trajectory[start:end:step]): for group_id, molecules in groups.items(): for mol_id in molecules: - mol = entropy_manager._get_molecule_container( + mol = self._universe_operations.get_molecule_container( reduced_atom, mol_id ) for level in levels[mol_id]: - mol = entropy_manager._get_molecule_container( - reduced_atom, mol_id - ) - resname = mol.atoms[0].resname resid = mol.atoms[0].resid segid = mol.atoms[0].segid @@ -281,6 +288,7 @@ def build_covariance_matrices( force_avg, torque_avg, frame_counts, + force_partitioning, ) progress.advance(task) @@ -299,6 +307,7 @@ def update_force_torque_matrices( force_avg, torque_avg, frame_counts, + force_partitioning, ): """ Update the running averages of force and torque covariance matrices @@ -339,7 +348,8 @@ def update_force_torque_matrices( frame_counts : dict Dictionary holding the count of frames processed for each molecule/level combination. - + force_partitioning : float + Factor to adjust force contributions, default is 0.5. Returns ------- None @@ -354,7 +364,7 @@ def update_force_torque_matrices( if level == "united_atom": for res_id, residue in enumerate(mol.residues): key = (group_id, res_id) - res = entropy_manager._run_manager.new_U_select_atom( + res = self._universe_operations.new_U_select_atom( mol, f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}" ) @@ -368,10 +378,10 @@ def update_force_torque_matrices( f_mat, t_mat = self.get_matrices( res, level, - num_frames, highest, None if key not in force_avg["ua"] else force_avg["ua"][key], None if key not in torque_avg["ua"] else torque_avg["ua"][key], + force_partitioning, ) if key not in force_avg["ua"]: @@ -397,7 +407,6 @@ def update_force_torque_matrices( f_mat, t_mat = self.get_matrices( mol, level, - num_frames, highest, None if force_avg[key][group_id] is None else force_avg[key][group_id], ( @@ -405,6 +414,7 @@ def update_force_torque_matrices( if torque_avg[key][group_id] is None else torque_avg[key][group_id] ), + force_partitioning, ) if force_avg[key][group_id] is None: diff --git a/CodeEntropy/levels/matrix_operations.py b/CodeEntropy/levels/matrix_operations.py index 404ecc9f..7c463c98 100644 --- a/CodeEntropy/levels/matrix_operations.py +++ b/CodeEntropy/levels/matrix_operations.py @@ -93,10 +93,10 @@ def get_matrices( self, data_container, level, - number_frames, highest_level, force_matrix, torque_matrix, + force_partitioning, ): """ Compute and accumulate force/torque covariance matrices for a given level. @@ -104,10 +104,11 @@ def get_matrices( Parameters: data_container (MDAnalysis.Universe): Data for a molecule or residue. level (str): 'polymer', 'residue', or 'united_atom'. - number_frames (int): Number of frames being processed. highest_level (bool): Whether this is the top (largest bead size) level. force_matrix, torque_matrix (np.ndarray or None): Accumulated matrices to add to. + force_partitioning (float): Factor to adjust force contributions, + default is 0.5. Returns: force_matrix (np.ndarray): Accumulated force covariance matrix. @@ -126,17 +127,23 @@ def get_matrices( # Calculate forces/torques for each bead for bead_index in range(number_beads): + bead = list_of_beads[bead_index] # Set up axes # translation and rotation use different axes # how the axes are defined depends on the level - trans_axes, rot_axes = self.get_axes(data_container, level, bead_index) + trans_axes = data_container.atoms.principal_axes() + rot_axes = np.real(bead.principal_axes()) # Sort out coordinates, forces, and torques for each atom in the bead weighted_forces[bead_index] = self.get_weighted_forces( - data_container, list_of_beads[bead_index], trans_axes, highest_level + data_container, + bead, + trans_axes, + highest_level, + force_partitioning, ) weighted_torques[bead_index] = self.get_weighted_torques( - data_container, list_of_beads[bead_index], rot_axes + data_container, bead, rot_axes, force_partitioning ) # Create covariance submatrices From 8d75257d9d6bef5b02843cf63e7b371d2f80c3c7 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 15 Jan 2026 15:56:17 +0000 Subject: [PATCH 030/101] remove manual coordinate calculations which were removed in PR #246 --- CodeEntropy/levels/coordinate_system.py | 227 ----------------------- CodeEntropy/levels/hierarchy_graph.py | 3 - CodeEntropy/levels/nodes/compute_axes.py | 37 ---- 3 files changed, 267 deletions(-) delete mode 100644 CodeEntropy/levels/coordinate_system.py delete mode 100644 CodeEntropy/levels/nodes/compute_axes.py diff --git a/CodeEntropy/levels/coordinate_system.py b/CodeEntropy/levels/coordinate_system.py deleted file mode 100644 index c25682f7..00000000 --- a/CodeEntropy/levels/coordinate_system.py +++ /dev/null @@ -1,227 +0,0 @@ -import logging - -import numpy as np - -logger = logging.getLogger(__name__) - - -class CoordinateSystem: - """ """ - - def __init__(self): - """ - Initializes the CoordinateSystem with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ - - def get_axes(self, data_container, level, index=0): - """ - Function to set the translational and rotational axes. - The translational axes are based on the principal axes of the unit - one level larger than the level we are interested in (except for - the polymer level where there is no larger unit). The rotational - axes use the covalent links between residues or atoms where possible - to define the axes, or if the unit is not bonded to others of the - same level the prinicpal axes of the unit are used. - - Args: - data_container (MDAnalysis.Universe): the molecule and trajectory data - level (str): the level (united atom, residue, or polymer) of interest - index (int): residue index - - Returns: - trans_axes : translational axes - rot_axes : rotational axes - """ - index = int(index) - - if level == "polymer": - # for polymer use principle axis for both translation and rotation - trans_axes = data_container.atoms.principal_axes() - rot_axes = data_container.atoms.principal_axes() - - elif level == "residue": - # Translation - # for residues use principal axes of whole molecule for translation - trans_axes = data_container.atoms.principal_axes() - - # Rotation - # find bonds between atoms in residue of interest and other residues - # we are assuming bonds only exist between adjacent residues - # (linear chains of residues) - # TODO refine selection so that it will work for branched polymers - index_prev = index - 1 - index_next = index + 1 - atom_set = data_container.select_atoms( - f"(resindex {index_prev} or resindex {index_next}) " - f"and bonded resid {index}" - ) - residue = data_container.select_atoms(f"resindex {index}") - - if len(atom_set) == 0: - # if no bonds to other residues use pricipal axes of residue - rot_axes = residue.atoms.principal_axes() - - else: - # set center of rotation to center of mass of the residue - center = residue.atoms.center_of_mass() - - # get vector for average position of bonded atoms - vector = self.get_avg_pos(atom_set, center) - - # use spherical coordinates function to get rotational axes - rot_axes = self.get_sphCoord_axes(vector) - - elif level == "united_atom": - # Translation - # for united atoms use principal axes of residue for translation - trans_axes = data_container.residues.principal_axes() - - # Rotation - # for united atoms use heavy atoms bonded to the heavy atom - atom_set = data_container.select_atoms( - f"(prop mass > 1.1) and bonded index {index}" - ) - - if len(atom_set) == 0: - # if no bonds to other residues use pricipal axes of residue - rot_axes = data_container.residues.principal_axes() - else: - # center at position of heavy atom - atom_group = data_container.select_atoms(f"index {index}") - center = atom_group.positions[0] - - # get vector for average position of bonded atoms - vector = self.get_avg_pos(atom_set, center) - - # use spherical coordinates function to get rotational axes - rot_axes = self.get_sphCoord_axes(vector) - - logger.debug(f"Translational Axes: {trans_axes}") - logger.debug(f"Rotational Axes: {rot_axes}") - - return trans_axes, rot_axes - - def get_avg_pos(self, atom_set, center): - """ - Function to get the average position of a set of atoms. - - Args: - atom_set : MDAnalysis atom group - center : position for center of rotation - - Returns: - avg_position : three dimensional vector - """ - # start with an empty vector - avg_position = np.zeros((3)) - - # get number of atoms - number_atoms = len(atom_set.names) - - if number_atoms != 0: - # sum positions for all atoms in the given set - for atom_index in range(number_atoms): - atom_position = atom_set.atoms[atom_index].position - - avg_position += atom_position - - avg_position /= number_atoms # divide by number of atoms to get average - - else: - # if no atoms in set the unit has no bonds to restrict its rotational - # motion, so we can use a random vector to get spherical - # coordinate axes - avg_position = np.random.random(3) - - # 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 - - def get_sphCoord_axes(self, arg_r): - """ - For a given vector in space, treat it is a radial vector rooted at - 0,0,0 and derive a curvilinear coordinate system according to the - rules of polar spherical coordinates - - Args: - arg_r: 3 dimensional vector - - Returns: - spherical_basis: axes set (3 vectors) - """ - - x2y2 = arg_r[0] ** 2 + arg_r[1] ** 2 - r2 = x2y2 + arg_r[2] ** 2 - - # Check for division by zero - if r2 == 0.0: - raise ValueError("r2 is zero, cannot compute spherical coordinates.") - - if x2y2 == 0.0: - raise ValueError("x2y2 is zero, cannot compute sin_phi and cos_phi.") - - # These conditions are mathematically unreachable for real-valued vectors. - # Marked as no cover to avoid false negatives in coverage reports. - - # Check for non-negative values inside the square root - if x2y2 / r2 < 0: # pragma: no cover - raise ValueError( - f"Negative value encountered for sin_theta calculation: {x2y2 / r2}. " - f"Cannot take square root." - ) - - if x2y2 < 0: # pragma: no cover - raise ValueError( - f"Negative value encountered for sin_phi and cos_phi " - f"calculation: {x2y2}. " - f"Cannot take square root." - ) - - if x2y2 != 0.0: - sin_theta = np.sqrt(x2y2 / r2) - cos_theta = arg_r[2] / np.sqrt(r2) - - sin_phi = arg_r[1] / np.sqrt(x2y2) - cos_phi = arg_r[0] / np.sqrt(x2y2) - - else: # pragma: no cover - sin_theta = 0.0 - cos_theta = 1 - - sin_phi = 0.0 - cos_phi = 1 - - # if abs(sin_theta) > 1 or abs(sin_phi) > 1: - # print('Bad sine : T {} , P {}'.format(sin_theta, sin_phi)) - - # cos_theta = np.sqrt(1 - sin_theta*sin_theta) - # cos_phi = np.sqrt(1 - sin_phi*sin_phi) - - # print('{} {} {}'.format(*arg_r)) - # print('Sin T : {}, cos T : {}'.format(sin_theta, cos_theta)) - # print('Sin P : {}, cos P : {}'.format(sin_phi, cos_phi)) - - spherical_basis = np.zeros((3, 3)) - - # r^ - spherical_basis[0, :] = np.asarray( - [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta] - ) - - # Theta^ - spherical_basis[1, :] = np.asarray( - [cos_theta * cos_phi, cos_theta * sin_phi, -sin_theta] - ) - - # Phi^ - spherical_basis[2, :] = np.asarray([-sin_phi, cos_phi, 0.0]) - - logger.debug(f"Spherical Basis: {spherical_basis}") - - return spherical_basis diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 7907805d..4fc88d16 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -6,7 +6,6 @@ from CodeEntropy.levels.nodes.build_covariance_matrices import ( BuildCovarianceMatricesNode, ) -from CodeEntropy.levels.nodes.compute_axes import ComputeAxesNode from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.compute_weighted_forces import ComputeWeightedForcesNode from CodeEntropy.levels.nodes.compute_weighted_torques import ComputeWeightedTorquesNode @@ -33,8 +32,6 @@ def build(self): self.add("build_beads", BuildBeadsNode(), ["detect_levels"]) - self.add("compute_axes", ComputeAxesNode(), ["build_beads"]) - self.add( "compute_weighted_forces", ComputeWeightedForcesNode(), diff --git a/CodeEntropy/levels/nodes/compute_axes.py b/CodeEntropy/levels/nodes/compute_axes.py deleted file mode 100644 index 29bf11fa..00000000 --- a/CodeEntropy/levels/nodes/compute_axes.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging - -from CodeEntropy.levels.coordinate_system import CoordinateSystem - -logger = logging.getLogger(__name__) - - -class ComputeAxesNode: - def __init__(self): - self._coord = CoordinateSystem() - - def run(self, shared_data): - beads = shared_data["beads"] - - trans_axes = {} - rot_axes = {} - avg_pos = {} - - for key, bead_list in beads.items(): - trans_axes[key] = [] - rot_axes[key] = [] - avg_pos[key] = [] - - for bead in bead_list: - t_ax, r_ax = self._coord.get_axes( - bead.data_container, bead.level, bead.index - ) - - trans_axes[key].append(t_ax) - rot_axes[key].append(r_ax) - avg_pos[key].append( - self._coord.get_avg_pos(bead.atoms, bead.atoms.center_of_mass()) - ) - - shared_data["trans_axes"] = trans_axes - shared_data["rot_axes"] = rot_axes - shared_data["avg_positions"] = avg_pos From 9d47ff9a9b4ef3a0e64b59acb67473db134e1471 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 15 Jan 2026 16:23:53 +0000 Subject: [PATCH 031/101] replace reference to `compute_axes` to `build_beads` within `hierarchy_graph` --- CodeEntropy/levels/hierarchy_graph.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 4fc88d16..ea34d1c1 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -29,18 +29,17 @@ def __init__(self, universe_operations): def build(self): self.add("detect_molecules", DetectMoleculesNode()) self.add("detect_levels", DetectLevelsNode(), ["detect_molecules"]) - self.add("build_beads", BuildBeadsNode(), ["detect_levels"]) self.add( "compute_weighted_forces", ComputeWeightedForcesNode(), - ["compute_axes"], + ["build_beads"], ) self.add( "compute_weighted_torques", ComputeWeightedTorquesNode(), - ["compute_axes"], + ["build_beads"], ) self.add( @@ -73,4 +72,5 @@ def execute(self, shared_data): Nodes mutate shared_data in-place. """ for node_name in nx.topological_sort(self.graph): + logger.info(f"Running node: {node_name}") self.nodes[node_name].run(shared_data) From 0f911a335882774ac11a2049752c6dbe16e8e664 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 16 Jan 2026 17:12:53 +0000 Subject: [PATCH 032/101] remove `levels/neighbours.py` and `levels/nodes/compute_neighbours.py` --- CodeEntropy/levels/neighbours.py | 7 ------- CodeEntropy/levels/nodes/compute_neighbours.py | 10 ---------- 2 files changed, 17 deletions(-) delete mode 100644 CodeEntropy/levels/neighbours.py delete mode 100644 CodeEntropy/levels/nodes/compute_neighbours.py diff --git a/CodeEntropy/levels/neighbours.py b/CodeEntropy/levels/neighbours.py deleted file mode 100644 index 96464885..00000000 --- a/CodeEntropy/levels/neighbours.py +++ /dev/null @@ -1,7 +0,0 @@ -class Neighbours: - """ """ - - def __init__(self): - """ - Initializes the Neighbours with placeholders - """ diff --git a/CodeEntropy/levels/nodes/compute_neighbours.py b/CodeEntropy/levels/nodes/compute_neighbours.py deleted file mode 100644 index c47abf51..00000000 --- a/CodeEntropy/levels/nodes/compute_neighbours.py +++ /dev/null @@ -1,10 +0,0 @@ -from CodeEntropy.levels.neighbours import Neighbours - - -class ComputeNeighboursNode: - def __init__(self): - self._nb = Neighbours() - - def run(self, shared_data): - beads = shared_data["beads"] - shared_data["neighbours"] = self._nb.compute(beads) From 431f01687b34509c111f2aafb6a9dcaa64368f5d Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 26 Jan 2026 21:35:27 +0000 Subject: [PATCH 033/101] implement full serial graph architecture with `LevelDAG` and `EntropyGraph` --- .../entropy/configurational_entropy.py | 16 +- CodeEntropy/entropy/entropy_graph.py | 19 +-- CodeEntropy/entropy/entropy_manager.py | 29 ++-- .../nodes/configurational_entropy_node.py | 106 +++++++------ .../entropy/nodes/vibrational_entropy_node.py | 146 ++++++------------ CodeEntropy/entropy/vibrational_entropy.py | 12 +- CodeEntropy/levels/force_torque_manager.py | 81 +++++++++- CodeEntropy/levels/hierarchy_graph.py | 45 ++---- CodeEntropy/levels/nodes/build_beads.py | 5 +- .../levels/nodes/build_covariance_matrices.py | 25 +-- CodeEntropy/levels/nodes/compute_dihedrals.py | 26 +++- CodeEntropy/levels/nodes/detect_levels.py | 5 +- CodeEntropy/levels/nodes/detect_molecules.py | 5 +- 13 files changed, 273 insertions(+), 247 deletions(-) diff --git a/CodeEntropy/entropy/configurational_entropy.py b/CodeEntropy/entropy/configurational_entropy.py index 70f308fa..a976ff6e 100644 --- a/CodeEntropy/entropy/configurational_entropy.py +++ b/CodeEntropy/entropy/configurational_entropy.py @@ -6,17 +6,17 @@ class ConformationalEntropy: - """ - Performs conformational entropy calculations based on molecular dynamics data. - """ - def __init__( self, run_manager, args, universe, data_logger, level_manager, group_molecules ): - """ - Initializes the ConformationalEntropy manager with all required components and - sets the gas constant used in conformational entropy calculations. - """ + self._run_manager = run_manager + self._args = args + self._universe = universe + self._data_logger = data_logger + self._level_manager = level_manager + self._group_molecules = group_molecules + + self._GAS_CONST = 8.3144598484848 def assign_conformation( self, data_container, dihedral, number_frames, bin_width, start, end, step diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index 580b4cf7..c686e5fe 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -11,9 +11,8 @@ class EntropyGraph: DAG representing the entropy computation pipeline: 1. Vibrational entropy - 2. Rotational (orientational) entropy - 3. Conformational entropy - 4. Aggregate entropy across levels and groups + 2. Conformational (configurational) entropy + (orientational entropy can be added later) """ def __init__(self): @@ -23,22 +22,16 @@ def __init__(self): def build(self): self.add("vibrational_entropy", VibrationalEntropyNode()) - # self.add( - # "orientational_entropy", - # OrientationalEntropyNode(), - # depends_on=["vibrational_entropy"], - # ) - self.add( "configurational_entropy", ConfigurationalEntropyNode(), - depends_on=["orientational_entropy"], + depends_on=["vibrational_entropy"], ) # self.add( - # "aggregate_entropy", - # EntropyAggregatorNode(), - # depends_on=["configurational_entropy"], + # "orientational_entropy", + # OrientationalEntropyNode(), + # depends_on=["vibrational_entropy"], # ) return self diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 20eda4ec..8d13ca33 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -53,18 +53,6 @@ def __init__( self._GAS_CONST = 8.3144598484848 def execute(self): - """ - Run the full entropy computation workflow using DAGs. - - Responsibilities: - 1. Prepare trajectory bounds - 2. Build reduced universe - 3. Prepare shared_data - 4. Execute LEVEL DAG (structure & physics) - 5. Execute ENTROPY DAG (thermodynamics) - 6. Finalize and log results - """ - start, end, step = self._get_trajectory_bounds() n_frames = self._get_number_frames(start, end, step) @@ -73,12 +61,12 @@ def execute(self): reduced_universe = self._get_reduced_universe() level_hierarchy = LevelHierarchy() - number_molecules, levels = level_hierarchy.select_levels(reduced_universe) groups = self._group_molecules.grouping_molecules( reduced_universe, self._args.grouping ) + logger.info(f"Number of molecule groups: {len(groups)}") water_atoms = self._universe.select_atoms("water") water_resids = {res.resid for res in water_atoms.residues} @@ -102,29 +90,30 @@ def execute(self): nonwater_groups.update(water_groups) shared_data = { + "entropy_manager": self, + "level_manager": self._level_manager, + "run_manager": self._run_manager, + "data_logger": self._data_logger, + "args": self._args, "universe": self._universe, "reduced_universe": reduced_universe, "levels": levels, "groups": nonwater_groups, - "args": self._args, "start": start, "end": end, "step": step, "n_frames": n_frames, - "data_logger": self._data_logger, - "run_manager": self._run_manager, } logger.info(f"shared_data: {shared_data}") - level_results = LevelDAG(self._universe_operations).build().execute(shared_data) - - shared_data.update(level_results) + LevelDAG(self._universe_operations).build().execute(shared_data) entropy_results = EntropyGraph().build().execute(shared_data) - shared_data.update(entropy_results) + logger.info(f"entropy_results: {entropy_results}") + self._finalize_molecule_results() self._data_logger.log_tables() diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 9fb9352f..50e1dce1 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -1,6 +1,5 @@ import logging - -import numpy as np +from typing import Any, Dict from CodeEntropy.entropy.configurational_entropy import ConformationalEntropy @@ -9,74 +8,81 @@ class ConfigurationalEntropyNode: """ - DAG node responsible for computing configurational entropy - from precomputed dihedral conformational states. + Computes conformational (configurational) entropy from conformational states. + + Requires: + shared_data["conformational_states"] = {"ua": ..., "res": ...} + shared_data["n_frames"] + shared_data["levels"], shared_data["groups"] """ - def __init__(self, data_logger): - self._ce = ConformationalEntropy() - self._data_logger = data_logger + def run(self, shared_data: Dict[str, Any], **_kwargs): + run_manager = shared_data["run_manager"] + args = shared_data["args"] + universe = shared_data["reduced_universe"] + data_logger = shared_data.get("data_logger") + + level_manager = shared_data.get("level_manager") + group_molecules = shared_data.get("group_molecules") - def run(self, shared_data, **_): levels = shared_data["levels"] groups = shared_data["groups"] - - states_ua = shared_data["conformational_states"]["ua"] - states_res = shared_data["conformational_states"]["res"] + number_frames = shared_data["n_frames"] + + if "conformational_states" in shared_data: + states_ua = shared_data["conformational_states"]["ua"] + states_res = shared_data["conformational_states"]["res"] + else: + states_ua = shared_data.get("states_united_atom", {}) + states_res = shared_data.get("states_residue", []) + + ce = ConformationalEntropy( + run_manager=run_manager, + args=args, + universe=universe, + data_logger=data_logger, + level_manager=level_manager, + group_molecules=group_molecules, + ) conf_results = {} - for group_id, mol_ids in groups.items(): - mol_index = mol_ids[0] - conf_results[group_id] = {} + for group_id, mol_indices in groups.items(): + group_total = 0.0 + mol_index = mol_indices[0] for level in levels[mol_index]: - if level == "united_atom": - S_conf = self._ua_entropy(group_id, states_ua) + group_total += self._ua_entropy( + ce, group_id, states_ua, number_frames + ) elif level == "residue": - S_conf = self._residue_entropy(group_id, states_res) - - else: - continue # polymer has no conformational entropy - - conf_results[group_id][level] = S_conf + group_total += self._residue_entropy( + ce, group_id, states_res, number_frames + ) - self._data_logger.add_results_data( - group_id, level, "Conformational", S_conf - ) + conf_results[group_id] = group_total - shared_data["configurational_entropy"] = conf_results + logger.info("[ConfigurationalEntropyNode] Done") return {"configurational_entropy": conf_results} - def _ua_entropy(self, group_id, states): - S_total = 0.0 + def _has_states(self, values): + return values is not None and len(values) > 0 - for key, values in states.items(): + def _ua_entropy(self, ce, group_id, states_ua, number_frames): + total = 0.0 + for key, values in states_ua.items(): if key[0] != group_id: continue - if self._has_states(values): - S_total += self._ce.conformational_entropy_calculation(values) - - return S_total + total += ce.conformational_entropy_calculation(values, number_frames) + return total - def _residue_entropy(self, group_id, states): - if group_id >= len(states): + def _residue_entropy(self, ce, group_id, states_res, number_frames): + if group_id >= len(states_res): return 0.0 - - values = states[group_id] - - if self._has_states(values): - return self._ce.conformational_entropy_calculation(values) - - return 0.0 - - @staticmethod - def _has_states(values): - if values is None: - return False - if isinstance(values, np.ndarray): - return np.any(values) - return any(values) + values = states_res[group_id] + if not self._has_states(values): + return 0.0 + return ce.conformational_entropy_calculation(values, number_frames) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 8e50d862..0a57eed3 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -1,6 +1,5 @@ import logging - -import numpy as np +from typing import Any, Dict from CodeEntropy.entropy.vibrational_entropy import VibrationalEntropy @@ -9,114 +8,59 @@ class VibrationalEntropyNode: """ - DAG node responsible for computing vibrational entropy - from precomputed force and torque covariance matrices. + Computes vibrational entropy from force/torque covariance matrices. + Expects Level DAG to have filled: + shared_data["force_covariances"], shared_data["torque_covariances"] """ - def __init__(self, run_manager, data_logger): - self._ve = VibrationalEntropy(run_manager) - self._data_logger = data_logger - - def run(self, shared_data, **_): - levels = shared_data["levels"] - groups = shared_data["groups"] + def run(self, shared_data: Dict[str, Any], **_kwargs): + run_manager = shared_data["run_manager"] args = shared_data["args"] + universe = shared_data["reduced_universe"] + data_logger = shared_data.get("data_logger") + + level_manager = shared_data.get("level_manager") + group_molecules = shared_data.get("group_molecules") + + ve = VibrationalEntropy( + run_manager=run_manager, + args=args, + universe=universe, + data_logger=data_logger, + level_manager=level_manager, + group_molecules=group_molecules, + ) - force_cov = shared_data["force_covariance"] - torque_cov = shared_data["torque_covariance"] - frame_counts = shared_data["frame_counts"] - - vibrational_results = {} - - for group_id, mol_ids in groups.items(): - mol_index = mol_ids[0] - vibrational_results[group_id] = {} - - for level in levels[mol_index]: - highest = level == levels[mol_index][-1] + temp = args.temperature + # levels = shared_data["levels"] + groups = shared_data["groups"] - if level == "united_atom": - S_trans, S_rot = self._ua_entropy( - group_id, - force_cov["ua"], - torque_cov["ua"], - frame_counts["ua"], - args.temperature, - highest, - ) + force_cov = shared_data["force_covariances"] + # torque_cov = shared_data["torque_covariances"] - else: - S_trans, S_rot = self._level_entropy( - group_id, - level, - force_cov[level][group_id], - torque_cov[level][group_id], - args.temperature, - highest, - ) + vib_results = {} - vibrational_results[group_id][level] = { - "trans": S_trans, - "rot": S_rot, - } + for group_id in groups.keys(): + vib_results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} - self._data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans + # UA is dict keyed by (group_id, res_id) + for (gid, _res_id), mat in force_cov["ua"].items(): + if gid != group_id: + continue + vib_results[group_id]["ua"] += ve.vibrational_entropy_calculation( + mat, "force", temp, highest_level=False ) - self._data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) - - shared_data["vibrational_entropy"] = vibrational_results - return {"vibrational_entropy": vibrational_results} - def _ua_entropy( - self, - group_id, - force_matrices, - torque_matrices, - frame_counts, - temperature, - highest, - ): - S_trans = 0.0 - S_rot = 0.0 - - for key, fmat in force_matrices.items(): - fmat = self._filter_matrix(fmat) - tmat = self._filter_matrix(torque_matrices[key]) - - S_trans += self._ve.vibrational_entropy_calculation( - fmat, "force", temperature, highest - ) - S_rot += self._ve.vibrational_entropy_calculation( - tmat, "torque", temperature, highest - ) - - return S_trans, S_rot - - def _level_entropy( - self, - group_id, - level, - force_matrix, - torque_matrix, - temperature, - highest, - ): - fmat = self._filter_matrix(force_matrix) - tmat = self._filter_matrix(torque_matrix) - - S_trans = self._ve.vibrational_entropy_calculation( - fmat, "force", temperature, highest - ) - S_rot = self._ve.vibrational_entropy_calculation( - tmat, "torque", temperature, highest - ) + # residue / polymer are list indexed by group_id + if force_cov["res"][group_id] is not None: + vib_results[group_id]["res"] += ve.vibrational_entropy_calculation( + force_cov["res"][group_id], "force", temp, highest_level=False + ) - return S_trans, S_rot + if force_cov["poly"][group_id] is not None: + vib_results[group_id]["poly"] += ve.vibrational_entropy_calculation( + force_cov["poly"][group_id], "force", temp, highest_level=True + ) - @staticmethod - def _filter_matrix(matrix): - mask = ~(np.all(matrix == 0, axis=0)) - return matrix[np.ix_(mask, mask)] + logger.info("[VibrationalEntropyNode] Done") + return {"vibrational_entropy": vib_results} diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index 4731dba0..91743a01 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -14,11 +14,15 @@ class VibrationalEntropy: def __init__( self, run_manager, args, universe, data_logger, level_manager, group_molecules ): - """ - Initializes the VibrationalEntropy manager with all required components and - defines physical constants used in vibrational entropy calculations. - """ + self._run_manager = run_manager + self._args = args + self._universe = universe + self._data_logger = data_logger + self._level_manager = level_manager + self._group_molecules = group_molecules + self._PLANCK_CONST = 6.62607004081818e-34 + self._GAS_CONST = 8.3144598484848 def frequency_calculation(self, lambdas, temp): """ diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py index 5b3f1158..89416ad0 100644 --- a/CodeEntropy/levels/force_torque_manager.py +++ b/CodeEntropy/levels/force_torque_manager.py @@ -9,18 +9,25 @@ TimeElapsedColumn, ) +from CodeEntropy.levels.level_hierarchy import LevelHierarchy +from CodeEntropy.levels.matrix_operations import MatrixOperations +from CodeEntropy.levels.mda_universe_operations import UniverseOperations + logger = logging.getLogger(__name__) class ForceTorqueManager: """ """ - def __init__(self): + def __init__(self, universe_operations=None): """ Initializes the ForceTorqueManager with placeholders for level-related data, including translational and rotational axes, number of beads, and a general-purpose data container. """ + self._universe_operations = universe_operations or UniverseOperations() + self._hierarchy = LevelHierarchy() + self._mat_ops = MatrixOperations() def get_weighted_forces( self, data_container, bead, trans_axes, highest_level, force_partitioning @@ -428,3 +435,75 @@ def update_force_torque_matrices( torque_avg[key][group_id] += (t_mat - torque_avg[key][group_id]) / n return frame_counts + + def get_matrices( + self, + data_container, + level, + highest_level, + force_matrix, + torque_matrix, + force_partitioning, + ): + """ + Build ONE-FRAME force/torque covariance matrices for the given level. + Returns matrices for the current frame only (no accumulation here). + """ + + # Build beads for this container + level + beads = self._hierarchy.get_beads(data_container, level) + n_beads = len(beads) + + # Compute weighted forces/torques per bead (current frame) + weighted_forces = [None] * n_beads + weighted_torques = [None] * n_beads + + # Translation axes (simple default — matches your recent refactor) + trans_axes = data_container.atoms.principal_axes() + + for i, bead in enumerate(beads): + # Rotation axes per bead + rot_axes = np.real(bead.principal_axes()) + + weighted_forces[i] = self.get_weighted_forces( + data_container=data_container, + bead=bead, + trans_axes=trans_axes, + highest_level=highest_level, + force_partitioning=force_partitioning, + ) + + weighted_torques[i] = self.get_weighted_torques( + data_container=data_container, + bead=bead, + rot_axes=rot_axes, + force_partitioning=force_partitioning, + ) + + # Build block covariance matrices (3x3 blocks) + f_blocks = [[None] * n_beads for _ in range(n_beads)] + t_blocks = [[None] * n_beads for _ in range(n_beads)] + + for i in range(n_beads): + for j in range(i, n_beads): + f_sub = self._mat_ops.create_submatrix( + weighted_forces[i], weighted_forces[j] + ) + t_sub = self._mat_ops.create_submatrix( + weighted_torques[i], weighted_torques[j] + ) + + f_blocks[i][j] = f_sub + f_blocks[j][i] = f_sub.T + + t_blocks[i][j] = t_sub + t_blocks[j][i] = t_sub.T + + force_block = np.block( + [[f_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)] + ) + torque_block = np.block( + [[t_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)] + ) + + return force_block, torque_block diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index ea34d1c1..5f26eb22 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,3 +1,5 @@ +# CodeEntropy/levels/hierarchy_graph.py + import logging import networkx as nx @@ -7,8 +9,6 @@ BuildCovarianceMatricesNode, ) from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode -from CodeEntropy.levels.nodes.compute_weighted_forces import ComputeWeightedForcesNode -from CodeEntropy.levels.nodes.compute_weighted_torques import ComputeWeightedTorquesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode @@ -17,43 +17,34 @@ class LevelDAG: """ - DAG for computing level-resolved structural quantities. - Uses shared_data as the single state container. + Level-processing DAG. + + IMPORTANT: + - This DAG is NOT "per frame". + - Covariances require averaging over frames, so the frame-loop stays inside + ForceTorqueManager.build_covariance_matrices(), preserving your original math. """ def __init__(self, universe_operations): self.graph = nx.DiGraph() self.nodes = {} - self._universe_operations = universe_operations + self._uops = universe_operations def build(self): self.add("detect_molecules", DetectMoleculesNode()) self.add("detect_levels", DetectLevelsNode(), ["detect_molecules"]) + self.add("build_beads", BuildBeadsNode(), ["detect_levels"]) self.add( - "compute_weighted_forces", - ComputeWeightedForcesNode(), - ["build_beads"], - ) - self.add( - "compute_weighted_torques", - ComputeWeightedTorquesNode(), - ["build_beads"], + "compute_conformational_states", + ComputeConformationalStatesNode(self._uops), + ["detect_levels"], ) self.add( "build_covariance", - BuildCovarianceMatricesNode(), - [ - "compute_weighted_forces", - "compute_weighted_torques", - ], - ) - - self.add( - "compute_conformational_states", - ComputeConformationalStatesNode(self._universe_operations), + BuildCovarianceMatricesNode(self._uops), ["detect_levels"], ) @@ -67,10 +58,8 @@ def add(self, name, obj, deps=None): self.graph.add_edge(d, name) def execute(self, shared_data): - """ - Execute DAG in topological order. - Nodes mutate shared_data in-place. - """ for node_name in nx.topological_sort(self.graph): - logger.info(f"Running node: {node_name}") + logger.info(f"[LevelDAG] Running node: {node_name}") self.nodes[node_name].run(shared_data) + + return shared_data diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py index a6876000..d31190c7 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -1,3 +1,5 @@ +# CodeEntropy/levels/nodes/build_beads.py + from CodeEntropy.levels.level_hierarchy import LevelHierarchy from CodeEntropy.levels.mda_universe_operations import UniverseOperations @@ -8,11 +10,10 @@ def __init__(self): self._mda = UniverseOperations() def run(self, shared_data): - u = shared_data["universe"] + u = shared_data["reduced_universe"] levels = shared_data["levels"] beads = {} - for mol_id, level_list in enumerate(levels): mol_u = self._mda.get_molecule_container(u, mol_id) for level in level_list: diff --git a/CodeEntropy/levels/nodes/build_covariance_matrices.py b/CodeEntropy/levels/nodes/build_covariance_matrices.py index b06d7bcd..c8b27dc8 100644 --- a/CodeEntropy/levels/nodes/build_covariance_matrices.py +++ b/CodeEntropy/levels/nodes/build_covariance_matrices.py @@ -1,18 +1,25 @@ +# CodeEntropy/levels/nodes/build_covariance_matrices.py + from CodeEntropy.levels.force_torque_manager import ForceTorqueManager class BuildCovarianceMatricesNode: - def __init__(self): - self._ft = ForceTorqueManager() + def __init__(self, universe_operations): + self._ft = ForceTorqueManager(universe_operations) def run(self, shared_data): - forces = shared_data["weighted_forces"] - torques = shared_data["weighted_torques"] - - force_cov, torque_cov, frame_counts = self._ft.build_covariance_matrices( - forces, torques + force_avg, torque_avg, frame_counts = self._ft.build_covariance_matrices( + entropy_manager=shared_data.get("entropy_manager"), + reduced_atom=shared_data["reduced_universe"], + levels=shared_data["levels"], + groups=shared_data["groups"], + start=shared_data["start"], + end=shared_data["end"], + step=shared_data["step"], + number_frames=shared_data["n_frames"], + force_partitioning=shared_data["args"].force_partitioning, ) - shared_data["force_covariances"] = force_cov - shared_data["torque_covariances"] = torque_cov + shared_data["force_covariances"] = force_avg + shared_data["torque_covariances"] = torque_avg shared_data["frame_counts"] = frame_counts diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py index 6b50581d..e0eb3886 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -1,12 +1,22 @@ +import logging +from typing import Any, Dict + from CodeEntropy.levels.dihedral_tools import DihedralAnalysis +logger = logging.getLogger(__name__) + class ComputeConformationalStatesNode: + """ + Builds conformational state descriptors (UA + residue) from dihedral angles, + and stores them into shared_data using a stable contract. + """ + def __init__(self, universe_operations): self._dih = DihedralAnalysis(universe_operations) - def run(self, shared_data): - u = shared_data["universe"] + def run(self, shared_data: Dict[str, Any]): + u = shared_data["reduced_universe"] levels = shared_data["levels"] groups = shared_data["groups"] @@ -15,6 +25,8 @@ def run(self, shared_data): step = shared_data["step"] bin_width = shared_data["args"].bin_width + logger.info("[ComputeConformationalStatesNode] Building conformational states") + states_ua, states_res = self._dih.build_conformational_states( data_container=u, levels=levels, @@ -25,10 +37,10 @@ def run(self, shared_data): bin_width=bin_width, ) + shared_data["conformational_states"] = { + "ua": states_ua, + "res": states_res, + } + shared_data["states_united_atom"] = states_ua shared_data["states_residue"] = states_res - - return { - "states_united_atom": states_ua, - "states_residue": states_res, - } diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index ee74d49e..d87d3fb6 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -1,3 +1,5 @@ +# CodeEntropy/levels/nodes/detect_levels.py + from CodeEntropy.levels.level_hierarchy import LevelHierarchy @@ -6,8 +8,7 @@ def __init__(self): self._hier = LevelHierarchy() def run(self, shared_data): - u = shared_data["universe"] - + u = shared_data["reduced_universe"] num_mol, levels = self._hier.select_levels(u) shared_data["number_molecules"] = num_mol diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index fec1ec47..4f099ec1 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -1,3 +1,5 @@ +# CodeEntropy/levels/nodes/detect_molecules.py + import logging logger = logging.getLogger(__name__) @@ -5,8 +7,7 @@ class DetectMoleculesNode: def run(self, shared_data): - u = shared_data["universe"] - + u = shared_data["reduced_universe"] fragments = u.atoms.fragments num_mol = len(fragments) From a58c427a48db6b2cabda1c95b7efd94e4d18eed0 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 30 Jan 2026 09:44:44 +0000 Subject: [PATCH 034/101] remove all reference of `LevelManager` --- CodeEntropy/config/run.py | 5 - .../entropy/configurational_entropy.py | 5 +- CodeEntropy/entropy/entropy_manager.py | 8 +- .../nodes/configurational_entropy_node.py | 2 - .../entropy/nodes/vibrational_entropy_node.py | 2 - CodeEntropy/entropy/orientational_entropy.py | 4 +- CodeEntropy/entropy/vibrational_entropy.py | 5 +- CodeEntropy/levels/force_torque_manager.py | 506 +++--------------- CodeEntropy/levels/hierarchy_graph.py | 112 ++-- CodeEntropy/levels/level_manager.py | 52 -- CodeEntropy/levels/matrix_operations.py | 111 ---- CodeEntropy/levels/nodes/build_beads.py | 39 +- .../levels/nodes/build_conformations.py | 10 - .../levels/nodes/build_covariance_matrices.py | 25 - CodeEntropy/levels/nodes/compute_dihedrals.py | 26 +- .../levels/nodes/compute_weighted_forces.py | 21 - .../levels/nodes/compute_weighted_torques.py | 21 - CodeEntropy/levels/nodes/detect_levels.py | 8 +- CodeEntropy/levels/nodes/detect_molecules.py | 16 +- CodeEntropy/levels/nodes/frame_covariance.py | 111 ++++ .../nodes/init_covariance_accumulators.py | 36 ++ 21 files changed, 351 insertions(+), 774 deletions(-) delete mode 100644 CodeEntropy/levels/level_manager.py delete mode 100644 CodeEntropy/levels/nodes/build_conformations.py delete mode 100644 CodeEntropy/levels/nodes/build_covariance_matrices.py delete mode 100644 CodeEntropy/levels/nodes/compute_weighted_forces.py delete mode 100644 CodeEntropy/levels/nodes/compute_weighted_torques.py create mode 100644 CodeEntropy/levels/nodes/frame_covariance.py create mode 100644 CodeEntropy/levels/nodes/init_covariance_accumulators.py diff --git a/CodeEntropy/config/run.py b/CodeEntropy/config/run.py index 9d7d97bd..8df7d14c 100644 --- a/CodeEntropy/config/run.py +++ b/CodeEntropy/config/run.py @@ -20,7 +20,6 @@ from CodeEntropy.entropy.entropy_manager import EntropyManager from CodeEntropy.group_molecules.group_molecules import GroupMolecules from CodeEntropy.levels.dihedral_tools import DihedralAnalysis -from CodeEntropy.levels.level_manager import LevelManager from CodeEntropy.levels.mda_universe_operations import UniverseOperations logger = logging.getLogger(__name__) @@ -264,9 +263,6 @@ def run_entropy_workflow(self): self._config_manager.input_parameters_validation(u, args) - # Create LevelManager instance - level_manager = LevelManager(universe_operations) - # Create GroupMolecules instance group_molecules = GroupMolecules() @@ -281,7 +277,6 @@ def run_entropy_workflow(self): args=args, universe=u, data_logger=self._data_logger, - level_manager=level_manager, group_molecules=group_molecules, dihedral_analysis=dihedral_analysis, universe_operations=universe_operations, diff --git a/CodeEntropy/entropy/configurational_entropy.py b/CodeEntropy/entropy/configurational_entropy.py index a976ff6e..fbb2d926 100644 --- a/CodeEntropy/entropy/configurational_entropy.py +++ b/CodeEntropy/entropy/configurational_entropy.py @@ -6,14 +6,11 @@ class ConformationalEntropy: - def __init__( - self, run_manager, args, universe, data_logger, level_manager, group_molecules - ): + def __init__(self, run_manager, args, universe, data_logger, group_molecules): self._run_manager = run_manager self._args = args self._universe = universe self._data_logger = data_logger - self._level_manager = level_manager self._group_molecules = group_molecules self._GAS_CONST = 8.3144598484848 diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 8d13ca33..7592314a 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -25,7 +25,6 @@ def __init__( args, universe, data_logger, - level_manager, group_molecules, dihedral_analysis, universe_operations, @@ -38,7 +37,6 @@ def __init__( args: Argument namespace containing user parameters. universe: MDAnalysis universe representing the simulation system. data_logger: Logger for storing and exporting entropy data. - level_manager: Provides level-specific data such as matrices and dihedrals. group_molecules: includes the grouping functions for averaging over molecules. """ @@ -46,7 +44,6 @@ def __init__( self._args = args self._universe = universe self._data_logger = data_logger - self._level_manager = level_manager self._group_molecules = group_molecules self._dihedral_analysis = dihedral_analysis self._universe_operations = universe_operations @@ -91,7 +88,6 @@ def execute(self): shared_data = { "entropy_manager": self, - "level_manager": self._level_manager, "run_manager": self._run_manager, "data_logger": self._data_logger, "args": self._args, @@ -164,8 +160,10 @@ def _initialize_molecules(self): # Based on the selection string, create a new MDAnalysis universe reduced_atom = self._get_reduced_universe() + level_hierarchy = LevelHierarchy() + # Count the molecules and identify the length scale levels for each one - number_molecules, levels = self._level_manager.select_levels(reduced_atom) + number_molecules, levels = level_hierarchy.select_levels(reduced_atom) # Group the molecules for averaging grouping = self._args.grouping diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 50e1dce1..1ec3bac0 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -22,7 +22,6 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): universe = shared_data["reduced_universe"] data_logger = shared_data.get("data_logger") - level_manager = shared_data.get("level_manager") group_molecules = shared_data.get("group_molecules") levels = shared_data["levels"] @@ -41,7 +40,6 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): args=args, universe=universe, data_logger=data_logger, - level_manager=level_manager, group_molecules=group_molecules, ) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 0a57eed3..4069878b 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -19,7 +19,6 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): universe = shared_data["reduced_universe"] data_logger = shared_data.get("data_logger") - level_manager = shared_data.get("level_manager") group_molecules = shared_data.get("group_molecules") ve = VibrationalEntropy( @@ -27,7 +26,6 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): args=args, universe=universe, data_logger=data_logger, - level_manager=level_manager, group_molecules=group_molecules, ) diff --git a/CodeEntropy/entropy/orientational_entropy.py b/CodeEntropy/entropy/orientational_entropy.py index 2d8d20c2..74a6e425 100644 --- a/CodeEntropy/entropy/orientational_entropy.py +++ b/CodeEntropy/entropy/orientational_entropy.py @@ -11,9 +11,7 @@ class OrientationalEntropy: Performs orientational entropy calculations using molecular dynamics data. """ - def __init__( - self, run_manager, args, universe, data_logger, level_manager, group_molecules - ): + def __init__(self, run_manager, args, universe, data_logger, group_molecules): """ Initializes the OrientationalEntropy manager with all required components and sets the gas constant used in orientational entropy calculations. diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index 91743a01..1968e2c3 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -11,14 +11,11 @@ class VibrationalEntropy: Performs vibrational entropy calculations using molecular trajectory data. """ - def __init__( - self, run_manager, args, universe, data_logger, level_manager, group_molecules - ): + def __init__(self, run_manager, args, universe, data_logger, group_molecules): self._run_manager = run_manager self._args = args self._universe = universe self._data_logger = data_logger - self._level_manager = level_manager self._group_molecules = group_molecules self._PLANCK_CONST = 6.62607004081818e-34 diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py index 89416ad0..69e93e22 100644 --- a/CodeEntropy/levels/force_torque_manager.py +++ b/CodeEntropy/levels/force_torque_manager.py @@ -1,509 +1,145 @@ +# CodeEntropy/levels/force_torque_manager.py + import logging +from typing import List, Tuple import numpy as np -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) -from CodeEntropy.levels.level_hierarchy import LevelHierarchy from CodeEntropy.levels.matrix_operations import MatrixOperations -from CodeEntropy.levels.mda_universe_operations import UniverseOperations logger = logging.getLogger(__name__) class ForceTorqueManager: - """ """ - - def __init__(self, universe_operations=None): - """ - Initializes the ForceTorqueManager with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ - self._universe_operations = universe_operations or UniverseOperations() - self._hierarchy = LevelHierarchy() - self._mat_ops = MatrixOperations() - - def get_weighted_forces( - self, data_container, bead, trans_axes, highest_level, force_partitioning - ): - """ - Compute mass-weighted translational forces for a bead. - - The forces acting on all atoms belonging to the bead are first transformed - into the provided translational reference frame and summed. If this bead - corresponds to the highest level of a hierarchical coarse-graining scheme, - the total force is scaled by a force-partitioning factor to avoid double - counting forces from weakly correlated atoms. - - The resulting force vector is then normalized by the square root of the - bead's total mass. - - Parameters - ---------- - data_container : MDAnalysis.Universe - Container holding atomic positions and forces. - bead : object - Molecular subunit whose atoms contribute to the force. - trans_axes : np.ndarray - Transformation matrix defining the translational reference frame. - highest_level : bool - Whether this bead is the highest level in the length-scale hierarchy. - If True, force partitioning is applied. - force_partitioning : float - Scaling factor applied to forces to avoid over-counting correlated - contributions (typically 0.5). + """ + Frame-local force/torque -> covariance builder. - Returns - ------- - weighted_force : np.ndarray - Mass-weighted translational force acting on the bead. + """ - Raises - ------ - ValueError - If the bead mass is zero or negative. - """ - forces_trans = np.zeros((3,)) + def __init__(self): + self._mops = MatrixOperations() + def get_weighted_forces( + self, + bead, + trans_axes: np.ndarray, + highest_level: bool, + force_partitioning: float, + ) -> np.ndarray: + forces_trans = np.zeros((3,), dtype=float) + + # atom.force is in the current Universe timestep already for atom in bead.atoms: - forces_local = np.matmul(trans_axes, data_container.atoms[atom.index].force) + forces_local = np.matmul(trans_axes, atom.force) forces_trans += forces_local if highest_level: forces_trans = force_partitioning * forces_trans mass = bead.total_mass() - if mass <= 0: - raise ValueError( - f"Invalid mass value: {mass}. Mass must be positive to compute the " - f"square root." - ) - - weighted_force = forces_trans / np.sqrt(mass) - - logger.debug(f"Weighted Force: {weighted_force}") - - return weighted_force - - def get_weighted_torques(self, data_container, bead, rot_axes, force_partitioning): - """ - Compute moment-of-inertia weighted torques for a bead. + raise ValueError(f"Invalid bead mass {mass}; cannot weight force.") - Atomic coordinates and forces are transformed into the provided rotational - reference frame. Torques are computed as the cross product of position - vectors (relative to the bead center of mass) and forces, with a - force-partitioning factor applied to reduce over-counting of correlated - atomic contributions. + return forces_trans / np.sqrt(mass) - The total torque vector is then weighted by the square root of the bead's - principal moments of inertia. Weighting is performed component-wise using - the sorted eigenvalues of the moment of inertia tensor. - - To ensure numerical stability: - - Torque components that are effectively zero are skipped. - - Zero moments of inertia result in zero weighted torque with a warning. - - Negative moments of inertia raise an error. - - Parameters - ---------- - data_container : object - Container holding atomic positions and forces. - bead : object - Molecular subunit whose atoms contribute to the torque. - rot_axes : np.ndarray - Transformation matrix defining the rotational reference frame. - force_partitioning : float - Scaling factor applied to forces to avoid over-counting correlated - contributions (typically 0.5). - - Returns - ------- - weighted_torque : np.ndarray - Moment-of-inertia weighted torque acting on the bead. - - Raises - ------ - ValueError - If a negative principal moment of inertia is encountered. - """ - torques = np.zeros((3,)) - weighted_torque = np.zeros((3,)) - moment_of_inertia = np.zeros(3) + def get_weighted_torques( + self, + bead, + rot_axes: np.ndarray, + force_partitioning: float, + ) -> np.ndarray: + torques = np.zeros((3,), dtype=float) + com = bead.center_of_mass() for atom in bead.atoms: - coords_rot = ( - data_container.atoms[atom.index].position - bead.center_of_mass() - ) - coords_rot = np.matmul(rot_axes, coords_rot) - forces_rot = np.matmul(rot_axes, data_container.atoms[atom.index].force) + r = atom.position - com + r = np.matmul(rot_axes, r) - forces_rot = force_partitioning * forces_rot + f = np.matmul(rot_axes, atom.force) + f = force_partitioning * f - torques_local = np.cross(coords_rot, forces_rot) - torques += torques_local + torques += np.cross(r, f) - eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) - moments_of_inertia = sorted(eigenvalues, reverse=True) + # MOI weighting + eigvals, _ = np.linalg.eig(bead.moment_of_inertia()) + moi = sorted(np.real(eigvals), reverse=True) - for dimension in range(3): - if np.isclose(torques[dimension], 0): - weighted_torque[dimension] = 0 + weighted = np.zeros((3,), dtype=float) + for k in range(3): + if np.isclose(torques[k], 0.0): + weighted[k] = 0.0 continue - - if np.isclose(moments_of_inertia[dimension], 0): - weighted_torque[dimension] = 0 - logger.warning("Zero moment of inertia. Setting torque to 0") + if np.isclose(moi[k], 0.0): + weighted[k] = 0.0 + logger.warning( + "Zero principal moment of inertia; setting torque component to 0." + ) continue - - if moments_of_inertia[dimension] < 0: + if moi[k] < 0: raise ValueError( - f"Negative value encountered for moment of inertia: " - f"{moment_of_inertia[dimension]} " - f"Cannot compute weighted torque." + f"Negative principal moment of inertia encountered: {moi[k]}" ) + weighted[k] = torques[k] / np.sqrt(moi[k]) - weighted_torque[dimension] = torques[dimension] / np.sqrt( - moments_of_inertia[dimension] - ) - - logger.debug(f"Weighted Torque: {weighted_torque}") - - return weighted_torque + return weighted - def build_covariance_matrices( + def compute_frame_covariance( self, - entropy_manager, - reduced_atom, - levels, - groups, - start, - end, - step, - number_frames, - force_partitioning, - ): + beads: List, + trans_axes: np.ndarray, + highest_level: bool, + force_partitioning: float, + ) -> Tuple[np.ndarray, np.ndarray]: """ - Construct average force and torque covariance matrices for all molecules and - entropy levels. - - Parameters - ---------- - entropy_manager : EntropyManager - Instance of the EntropyManager. - reduced_atom : Universe - The reduced atom selection. - levels : dict - Dictionary mapping molecule IDs to lists of entropy levels. - groups : dict - Dictionary mapping group IDs to lists of molecule IDs. - start : int - Start frame index. - end : int - End frame index. - step : int - Step size for frame iteration. - number_frames : int - Total number of frames to process. - force_partitioning : float - Factor to adjust force contributions, default is 0.5. - - - Returns - ------- - tuple - force_avg : dict - Averaged force covariance matrices by entropy level. - torque_avg : dict - Averaged torque covariance matrices by entropy level. + Compute full block covariance matrices for this frame: + - force covariance (3N x 3N) + - torque covariance (3N x 3N) """ - number_groups = len(groups) - - force_avg = { - "ua": {}, - "res": [None] * number_groups, - "poly": [None] * number_groups, - } - torque_avg = { - "ua": {}, - "res": [None] * number_groups, - "poly": [None] * number_groups, - } - - total_steps = len(reduced_atom.trajectory[start:end:step]) - total_items = ( - sum(len(levels[mol_id]) for mols in groups.values() for mol_id in mols) - * total_steps - ) - - frame_counts = { - "ua": {}, - "res": np.zeros(number_groups, dtype=int), - "poly": np.zeros(number_groups, dtype=int), - } - - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) as progress: - - task = progress.add_task( - "[green]Processing...", - total=total_items, - title="Starting...", - ) - - indices = list(range(number_frames)) - for time_index, _ in zip(indices, reduced_atom.trajectory[start:end:step]): - for group_id, molecules in groups.items(): - for mol_id in molecules: - mol = self._universe_operations.get_molecule_container( - reduced_atom, mol_id - ) - for level in levels[mol_id]: - resname = mol.atoms[0].resname - resid = mol.atoms[0].resid - segid = mol.atoms[0].segid - - mol_label = f"{resname}_{resid} (segid {segid})" - - progress.update( - task, - title=f"Building covariance matrices | " - f"Timestep {time_index} | " - f"Molecule: {mol_label} | " - f"Level: {level}", - ) - - self.update_force_torque_matrices( - entropy_manager, - mol, - group_id, - level, - levels[mol_id], - time_index, - number_frames, - force_avg, - torque_avg, - frame_counts, - force_partitioning, - ) - - progress.advance(task) - - return force_avg, torque_avg, frame_counts - - def update_force_torque_matrices( - self, - entropy_manager, - mol, - group_id, - level, - level_list, - time_index, - num_frames, - force_avg, - torque_avg, - frame_counts, - force_partitioning, - ): - """ - Update the running averages of force and torque covariance matrices - for a given molecule and entropy level. - - This function computes the force and torque covariance matrices for the - current frame and updates the existing averages in-place using the incremental - mean formula: - - new_avg = old_avg + (value - old_avg) / n - - where n is the number of frames processed so far for that molecule/level - combination. This ensures that the averages are maintained without storing - all previous frame data. - - Parameters - ---------- - entropy_manager : EntropyManager - Instance of the EntropyManager. - mol : AtomGroup - The molecule to process. - group_id : int - Index of the group to which the molecule belongs. - level : str - Current entropy level ("united_atom", "residue", or "polymer"). - level_list : list - List of entropy levels for the molecule. - time_index : int - Index of the current frame relative to the start of the trajectory slice. - num_frames : int - Total number of frames to process. - force_avg : dict - Dictionary holding the running average force matrices, keyed by entropy - level. - torque_avg : dict - Dictionary holding the running average torque matrices, keyed by entropy - level. - frame_counts : dict - Dictionary holding the count of frames processed for each molecule/level - combination. - force_partitioning : float - Factor to adjust force contributions, default is 0.5. - Returns - ------- - None - Updates are performed in-place on `force_avg`, `torque_avg`, and - `frame_counts`. - """ - highest = level == level_list[-1] - - # United atom level calculations are done separately for each residue - # This allows information per residue to be output and keeps the - # matrices from becoming too large - if level == "united_atom": - for res_id, residue in enumerate(mol.residues): - key = (group_id, res_id) - res = self._universe_operations.new_U_select_atom( - mol, f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}" - ) - - # This is to get MDAnalysis to get the information from the - # correct frame of the trajectory - res.trajectory[time_index] - - # Build the matrices, adding data from each timestep - # Being careful for the first timestep when data has not yet - # been added to the matrices - f_mat, t_mat = self.get_matrices( - res, - level, - highest, - None if key not in force_avg["ua"] else force_avg["ua"][key], - None if key not in torque_avg["ua"] else torque_avg["ua"][key], - force_partitioning, - ) - - if key not in force_avg["ua"]: - force_avg["ua"][key] = f_mat.copy() - torque_avg["ua"][key] = t_mat.copy() - frame_counts["ua"][key] = 1 - else: - frame_counts["ua"][key] += 1 - n = frame_counts["ua"][key] - force_avg["ua"][key] += (f_mat - force_avg["ua"][key]) / n - torque_avg["ua"][key] += (t_mat - torque_avg["ua"][key]) / n - - elif level in ["residue", "polymer"]: - # This is to get MDAnalysis to get the information from the - # correct frame of the trajectory - mol.trajectory[time_index] - - key = "res" if level == "residue" else "poly" - - # Build the matrices, adding data from each timestep - # Being careful for the first timestep when data has not yet - # been added to the matrices - f_mat, t_mat = self.get_matrices( - mol, - level, - highest, - None if force_avg[key][group_id] is None else force_avg[key][group_id], - ( - None - if torque_avg[key][group_id] is None - else torque_avg[key][group_id] - ), - force_partitioning, - ) - - if force_avg[key][group_id] is None: - force_avg[key][group_id] = f_mat.copy() - torque_avg[key][group_id] = t_mat.copy() - frame_counts[key][group_id] = 1 - else: - frame_counts[key][group_id] += 1 - n = frame_counts[key][group_id] - force_avg[key][group_id] += (f_mat - force_avg[key][group_id]) / n - torque_avg[key][group_id] += (t_mat - torque_avg[key][group_id]) / n - - return frame_counts - - def get_matrices( - self, - data_container, - level, - highest_level, - force_matrix, - torque_matrix, - force_partitioning, - ): - """ - Build ONE-FRAME force/torque covariance matrices for the given level. - Returns matrices for the current frame only (no accumulation here). - """ - - # Build beads for this container + level - beads = self._hierarchy.get_beads(data_container, level) n_beads = len(beads) + if n_beads == 0: + return np.zeros((0, 0)), np.zeros((0, 0)) - # Compute weighted forces/torques per bead (current frame) weighted_forces = [None] * n_beads weighted_torques = [None] * n_beads - # Translation axes (simple default — matches your recent refactor) - trans_axes = data_container.atoms.principal_axes() - for i, bead in enumerate(beads): - # Rotation axes per bead + if len(bead.atoms) == 0: + raise ValueError("AtomGroup is empty (bead has 0 atoms).") + + # rotation axes per bead (principal axes) rot_axes = np.real(bead.principal_axes()) weighted_forces[i] = self.get_weighted_forces( - data_container=data_container, bead=bead, trans_axes=trans_axes, highest_level=highest_level, force_partitioning=force_partitioning, ) - weighted_torques[i] = self.get_weighted_torques( - data_container=data_container, bead=bead, rot_axes=rot_axes, force_partitioning=force_partitioning, ) - # Build block covariance matrices (3x3 blocks) + # build block matrices f_blocks = [[None] * n_beads for _ in range(n_beads)] t_blocks = [[None] * n_beads for _ in range(n_beads)] for i in range(n_beads): for j in range(i, n_beads): - f_sub = self._mat_ops.create_submatrix( + f_sub = self._mops.create_submatrix( weighted_forces[i], weighted_forces[j] ) - t_sub = self._mat_ops.create_submatrix( + t_sub = self._mops.create_submatrix( weighted_torques[i], weighted_torques[j] ) f_blocks[i][j] = f_sub f_blocks[j][i] = f_sub.T - t_blocks[i][j] = t_sub t_blocks[j][i] = t_sub.T - force_block = np.block( - [[f_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)] - ) - torque_block = np.block( - [[t_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)] - ) + F = np.block([[f_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)]) + T = np.block([[t_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)]) - return force_block, torque_block + return F, T diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 5f26eb22..bcf09715 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,65 +1,99 @@ -# CodeEntropy/levels/hierarchy_graph.py - import logging +from typing import Any, Dict, Optional import networkx as nx from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode -from CodeEntropy.levels.nodes.build_covariance_matrices import ( - BuildCovarianceMatricesNode, -) from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode +from CodeEntropy.levels.nodes.frame_covariance import FrameCovarianceNode +from CodeEntropy.levels.nodes.init_covariance_accumulators import ( + InitCovarianceAccumulatorsNode, +) logger = logging.getLogger(__name__) class LevelDAG: """ - Level-processing DAG. + Baseline two-stage DAG that matches original procedural behavior. - IMPORTANT: - - This DAG is NOT "per frame". - - Covariances require averaging over frames, so the frame-loop stays inside - ForceTorqueManager.build_covariance_matrices(), preserving your original math. + Stage 1 (static): detect molecules, levels, build beads, init accumulators, + conformational states + Stage 2 (frame loop): for each frame, update running covariance means + (forces/torques) """ - def __init__(self, universe_operations): - self.graph = nx.DiGraph() - self.nodes = {} - self._uops = universe_operations + def __init__(self, universe_operations=None): + self._universe_operations = universe_operations - def build(self): - self.add("detect_molecules", DetectMoleculesNode()) - self.add("detect_levels", DetectLevelsNode(), ["detect_molecules"]) + self.static_graph = nx.DiGraph() + self.static_nodes: Dict[str, Any] = {} - self.add("build_beads", BuildBeadsNode(), ["detect_levels"]) + self.frame_graph = nx.DiGraph() + self.frame_nodes: Dict[str, Any] = {} - self.add( - "compute_conformational_states", - ComputeConformationalStatesNode(self._uops), - ["detect_levels"], - ) + def build(self) -> "LevelDAG": + # ---- static ---- + self._add_static("detect_molecules", DetectMoleculesNode()) + self._add_static("detect_levels", DetectLevelsNode(), deps=["detect_molecules"]) + self._add_static("build_beads", BuildBeadsNode(), deps=["detect_levels"]) - self.add( - "build_covariance", - BuildCovarianceMatricesNode(self._uops), - ["detect_levels"], + self._add_static( + "init_covariance", InitCovarianceAccumulatorsNode(), deps=["detect_levels"] ) - return self + # conformational states (trajectory scan inside) + self._add_static( + "compute_conformational_states", + ComputeConformationalStatesNode(self._universe_operations), + deps=["detect_levels"], + ) - def add(self, name, obj, deps=None): - self.nodes[name] = obj - self.graph.add_node(name) - if deps: - for d in deps: - self.graph.add_edge(d, name) + # ---- per-frame ---- + self._add_frame("frame_covariance", FrameCovarianceNode()) - def execute(self, shared_data): - for node_name in nx.topological_sort(self.graph): - logger.info(f"[LevelDAG] Running node: {node_name}") - self.nodes[node_name].run(shared_data) + return self - return shared_data + def _add_static( + self, name: str, node: Any, deps: Optional[list[str]] = None + ) -> None: + self.static_nodes[name] = node + self.static_graph.add_node(name) + for d in deps or []: + self.static_graph.add_edge(d, name) + + def _add_frame( + self, name: str, node: Any, deps: Optional[list[str]] = None + ) -> None: + self.frame_nodes[name] = node + self.frame_graph.add_node(name) + for d in deps or []: + self.frame_graph.add_edge(d, name) + + def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: + # ---- run static stage ---- + for node_name in nx.topological_sort(self.static_graph): + logger.info(f"[LevelDAG] Running static node: {node_name}") + self.static_nodes[node_name].run(shared_data) + + # ---- frame loop (ONLY place trajectory advances) ---- + u = shared_data["reduced_universe"] + start, end, step = shared_data["start"], shared_data["end"], shared_data["step"] + + for ts in u.trajectory[start:end:step]: + shared_data["frame_index"] = ts.frame # informational/debug only + + for node_name in nx.topological_sort(self.frame_graph): + self.frame_nodes[node_name].run(shared_data) + + # outputs already accumulated in shared_data by FrameCovarianceNode + return { + "levels": shared_data["levels"], + "beads": shared_data["beads"], + "force_covariances": shared_data["force_covariances"], + "torque_covariances": shared_data["torque_covariances"], + "frame_counts": shared_data["frame_counts"], + "conformational_states": shared_data["conformational_states"], + } diff --git a/CodeEntropy/levels/level_manager.py b/CodeEntropy/levels/level_manager.py deleted file mode 100644 index dfb5f94e..00000000 --- a/CodeEntropy/levels/level_manager.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -from typing import Any, Dict - -from CodeEntropy.levels.hierarchy_graph import LevelDAG - -logger = logging.getLogger(__name__) - - -class LevelManager: - """ - High-level coordinator that runs the Level DAG and returns results required - later for entropy nodes. - - Output from this class is forwarded into EntropyGraph. - """ - - def __init__(self, run_manager=None): - self.run_manager = run_manager - self.level_results = None - - def run(self, universe) -> Dict[str, Any]: - """ - Execute the level-processing DAG and return all structural results. - - Input: - universe (MDAnalysis.Universe) - - Output dictionary feeds forward into the entropy pipeline. - """ - dag = LevelDAG().build() - - shared_data = {"universe": universe, "run_manager": self.run_manager} - - results = dag.execute(shared_data) - - self.level_results = { - "levels": results["detect_levels"]["levels"], - "molecule_count": results["detect_molecules"]["molecule_count"], - "beads": results["build_beads"]["beads_by_mol_level"], - "axes": results["compute_axes"]["axes"], - "forces": results["compute_weighted_forces"]["forces"], - "torques": results["compute_weighted_torques"]["torques"], - "cov_matrices": results["build_covariance"]["covariance"], - "dihedrals": results["compute_dihedrials"]["dihedrals"], - "conformations": results["build_conformations"]["states"], - "neighbours": results["compute_neighbours"]["neighbours"], - } - - return self.level_results - - def get(self, key): - return self.level_results.get(key) diff --git a/CodeEntropy/levels/matrix_operations.py b/CodeEntropy/levels/matrix_operations.py index 7c463c98..c6c3b0f6 100644 --- a/CodeEntropy/levels/matrix_operations.py +++ b/CodeEntropy/levels/matrix_operations.py @@ -88,114 +88,3 @@ def filter_zero_rows_columns(self, arg_matrix): logger.debug(f"arg_matrix: {arg_matrix}") return arg_matrix - - def get_matrices( - self, - data_container, - level, - highest_level, - force_matrix, - torque_matrix, - force_partitioning, - ): - """ - Compute and accumulate force/torque covariance matrices for a given level. - - Parameters: - data_container (MDAnalysis.Universe): Data for a molecule or residue. - level (str): 'polymer', 'residue', or 'united_atom'. - highest_level (bool): Whether this is the top (largest bead size) level. - force_matrix, torque_matrix (np.ndarray or None): Accumulated matrices to add - to. - force_partitioning (float): Factor to adjust force contributions, - default is 0.5. - - Returns: - force_matrix (np.ndarray): Accumulated force covariance matrix. - torque_matrix (np.ndarray): Accumulated torque covariance matrix. - """ - - # Make beads - list_of_beads = self.get_beads(data_container, level) - - # number of beads and frames in trajectory - number_beads = len(list_of_beads) - - # initialize force and torque arrays - weighted_forces = [None for _ in range(number_beads)] - weighted_torques = [None for _ in range(number_beads)] - - # Calculate forces/torques for each bead - for bead_index in range(number_beads): - bead = list_of_beads[bead_index] - # Set up axes - # translation and rotation use different axes - # how the axes are defined depends on the level - trans_axes = data_container.atoms.principal_axes() - rot_axes = np.real(bead.principal_axes()) - - # Sort out coordinates, forces, and torques for each atom in the bead - weighted_forces[bead_index] = self.get_weighted_forces( - data_container, - bead, - trans_axes, - highest_level, - force_partitioning, - ) - weighted_torques[bead_index] = self.get_weighted_torques( - data_container, bead, rot_axes, force_partitioning - ) - - # Create covariance submatrices - force_submatrix = [ - [0 for _ in range(number_beads)] for _ in range(number_beads) - ] - torque_submatrix = [ - [0 for _ in range(number_beads)] for _ in range(number_beads) - ] - - for i in range(number_beads): - for j in range(i, number_beads): - f_sub = self.create_submatrix(weighted_forces[i], weighted_forces[j]) - t_sub = self.create_submatrix(weighted_torques[i], weighted_torques[j]) - force_submatrix[i][j] = f_sub - force_submatrix[j][i] = f_sub.T - torque_submatrix[i][j] = t_sub - torque_submatrix[j][i] = t_sub.T - - # Convert block matrices to full matrix - force_block = np.block( - [ - [force_submatrix[i][j] for j in range(number_beads)] - for i in range(number_beads) - ] - ) - torque_block = np.block( - [ - [torque_submatrix[i][j] for j in range(number_beads)] - for i in range(number_beads) - ] - ) - - # Enforce consistent shape before accumulation - if force_matrix is None: - force_matrix = np.zeros_like(force_block) - elif force_matrix.shape != force_block.shape: - raise ValueError( - f"Inconsistent force matrix shape: existing " - f"{force_matrix.shape}, new {force_block.shape}" - ) - else: - force_matrix = force_block - - if torque_matrix is None: - torque_matrix = np.zeros_like(torque_block) - elif torque_matrix.shape != torque_block.shape: - raise ValueError( - f"Inconsistent torque matrix shape: existing " - f"{torque_matrix.shape}, new {torque_block.shape}" - ) - else: - torque_matrix = torque_block - - return force_matrix, torque_matrix diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py index d31190c7..492ac6f8 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -1,22 +1,47 @@ -# CodeEntropy/levels/nodes/build_beads.py - from CodeEntropy.levels.level_hierarchy import LevelHierarchy -from CodeEntropy.levels.mda_universe_operations import UniverseOperations class BuildBeadsNode: + """ + Build bead definitions ONCE, in reduced_universe index space. + + shared_data["beads"] dict keys: + (mol_id, "united_atom", res_id) -> list[np.ndarray] + (mol_id, "residue") -> list[np.ndarray] + (mol_id, "polymer") -> list[np.ndarray] + """ + def __init__(self): self._hier = LevelHierarchy() - self._mda = UniverseOperations() def run(self, shared_data): u = shared_data["reduced_universe"] levels = shared_data["levels"] beads = {} + fragments = u.atoms.fragments + for mol_id, level_list in enumerate(levels): - mol_u = self._mda.get_molecule_container(u, mol_id) - for level in level_list: - beads[(mol_id, level)] = self._hier.get_beads(mol_u, level) + mol = fragments[mol_id] + + if "united_atom" in level_list: + for res_id, residue in enumerate(mol.residues): + ua_beads = self._hier.get_beads(residue.atoms, "united_atom") + beads[(mol_id, "united_atom", res_id)] = [ + b.indices.copy() for b in ua_beads if len(b) > 0 + ] + + if "residue" in level_list: + res_beads = self._hier.get_beads(mol, "residue") + beads[(mol_id, "residue")] = [ + b.indices.copy() for b in res_beads if len(b) > 0 + ] + + if "polymer" in level_list: + poly_beads = self._hier.get_beads(mol, "polymer") + beads[(mol_id, "polymer")] = [ + b.indices.copy() for b in poly_beads if len(b) > 0 + ] shared_data["beads"] = beads + return {"beads": beads} diff --git a/CodeEntropy/levels/nodes/build_conformations.py b/CodeEntropy/levels/nodes/build_conformations.py deleted file mode 100644 index e8702141..00000000 --- a/CodeEntropy/levels/nodes/build_conformations.py +++ /dev/null @@ -1,10 +0,0 @@ -from CodeEntropy.levels.dihedral_tools import DihedralAnalysis - - -class BuildConformationsNode: - def __init__(self): - self._dih = DihedralAnalysis() - - def run(self, shared_data): - dihedrals = shared_data["dihedrals"] - shared_data["conformations"] = self._dih.build_conformational_states(dihedrals) diff --git a/CodeEntropy/levels/nodes/build_covariance_matrices.py b/CodeEntropy/levels/nodes/build_covariance_matrices.py deleted file mode 100644 index c8b27dc8..00000000 --- a/CodeEntropy/levels/nodes/build_covariance_matrices.py +++ /dev/null @@ -1,25 +0,0 @@ -# CodeEntropy/levels/nodes/build_covariance_matrices.py - -from CodeEntropy.levels.force_torque_manager import ForceTorqueManager - - -class BuildCovarianceMatricesNode: - def __init__(self, universe_operations): - self._ft = ForceTorqueManager(universe_operations) - - def run(self, shared_data): - force_avg, torque_avg, frame_counts = self._ft.build_covariance_matrices( - entropy_manager=shared_data.get("entropy_manager"), - reduced_atom=shared_data["reduced_universe"], - levels=shared_data["levels"], - groups=shared_data["groups"], - start=shared_data["start"], - end=shared_data["end"], - step=shared_data["step"], - number_frames=shared_data["n_frames"], - force_partitioning=shared_data["args"].force_partitioning, - ) - - shared_data["force_covariances"] = force_avg - shared_data["torque_covariances"] = torque_avg - shared_data["frame_counts"] = frame_counts diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py index e0eb3886..30a98f5c 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -1,21 +1,18 @@ -import logging -from typing import Any, Dict - from CodeEntropy.levels.dihedral_tools import DihedralAnalysis -logger = logging.getLogger(__name__) - class ComputeConformationalStatesNode: """ - Builds conformational state descriptors (UA + residue) from dihedral angles, - and stores them into shared_data using a stable contract. + Static node (runs once). Internally scans the trajectory to build conformational + states. + Produces: + shared_data["conformational_states"] = {"ua": states_ua, "res": states_res} """ def __init__(self, universe_operations): - self._dih = DihedralAnalysis(universe_operations) + self._dih = DihedralAnalysis(universe_operations=universe_operations) - def run(self, shared_data: Dict[str, Any]): + def run(self, shared_data): u = shared_data["reduced_universe"] levels = shared_data["levels"] groups = shared_data["groups"] @@ -25,8 +22,6 @@ def run(self, shared_data: Dict[str, Any]): step = shared_data["step"] bin_width = shared_data["args"].bin_width - logger.info("[ComputeConformationalStatesNode] Building conformational states") - states_ua, states_res = self._dih.build_conformational_states( data_container=u, levels=levels, @@ -37,10 +32,5 @@ def run(self, shared_data: Dict[str, Any]): bin_width=bin_width, ) - shared_data["conformational_states"] = { - "ua": states_ua, - "res": states_res, - } - - shared_data["states_united_atom"] = states_ua - shared_data["states_residue"] = states_res + shared_data["conformational_states"] = {"ua": states_ua, "res": states_res} + return {"conformational_states": shared_data["conformational_states"]} diff --git a/CodeEntropy/levels/nodes/compute_weighted_forces.py b/CodeEntropy/levels/nodes/compute_weighted_forces.py deleted file mode 100644 index 83f0c39a..00000000 --- a/CodeEntropy/levels/nodes/compute_weighted_forces.py +++ /dev/null @@ -1,21 +0,0 @@ -from CodeEntropy.levels.force_torque_manager import ForceTorqueManager - - -class ComputeWeightedForcesNode: - def __init__(self): - self._ft = ForceTorqueManager() - - def run(self, shared_data): - u = shared_data["universe"] - beads = shared_data["beads"] - trans_axes = shared_data["trans_axes"] - - forces = {} - - for key, bead_list in beads.items(): - forces[key] = [ - self._ft.get_weighted_forces(u, bead, t_ax, False) - for bead, t_ax in zip(bead_list, trans_axes[key]) - ] - - shared_data["weighted_forces"] = forces diff --git a/CodeEntropy/levels/nodes/compute_weighted_torques.py b/CodeEntropy/levels/nodes/compute_weighted_torques.py deleted file mode 100644 index 6f839f80..00000000 --- a/CodeEntropy/levels/nodes/compute_weighted_torques.py +++ /dev/null @@ -1,21 +0,0 @@ -from CodeEntropy.levels.force_torque_manager import ForceTorqueManager - - -class ComputeWeightedTorquesNode: - def __init__(self): - self._ft = ForceTorqueManager() - - def run(self, shared_data): - u = shared_data["universe"] - beads = shared_data["beads"] - rot_axes = shared_data["rot_axes"] - - torques = {} - - for key, bead_list in beads.items(): - torques[key] = [ - self._ft.get_weighted_torques(u, bead, r_ax) - for bead, r_ax in zip(bead_list, rot_axes[key]) - ] - - shared_data["weighted_torques"] = torques diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index d87d3fb6..595a2c65 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -1,5 +1,3 @@ -# CodeEntropy/levels/nodes/detect_levels.py - from CodeEntropy.levels.level_hierarchy import LevelHierarchy @@ -9,7 +7,7 @@ def __init__(self): def run(self, shared_data): u = shared_data["reduced_universe"] - num_mol, levels = self._hier.select_levels(u) - - shared_data["number_molecules"] = num_mol + n_mol, levels = self._hier.select_levels(u) shared_data["levels"] = levels + shared_data["number_molecules"] = n_mol + return {"levels": levels, "number_molecules": n_mol} diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index 4f099ec1..8a78d1fb 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -1,17 +1,23 @@ -# CodeEntropy/levels/nodes/detect_molecules.py - import logging logger = logging.getLogger(__name__) class DetectMoleculesNode: + """ + Detect molecules in reduced_universe (so indices match all downstream work). + """ + def run(self, shared_data): u = shared_data["reduced_universe"] fragments = u.atoms.fragments - num_mol = len(fragments) + n_mol = len(fragments) - logger.info(f"[DetectMoleculesNode] {num_mol} molecules detected") + logger.info( + f"[DetectMoleculesNode] {n_mol} molecules detected (reduced_universe)" + ) shared_data["fragments"] = fragments - shared_data["number_molecules"] = num_mol + shared_data["number_molecules"] = n_mol + + return {"number_molecules": n_mol} diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py new file mode 100644 index 00000000..f43f9d6e --- /dev/null +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -0,0 +1,111 @@ +# CodeEntropy/levels/nodes/frame_covariance.py + +import logging + +from CodeEntropy.levels.force_torque_manager import ForceTorqueManager + +logger = logging.getLogger(__name__) + + +class FrameCovarianceNode: + """ + Per-frame covariance computation. + Uses ForceTorqueManager exactly as designed. + """ + + def __init__(self): + self._ft = ForceTorqueManager() + + def run(self, shared_data): + u = shared_data["reduced_universe"] + # t = shared_data["time_index"] + + groups = shared_data["groups"] + levels = shared_data["levels"] + beads = shared_data["beads"] + + force_avg = shared_data["force_covariances"] + torque_avg = shared_data["torque_covariances"] + counts = shared_data["frame_counts"] + + fp = shared_data["args"].force_partitioning + + # # advance trajectory safely + # u.trajectory[t] + + fragments = u.atoms.fragments + + for group_id, mol_ids in groups.items(): + for mol_id in mol_ids: + mol = fragments[mol_id] + trans_axes = mol.principal_axes() + + level_list = levels[mol_id] + + for level in level_list: + highest = level == level_list[-1] + + # --- UNITED ATOM (per residue) --- + if level == "united_atom": + for res_id in range(len(mol.residues)): + key = (mol_id, "united_atom", res_id) + bead_groups = beads.get(key, []) + if not bead_groups: + continue + + F, T = self._ft.compute_frame_covariance( + beads=[u.atoms[idx] for idx in bead_groups], + trans_axes=trans_axes, + highest_level=highest, + force_partitioning=fp, + ) + + acc_key = (group_id, res_id) + n = counts["ua"].get(acc_key, 0) + 1 + counts["ua"][acc_key] = n + + force_avg["ua"][acc_key] = ( + F + if acc_key not in force_avg["ua"] + else force_avg["ua"][acc_key] + + (F - force_avg["ua"][acc_key]) / n + ) + + torque_avg["ua"][acc_key] = ( + T + if acc_key not in torque_avg["ua"] + else torque_avg["ua"][acc_key] + + (T - torque_avg["ua"][acc_key]) / n + ) + + # --- RESIDUE / POLYMER --- + elif level in ("residue", "polymer"): + key = (mol_id, level) + bead_groups = beads.get(key, []) + if not bead_groups: + continue + + F, T = self._ft.compute_frame_covariance( + beads=[u.atoms[idx] for idx in bead_groups], + trans_axes=trans_axes, + highest_level=highest, + force_partitioning=fp, + ) + + k = "res" if level == "residue" else "poly" + counts[k][group_id] += 1 + n = counts[k][group_id] + + force_avg[k][group_id] = ( + F + if force_avg[k][group_id] is None + else force_avg[k][group_id] + + (F - force_avg[k][group_id]) / n + ) + + torque_avg[k][group_id] = ( + T + if torque_avg[k][group_id] is None + else torque_avg[k][group_id] + + (T - torque_avg[k][group_id]) / n + ) diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/init_covariance_accumulators.py new file mode 100644 index 00000000..91c909fb --- /dev/null +++ b/CodeEntropy/levels/nodes/init_covariance_accumulators.py @@ -0,0 +1,36 @@ +# CodeEntropy/levels/nodes/init_covariance_accumulators.py + +import numpy as np + + +class InitCovarianceAccumulatorsNode: + """ + Allocate containers for running averages, matching the original manager: + + force_covariances = {"ua": {}, "res": [None]*n_groups, "poly": [None]*n_groups} + torque_covariances = same + frame_counts = {"ua": {}, "res": np.zeros(n_groups), "poly": np.zeros(n_groups)} + """ + + def run(self, shared_data): + groups = shared_data["groups"] + n_groups = len(groups) + + force_avg = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} + torque_avg = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} + + frame_counts = { + "ua": {}, + "res": np.zeros(n_groups, dtype=int), + "poly": np.zeros(n_groups, dtype=int), + } + + shared_data["force_covariances"] = force_avg + shared_data["torque_covariances"] = torque_avg + shared_data["frame_counts"] = frame_counts + + return { + "force_covariances": force_avg, + "torque_covariances": torque_avg, + "frame_counts": frame_counts, + } From dd2ba6f307d546813f23a6cdd2fe1f824414f284 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 30 Jan 2026 10:11:19 +0000 Subject: [PATCH 035/101] ensure `frame_covariance` node is orchanstation only --- CodeEntropy/levels/nodes/frame_covariance.py | 89 +++++++++----------- 1 file changed, 40 insertions(+), 49 deletions(-) diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index f43f9d6e..e31c6eed 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -10,7 +10,8 @@ class FrameCovarianceNode: """ Per-frame covariance computation. - Uses ForceTorqueManager exactly as designed. + Assumes reduced_universe trajectory has already been advanced by the caller. + Uses ForceTorqueManager for the physics and updates running means here. """ def __init__(self): @@ -18,7 +19,6 @@ def __init__(self): def run(self, shared_data): u = shared_data["reduced_universe"] - # t = shared_data["time_index"] groups = shared_data["groups"] levels = shared_data["levels"] @@ -29,83 +29,74 @@ def run(self, shared_data): counts = shared_data["frame_counts"] fp = shared_data["args"].force_partitioning - - # # advance trajectory safely - # u.trajectory[t] - fragments = u.atoms.fragments for group_id, mol_ids in groups.items(): for mol_id in mol_ids: mol = fragments[mol_id] trans_axes = mol.principal_axes() - level_list = levels[mol_id] for level in level_list: highest = level == level_list[-1] - # --- UNITED ATOM (per residue) --- if level == "united_atom": for res_id in range(len(mol.residues)): - key = (mol_id, "united_atom", res_id) - bead_groups = beads.get(key, []) - if not bead_groups: + bead_key = (mol_id, "united_atom", res_id) + bead_idx_arrays = beads.get(bead_key, []) + if not bead_idx_arrays: + continue + + bead_ags = [u.atoms[idx] for idx in bead_idx_arrays] + if any(len(ag) == 0 for ag in bead_ags): continue F, T = self._ft.compute_frame_covariance( - beads=[u.atoms[idx] for idx in bead_groups], - trans_axes=trans_axes, - highest_level=highest, - force_partitioning=fp, + bead_ags, trans_axes, highest, fp ) acc_key = (group_id, res_id) n = counts["ua"].get(acc_key, 0) + 1 counts["ua"][acc_key] = n - force_avg["ua"][acc_key] = ( - F - if acc_key not in force_avg["ua"] - else force_avg["ua"][acc_key] - + (F - force_avg["ua"][acc_key]) / n - ) + if acc_key not in force_avg["ua"]: + force_avg["ua"][acc_key] = F + torque_avg["ua"][acc_key] = T + else: + force_avg["ua"][acc_key] += ( + F - force_avg["ua"][acc_key] + ) / n + torque_avg["ua"][acc_key] += ( + T - torque_avg["ua"][acc_key] + ) / n - torque_avg["ua"][acc_key] = ( - T - if acc_key not in torque_avg["ua"] - else torque_avg["ua"][acc_key] - + (T - torque_avg["ua"][acc_key]) / n - ) - - # --- RESIDUE / POLYMER --- elif level in ("residue", "polymer"): - key = (mol_id, level) - bead_groups = beads.get(key, []) - if not bead_groups: + bead_key = (mol_id, level) + bead_idx_arrays = beads.get(bead_key, []) + if not bead_idx_arrays: + continue + + bead_ags = [u.atoms[idx] for idx in bead_idx_arrays] + if any(len(ag) == 0 for ag in bead_ags): continue F, T = self._ft.compute_frame_covariance( - beads=[u.atoms[idx] for idx in bead_groups], - trans_axes=trans_axes, - highest_level=highest, - force_partitioning=fp, + bead_ags, trans_axes, highest, fp ) k = "res" if level == "residue" else "poly" counts[k][group_id] += 1 n = counts[k][group_id] - force_avg[k][group_id] = ( - F - if force_avg[k][group_id] is None - else force_avg[k][group_id] - + (F - force_avg[k][group_id]) / n - ) - - torque_avg[k][group_id] = ( - T - if torque_avg[k][group_id] is None - else torque_avg[k][group_id] - + (T - torque_avg[k][group_id]) / n - ) + if force_avg[k][group_id] is None: + force_avg[k][group_id] = F + torque_avg[k][group_id] = T + else: + force_avg[k][group_id] += (F - force_avg[k][group_id]) / n + torque_avg[k][group_id] += (T - torque_avg[k][group_id]) / n + + return { + "force_covariances": force_avg, + "torque_covariances": torque_avg, + "frame_counts": counts, + } From f0827635230c577de2f7e348fd183f02e58023c5 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 30 Jan 2026 14:33:48 +0000 Subject: [PATCH 036/101] split graph representation into static and dynamic components --- CodeEntropy/levels/frame_dag.py | 32 ++++ CodeEntropy/levels/hierarchy_graph.py | 147 ++++++++++++------- CodeEntropy/levels/nodes/frame_covariance.py | 73 ++++----- 3 files changed, 154 insertions(+), 98 deletions(-) create mode 100644 CodeEntropy/levels/frame_dag.py diff --git a/CodeEntropy/levels/frame_dag.py b/CodeEntropy/levels/frame_dag.py new file mode 100644 index 00000000..2db290d5 --- /dev/null +++ b/CodeEntropy/levels/frame_dag.py @@ -0,0 +1,32 @@ +# CodeEntropy/levels/frame_dag.py + +import networkx as nx + + +class FrameDAG: + """ + Per-frame DAG (MAP stage). + Should NOT mutate global running averages. + It returns a per-frame result dictionary. + """ + + def __init__(self): + self.graph = nx.DiGraph() + self.nodes = {} + + def add(self, name, node, deps=None): + self.nodes[name] = node + self.graph.add_node(name) + for d in deps or []: + self.graph.add_edge(d, name) + + def build(self, frame_covariance_node): + self.add("frame_covariance", frame_covariance_node) + return self + + def execute(self, shared_data): + results = {} + for node_name in nx.topological_sort(self.graph): + out = self.nodes[node_name].run(shared_data) + results[node_name] = out + return results diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index bcf09715..6c9840b6 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,8 +1,10 @@ +# CodeEntropy/levels/hierarchy_graph.py + import logging -from typing import Any, Dict, Optional import networkx as nx +from CodeEntropy.levels.frame_dag import FrameDAG from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode @@ -17,83 +19,128 @@ class LevelDAG: """ - Baseline two-stage DAG that matches original procedural behavior. + Full level pipeline: + + STATIC DAG (once) + -> prepares molecules, levels, beads, conformational states, + and accumulator shapes + + FRAME MAP DAG (per frame, parallelisable) + -> returns per-frame covariance contributions - Stage 1 (static): detect molecules, levels, build beads, init accumulators, - conformational states - Stage 2 (frame loop): for each frame, update running covariance means - (forces/torques) + REDUCE step (once) + -> reduces per-frame contributions into running means identical to original """ - def __init__(self, universe_operations=None): + def __init__(self, universe_operations): self._universe_operations = universe_operations self.static_graph = nx.DiGraph() - self.static_nodes: Dict[str, Any] = {} + self.static_nodes = {} - self.frame_graph = nx.DiGraph() - self.frame_nodes: Dict[str, Any] = {} + # A separate per-frame DAG + self.frame_dag = FrameDAG().build(FrameCovarianceNode()) - def build(self) -> "LevelDAG": - # ---- static ---- - self._add_static("detect_molecules", DetectMoleculesNode()) - self._add_static("detect_levels", DetectLevelsNode(), deps=["detect_molecules"]) - self._add_static("build_beads", BuildBeadsNode(), deps=["detect_levels"]) + def add_static(self, name, node, deps=None): + self.static_nodes[name] = node + self.static_graph.add_node(name) + for d in deps or []: + self.static_graph.add_edge(d, name) - self._add_static( - "init_covariance", InitCovarianceAccumulatorsNode(), deps=["detect_levels"] + def build(self): + # STATIC DAG + self.add_static("detect_molecules", DetectMoleculesNode()) + self.add_static("detect_levels", DetectLevelsNode(), deps=["detect_molecules"]) + self.add_static("build_beads", BuildBeadsNode(), deps=["detect_levels"]) + self.add_static( + "init_covariance_accumulators", + InitCovarianceAccumulatorsNode(), + deps=["detect_levels"], ) - # conformational states (trajectory scan inside) - self._add_static( + # Conformational states scans the trajectory internally (not frame-local) + self.add_static( "compute_conformational_states", ComputeConformationalStatesNode(self._universe_operations), deps=["detect_levels"], ) - - # ---- per-frame ---- - self._add_frame("frame_covariance", FrameCovarianceNode()) - return self - def _add_static( - self, name: str, node: Any, deps: Optional[list[str]] = None - ) -> None: - self.static_nodes[name] = node - self.static_graph.add_node(name) - for d in deps or []: - self.static_graph.add_edge(d, name) - - def _add_frame( - self, name: str, node: Any, deps: Optional[list[str]] = None - ) -> None: - self.frame_nodes[name] = node - self.frame_graph.add_node(name) - for d in deps or []: - self.frame_graph.add_edge(d, name) - - def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: - # ---- run static stage ---- + @staticmethod + def _inc_mean(avg, new, n): + return new.copy() if avg is None else avg + (new - avg) / float(n) + + def _reduce_one_frame(self, shared_data, frame_out): + """ + Reduce MAP output into running means (matches your original). + """ + f_cov = shared_data["force_covariances"] + t_cov = shared_data["torque_covariances"] + counts = shared_data["frame_counts"] + + f_frame = frame_out["force"] + t_frame = frame_out["torque"] + + # UA: dict keyed by (group_id, res_id) + for key, F in f_frame["ua"].items(): + counts["ua"][key] = counts["ua"].get(key, 0) + 1 + n = counts["ua"][key] + f_cov["ua"][key] = self._inc_mean(f_cov["ua"].get(key), F, n) + + for key, T in t_frame["ua"].items(): + # same counter as force for UA key + n = counts["ua"][key] + t_cov["ua"][key] = self._inc_mean(t_cov["ua"].get(key), T, n) + + # residue/poly: arrays indexed by group_id + for gid, F in f_frame["res"].items(): + counts["res"][gid] += 1 + n = counts["res"][gid] + f_cov["res"][gid] = self._inc_mean(f_cov["res"][gid], F, n) + + for gid, T in t_frame["res"].items(): + n = counts["res"][gid] + t_cov["res"][gid] = self._inc_mean(t_cov["res"][gid], T, n) + + for gid, F in f_frame["poly"].items(): + counts["poly"][gid] += 1 + n = counts["poly"][gid] + f_cov["poly"][gid] = self._inc_mean(f_cov["poly"][gid], F, n) + + for gid, T in t_frame["poly"].items(): + n = counts["poly"][gid] + t_cov["poly"][gid] = self._inc_mean(t_cov["poly"][gid], T, n) + + def execute(self, shared_data): + # --- Run STATIC DAG --- for node_name in nx.topological_sort(self.static_graph): - logger.info(f"[LevelDAG] Running static node: {node_name}") + logger.info(f"[LevelDAG] STATIC: {node_name}") self.static_nodes[node_name].run(shared_data) - # ---- frame loop (ONLY place trajectory advances) ---- + # --- Frame MAP loop --- u = shared_data["reduced_universe"] start, end, step = shared_data["start"], shared_data["end"], shared_data["step"] for ts in u.trajectory[start:end:step]: - shared_data["frame_index"] = ts.frame # informational/debug only + frame_results = self.frame_dag.execute(shared_data) + frame_cov = frame_results["frame_covariance"] - for node_name in nx.topological_sort(self.frame_graph): - self.frame_nodes[node_name].run(shared_data) + self._reduce_one_frame(shared_data, frame_cov) - # outputs already accumulated in shared_data by FrameCovarianceNode return { - "levels": shared_data["levels"], - "beads": shared_data["beads"], "force_covariances": shared_data["force_covariances"], "torque_covariances": shared_data["torque_covariances"], "frame_counts": shared_data["frame_counts"], - "conformational_states": shared_data["conformational_states"], + "conformational_states": shared_data.get("conformational_states"), + "levels": shared_data["levels"], + } + + def describe(self): + static_edges = list(self.static_graph.edges()) + frame_edges = list(self.frame_dag.graph.edges()) + return { + "static_nodes": list(self.static_graph.nodes()), + "static_edges": static_edges, + "frame_nodes": list(self.frame_dag.graph.nodes()), + "frame_edges": frame_edges, } diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index e31c6eed..d237aaa1 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -1,17 +1,12 @@ # CodeEntropy/levels/nodes/frame_covariance.py -import logging - from CodeEntropy.levels.force_torque_manager import ForceTorqueManager -logger = logging.getLogger(__name__) - class FrameCovarianceNode: """ - Per-frame covariance computation. - Assumes reduced_universe trajectory has already been advanced by the caller. - Uses ForceTorqueManager for the physics and updates running means here. + MAP stage node: compute covariances for ONE frame. + Returns per-frame covariance dicts; does not reduce/average. """ def __init__(self): @@ -19,16 +14,14 @@ def __init__(self): def run(self, shared_data): u = shared_data["reduced_universe"] - groups = shared_data["groups"] levels = shared_data["levels"] beads = shared_data["beads"] + fp = shared_data["args"].force_partitioning - force_avg = shared_data["force_covariances"] - torque_avg = shared_data["torque_covariances"] - counts = shared_data["frame_counts"] + force_frame = {"ua": {}, "res": {}, "poly": {}} + torque_frame = {"ua": {}, "res": {}, "poly": {}} - fp = shared_data["args"].force_partitioning fragments = u.atoms.fragments for group_id, mol_ids in groups.items(): @@ -43,60 +36,44 @@ def run(self, shared_data): if level == "united_atom": for res_id in range(len(mol.residues)): bead_key = (mol_id, "united_atom", res_id) - bead_idx_arrays = beads.get(bead_key, []) - if not bead_idx_arrays: + bead_indices_list = beads.get(bead_key, []) + if not bead_indices_list: continue - bead_ags = [u.atoms[idx] for idx in bead_idx_arrays] + bead_ags = [u.atoms[idx] for idx in bead_indices_list] if any(len(ag) == 0 for ag in bead_ags): continue F, T = self._ft.compute_frame_covariance( - bead_ags, trans_axes, highest, fp + beads=bead_ags, + trans_axes=trans_axes, + highest_level=highest, + force_partitioning=fp, ) acc_key = (group_id, res_id) - n = counts["ua"].get(acc_key, 0) + 1 - counts["ua"][acc_key] = n - - if acc_key not in force_avg["ua"]: - force_avg["ua"][acc_key] = F - torque_avg["ua"][acc_key] = T - else: - force_avg["ua"][acc_key] += ( - F - force_avg["ua"][acc_key] - ) / n - torque_avg["ua"][acc_key] += ( - T - torque_avg["ua"][acc_key] - ) / n + force_frame["ua"][acc_key] = F + torque_frame["ua"][acc_key] = T elif level in ("residue", "polymer"): bead_key = (mol_id, level) - bead_idx_arrays = beads.get(bead_key, []) - if not bead_idx_arrays: + bead_indices_list = beads.get(bead_key, []) + if not bead_indices_list: continue - bead_ags = [u.atoms[idx] for idx in bead_idx_arrays] + bead_ags = [u.atoms[idx] for idx in bead_indices_list] if any(len(ag) == 0 for ag in bead_ags): continue F, T = self._ft.compute_frame_covariance( - bead_ags, trans_axes, highest, fp + beads=bead_ags, + trans_axes=trans_axes, + highest_level=highest, + force_partitioning=fp, ) k = "res" if level == "residue" else "poly" - counts[k][group_id] += 1 - n = counts[k][group_id] - - if force_avg[k][group_id] is None: - force_avg[k][group_id] = F - torque_avg[k][group_id] = T - else: - force_avg[k][group_id] += (F - force_avg[k][group_id]) / n - torque_avg[k][group_id] += (T - torque_avg[k][group_id]) / n - - return { - "force_covariances": force_avg, - "torque_covariances": torque_avg, - "frame_counts": counts, - } + force_frame[k][group_id] = F + torque_frame[k][group_id] = T + + return {"force": force_frame, "torque": torque_frame} From 2ae62ba7a310a9bd48a8368269c75b7ab5916a59 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 30 Jan 2026 15:20:33 +0000 Subject: [PATCH 037/101] update entropy graph build and execution to reflect level graph --- CodeEntropy/entropy/entropy_graph.py | 35 ++--- .../entropy/nodes/aggregate_entropy_node.py | 22 ++++ .../nodes/configurational_entropy_node.py | 106 +++++++-------- .../entropy/nodes/vibrational_entropy_node.py | 123 +++++++++++++----- 4 files changed, 180 insertions(+), 106 deletions(-) create mode 100644 CodeEntropy/entropy/nodes/aggregate_entropy_node.py diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index c686e5fe..3b060032 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,5 +1,8 @@ +# CodeEntropy/entropy/entropy_graph.py + import networkx as nx +from CodeEntropy.entropy.nodes.aggregate_entropy_node import AggregateEntropyNode from CodeEntropy.entropy.nodes.configurational_entropy_node import ( ConfigurationalEntropyNode, ) @@ -7,50 +10,34 @@ class EntropyGraph: - """ - DAG representing the entropy computation pipeline: - - 1. Vibrational entropy - 2. Conformational (configurational) entropy - (orientational entropy can be added later) - """ - def __init__(self): self.graph = nx.DiGraph() self.nodes = {} def build(self): self.add("vibrational_entropy", VibrationalEntropyNode()) - self.add( "configurational_entropy", ConfigurationalEntropyNode(), depends_on=["vibrational_entropy"], ) - - # self.add( - # "orientational_entropy", - # OrientationalEntropyNode(), - # depends_on=["vibrational_entropy"], - # ) - + self.add( + "aggregate_entropy", + AggregateEntropyNode(), + depends_on=["configurational_entropy"], + ) return self def add(self, name, obj, depends_on=None): self.nodes[name] = obj self.graph.add_node(name) - if depends_on: - for dep in depends_on: - self.graph.add_edge(dep, name) + for dep in depends_on or []: + self.graph.add_edge(dep, name) def execute(self, shared_data): results = {} - for node in nx.topological_sort(self.graph): preds = list(self.graph.predecessors(node)) kwargs = {p: results[p] for p in preds} - - output = self.nodes[node].run(shared_data, **kwargs) - results[node] = output - + results[node] = self.nodes[node].run(shared_data, **kwargs) return results diff --git a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py new file mode 100644 index 00000000..53a9c5d8 --- /dev/null +++ b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py @@ -0,0 +1,22 @@ +# CodeEntropy/entropy/nodes/aggregate_entropy_node.py + +from typing import Any, Dict + + +class AggregateEntropyNode: + """ + Aggregates entropy outputs into shared_data for downstream use. + """ + + def run( + self, + shared_data: Dict[str, Any], + vibrational_entropy=None, + configurational_entropy=None, + **_, + ): + shared_data["entropy_results"] = { + "vibrational": vibrational_entropy, + "configurational": configurational_entropy, + } + return {"entropy_results": shared_data["entropy_results"]} diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 1ec3bac0..5d63ff33 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -1,3 +1,5 @@ +# CodeEntropy/entropy/nodes/configurational_entropy_node.py + import logging from typing import Any, Dict @@ -8,12 +10,13 @@ class ConfigurationalEntropyNode: """ - Computes conformational (configurational) entropy from conformational states. + Computes conformational entropy from conformational states produced by LevelDAG. - Requires: - shared_data["conformational_states"] = {"ua": ..., "res": ...} - shared_data["n_frames"] - shared_data["levels"], shared_data["groups"] + Expected shapes: + shared_data["conformational_states"]["ua"] + -> dict[(group_id, res_id)] = list[str] + shared_data["conformational_states"]["res"] + -> list indexed by group_id = list[str] or [] """ def run(self, shared_data: Dict[str, Any], **_kwargs): @@ -22,65 +25,64 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): universe = shared_data["reduced_universe"] data_logger = shared_data.get("data_logger") - group_molecules = shared_data.get("group_molecules") - - levels = shared_data["levels"] - groups = shared_data["groups"] - number_frames = shared_data["n_frames"] - - if "conformational_states" in shared_data: - states_ua = shared_data["conformational_states"]["ua"] - states_res = shared_data["conformational_states"]["res"] - else: - states_ua = shared_data.get("states_united_atom", {}) - states_res = shared_data.get("states_residue", []) - ce = ConformationalEntropy( run_manager=run_manager, args=args, universe=universe, data_logger=data_logger, - group_molecules=group_molecules, + group_molecules=shared_data.get("group_molecules"), ) - conf_results = {} + conf_states = shared_data["conformational_states"] - for group_id, mol_indices in groups.items(): - group_total = 0.0 + n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) + if n_frames is None: + raise KeyError("shared_data must contain n_frames (or number_frames)") - mol_index = mol_indices[0] - for level in levels[mol_index]: - if level == "united_atom": - group_total += self._ua_entropy( - ce, group_id, states_ua, number_frames + groups = shared_data["groups"] + levels = shared_data["levels"] + + results: Dict[int, Dict[str, float]] = {} + + states_ua = conf_states.get("ua", {}) + states_res = conf_states.get("res", []) + + for group_id, mol_ids in groups.items(): + mol_id = mol_ids[0] + level_list = levels[mol_id] + + results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} + + # -------- united atom (sum over residues) -------- + if "united_atom" in level_list: + total = 0.0 + for (gid, _res_id), states in states_ua.items(): + if gid != group_id: + continue + if not states: + continue + total += ce.conformational_entropy_calculation(states, n_frames) + + results[group_id]["ua"] = total + if data_logger is not None: + data_logger.add_results_data( + group_id, "united_atom", "Conformational", total ) - elif level == "residue": - group_total += self._residue_entropy( - ce, group_id, states_res, number_frames + # -------- residue (one per group) -------- + if "residue" in level_list: + if group_id < len(states_res) and states_res[group_id]: + val = ce.conformational_entropy_calculation( + states_res[group_id], n_frames ) + else: + val = 0.0 - conf_results[group_id] = group_total + results[group_id]["res"] = val + if data_logger is not None: + data_logger.add_results_data( + group_id, "residue", "Conformational", val + ) logger.info("[ConfigurationalEntropyNode] Done") - return {"configurational_entropy": conf_results} - - def _has_states(self, values): - return values is not None and len(values) > 0 - - def _ua_entropy(self, ce, group_id, states_ua, number_frames): - total = 0.0 - for key, values in states_ua.items(): - if key[0] != group_id: - continue - if self._has_states(values): - total += ce.conformational_entropy_calculation(values, number_frames) - return total - - def _residue_entropy(self, ce, group_id, states_res, number_frames): - if group_id >= len(states_res): - return 0.0 - values = states_res[group_id] - if not self._has_states(values): - return 0.0 - return ce.conformational_entropy_calculation(values, number_frames) + return {"configurational_entropy": results} diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 4069878b..c55559c8 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -1,24 +1,34 @@ +# CodeEntropy/entropy/nodes/vibrational_entropy_node.py + import logging from typing import Any, Dict +import numpy as np + from CodeEntropy.entropy.vibrational_entropy import VibrationalEntropy +from CodeEntropy.levels.matrix_operations import MatrixOperations logger = logging.getLogger(__name__) class VibrationalEntropyNode: """ - Computes vibrational entropy from force/torque covariance matrices. - Expects Level DAG to have filled: - shared_data["force_covariances"], shared_data["torque_covariances"] + Computes vibrational entropy from *force* and *torque* covariance matrices. + + Expects Level DAG to have produced: + shared_data["force_covariances"] : {"ua": dict, "res": list, "poly": list} + shared_data["torque_covariances"] : {"ua": dict, "res": list, "poly": list} + shared_data["levels"], shared_data["groups"] """ + def __init__(self): + self._mat_ops = MatrixOperations() + def run(self, shared_data: Dict[str, Any], **_kwargs): run_manager = shared_data["run_manager"] args = shared_data["args"] universe = shared_data["reduced_universe"] data_logger = shared_data.get("data_logger") - group_molecules = shared_data.get("group_molecules") ve = VibrationalEntropy( @@ -30,35 +40,88 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): ) temp = args.temperature - # levels = shared_data["levels"] + levels = shared_data["levels"] groups = shared_data["groups"] force_cov = shared_data["force_covariances"] - # torque_cov = shared_data["torque_covariances"] - - vib_results = {} - - for group_id in groups.keys(): - vib_results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} - - # UA is dict keyed by (group_id, res_id) - for (gid, _res_id), mat in force_cov["ua"].items(): - if gid != group_id: - continue - vib_results[group_id]["ua"] += ve.vibrational_entropy_calculation( - mat, "force", temp, highest_level=False - ) - - # residue / polymer are list indexed by group_id - if force_cov["res"][group_id] is not None: - vib_results[group_id]["res"] += ve.vibrational_entropy_calculation( - force_cov["res"][group_id], "force", temp, highest_level=False - ) - - if force_cov["poly"][group_id] is not None: - vib_results[group_id]["poly"] += ve.vibrational_entropy_calculation( - force_cov["poly"][group_id], "force", temp, highest_level=True - ) + torque_cov = shared_data["torque_covariances"] + + vib_results: Dict[int, Dict[str, Dict[str, float]]] = {} + + for group_id, mol_ids in groups.items(): + mol_id = mol_ids[0] + level_list = levels[mol_id] + + vib_results[group_id] = {} + + for level in level_list: + # highest = level == level_list[-1] + + if level == "united_atom": + S_trans = 0.0 + S_rot = 0.0 + + for (gid, _res_id), fmat in force_cov["ua"].items(): + if gid != group_id: + continue + + tmat = torque_cov["ua"].get((gid, _res_id)) + if tmat is None: + continue + + fmat = self._mat_ops.filter_zero_rows_columns(np.array(fmat)) + tmat = self._mat_ops.filter_zero_rows_columns(np.array(tmat)) + + S_trans += ve.vibrational_entropy_calculation( + fmat, "force", temp, highest_level=False + ) + S_rot += ve.vibrational_entropy_calculation( + tmat, "torque", temp, highest_level=False + ) + + elif level == "residue": + fmat = force_cov["res"][group_id] + tmat = torque_cov["res"][group_id] + if fmat is None or tmat is None: + S_trans, S_rot = 0.0, 0.0 + else: + fmat = self._mat_ops.filter_zero_rows_columns(np.array(fmat)) + tmat = self._mat_ops.filter_zero_rows_columns(np.array(tmat)) + + S_trans = ve.vibrational_entropy_calculation( + fmat, "force", temp, highest_level=False + ) + S_rot = ve.vibrational_entropy_calculation( + tmat, "torque", temp, highest_level=False + ) + + elif level == "polymer": + fmat = force_cov["poly"][group_id] + tmat = torque_cov["poly"][group_id] + if fmat is None or tmat is None: + S_trans, S_rot = 0.0, 0.0 + else: + fmat = self._mat_ops.filter_zero_rows_columns(np.array(fmat)) + tmat = self._mat_ops.filter_zero_rows_columns(np.array(tmat)) + + S_trans = ve.vibrational_entropy_calculation( + fmat, "force", temp, highest_level=True + ) + S_rot = ve.vibrational_entropy_calculation( + tmat, "torque", temp, highest_level=True + ) + else: + raise ValueError(f"Unknown level: {level}") + + vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} + + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot + ) logger.info("[VibrationalEntropyNode] Done") return {"vibrational_entropy": vib_results} From c487dd501e205c2811761451e8a1862449787a5f Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 2 Feb 2026 10:09:31 +0000 Subject: [PATCH 038/101] update to include the residue table results --- .../nodes/configurational_entropy_node.py | 59 +++++--- .../entropy/nodes/vibrational_entropy_node.py | 140 ++++++++++++++---- 2 files changed, 149 insertions(+), 50 deletions(-) diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 5d63ff33..8d5a7a2c 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -14,9 +14,9 @@ class ConfigurationalEntropyNode: Expected shapes: shared_data["conformational_states"]["ua"] - -> dict[(group_id, res_id)] = list[str] + -> dict[(group_id, res_id)] = list[int] or list[str] (length ~ n_frames) shared_data["conformational_states"]["res"] - -> list indexed by group_id = list[str] or [] + -> list indexed by group_id = list[int] or list[str] (length ~ n_frames) OR [] """ def run(self, shared_data: Dict[str, Any], **_kwargs): @@ -33,7 +33,15 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): group_molecules=shared_data.get("group_molecules"), ) + if "conformational_states" not in shared_data: + raise KeyError( + "shared_data['conformational_states'] is missing. " + "Did LevelDAG run ComputeConformationalStatesNode?" + ) + conf_states = shared_data["conformational_states"] + states_ua = conf_states.get("ua", {}) # dict[(group_id, res_id)] -> states + states_res = conf_states.get("res", []) # list[group_id] -> states n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) if n_frames is None: @@ -42,43 +50,58 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): groups = shared_data["groups"] levels = shared_data["levels"] - results: Dict[int, Dict[str, float]] = {} + fragments = universe.atoms.fragments - states_ua = conf_states.get("ua", {}) - states_res = conf_states.get("res", []) + results: Dict[int, Dict[str, float]] = {} for group_id, mol_ids in groups.items(): mol_id = mol_ids[0] level_list = levels[mol_id] + mol = fragments[mol_id] results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} - # -------- united atom (sum over residues) -------- if "united_atom" in level_list: - total = 0.0 - for (gid, _res_id), states in states_ua.items(): - if gid != group_id: - continue - if not states: + total_ua = 0.0 + + for (gid, res_id), states in states_ua.items(): + if gid != group_id or not states: continue - total += ce.conformational_entropy_calculation(states, n_frames) - results[group_id]["ua"] = total + s_res = ce.conformational_entropy_calculation(states, n_frames) + total_ua += s_res + + if data_logger is not None: + if res_id < len(mol.residues): + resname = mol.residues[res_id].resname + else: + resname = f"RES{res_id}" + + data_logger.add_residue_data( + group_id=group_id, + resname=resname, + level="united_atom", + entropy_type="Conformational", + frame_count=n_frames, + value=s_res, + ) + + results[group_id]["ua"] = total_ua + if data_logger is not None: data_logger.add_results_data( - group_id, "united_atom", "Conformational", total + group_id, "united_atom", "Conformational", total_ua ) - # -------- residue (one per group) -------- if "residue" in level_list: if group_id < len(states_res) and states_res[group_id]: - val = ce.conformational_entropy_calculation( - states_res[group_id], n_frames - ) + s = states_res[group_id] + val = ce.conformational_entropy_calculation(s, n_frames) else: val = 0.0 results[group_id]["res"] = val + if data_logger is not None: data_logger.add_results_data( group_id, "residue", "Conformational", val diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index c55559c8..508701d7 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -13,12 +13,15 @@ class VibrationalEntropyNode: """ - Computes vibrational entropy from *force* and *torque* covariance matrices. + Computes vibrational entropy from force and torque covariance matrices. - Expects Level DAG to have produced: + Expects LevelDAG to have produced: shared_data["force_covariances"] : {"ua": dict, "res": list, "poly": list} shared_data["torque_covariances"] : {"ua": dict, "res": list, "poly": list} shared_data["levels"], shared_data["groups"] + + Also optionally uses: + shared_data["frame_counts"] for residue table counts """ def __init__(self): @@ -45,83 +48,156 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): force_cov = shared_data["force_covariances"] torque_cov = shared_data["torque_covariances"] + frame_counts = shared_data.get("frame_counts", None) + + fragments = universe.atoms.fragments vib_results: Dict[int, Dict[str, Dict[str, float]]] = {} for group_id, mol_ids in groups.items(): mol_id = mol_ids[0] level_list = levels[mol_id] + mol = fragments[mol_id] vib_results[group_id] = {} for level in level_list: - # highest = level == level_list[-1] - if level == "united_atom": - S_trans = 0.0 - S_rot = 0.0 + S_trans_total = 0.0 + S_rot_total = 0.0 - for (gid, _res_id), fmat in force_cov["ua"].items(): + # UA is stored per (group_id, res_id) + for (gid, res_id), fmat in force_cov["ua"].items(): if gid != group_id: continue - tmat = torque_cov["ua"].get((gid, _res_id)) + tmat = torque_cov["ua"].get((gid, res_id)) if tmat is None: continue - fmat = self._mat_ops.filter_zero_rows_columns(np.array(fmat)) - tmat = self._mat_ops.filter_zero_rows_columns(np.array(tmat)) + fmat_arr = self._mat_ops.filter_zero_rows_columns( + np.array(fmat) + ) + tmat_arr = self._mat_ops.filter_zero_rows_columns( + np.array(tmat) + ) + + S_trans = ve.vibrational_entropy_calculation( + fmat_arr, "force", temp, highest_level=False + ) + S_rot = ve.vibrational_entropy_calculation( + tmat_arr, "torque", temp, highest_level=False + ) - S_trans += ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=False + S_trans_total += S_trans + S_rot_total += S_rot + + if data_logger is not None: + if res_id < len(mol.residues): + resname = mol.residues[res_id].resname + else: + resname = f"RES{res_id}" + + count_val = None + if frame_counts is not None: + count_val = frame_counts["ua"].get( + (group_id, res_id), None + ) + + data_logger.add_residue_data( + group_id=group_id, + resname=resname, + level="united_atom", + entropy_type="Transvibrational", + frame_count=count_val, + value=S_trans, + ) + data_logger.add_residue_data( + group_id=group_id, + resname=resname, + level="united_atom", + entropy_type="Rovibrational", + frame_count=count_val, + value=S_rot, + ) + + vib_results[group_id][level] = { + "trans": S_trans_total, + "rot": S_rot_total, + } + + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans_total ) - S_rot += ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=False + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot_total ) elif level == "residue": fmat = force_cov["res"][group_id] tmat = torque_cov["res"][group_id] + if fmat is None or tmat is None: S_trans, S_rot = 0.0, 0.0 else: - fmat = self._mat_ops.filter_zero_rows_columns(np.array(fmat)) - tmat = self._mat_ops.filter_zero_rows_columns(np.array(tmat)) + fmat_arr = self._mat_ops.filter_zero_rows_columns( + np.array(fmat) + ) + tmat_arr = self._mat_ops.filter_zero_rows_columns( + np.array(tmat) + ) S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=False + fmat_arr, "force", temp, highest_level=False ) S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=False + tmat_arr, "torque", temp, highest_level=False + ) + + vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} + + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot ) elif level == "polymer": fmat = force_cov["poly"][group_id] tmat = torque_cov["poly"][group_id] + if fmat is None or tmat is None: S_trans, S_rot = 0.0, 0.0 else: - fmat = self._mat_ops.filter_zero_rows_columns(np.array(fmat)) - tmat = self._mat_ops.filter_zero_rows_columns(np.array(tmat)) + fmat_arr = self._mat_ops.filter_zero_rows_columns( + np.array(fmat) + ) + tmat_arr = self._mat_ops.filter_zero_rows_columns( + np.array(tmat) + ) S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=True + fmat_arr, "force", temp, highest_level=True ) S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=True + tmat_arr, "torque", temp, highest_level=True ) - else: - raise ValueError(f"Unknown level: {level}") - vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} + vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} + + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot + ) - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) + else: + raise ValueError(f"Unknown level: {level}") logger.info("[VibrationalEntropyNode] Done") return {"vibrational_entropy": vib_results} From 2c28987b3c19a5bc8d8c029a3bb2b53a36d2eead Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 5 Feb 2026 15:40:17 +0000 Subject: [PATCH 039/101] Add axes.py from origin/main --- CodeEntropy/axes.py | 507 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 CodeEntropy/axes.py diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py new file mode 100644 index 00000000..d698b482 --- /dev/null +++ b/CodeEntropy/axes.py @@ -0,0 +1,507 @@ +import logging + +import numpy as np +from MDAnalysis.lib.mdamath import make_whole + +logger = logging.getLogger(__name__) + + +class AxesManager: + """ + Manages the structural and dynamic levels involved in entropy calculations. This + includes selecting relevant levels, computing axes for translation and rotation, + and handling bead-based representations of molecular systems. Provides utility + methods to extract averaged positions, convert coordinates to spherical systems, + compute weighted forces and torques, and manipulate matrices used in entropy + analysis. + """ + + def __init__(self): + """ + Initializes the AxesManager with placeholders for level-related data, + including translational and rotational axes, number of beads, and a + general-purpose data container. + """ + self.data_container = None + self._levels = None + self._trans_axes = None + self._rot_axes = None + self._number_of_beads = None + + def get_residue_axes(self, data_container, index): + """ + The translational and rotational axes at the residue level. + + Args: + data_container (MDAnalysis.Universe): the molecule and trajectory data + index (int): residue index + + Returns: + trans_axes : translational axes (3,3) + rot_axes : rotational axes (3,3) + center: center of mass (3,) + moment_of_inertia: moment of inertia (3,) + """ + # TODO refine selection so that it will work for branched polymers + index_prev = index - 1 + index_next = index + 1 + atom_set = data_container.select_atoms( + f"(resindex {index_prev} or resindex {index_next}) " + f"and bonded resid {index}" + ) + residue = data_container.select_atoms(f"resindex {index}") + center = residue.atoms.center_of_mass(unwrap=True) + + if len(atom_set) == 0: + # No bonds to other residues + # Use a custom principal axes, from a MOI tensor + # that uses positions of heavy atoms only, but including masses + # of heavy atom + bonded hydrogens + UAs = residue.select_atoms("mass 2 to 999") + UA_masses = self.get_UA_masses(residue) + moment_of_inertia_tensor = self.get_moment_of_inertia_tensor( + center, UAs.positions, UA_masses, data_container.dimensions[:3] + ) + rot_axes, moment_of_inertia = self.get_custom_principal_axes( + moment_of_inertia_tensor + ) + trans_axes = ( + rot_axes # set trans axes to same as rot axes as per Jon's code + ) + else: + # if bonded to other residues, use default axes and MOI + make_whole(data_container.atoms) + trans_axes = data_container.atoms.principal_axes() + rot_axes, moment_of_inertia = self.get_vanilla_axes(residue) + center = residue.center_of_mass(unwrap=True) + + return trans_axes, rot_axes, center, moment_of_inertia + + def get_UA_axes(self, data_container, index): + """ + The translational and rotational axes at the united-atom level. + + Args: + data_container (MDAnalysis.Universe): the molecule and trajectory data + index (int): residue index + + Returns: + trans_axes : translational axes (3,3) + rot_axes : rotational axes (3,3) + center: center of mass (3,) + moment_of_inertia: moment of inertia (3,) + """ + + index = int(index) + + # use the same customPI trans axes as the residue level + UAs = data_container.select_atoms("mass 2 to 999") + UA_masses = self.get_UA_masses(data_container.atoms) + center = data_container.atoms.center_of_mass(unwrap=True) + moment_of_inertia_tensor = self.get_moment_of_inertia_tensor( + center, UAs.positions, UA_masses, data_container.dimensions[:3] + ) + trans_axes, _moment_of_inertia = self.get_custom_principal_axes( + moment_of_inertia_tensor + ) + + # look for heavy atoms in residue of interest + heavy_atoms = data_container.select_atoms("prop mass > 1.1") + heavy_atom_indices = [] + for atom in heavy_atoms: + heavy_atom_indices.append(atom.index) + # we find the nth heavy atom + # where n is the bead index + heavy_atom_index = heavy_atom_indices[index] + heavy_atom = data_container.select_atoms(f"index {heavy_atom_index}") + + center = heavy_atom.positions[0] + rot_axes, moment_of_inertia = self.get_bonded_axes( + data_container, heavy_atom[0], data_container.dimensions[:3] + ) + + logger.debug(f"Translational Axes: {trans_axes}") + logger.debug(f"Rotational Axes: {rot_axes}") + logger.debug(f"Center: {center}") + logger.debug(f"Moment of Inertia: {moment_of_inertia}") + + return trans_axes, rot_axes, center, moment_of_inertia + + def get_bonded_axes(self, system, atom, dimensions): + """ + For a given heavy atom, use its bonded atoms to get the axes + for rotating forces around. Few cases for choosing united atom axes, + which are dependent on the bonds to the atom: + + :: + + X -- H = bonded to zero or more light atom/s (case1) + + X -- R = bonded to one heavy atom (case2) + + R -- X -- H = bonded to one heavy and at least one light atom (case3) + + R1 -- X -- R2 = bonded to two heavy atoms (case4) + + R1 -- X -- R2 = bonded to more than two heavy atoms (case5) + | + R3 + + Note that axis2 is calculated by taking the cross product between axis1 and + the vector chosen for each case, dependent on bonding: + + - case1: if all the bonded atoms are hydrogens, use the principal axes. + + - case2: use XR vector as axis1, arbitrary axis2. + + - case3: use XR vector as axis1, vector XH to calculate axis2 + + - case4: use vector XR1 as axis1, and XR2 to calculate axis2 + + - case5: get the sum of all XR normalised vectors as axis1, then use vector + R1R2 to calculate axis2 + + axis3 is always the cross product of axis1 and axis2. + + Args: + system: mdanalysis instance of all atoms in current frame + atom: mdanalysis instance of a heavy atom + dimensions: dimensions of the simulation box (3,) + + Returns: + custom_axes: custom axes for the UA, (3,3) array + custom_moment_of_inertia + """ + # check atom is a heavy atom + if not atom.mass > 1.1: + return None + # set default values + custom_moment_of_inertia = None + custom_axes = None + + # find the heavy bonded atoms and light bonded atoms + heavy_bonded, light_bonded = self.find_bonded_atoms(atom.index, system) + UA = atom + light_bonded + UA_all = atom + heavy_bonded + light_bonded + + # now find which atoms to select to find the axes for rotating forces: + # case1 + if len(heavy_bonded) == 0: + custom_axes, custom_moment_of_inertia = self.get_vanilla_axes(UA_all) + # case2 + if len(heavy_bonded) == 1 and len(light_bonded) == 0: + custom_axes = self.get_custom_axes( + atom.position, [heavy_bonded[0].position], np.zeros(3), dimensions + ) + # case3 + if len(heavy_bonded) == 1 and len(light_bonded) >= 1: + custom_axes = self.get_custom_axes( + atom.position, + [heavy_bonded[0].position], + light_bonded[0].position, + dimensions, + ) + # case4, not used in Jon's 2019 paper code, use case5 instead + # case5 + if len(heavy_bonded) >= 2: + custom_axes = self.get_custom_axes( + atom.position, + heavy_bonded.positions, + heavy_bonded[1].position, + dimensions, + ) + + if custom_moment_of_inertia is None: + # find moment of inertia using custom axes and atom position as COM + custom_moment_of_inertia = self.get_custom_moment_of_inertia( + UA, custom_axes, atom.position, dimensions + ) + + # get the moment of inertia from the custom axes + if custom_axes is not None: + # flip axes to face correct way wrt COM + custom_axes = self.get_flipped_axes( + UA, custom_axes, atom.position, dimensions + ) + + return custom_axes, custom_moment_of_inertia + + def find_bonded_atoms(self, atom_idx: int, system): + """ + for a given atom, find its bonded heavy and H atoms + + Args: + atom_idx: atom index to find bonded heavy atom for + system: mdanalysis instance of all atoms in current frame + + Returns: + bonded_heavy_atoms: MDAnalysis instance of bonded heavy atoms + bonded_H_atoms: MDAnalysis instance of bonded hydrogen atoms + """ + bonded_atoms = system.select_atoms(f"bonded index {atom_idx}") + bonded_heavy_atoms = bonded_atoms.select_atoms("mass 2 to 999") + bonded_H_atoms = bonded_atoms.select_atoms("mass 1 to 1.1") + return bonded_heavy_atoms, bonded_H_atoms + + def get_vanilla_axes(self, molecule): + """ + Compute the principal axes and sorted moments of inertia for a molecule. + + This method computes the translationally invariant principal axes and + corresponding moments of inertia for a molecular selection using the + default MDAnalysis routines. The molecule is first made whole to ensure + correct handling of periodic boundary conditions. + + The moments of inertia are obtained by diagonalising the moment of inertia + tensor and are returned sorted from largest to smallest magnitude. + + Args: + molecule (MDAnalysis.core.groups.AtomGroup): + AtomGroup representing the molecule or bead for which the axes + and moments of inertia are to be computed. + + Returns: + Tuple[np.ndarray, np.ndarray]: + A tuple containing: + + - principal_axes (np.ndarray): + Array of shape ``(3, 3)`` whose rows correspond to the + principal axes of the molecule. + - moment_of_inertia (np.ndarray): + Array of shape ``(3,)`` containing the moments of inertia + sorted in descending order. + """ + moment_of_inertia = molecule.moment_of_inertia(unwrap=True) + make_whole(molecule.atoms) + principal_axes = molecule.principal_axes() + + eigenvalues, _eigenvectors = np.linalg.eig(moment_of_inertia) + + # Sort eigenvalues from largest to smallest magnitude + order = np.argsort(np.abs(eigenvalues))[::-1] + moment_of_inertia = eigenvalues[order] + + return principal_axes, moment_of_inertia + + def get_custom_axes( + self, a: np.ndarray, b_list: list, c: np.ndarray, dimensions: np.ndarray + ): + r""" + For atoms a, b_list and c, calculate the axis to rotate forces around: + + - axis1: use the normalised vector ab as axis1. If there is more than one bonded + heavy atom (HA), average over all the normalised vectors calculated from b_list + and use this as axis1). b_list contains all the bonded heavy atom + coordinates. + + - axis2: use the cross product of normalised vector ac and axis1 as axis2. + If there are more than two bonded heavy atoms, then use normalised vector + b[0]c to cross product with axis1, this gives the axis perpendicular + (represented by |_ symbol below) to axis1. + + - axis3: the cross product of axis1 and axis2, which is perpendicular to + axis1 and axis2. + + Args: + a: central united-atom coordinates (3,) + b_list: list of heavy bonded atom positions (3,N) + c: atom coordinates of either a second heavy atom or a hydrogen atom + if there are no other bonded heavy atoms in b_list (where N=1 in b_list) + (3,) + dimensions: dimensions of the simulation box (3,) + + :: + + a 1 = norm_ab + / \ 2 = |_ norm_ab and norm_ac (use bc if more than 2 HAs) + / \ 3 = |_ 1 and 2 + b c + + Returns: + custom_axes: (3,3) array of the axes used to rotate forces + """ + unscaled_axis1 = np.zeros(3) + # average of all heavy atom covalent bond vectors for axis1 + for b in b_list: + ab_vector = self.get_vector(a, b, dimensions) + unscaled_axis1 += ab_vector + if len(b_list) >= 2: + # use the first heavy bonded atom as atom a + ac_vector = self.get_vector(c, b_list[0], dimensions) + else: + ac_vector = self.get_vector(c, a, dimensions) + + unscaled_axis2 = np.cross(ac_vector, unscaled_axis1) + unscaled_axis3 = np.cross(unscaled_axis2, unscaled_axis1) + + unscaled_custom_axes = np.array( + (unscaled_axis1, unscaled_axis2, unscaled_axis3) + ) + mod = np.sqrt(np.sum(unscaled_custom_axes**2, axis=1)) + scaled_custom_axes = unscaled_custom_axes / mod[:, np.newaxis] + + return scaled_custom_axes + + def get_custom_moment_of_inertia( + self, + UA, + custom_rotation_axes: np.ndarray, + center_of_mass: np.ndarray, + dimensions: np.ndarray, + ): + """ + Get the moment of inertia (specifically used for the united atom level) + from a set of rotation axes and a given center of mass + (COM is usually the heavy atom position in a UA). + + Args: + UA: MDAnalysis instance of a united-atom + custom_rotation_axes: (3,3) arrray of rotation axes + center_of_mass: (3,) center of mass for collection of atoms N + + Returns: + custom_moment_of_inertia: (3,) array for moment of inertia + """ + translated_coords = self.get_vector(center_of_mass, UA.positions, dimensions) + custom_moment_of_inertia = np.zeros(3) + for coord, mass in zip(translated_coords, UA.masses): + axis_component = np.sum( + np.cross(custom_rotation_axes, coord) ** 2 * mass, axis=1 + ) + custom_moment_of_inertia += axis_component + + # Remove lowest MOI degree of freedom if UA only has a single bonded H + if len(UA) == 2: + order = custom_moment_of_inertia.argsort()[::-1] # decending order + custom_moment_of_inertia[order[-1]] = 0 + + return custom_moment_of_inertia + + def get_flipped_axes(self, UA, custom_axes, center_of_mass, dimensions): + """ + For a given set of custom axes, ensure the axes are pointing in the + correct direction wrt the heavy atom position and the chosen center + of mass. + + Args: + UA: MDAnalysis instance of a united-atom + custom_axes: (3,3) array of the rotation axes + center_of_mass: (3,) array for center of mass (usually HA position) + dimensions: (3,) array of system box dimensions. + """ + # sorting out PIaxes for MoI for UA fragment + + # get dot product of Paxis1 and CoM->atom1 vect + # will just be [0,0,0] + RRaxis = self.get_vector(UA[0].position, center_of_mass, dimensions) + + # flip each Paxis if its pointing out of UA + custom_axis = np.sum(custom_axes**2, axis=1) + custom_axes_flipped = custom_axes / custom_axis**0.5 + for i in range(3): + dotProd1 = np.dot(custom_axes_flipped[i], RRaxis) + custom_axes_flipped[i] = np.where( + dotProd1 < 0, -custom_axes_flipped[i], custom_axes_flipped[i] + ) + return custom_axes_flipped + + def get_vector(self, a: np.ndarray, b: np.ndarray, dimensions: np.ndarray): + """ + For vector of two coordinates over periodic boundary conditions (PBCs). + + Args: + a: (N,3) array of atom cooordinates + b: (3,) array of atom cooordinates + dimensions: (3,) array of system box dimensions. + + Returns: + delta_wrapped: (N,3) array of the vector + """ + delta = b - a + delta -= dimensions * np.round(delta / dimensions) + + return delta + + def get_moment_of_inertia_tensor( + self, + center_of_mass: np.ndarray, + positions: np.ndarray, + masses: list, + dimensions: np.array, + ) -> np.ndarray: + """ + Calculate a custom moment of inertia tensor. + E.g., for cases where the mass list will contain masses of UAs rather than + individual atoms and the postions will be those for the UAs only + (excluding the H atoms coordinates). + + Args: + center_of_mass: a (3,) array of the chosen center of mass + positions: a (N,3) array of point positions + masses: a (N,) list of point masses + + Returns: + moment_of_inertia_tensor: a (3,3) moment of inertia tensor + """ + r = self.get_vector(center_of_mass, positions, dimensions) + r2 = np.sum(r**2, axis=1) + moment_of_inertia_tensor = np.eye(3) * np.sum(masses * r2) + moment_of_inertia_tensor -= np.einsum("i,ij,ik->jk", masses, r, r) + + return moment_of_inertia_tensor + + def get_custom_principal_axes( + self, moment_of_inertia_tensor: np.ndarray + ) -> tuple[np.ndarray, np.ndarray]: + """ + Principal axes and centre of axes from the ordered eigenvalues + and eigenvectors of a moment of inertia tensor. This function allows for + a custom moment of inertia tensor to be used, which isn't possible with + the built-in MDAnalysis principal_axes() function. + + Args: + moment_of_inertia_tensor: a (3,3) array of a custom moment of + inertia tensor + + Returns: + principal_axes: a (3,3) array for the principal axes + moment_of_inertia: a (3,) array of the principal axes center + """ + eigenvalues, eigenvectors = np.linalg.eig(moment_of_inertia_tensor) + order = abs(eigenvalues).argsort()[::-1] # decending order + transposed = np.transpose(eigenvectors) # turn columns to rows + moment_of_inertia = eigenvalues[order] + principal_axes = transposed[order] + + # point z axis in correct direction, as per Jon's code + cross_xy = np.cross(principal_axes[0], principal_axes[1]) + dot_z = np.dot(cross_xy, principal_axes[2]) + if dot_z < 0: + principal_axes[2] *= -1 + + return principal_axes, moment_of_inertia + + def get_UA_masses(self, molecule) -> list[float]: + """ + For a given molecule, return a list of masses of UAs + (combination of the heavy atoms + bonded hydrogen atoms. This list is used to + get the moment of inertia tensor for molecules larger than one UA. + + Args: + molecule: mdanalysis instance of molecule + + Returns: + UA_masses: list of masses for each UA in a molecule + """ + UA_masses = [] + for atom in molecule: + if atom.mass > 1.1: + UA_mass = atom.mass + bonded_atoms = molecule.select_atoms(f"bonded index {atom.index}") + bonded_H_atoms = bonded_atoms.select_atoms("mass 1 to 1.1") + for H in bonded_H_atoms: + UA_mass += H.mass + UA_masses.append(UA_mass) + else: + continue + return UA_masses From 39903d1a5ab69f718ceef85a82f4104e1869cbc2 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 6 Feb 2026 16:44:21 +0000 Subject: [PATCH 040/101] update entropy calculations within DAG to behave like procedural implementation --- CodeEntropy/entropy/entropy_graph.py | 43 ++-- .../entropy/nodes/aggregate_entropy_node.py | 26 ++- .../nodes/configurational_entropy_node.py | 80 ++++---- .../entropy/nodes/vibrational_entropy_node.py | 194 ++++++++---------- 4 files changed, 168 insertions(+), 175 deletions(-) diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index 3b060032..fa8a6f0e 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,5 +1,7 @@ # CodeEntropy/entropy/entropy_graph.py +from __future__ import annotations + import networkx as nx from CodeEntropy.entropy.nodes.aggregate_entropy_node import AggregateEntropyNode @@ -10,29 +12,34 @@ class EntropyGraph: + """ + Entropy DAG. + + Nodes operate on shared_data (produced by EntropyManager + LevelDAG) and + write results to DataLogger. + + Graph: + vibrational_entropy ----\ + -> aggregate_entropy + configurational_entropy --/ + """ + def __init__(self): self.graph = nx.DiGraph() self.nodes = {} - def build(self): - self.add("vibrational_entropy", VibrationalEntropyNode()) - self.add( - "configurational_entropy", - ConfigurationalEntropyNode(), - depends_on=["vibrational_entropy"], - ) - self.add( - "aggregate_entropy", - AggregateEntropyNode(), - depends_on=["configurational_entropy"], - ) - return self + def build(self) -> "EntropyGraph": + self.nodes["vibrational_entropy"] = VibrationalEntropyNode() + self.nodes["configurational_entropy"] = ConfigurationalEntropyNode() + self.nodes["aggregate_entropy"] = AggregateEntropyNode() + + for n in self.nodes: + self.graph.add_node(n) - def add(self, name, obj, depends_on=None): - self.nodes[name] = obj - self.graph.add_node(name) - for dep in depends_on or []: - self.graph.add_edge(dep, name) + self.graph.add_edge("vibrational_entropy", "aggregate_entropy") + self.graph.add_edge("configurational_entropy", "aggregate_entropy") + + return self def execute(self, shared_data): results = {} diff --git a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py index 53a9c5d8..2f00bb21 100644 --- a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py +++ b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py @@ -1,22 +1,26 @@ -# CodeEntropy/entropy/nodes/aggregate_entropy_node.py - +import logging from typing import Any, Dict +logger = logging.getLogger(__name__) + class AggregateEntropyNode: """ - Aggregates entropy outputs into shared_data for downstream use. + Aggregates entropy results for convenience. """ def run( self, shared_data: Dict[str, Any], - vibrational_entropy=None, - configurational_entropy=None, - **_, - ): - shared_data["entropy_results"] = { - "vibrational": vibrational_entropy, - "configurational": configurational_entropy, + vibrational_entropy: Dict[str, Any], + configurational_entropy: Dict[str, Any], + **_kwargs, + ) -> Dict[str, Any]: + out = { + "vibrational_entropy": vibrational_entropy.get("vibrational_entropy", {}), + "configurational_entropy": configurational_entropy.get( + "configurational_entropy", {} + ), } - return {"entropy_results": shared_data["entropy_results"]} + logger.info("[AggregateEntropyNode] Done") + return {"entropy": out} diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 8d5a7a2c..6fbe5cd1 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -1,5 +1,3 @@ -# CodeEntropy/entropy/nodes/configurational_entropy_node.py - import logging from typing import Any, Dict @@ -8,40 +6,48 @@ logger = logging.getLogger(__name__) +def _build_gid2i(shared_data: Dict[str, Any]) -> Dict[int, int]: + """ + Prefer LevelDAG-provided mapping. Otherwise create a stable mapping based on + insertion order of groups. + """ + gid2i = shared_data.get("group_id_to_index") + if isinstance(gid2i, dict) and gid2i: + return gid2i + + groups = shared_data["groups"] + return {gid: i for i, gid in enumerate(groups.keys())} + + class ConfigurationalEntropyNode: """ Computes conformational entropy from conformational states produced by LevelDAG. - Expected shapes: + Expected: shared_data["conformational_states"]["ua"] - -> dict[(group_id, res_id)] = list[int] or list[str] (length ~ n_frames) + dict[(group_id, res_id)] -> list/array of state labels (len ~ n_frames) shared_data["conformational_states"]["res"] - -> list indexed by group_id = list[int] or list[str] (length ~ n_frames) OR [] + list indexed by group_index -> list/array of state labels """ - def run(self, shared_data: Dict[str, Any], **_kwargs): + def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: run_manager = shared_data["run_manager"] args = shared_data["args"] universe = shared_data["reduced_universe"] data_logger = shared_data.get("data_logger") + group_molecules = shared_data.get("group_molecules") ce = ConformationalEntropy( run_manager=run_manager, args=args, universe=universe, data_logger=data_logger, - group_molecules=shared_data.get("group_molecules"), + group_molecules=group_molecules, ) - if "conformational_states" not in shared_data: - raise KeyError( - "shared_data['conformational_states'] is missing. " - "Did LevelDAG run ComputeConformationalStatesNode?" - ) - conf_states = shared_data["conformational_states"] - states_ua = conf_states.get("ua", {}) # dict[(group_id, res_id)] -> states - states_res = conf_states.get("res", []) # list[group_id] -> states + states_ua = conf_states.get("ua", {}) or {} + states_res = conf_states.get("res", []) or [] n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) if n_frames is None: @@ -49,59 +55,57 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): groups = shared_data["groups"] levels = shared_data["levels"] + frame_counts = shared_data.get("frame_counts", {}) + ua_counts = frame_counts.get("ua", {}) if isinstance(frame_counts, dict) else {} - fragments = universe.atoms.fragments + gid2i = _build_gid2i(shared_data) + fragments = universe.atoms.fragments results: Dict[int, Dict[str, float]] = {} for group_id, mol_ids in groups.items(): mol_id = mol_ids[0] - level_list = levels[mol_id] mol = fragments[mol_id] + level_list = levels[mol_id] - results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} + results[group_id] = {"ua": 0.0, "res": 0.0} if "united_atom" in level_list: total_ua = 0.0 - for (gid, res_id), states in states_ua.items(): - if gid != group_id or not states: - continue + for res_id, res in enumerate(mol.residues): + key = (group_id, res_id) + st = states_ua.get(key, None) + if not st: + val = 0.0 + else: + val = ce.conformational_entropy_calculation(st, n_frames) - s_res = ce.conformational_entropy_calculation(states, n_frames) - total_ua += s_res + total_ua += val if data_logger is not None: - if res_id < len(mol.residues): - resname = mol.residues[res_id].resname - else: - resname = f"RES{res_id}" - + fc = ua_counts.get(key, n_frames) data_logger.add_residue_data( group_id=group_id, - resname=resname, + resname=res.resname, level="united_atom", entropy_type="Conformational", - frame_count=n_frames, - value=s_res, + frame_count=fc, + value=val, ) results[group_id]["ua"] = total_ua - if data_logger is not None: data_logger.add_results_data( group_id, "united_atom", "Conformational", total_ua ) if "residue" in level_list: - if group_id < len(states_res) and states_res[group_id]: - s = states_res[group_id] - val = ce.conformational_entropy_calculation(s, n_frames) - else: - val = 0.0 + gi = gid2i[group_id] + st = states_res[gi] if gi < len(states_res) else None + val = ce.conformational_entropy_calculation(st, n_frames) if st else 0.0 results[group_id]["res"] = val - if data_logger is not None: data_logger.add_results_data( group_id, "residue", "Conformational", val diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 508701d7..2123c264 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -1,5 +1,3 @@ -# CodeEntropy/entropy/nodes/vibrational_entropy_node.py - import logging from typing import Any, Dict @@ -11,23 +9,29 @@ logger = logging.getLogger(__name__) +def _build_gid2i(shared_data: Dict[str, Any]) -> Dict[int, int]: + gid2i = shared_data.get("group_id_to_index") + if isinstance(gid2i, dict) and gid2i: + return gid2i + groups = shared_data["groups"] + return {gid: i for i, gid in enumerate(groups.keys())} + + class VibrationalEntropyNode: """ - Computes vibrational entropy from force and torque covariance matrices. + Computes vibrational entropy from force/torque covariance matrices. - Expects LevelDAG to have produced: + Expects: shared_data["force_covariances"] : {"ua": dict, "res": list, "poly": list} shared_data["torque_covariances"] : {"ua": dict, "res": list, "poly": list} - shared_data["levels"], shared_data["groups"] - - Also optionally uses: - shared_data["frame_counts"] for residue table counts + shared_data["frame_counts"] : {"ua": dict, "res": array/list, + "poly": array/list} """ def __init__(self): self._mat_ops = MatrixOperations() - def run(self, shared_data: Dict[str, Any], **_kwargs): + def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: run_manager = shared_data["run_manager"] args = shared_data["args"] universe = shared_data["reduced_universe"] @@ -43,12 +47,18 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): ) temp = args.temperature - levels = shared_data["levels"] groups = shared_data["groups"] + levels = shared_data["levels"] force_cov = shared_data["force_covariances"] torque_cov = shared_data["torque_covariances"] - frame_counts = shared_data.get("frame_counts", None) + counts = shared_data.get("frame_counts", {}) + + ua_counts = counts.get("ua", {}) if isinstance(counts, dict) else {} + # res_counts = counts.get("res", None) + # poly_counts = counts.get("poly", None) + + gid2i = _build_gid2i(shared_data) fragments = universe.atoms.fragments @@ -56,148 +66,116 @@ def run(self, shared_data: Dict[str, Any], **_kwargs): for group_id, mol_ids in groups.items(): mol_id = mol_ids[0] - level_list = levels[mol_id] mol = fragments[mol_id] + level_list = levels[mol_id] vib_results[group_id] = {} for level in level_list: + # highest = level == level_list[-1] + if level == "united_atom": - S_trans_total = 0.0 - S_rot_total = 0.0 + S_trans = 0.0 + S_rot = 0.0 - # UA is stored per (group_id, res_id) - for (gid, res_id), fmat in force_cov["ua"].items(): - if gid != group_id: - continue + for res_id, res in enumerate(mol.residues): + key = (group_id, res_id) - tmat = torque_cov["ua"].get((gid, res_id)) - if tmat is None: - continue + fmat = force_cov["ua"].get(key) + tmat = torque_cov["ua"].get(key) - fmat_arr = self._mat_ops.filter_zero_rows_columns( - np.array(fmat) - ) - tmat_arr = self._mat_ops.filter_zero_rows_columns( - np.array(tmat) - ) + if fmat is None or tmat is None: + val_trans, val_rot = 0.0, 0.0 + else: + fmat = self._mat_ops.filter_zero_rows_columns( + np.asarray(fmat) + ) + tmat = self._mat_ops.filter_zero_rows_columns( + np.asarray(tmat) + ) - S_trans = ve.vibrational_entropy_calculation( - fmat_arr, "force", temp, highest_level=False - ) - S_rot = ve.vibrational_entropy_calculation( - tmat_arr, "torque", temp, highest_level=False - ) + val_trans = ve.vibrational_entropy_calculation( + fmat, "force", temp, highest_level=False + ) + val_rot = ve.vibrational_entropy_calculation( + tmat, "torque", temp, highest_level=False + ) - S_trans_total += S_trans - S_rot_total += S_rot + S_trans += val_trans + S_rot += val_rot if data_logger is not None: - if res_id < len(mol.residues): - resname = mol.residues[res_id].resname - else: - resname = f"RES{res_id}" - - count_val = None - if frame_counts is not None: - count_val = frame_counts["ua"].get( - (group_id, res_id), None - ) - + fc = ua_counts.get(key, shared_data.get("n_frames", 0)) data_logger.add_residue_data( group_id=group_id, - resname=resname, + resname=res.resname, level="united_atom", entropy_type="Transvibrational", - frame_count=count_val, - value=S_trans, + frame_count=fc, + value=val_trans, ) data_logger.add_residue_data( group_id=group_id, - resname=resname, + resname=res.resname, level="united_atom", entropy_type="Rovibrational", - frame_count=count_val, - value=S_rot, + frame_count=fc, + value=val_rot, ) - vib_results[group_id][level] = { - "trans": S_trans_total, - "rot": S_rot_total, - } - - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans_total - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot_total - ) - elif level == "residue": - fmat = force_cov["res"][group_id] - tmat = torque_cov["res"][group_id] + gi = gid2i[group_id] + + fmat = force_cov["res"][gi] if gi < len(force_cov["res"]) else None + tmat = ( + torque_cov["res"][gi] if gi < len(torque_cov["res"]) else None + ) if fmat is None or tmat is None: S_trans, S_rot = 0.0, 0.0 else: - fmat_arr = self._mat_ops.filter_zero_rows_columns( - np.array(fmat) - ) - tmat_arr = self._mat_ops.filter_zero_rows_columns( - np.array(tmat) - ) - + fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) + tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) S_trans = ve.vibrational_entropy_calculation( - fmat_arr, "force", temp, highest_level=False + fmat, "force", temp, highest_level=False ) S_rot = ve.vibrational_entropy_calculation( - tmat_arr, "torque", temp, highest_level=False - ) - - vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} - - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot + tmat, "torque", temp, highest_level=False ) elif level == "polymer": - fmat = force_cov["poly"][group_id] - tmat = torque_cov["poly"][group_id] + gi = gid2i[group_id] + + fmat = ( + force_cov["poly"][gi] if gi < len(force_cov["poly"]) else None + ) + tmat = ( + torque_cov["poly"][gi] if gi < len(torque_cov["poly"]) else None + ) if fmat is None or tmat is None: S_trans, S_rot = 0.0, 0.0 else: - fmat_arr = self._mat_ops.filter_zero_rows_columns( - np.array(fmat) - ) - tmat_arr = self._mat_ops.filter_zero_rows_columns( - np.array(tmat) - ) - + fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) + tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) S_trans = ve.vibrational_entropy_calculation( - fmat_arr, "force", temp, highest_level=True + fmat, "force", temp, highest_level=True ) S_rot = ve.vibrational_entropy_calculation( - tmat_arr, "torque", temp, highest_level=True + tmat, "torque", temp, highest_level=True ) - - vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} - - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) - else: raise ValueError(f"Unknown level: {level}") + vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} + + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot + ) + logger.info("[VibrationalEntropyNode] Done") return {"vibrational_entropy": vib_results} From 6875f5403ab46fc0ae81db798d50710d8722a226 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 12 Feb 2026 10:13:25 +0000 Subject: [PATCH 041/101] include `forcetorque` and `forcetorque_covariances` withi DAG architecture --- CodeEntropy/entropy/entropy_graph.py | 58 +-- .../entropy/nodes/aggregate_entropy_node.py | 25 +- .../nodes/configurational_entropy_node.py | 68 ++- .../entropy/nodes/vibrational_entropy_node.py | 98 +++-- CodeEntropy/entropy/vibrational_entropy.py | 142 +++---- CodeEntropy/levels/force_torque_manager.py | 181 +++----- CodeEntropy/levels/hierarchy_graph.py | 47 ++- CodeEntropy/levels/level_hierarchy.py | 57 ++- CodeEntropy/levels/nodes/build_beads.py | 76 +++- CodeEntropy/levels/nodes/frame_axes.py | 26 +- CodeEntropy/levels/nodes/frame_covariance.py | 396 +++++++----------- .../nodes/init_covariance_accumulators.py | 52 ++- 12 files changed, 571 insertions(+), 655 deletions(-) diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index fa8a6f0e..80cd7b39 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,6 +1,5 @@ -# CodeEntropy/entropy/entropy_graph.py - -from __future__ import annotations +import logging +from typing import Any, Dict, Optional import networkx as nx @@ -10,41 +9,42 @@ ) from CodeEntropy.entropy.nodes.vibrational_entropy_node import VibrationalEntropyNode +logger = logging.getLogger(__name__) + class EntropyGraph: """ - Entropy DAG. - - Nodes operate on shared_data (produced by EntropyManager + LevelDAG) and - write results to DataLogger. - - Graph: - vibrational_entropy ----\ - -> aggregate_entropy - configurational_entropy --/ + Entropy DAG (simple, stable): + vibrational_entropy + configurational_entropy + aggregate_entropy """ def __init__(self): self.graph = nx.DiGraph() - self.nodes = {} + self.nodes: Dict[str, Any] = {} def build(self) -> "EntropyGraph": - self.nodes["vibrational_entropy"] = VibrationalEntropyNode() - self.nodes["configurational_entropy"] = ConfigurationalEntropyNode() - self.nodes["aggregate_entropy"] = AggregateEntropyNode() - - for n in self.nodes: - self.graph.add_node(n) - - self.graph.add_edge("vibrational_entropy", "aggregate_entropy") - self.graph.add_edge("configurational_entropy", "aggregate_entropy") - + self._add("vibrational_entropy", VibrationalEntropyNode()) + self._add("configurational_entropy", ConfigurationalEntropyNode()) + self._add( + "aggregate_entropy", + AggregateEntropyNode(), + deps=["vibrational_entropy", "configurational_entropy"], + ) return self - def execute(self, shared_data): - results = {} - for node in nx.topological_sort(self.graph): - preds = list(self.graph.predecessors(node)) - kwargs = {p: results[p] for p in preds} - results[node] = self.nodes[node].run(shared_data, **kwargs) + def _add(self, name: str, node: Any, deps: Optional[list[str]] = None) -> None: + self.nodes[name] = node + self.graph.add_node(name) + for d in deps or []: + self.graph.add_edge(d, name) + + def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: + results: Dict[str, Any] = {} + for node_name in nx.topological_sort(self.graph): + logger.info(f"[EntropyGraph] node: {node_name}") + out = self.nodes[node_name].run(shared_data) + if isinstance(out, dict): + results.update(out) return results diff --git a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py index 2f00bb21..ed5c7f79 100644 --- a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py +++ b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py @@ -1,26 +1,11 @@ -import logging from typing import Any, Dict -logger = logging.getLogger(__name__) - class AggregateEntropyNode: - """ - Aggregates entropy results for convenience. - """ - - def run( - self, - shared_data: Dict[str, Any], - vibrational_entropy: Dict[str, Any], - configurational_entropy: Dict[str, Any], - **_kwargs, - ) -> Dict[str, Any]: + def run(self, shared_data: Dict[str, Any], **_) -> Dict[str, Any]: out = { - "vibrational_entropy": vibrational_entropy.get("vibrational_entropy", {}), - "configurational_entropy": configurational_entropy.get( - "configurational_entropy", {} - ), + "vibrational_entropy": shared_data.get("vibrational_entropy"), + "configurational_entropy": shared_data.get("configurational_entropy"), } - logger.info("[AggregateEntropyNode] Done") - return {"entropy": out} + shared_data["entropy_results"] = out + return {"entropy_results": out} diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 6fbe5cd1..38797ddc 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -6,28 +6,15 @@ logger = logging.getLogger(__name__) -def _build_gid2i(shared_data: Dict[str, Any]) -> Dict[int, int]: - """ - Prefer LevelDAG-provided mapping. Otherwise create a stable mapping based on - insertion order of groups. - """ - gid2i = shared_data.get("group_id_to_index") - if isinstance(gid2i, dict) and gid2i: - return gid2i - - groups = shared_data["groups"] - return {gid: i for i, gid in enumerate(groups.keys())} - - class ConfigurationalEntropyNode: """ Computes conformational entropy from conformational states produced by LevelDAG. Expected: shared_data["conformational_states"]["ua"] - dict[(group_id, res_id)] -> list/array of state labels (len ~ n_frames) + -> dict[(group_id, res_id)] = list/int states shared_data["conformational_states"]["res"] - list indexed by group_index -> list/array of state labels + -> list indexed by group index OR dict[group_id]=states """ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: @@ -35,19 +22,16 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: args = shared_data["args"] universe = shared_data["reduced_universe"] data_logger = shared_data.get("data_logger") - group_molecules = shared_data.get("group_molecules") ce = ConformationalEntropy( run_manager=run_manager, args=args, universe=universe, data_logger=data_logger, - group_molecules=group_molecules, + group_molecules=shared_data.get("group_molecules"), ) conf_states = shared_data["conformational_states"] - states_ua = conf_states.get("ua", {}) or {} - states_res = conf_states.get("res", []) or [] n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) if n_frames is None: @@ -55,55 +39,65 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: groups = shared_data["groups"] levels = shared_data["levels"] - frame_counts = shared_data.get("frame_counts", {}) - ua_counts = frame_counts.get("ua", {}) if isinstance(frame_counts, dict) else {} + gid2i = shared_data.get( + "group_id_to_index", {gid: i for i, gid in enumerate(groups.keys())} + ) - gid2i = _build_gid2i(shared_data) + states_ua = conf_states.get("ua", {}) or {} + states_res = conf_states.get("res", {}) - fragments = universe.atoms.fragments results: Dict[int, Dict[str, float]] = {} + fragments = universe.atoms.fragments + for group_id, mol_ids in groups.items(): mol_id = mol_ids[0] mol = fragments[mol_id] level_list = levels[mol_id] - results[group_id] = {"ua": 0.0, "res": 0.0} + results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} if "united_atom" in level_list: - total_ua = 0.0 - + total = 0.0 for res_id, res in enumerate(mol.residues): key = (group_id, res_id) - st = states_ua.get(key, None) - if not st: + states = states_ua.get(key, []) + if not states: val = 0.0 else: - val = ce.conformational_entropy_calculation(st, n_frames) + val = ce.conformational_entropy_calculation(states, n_frames) - total_ua += val + total += val if data_logger is not None: - fc = ua_counts.get(key, n_frames) data_logger.add_residue_data( group_id=group_id, resname=res.resname, level="united_atom", entropy_type="Conformational", - frame_count=fc, + frame_count=n_frames, value=val, ) - results[group_id]["ua"] = total_ua + results[group_id]["ua"] = total if data_logger is not None: data_logger.add_results_data( - group_id, "united_atom", "Conformational", total_ua + group_id, "united_atom", "Conformational", total ) if "residue" in level_list: - gi = gid2i[group_id] - st = states_res[gi] if gi < len(states_res) else None - val = ce.conformational_entropy_calculation(st, n_frames) if st else 0.0 + val = 0.0 + + if isinstance(states_res, dict): + s = states_res.get(group_id, []) + if s: + val = ce.conformational_entropy_calculation(s, n_frames) + else: + gi = gid2i[group_id] + if gi < len(states_res) and states_res[gi]: + val = ce.conformational_entropy_calculation( + states_res[gi], n_frames + ) results[group_id]["res"] = val if data_logger is not None: diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 2123c264..fda2a59c 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -18,16 +18,6 @@ def _build_gid2i(shared_data: Dict[str, Any]) -> Dict[int, int]: class VibrationalEntropyNode: - """ - Computes vibrational entropy from force/torque covariance matrices. - - Expects: - shared_data["force_covariances"] : {"ua": dict, "res": list, "poly": list} - shared_data["torque_covariances"] : {"ua": dict, "res": list, "poly": list} - shared_data["frame_counts"] : {"ua": dict, "res": array/list, - "poly": array/list} - """ - def __init__(self): self._mat_ops = MatrixOperations() @@ -52,14 +42,14 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: force_cov = shared_data["force_covariances"] torque_cov = shared_data["torque_covariances"] - counts = shared_data.get("frame_counts", {}) + combined = bool(getattr(args, "combined_forcetorque", False)) + ft_cov = shared_data.get("forcetorque_covariances") if combined else None + + counts = shared_data.get("frame_counts", {}) ua_counts = counts.get("ua", {}) if isinstance(counts, dict) else {} - # res_counts = counts.get("res", None) - # poly_counts = counts.get("poly", None) gid2i = _build_gid2i(shared_data) - fragments = universe.atoms.fragments vib_results: Dict[int, Dict[str, Dict[str, float]]] = {} @@ -125,7 +115,6 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: elif level == "residue": gi = gid2i[group_id] - fmat = force_cov["res"][gi] if gi < len(force_cov["res"]) else None tmat = ( torque_cov["res"][gi] if gi < len(torque_cov["res"]) else None @@ -136,6 +125,7 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: else: fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) + S_trans = ve.vibrational_entropy_calculation( fmat, "force", temp, highest_level=False ) @@ -146,36 +136,72 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: elif level == "polymer": gi = gid2i[group_id] - fmat = ( - force_cov["poly"][gi] if gi < len(force_cov["poly"]) else None - ) - tmat = ( - torque_cov["poly"][gi] if gi < len(torque_cov["poly"]) else None - ) + if combined and ft_cov is not None: + ftmat = ft_cov["poly"][gi] if gi < len(ft_cov["poly"]) else None + if ftmat is None: + S_trans, S_rot = 0.0, 0.0 + else: + ftmat = np.asarray(ftmat) + ftmat = self._mat_ops.filter_zero_rows_columns(ftmat) - if fmat is None or tmat is None: - S_trans, S_rot = 0.0, 0.0 + S_trans = ve.vibrational_entropy_calculation( + ftmat, "forcetorqueTRANS", temp, highest_level=True + ) + S_rot = ve.vibrational_entropy_calculation( + ftmat, "forcetorqueROT", temp, highest_level=True + ) else: - fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) - tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) - S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=True + fmat = ( + force_cov["poly"][gi] + if gi < len(force_cov["poly"]) + else None ) - S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=True + tmat = ( + torque_cov["poly"][gi] + if gi < len(torque_cov["poly"]) + else None ) + + if fmat is None or tmat is None: + S_trans, S_rot = 0.0, 0.0 + else: + fmat = self._mat_ops.filter_zero_rows_columns( + np.asarray(fmat) + ) + tmat = self._mat_ops.filter_zero_rows_columns( + np.asarray(tmat) + ) + + S_trans = ve.vibrational_entropy_calculation( + fmat, "force", temp, highest_level=True + ) + S_rot = ve.vibrational_entropy_calculation( + tmat, "torque", temp, highest_level=True + ) + else: raise ValueError(f"Unknown level: {level}") - vib_results[group_id][level] = {"trans": S_trans, "rot": S_rot} + vib_results[group_id][level] = { + "trans": float(S_trans), + "rot": float(S_rot), + } if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) + if level == "polymer" and combined: + data_logger.add_results_data( + group_id, level, "FTmat-Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "FTmat-Rovibrational", S_rot + ) + else: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot + ) logger.info("[VibrationalEntropyNode] Done") return {"vibrational_entropy": vib_results} diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index 1968e2c3..b78ab40b 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -22,41 +22,15 @@ def __init__(self, run_manager, args, universe, data_logger, group_molecules): self._GAS_CONST = 8.3144598484848 def frequency_calculation(self, lambdas, temp): - """ - Function to calculate an array of vibrational frequencies from the eigenvalues - of the covariance matrix. - - Calculated from eq. (3) in Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, - Molecular Physics, 2018, 116, 1965–1976//eq. (3) in A. Chakravorty, J. Higham - and R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551 - - frequency=sqrt(λ/kT)/2π - - Args: - lambdas : array of floats - eigenvalues of the covariance matrix - temp: float - temperature - - Returns: - frequencies : array of floats - corresponding vibrational frequencies - """ pi = np.pi - # get kT in Joules from given temperature kT = self._run_manager.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}") - - # Filter out lambda values that are negative or imaginary numbers - # As these will produce supurious entropy results that can crash - # the calculation + lambdas = np.array(lambdas) lambdas = np.real_if_close(lambdas, tol=1000) + valid_mask = ( - np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-07)) + np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-7)) ) - - # If any lambdas were removed by the filter, warn the user - # as this will suggest insufficient sampling in the simulation data if len(lambdas) > np.count_nonzero(valid_mask): logger.warning( f"{len(lambdas) - np.count_nonzero(valid_mask)} " @@ -64,74 +38,64 @@ def frequency_calculation(self, lambdas, temp): ) lambdas = lambdas[valid_mask].real - - # Compute frequencies safely frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT) - logger.debug(f"Calculated frequencies: {frequencies}") - return frequencies def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_level): """ - Function to calculate the vibrational entropy for each level calculated from - eq. (4) in J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular - Physics, 2018, 116, 1965–1976 / eq. (2) in A. Chakravorty, J. Higham and - R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551. - - Args: - matrix : matrix - force/torque covariance matrix - matrix_type: string - temp: float - temperature - highest_level: bool - is this the highest level of the heirarchy - - Returns: - S_vib_total : float - transvibrational/rovibrational entropy + Supports matrix_type: + - "force" (3N x 3N) + - "torque" (3N x 3N) + - "forcetorqueTRANS" (6N x 6N -> translational part) + - "forcetorqueROT" (6N x 6N -> rotational part) + + Procedural matching behavior for FTmat: + - compute entropy components from the full 6N spectrum + - split into first 3N and last 3N *after sorting frequencies* + - so: FTmat-Trans + FTmat-Rot == total FT entropy """ - # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues - # Get eigenvalues of the given matrix and change units to SI units + matrix = np.asarray(matrix) lambdas = la.eigvals(matrix) - logger.debug(f"Eigenvalues (lambdas) before unit change: {lambdas}") - lambdas = self._run_manager.change_lambda_units(lambdas) - logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}") - - # Calculate frequencies from the eigenvalues - frequencies = self.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}") + freqs = self.frequency_calculation(lambdas, temp) + freqs = np.sort(freqs) kT = self._run_manager.get_KT2J(temp) - logger.debug(f"Temperature: {temp}, kT: {kT}") - exponent = self._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 * self._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 - S_vib_total = sum(S_components) - - # discard the 6 lowest frequencies to discard translation and rotation of - # the whole unit the overall translation and rotation of a unit is an - # internal motion of the level above - else: - S_vib_total = sum(S_components[6:]) - - 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 + exponent = self._PLANCK_CONST * freqs / kT + power_positive = np.exp(exponent) + power_negative = np.exp(-exponent) + + S_components = exponent / (power_positive - 1.0) - np.log(1.0 - power_negative) + S_components *= self._GAS_CONST + + n_modes = len(S_components) + + if matrix_type == "force": + if highest_level: + return float(np.sum(S_components)) + return float(np.sum(S_components[6:])) + + if matrix_type == "torque": + return float(np.sum(S_components)) + + if matrix_type in ("forcetorqueTRANS", "forcetorqueROT"): + if n_modes % 2 != 0: + logger.warning( + f"FTmat has odd number of modes ({n_modes}); cannot cleanly split." + ) + return float(np.sum(S_components)) + + half = n_modes // 2 # == 3N + trans_part = float(np.sum(S_components[:half])) + rot_part = float(np.sum(S_components[half:])) + + if not highest_level: + trans_keep = max(0, half - 6) + trans_part = ( + float(np.sum(S_components[6:half])) if trans_keep > 0 else 0.0 + ) + + return trans_part if matrix_type == "forcetorqueTRANS" else rot_part + + raise ValueError(f"Unknown matrix_type: {matrix_type}") diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py index d30d6fc4..8342e571 100644 --- a/CodeEntropy/levels/force_torque_manager.py +++ b/CodeEntropy/levels/force_torque_manager.py @@ -1,40 +1,27 @@ -# CodeEntropy/levels/force_torque_manager.py - import logging -from typing import List, Optional, Tuple +from typing import Any, List, Optional, Tuple import numpy as np -from CodeEntropy.levels.matrix_operations import MatrixOperations - logger = logging.getLogger(__name__) class ForceTorqueManager: - """ - Frame-local force/torque -> covariance builder. - - This class is intentionally "physics/math heavy" (same as the procedural code): - DAG nodes orchestrate; this computes the actual per-frame matrices. - """ - def __init__(self): - self._mops = MatrixOperations() + pass def get_weighted_forces( self, - data_container, bead, trans_axes: np.ndarray, highest_level: bool, force_partitioning: float, ) -> np.ndarray: """ - Match procedural semantics: - - rotate each atom force into trans_axes frame - - sum over bead - - apply force_partitioning ONLY at highest_level - - mass-weight by sqrt(total_mass) + Procedural-equivalent translational force: + sum( trans_axes @ atom.force ) over bead atoms + optionally scale by force_partitioning if highest_level + divide by sqrt(bead mass) """ forces_trans = np.zeros((3,), dtype=float) @@ -47,7 +34,7 @@ def get_weighted_forces( mass = bead.total_mass() if mass <= 0: - raise ValueError(f"Invalid bead mass {mass}; cannot weight force.") + raise ValueError(f"Invalid mass value: {mass}") return forces_trans / np.sqrt(mass) @@ -58,122 +45,76 @@ def get_weighted_torques( center: np.ndarray, force_partitioning: float, moment_of_inertia: np.ndarray, - axes_manager, - dimensions: np.ndarray, + axes_manager: Optional[Any], + box: Optional[np.ndarray], ) -> np.ndarray: """ - Match procedural semantics: - - compute r vectors with PBC wrapping using axes_manager.get_vector - - rotate r and f into rot_axes frame - - apply force_partitioning to forces (procedural does this for torques) - - torque = sum cross(r,f) - - MOI-weight component-wise by sqrt(moi_k) + Procedural-equivalent rotational torque: + coords = axes_manager.get_vector(center, bead.positions, box) (PBC) + rotate coords/forces into rot_axes + scale forces by force_partitioning + torque = sum( cross(r, f) ) + divide componentwise by sqrt(principal moments) """ - torques = np.zeros((3,), dtype=float) + if ( + axes_manager is not None + and hasattr(axes_manager, "get_vector") + and box is not None + ): + translated = axes_manager.get_vector(center, bead.positions, box) + else: + translated = bead.positions - center - for atom in bead.atoms: - # PBC-safe vector from center -> atom.position - r = axes_manager.get_vector(center, atom.position, dimensions) - r = np.matmul(rot_axes, r) + rotated_coords = np.tensordot(translated, rot_axes.T, axes=1) + rotated_forces = np.tensordot(bead.forces, rot_axes.T, axes=1) - f = np.matmul(rot_axes, atom.force) - f = force_partitioning * f + rotated_forces *= force_partitioning - torques += np.cross(r, f) + torques = np.cross(rotated_coords, rotated_forces) + torques = np.sum(torques, axis=0) - moi = np.array(moment_of_inertia, dtype=float) + moi = np.asarray(moment_of_inertia) + moi = np.real_if_close(moi, tol=1000) + moi = np.asarray(moi, dtype=float).reshape(-1) + if moi.size != 3: + raise ValueError(f"moment_of_inertia must be (3,), got {moi.shape}") weighted = np.zeros((3,), dtype=float) - for k in range(3): - if np.isclose(torques[k], 0.0) or np.isclose(moi[k], 0.0): - weighted[k] = 0.0 + for d in range(3): + if np.isclose(torques[d], 0.0): continue - if moi[k] < 0: - raise ValueError( - f"Negative principal moment of inertia encountered: {moi[k]}" - ) - weighted[k] = torques[k] / np.sqrt(moi[k]) + if moi[d] <= 0.0: + continue + weighted[d] = torques[d] / np.sqrt(moi[d]) return weighted - def compute_frame_covariance( - self, - data_container, - beads: List, - trans_axes: np.ndarray, - highest_level: bool, - force_partitioning: float, - rot_axes_list: Optional[List[np.ndarray]] = None, - centers: Optional[List[np.ndarray]] = None, - mois: Optional[List[np.ndarray]] = None, - axes_manager=None, - dimensions: Optional[np.ndarray] = None, - ) -> Tuple[np.ndarray, np.ndarray]: + @staticmethod + def _outer_second_moment(vectors: List[np.ndarray]) -> np.ndarray: """ - Compute per-frame block covariance matrices: - - force covariance (3N x 3N) - - torque covariance (3N x 3N) - - If rot_axes_list/centers/mois are supplied, they are used (this is how we - match the procedural AxesManager paths exactly). - Otherwise, caller should provide vanilla axes/mois/centers already or we error. + Procedural-style per-frame "covariance" (actually second moment): + If x is concatenated (3N,) vector of bead forces/torques, + return outer(x, x) -> (3N,3N) """ - n_beads = len(beads) - if n_beads == 0: - return np.zeros((0, 0)), np.zeros((0, 0)) - - weighted_forces = [None] * n_beads - weighted_torques = [None] * n_beads - - for i, bead in enumerate(beads): - if len(bead.atoms) == 0: - raise ValueError("AtomGroup is empty (bead has 0 atoms).") - - rot_axes = rot_axes_list[i] if rot_axes_list is not None else None - center = centers[i] if centers is not None else None - moi = mois[i] if mois is not None else None - - if rot_axes is None or center is None or moi is None: - raise ValueError( - "rot_axes/center/moment_of_inertia must be provided per bead." - ) + if not vectors: + return np.zeros((0, 0), dtype=float) - weighted_forces[i] = self.get_weighted_forces( - data_container=data_container, - bead=bead, - trans_axes=trans_axes, - highest_level=highest_level, - force_partitioning=force_partitioning, - ) - - weighted_torques[i] = self.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moi, - axes_manager=axes_manager, - dimensions=dimensions, - ) - - f_blocks = [[None] * n_beads for _ in range(n_beads)] - t_blocks = [[None] * n_beads for _ in range(n_beads)] - - for i in range(n_beads): - for j in range(i, n_beads): - f_sub = self._mops.create_submatrix( - weighted_forces[i], weighted_forces[j] + flat = np.concatenate( + [ + np.asarray(v, dtype=float).reshape( + 3, ) - t_sub = self._mops.create_submatrix( - weighted_torques[i], weighted_torques[j] - ) - - f_blocks[i][j] = f_sub - f_blocks[j][i] = f_sub.T - t_blocks[i][j] = t_sub - t_blocks[j][i] = t_sub.T - - F = np.block([[f_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)]) - T = np.block([[t_blocks[i][j] for j in range(n_beads)] for i in range(n_beads)]) + for v in vectors + ], + axis=0, + ) + return np.outer(flat, flat) + def compute_frame_covariance( + self, + force_vecs: List[np.ndarray], + torque_vecs: List[np.ndarray], + ) -> Tuple[np.ndarray, np.ndarray]: + F = self._outer_second_moment(force_vecs) + T = self._outer_second_moment(torque_vecs) return F, T diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 5ec85f54..d4e5d9a3 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Any, Dict, Optional @@ -8,6 +10,7 @@ from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode +from CodeEntropy.levels.nodes.frame_axes import FrameAxesNode from CodeEntropy.levels.nodes.init_covariance_accumulators import ( InitCovarianceAccumulatorsNode, ) @@ -16,19 +19,6 @@ class LevelDAG: - """ - STATIC DAG: - detect_molecules -> detect_levels -> build_beads - -> init_covariance_accumulators - -> compute_conformational_states - - FRAME MAP DAG (parallelisable later): - frame_axes -> frame_covariance - - REDUCE: - incremental mean reduction into shared_data accumulators - """ - def __init__(self, universe_operations=None): self._universe_operations = universe_operations @@ -41,6 +31,11 @@ def build(self) -> "LevelDAG": self._add_static("detect_molecules", DetectMoleculesNode()) self._add_static("detect_levels", DetectLevelsNode(), deps=["detect_molecules"]) self._add_static("build_beads", BuildBeadsNode(), deps=["detect_levels"]) + + self._add_static( + "frame_axes_manager", FrameAxesNode(), deps=["detect_molecules"] + ) + self._add_static( "init_covariance_accumulators", InitCovarianceAccumulatorsNode(), @@ -78,7 +73,6 @@ def _reduce_one_frame( f_frame = frame_out["force"] t_frame = frame_out["torque"] - # UA keyed by (group_id, res_id) for key, F in f_frame["ua"].items(): counts["ua"][key] = counts["ua"].get(key, 0) + 1 n = counts["ua"][key] @@ -88,7 +82,6 @@ def _reduce_one_frame( n = counts["ua"][key] t_cov["ua"][key] = self._inc_mean(t_cov["ua"].get(key), T, n) - # residue / polymer indexed by contiguous group index for gid, F in f_frame["res"].items(): gi = gid2i[gid] counts["res"][gi] += 1 @@ -111,13 +104,33 @@ def _reduce_one_frame( n = counts["poly"][gi] t_cov["poly"][gi] = self._inc_mean(t_cov["poly"][gi], T, n) + if "forcetorque" in frame_out and "forcetorque_covariances" in shared_data: + ft_cov = shared_data["forcetorque_covariances"] + ft_counts = shared_data["forcetorque_frame_counts"] + ft_frame = frame_out["forcetorque"] + + for key, M in ft_frame["ua"].items(): + ft_counts["ua"][key] = ft_counts["ua"].get(key, 0) + 1 + n = ft_counts["ua"][key] + ft_cov["ua"][key] = self._inc_mean(ft_cov["ua"].get(key), M, n) + + for gid, M in ft_frame["res"].items(): + gi = gid2i[gid] + ft_counts["res"][gi] += 1 + n = ft_counts["res"][gi] + ft_cov["res"][gi] = self._inc_mean(ft_cov["res"][gi], M, n) + + for gid, M in ft_frame["poly"].items(): + gi = gid2i[gid] + ft_counts["poly"][gi] += 1 + n = ft_counts["poly"][gi] + ft_cov["poly"][gi] = self._inc_mean(ft_cov["poly"][gi], M, n) + def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: - # --- STATIC DAG --- for node_name in nx.topological_sort(self.static_graph): logger.info(f"[LevelDAG] static node: {node_name}") self.static_nodes[node_name].run(shared_data) - # --- FRAME MAP + REDUCE --- u = shared_data["reduced_universe"] start, end, step = shared_data["start"], shared_data["end"], shared_data["step"] diff --git a/CodeEntropy/levels/level_hierarchy.py b/CodeEntropy/levels/level_hierarchy.py index 397c8895..5b0afcdc 100644 --- a/CodeEntropy/levels/level_hierarchy.py +++ b/CodeEntropy/levels/level_hierarchy.py @@ -15,21 +15,16 @@ def __init__(self): def select_levels(self, data_container): """ - Function to read input system and identify the number of molecules and - the levels (i.e. united atom, residue and/or polymer) that should be used. - The level refers to the size of the bead (atom or collection of atoms) - that will be used in the entropy calculations. + Identify the number of molecules and which levels (united atom, residue, + polymer) should be used for each molecule. Args: - arg_DataContainer: MDAnalysis universe object containing the system of - interest + data_container: MDAnalysis Universe for the system. Returns: - number_molecules (int): Number of molecules in the system. - levels (array): Strings describing the length scales for each molecule. + number_molecules (int) + levels (list[list[str]]) """ - - # fragments is MDAnalysis terminology for what chemists would call molecules number_molecules = len(data_container.atoms.fragments) logger.debug(f"The number of molecules is {number_molecules}.") @@ -37,9 +32,7 @@ def select_levels(self, data_container): levels = [[] for _ in range(number_molecules)] for molecule in range(number_molecules): - levels[molecule].append( - "united_atom" - ) # every molecule has at least one atom + levels[molecule].append("united_atom") atoms_in_fragment = fragments[molecule].select_atoms("prop mass > 1.1") number_residues = len(atoms_in_fragment.residues) @@ -51,36 +44,40 @@ def select_levels(self, data_container): levels[molecule].append("polymer") logger.debug(f"levels {levels}") - return number_molecules, levels def get_beads(self, data_container, level): """ - Function to define beads depending on the level in the hierarchy. + Define beads depending on the hierarchy level. + + IMPORTANT FIX: + - For "residue", DO NOT use "resindex i" selection strings. + resindex is global to the universe and will often produce empty beads + for molecules beyond the first one. + - Instead, directly use the residues belonging to the data_container. Args: - data_container (MDAnalysis.Universe): the molecule data - level (str): the heirarchy level (polymer, residue, or united atom) + data_container: MDAnalysis AtomGroup (typically a molecule/fragment or + residue.atoms) level (str): "polymer", "residue", or "united_atom" Returns: - list_of_beads : the relevent beads + list_of_beads: list[AtomGroup] """ - if level == "polymer": - list_of_beads = [] - atom_group = "all" - list_of_beads.append(data_container.select_atoms(atom_group)) + return [data_container.select_atoms("all")] if level == "residue": list_of_beads = [] - num_residues = len(data_container.residues) - for residue in range(num_residues): - atom_group = "resindex " + str(residue) - list_of_beads.append(data_container.select_atoms(atom_group)) + for res in data_container.residues: + bead = res.atoms + list_of_beads.append(bead) + logger.debug(f"Residue beads: {[len(b) for b in list_of_beads]}") + return list_of_beads if level == "united_atom": list_of_beads = [] heavy_atoms = data_container.select_atoms("prop mass > 1.1") + if len(heavy_atoms) == 0: list_of_beads.append(data_container.select_atoms("all")) else: @@ -92,8 +89,10 @@ def get_beads(self, data_container, level): + str(atom.index) + ")" ) - list_of_beads.append(data_container.select_atoms(atom_group)) + bead = data_container.select_atoms(atom_group) + list_of_beads.append(bead) - logger.debug(f"List of beads: {list_of_beads}") + logger.debug(f"United-atom beads: {[len(b) for b in list_of_beads]}") + return list_of_beads - return list_of_beads + raise ValueError(f"Unknown level: {level}") diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py index 492ac6f8..6d4f8a4a 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -1,47 +1,95 @@ +import logging +from collections import defaultdict +from typing import Any, Dict, List + +import numpy as np + from CodeEntropy.levels.level_hierarchy import LevelHierarchy +logger = logging.getLogger(__name__) + class BuildBeadsNode: """ Build bead definitions ONCE, in reduced_universe index space. shared_data["beads"] dict keys: - (mol_id, "united_atom", res_id) -> list[np.ndarray] + (mol_id, "united_atom", res_id) -> list[np.ndarray] # UA beads grouped by residue (mol_id, "residue") -> list[np.ndarray] (mol_id, "polymer") -> list[np.ndarray] + + IMPORTANT: + UA beads are generated at the MOLECULE level (mol) to preserve procedural ordering + (molecule-heavy-atom ordinal), then assigned into residue buckets. """ def __init__(self): self._hier = LevelHierarchy() - def run(self, shared_data): + def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: u = shared_data["reduced_universe"] levels = shared_data["levels"] - beads = {} + beads: Dict[Any, List[np.ndarray]] = {} fragments = u.atoms.fragments for mol_id, level_list in enumerate(levels): mol = fragments[mol_id] if "united_atom" in level_list: - for res_id, residue in enumerate(mol.residues): - ua_beads = self._hier.get_beads(residue.atoms, "united_atom") - beads[(mol_id, "united_atom", res_id)] = [ - b.indices.copy() for b in ua_beads if len(b) > 0 - ] + ua_beads_mol = self._hier.get_beads(mol, "united_atom") + + buckets: Dict[int, List[np.ndarray]] = defaultdict(list) + + for i, b in enumerate(ua_beads_mol): + if len(b) == 0: + logger.warning( + f"[BuildBeadsNode] EMPTY UA bead: mol={mol_id} bead_i={i}" + ) + continue + + heavy = b.select_atoms("prop mass > 1.1") + if len(heavy) == 0: + res_id = 0 + else: + heavy_resindex = int(heavy[0].resindex) + res_id = None + for local_i, res in enumerate(mol.residues): + if int(res.resindex) == heavy_resindex: + res_id = local_i + break + if res_id is None: + res_id = 0 + + buckets[res_id].append(b.indices.copy()) + + for res_id, res in enumerate(mol.residues): + kept = buckets.get(res_id, []) + beads[(mol_id, "united_atom", res_id)] = kept if "residue" in level_list: res_beads = self._hier.get_beads(mol, "residue") - beads[(mol_id, "residue")] = [ - b.indices.copy() for b in res_beads if len(b) > 0 - ] + kept = [] + for i, b in enumerate(res_beads): + if len(b) == 0: + continue + kept.append(b.indices.copy()) + beads[(mol_id, "residue")] = kept + + if len(kept) == 0: + logger.error( + f"[BuildBeadsNode] NO residue beads kept for mol={mol_id}. " + "This will force residue entropy to 0.0." + ) if "polymer" in level_list: poly_beads = self._hier.get_beads(mol, "polymer") - beads[(mol_id, "polymer")] = [ - b.indices.copy() for b in poly_beads if len(b) > 0 - ] + kept = [] + for i, b in enumerate(poly_beads): + if len(b) == 0: + continue + kept.append(b.indices.copy()) + beads[(mol_id, "polymer")] = kept shared_data["beads"] = beads return {"beads": beads} diff --git a/CodeEntropy/levels/nodes/frame_axes.py b/CodeEntropy/levels/nodes/frame_axes.py index 68866f24..e63cfee8 100644 --- a/CodeEntropy/levels/nodes/frame_axes.py +++ b/CodeEntropy/levels/nodes/frame_axes.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging from typing import Any, Dict @@ -12,13 +14,7 @@ class FrameAxesNode: """ Produces per-frame translational axes for each molecule. - - Input: - ctx["shared"] (preferred) or ctx (fallback) - ctx["frame_index"] - - Output: - ctx["frame_axes"] = {"trans": {mol_id: 3x3 np.ndarray}, "custom": bool} + Also exports the AxesManager into shared_data so torque can use PBC vectors. """ def __init__(self, universe_operations: UniverseOperations | None = None): @@ -26,7 +22,14 @@ def __init__(self, universe_operations: UniverseOperations | None = None): self._axes_manager = AxesManager() def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: - shared = ctx.get("shared", ctx) + if "shared" in ctx: + frame_ctx = ctx + shared = frame_ctx["shared"] + frame_index = frame_ctx["frame_index"] + else: + shared = ctx + frame_index = shared.get("frame_index", shared.get("time_index", 0)) + frame_ctx = {"shared": shared, "frame_index": frame_index} u = shared.get("reduced_universe", shared.get("universe")) if u is None: @@ -35,7 +38,8 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: args = shared["args"] use_custom = bool(getattr(args, "customised_axes", False)) - frame_index = ctx.get("frame_index", shared.get("frame_index", 0)) + shared["axes_manager"] = self._axes_manager + u.trajectory[frame_index] trans_axes: Dict[int, np.ndarray] = {} @@ -54,5 +58,5 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: trans_axes[mol_id] = A - ctx["frame_axes"] = {"trans": trans_axes, "custom": use_custom} - return ctx["frame_axes"] + frame_ctx["frame_axes"] = {"trans": trans_axes, "custom": use_custom} + return frame_ctx["frame_axes"] diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index c328b522..98669b72 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -1,235 +1,63 @@ -import inspect import logging -from typing import Any, Dict, Tuple +from typing import Any, Dict import numpy as np -from MDAnalysis.lib.mdamath import make_whole -from CodeEntropy.axes import AxesManager from CodeEntropy.levels.force_torque_manager import ForceTorqueManager logger = logging.getLogger(__name__) class FrameCovarianceNode: - """ - Per-frame covariance computation (FRAME DAG node). - - Input (frame_ctx): - frame_ctx["shared"] -> shared_data dict - frame_ctx["frame_index"] -> absolute trajectory frame index - frame_ctx["frame_axes"] -> optional output from FrameAxesNode: - {"trans": {mol_id: 3x3}, "custom": bool} - - Output (frame_ctx["frame_covariance"]): - { - "force": {"ua": {(gid,res_id): F}, "res": {gid: F}, "poly": {gid: F}}, - "torque": {"ua": {(gid,res_id): T}, "res": {gid: T}, "poly": {gid: T}}, - } - - IMPORTANT: - - produces pure per-frame output only - - reduction/averaging happens in LevelDAG - """ - def __init__(self): self._ft = ForceTorqueManager() - self._ft_sig = inspect.signature(self._ft.compute_frame_covariance) - self._axes_manager = AxesManager() - - def _call_ft( - self, - *, - data_container, - beads, - trans_axes, - highest_level: bool, - force_partitioning: float, - extra: Dict[str, Any], - ) -> Tuple[np.ndarray, np.ndarray]: - """ - Call ForceTorqueManager.compute_frame_covariance in a signature-adaptive way. - """ - kwargs = {} - - if "data_container" in self._ft_sig.parameters: - kwargs["data_container"] = data_container - if "beads" in self._ft_sig.parameters: - kwargs["beads"] = beads - if "trans_axes" in self._ft_sig.parameters: - kwargs["trans_axes"] = trans_axes - if "highest_level" in self._ft_sig.parameters: - kwargs["highest_level"] = highest_level - if "force_partitioning" in self._ft_sig.parameters: - kwargs["force_partitioning"] = force_partitioning - - for k, v in extra.items(): - if k in self._ft_sig.parameters: - kwargs[k] = v - - return self._ft.compute_frame_covariance(**kwargs) @staticmethod - def _prepare_vanilla_geometry(beads): - """ - Vanilla geometry path matching procedural fallback: - make_whole(bead), principal_axes, COM unwrap=True, MOI unwrap=True - Returns: rot_axes_list, centers, mois - """ - rot_axes_list = [] - centers = [] - mois = [] - - for bead_ag in beads: - make_whole(bead_ag) - rot_axes = np.real(bead_ag.principal_axes()) - center = bead_ag.center_of_mass(unwrap=True) - eigvals, _ = np.linalg.eig(bead_ag.moment_of_inertia(unwrap=True)) - moi = sorted(np.real(eigvals), reverse=True) - - rot_axes_list.append(rot_axes) - centers.append(np.asarray(center, dtype=float)) - mois.append(np.asarray(moi, dtype=float)) - - return rot_axes_list, centers, mois - - def _prepare_custom_geometry(self, *, level: str, data_container, n_beads: int): - """ - Customised axes path (procedural parity) if AxesManager exposes: - - get_UA_axes(data_container, bead_index) - - get_residue_axes(data_container, bead_index) - - Returns: trans_axes, rot_axes_list, centers, mois - """ - if level == "united_atom" and hasattr(self._axes_manager, "get_UA_axes"): - trans0, _, _, _ = self._axes_manager.get_UA_axes(data_container, 0) - rot_axes_list, centers, mois = [], [], [] - for bead_index in range(n_beads): - _t, rot_axes, center, moi = self._axes_manager.get_UA_axes( - data_container, bead_index - ) - rot_axes_list.append(np.asarray(rot_axes)) - centers.append(np.asarray(center, dtype=float)) - mois.append(np.asarray(moi, dtype=float)) - return np.asarray(trans0), rot_axes_list, centers, mois - - if level == "residue" and hasattr(self._axes_manager, "get_residue_axes"): - trans0, _, _, _ = self._axes_manager.get_residue_axes(data_container, 0) - rot_axes_list, centers, mois = [], [], [] - for bead_index in range(n_beads): - _t, rot_axes, center, moi = self._axes_manager.get_residue_axes( - data_container, bead_index - ) - rot_axes_list.append(np.asarray(rot_axes)) - centers.append(np.asarray(center, dtype=float)) - mois.append(np.asarray(moi, dtype=float)) - return np.asarray(trans0), rot_axes_list, centers, mois - - raise RuntimeError( - "Custom geometry requested but AxesManager lacks required methods." - ) + def _block_diag(F: np.ndarray, T: np.ndarray) -> np.ndarray: + nF = F.shape[0] + nT = T.shape[0] + M = np.zeros((nF + nT, nF + nT), dtype=float) + M[:nF, :nF] = F + M[nF:, nF:] = T + return M def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: - shared = ctx.get("shared", ctx) - - u = shared.get("reduced_universe", shared.get("universe")) - if u is None: - raise KeyError("shared_data must contain 'reduced_universe' or 'universe'") + if "shared" not in ctx: + raise KeyError("FrameCovarianceNode expects ctx['shared'].") - args = shared["args"] - fp = args.force_partitioning + shared = ctx["shared"] + u = shared["reduced_universe"] groups = shared["groups"] levels = shared["levels"] - beads_map = shared["beads"] - - frame_index = ctx.get("frame_index", shared.get("frame_index", 0)) - u.trajectory[frame_index] - - frame_axes = ctx.get("frame_axes") or {} - trans_axes_by_mol = frame_axes.get("trans", {}) - customised_axes = bool( - frame_axes.get("custom", getattr(args, "customised_axes", False)) - ) + beads = shared["beads"] + args = shared["args"] - fragments = u.atoms.fragments + fp = args.force_partitioning + combined = bool(getattr(args, "combined_forcetorque", False)) - dims = None + axes_manager = shared.get("axes_manager", None) + box = None try: - dims = np.asarray(u.dimensions[:3], dtype=float) + box = np.asarray(u.dimensions[:3], dtype=float) except Exception: - dims = None + box = None + + fragments = u.atoms.fragments out_force = {"ua": {}, "res": {}, "poly": {}} out_torque = {"ua": {}, "res": {}, "poly": {}} + out_ft = {"ua": {}, "res": {}, "poly": {}} if combined else None for group_id, mol_ids in groups.items(): for mol_id in mol_ids: mol = fragments[mol_id] level_list = levels[mol_id] - base_trans_axes = trans_axes_by_mol.get( - mol_id, np.asarray(mol.principal_axes()) - ) - - for level in level_list: - highest = level == level_list[-1] - - if level == "united_atom": - for res_id, residue in enumerate(mol.residues): - bead_key = (mol_id, "united_atom", res_id) - bead_idx_list = beads_map.get(bead_key, []) - if not bead_idx_list: - continue - - bead_groups = [u.atoms[idx] for idx in bead_idx_list] - if any(len(bg) == 0 for bg in bead_groups): - continue - - data_container = residue.atoms - - if customised_axes and hasattr( - self._axes_manager, "get_UA_axes" - ): - trans_axes, rot_axes_list, centers, mois = ( - self._prepare_custom_geometry( - level="united_atom", - data_container=data_container, - n_beads=len(bead_groups), - ) - ) - else: - make_whole(data_container.atoms) - trans_axes = np.asarray( - data_container.atoms.principal_axes() - ) - rot_axes_list, centers, mois = ( - self._prepare_vanilla_geometry(bead_groups) - ) - - extra = { - "rot_axes_list": rot_axes_list, - "centers": centers, - "mois": mois, - "axes_manager": self._axes_manager, - "dimensions": dims, - } - - F, T = self._call_ft( - data_container=data_container, - beads=bead_groups, - trans_axes=trans_axes, - highest_level=highest, - force_partitioning=fp, - extra=extra, - ) - - out_force["ua"][(group_id, res_id)] = F - out_torque["ua"][(group_id, res_id)] = T - - elif level in ("residue", "polymer"): - bead_key = (mol_id, level) - bead_idx_list = beads_map.get(bead_key, []) + if "united_atom" in level_list: + for local_res_i, res in enumerate(mol.residues): + bead_key = (mol_id, "united_atom", local_res_i) + bead_idx_list = beads.get(bead_key, []) if not bead_idx_list: continue @@ -237,50 +65,142 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: if any(len(bg) == 0 for bg in bead_groups): continue - data_container = mol - - if ( - level == "residue" - and customised_axes - and hasattr(self._axes_manager, "get_residue_axes") - ): - trans_axes, rot_axes_list, centers, mois = ( - self._prepare_custom_geometry( - level="residue", - data_container=data_container, - n_beads=len(bead_groups), + force_vecs = [] + torque_vecs = [] + + for ua_i, bead in enumerate(bead_groups): + + trans_axes, rot_axes, center, moi = ( + axes_manager.get_UA_axes(res.atoms, ua_i) + ) + + force_vecs.append( + self._ft.get_weighted_forces( + bead=bead, + trans_axes=np.asarray(trans_axes), + highest_level=False, + force_partitioning=fp, ) ) - else: - make_whole(data_container.atoms) - trans_axes = np.asarray(base_trans_axes) - rot_axes_list, centers, mois = ( - self._prepare_vanilla_geometry(bead_groups) + torque_vecs.append( + self._ft.get_weighted_torques( + bead=bead, + rot_axes=np.asarray(rot_axes), + center=np.asarray(center), + force_partitioning=fp, + moment_of_inertia=np.asarray(moi), + axes_manager=axes_manager, + box=box, + ) ) - extra = { - "rot_axes_list": rot_axes_list, - "centers": centers, - "mois": mois, - "axes_manager": self._axes_manager, - "dimensions": dims, - } - - F, T = self._call_ft( - data_container=data_container, - beads=bead_groups, - trans_axes=trans_axes, - highest_level=highest, - force_partitioning=fp, - extra=extra, + F, T = self._ft.compute_frame_covariance( + force_vecs, torque_vecs ) - bucket = "res" if level == "residue" else "poly" - out_force[bucket][group_id] = F - out_torque[bucket][group_id] = T + key = (group_id, local_res_i) + out_force["ua"][key] = F + out_torque["ua"][key] = T + if combined and out_ft is not None: + out_ft["ua"][key] = self._block_diag(F, T) + + if "residue" in level_list: + bead_key = (mol_id, "residue") + bead_idx_list = beads.get(bead_key, []) + if bead_idx_list: + bead_groups = [u.atoms[idx] for idx in bead_idx_list] + if not any(len(bg) == 0 for bg in bead_groups): + + force_vecs = [] + torque_vecs = [] + + for local_res_i, bead in enumerate(bead_groups): + res = mol.residues[local_res_i] + trans_axes, rot_axes, center, moi = ( + axes_manager.get_residue_axes( + mol, int(res.resindex) + ) + ) + + force_vecs.append( + self._ft.get_weighted_forces( + bead=bead, + trans_axes=np.asarray(trans_axes), + highest_level=False, + force_partitioning=fp, + ) + ) + torque_vecs.append( + self._ft.get_weighted_torques( + bead=bead, + rot_axes=np.asarray(rot_axes), + center=np.asarray(center), + force_partitioning=fp, + moment_of_inertia=np.asarray(moi), + axes_manager=axes_manager, + box=box, + ) + ) + + F, T = self._ft.compute_frame_covariance( + force_vecs, torque_vecs + ) + + out_force["res"][group_id] = F + out_torque["res"][group_id] = T + if combined and out_ft is not None: + out_ft["res"][group_id] = self._block_diag(F, T) + + if "polymer" in level_list: + bead_key = (mol_id, "polymer") + bead_idx_list = beads.get(bead_key, []) + if bead_idx_list: + bead_groups = [u.atoms[idx] for idx in bead_idx_list] + if not any(len(bg) == 0 for bg in bead_groups): + bead = bead_groups[0] + + if axes_manager is not None: + rot_axes, moi = axes_manager.get_vanilla_axes(bead) + trans_axes = rot_axes + center = bead.center_of_mass(unwrap=True) + else: + trans_axes = bead.principal_axes() + rot_axes = trans_axes + center = bead.center_of_mass(unwrap=True) + moi = np.linalg.eigvals(bead.moment_of_inertia()) + + force_vecs = [ + self._ft.get_weighted_forces( + bead=bead, + trans_axes=np.asarray(trans_axes), + highest_level=True, + force_partitioning=fp, + ) + ] + torque_vecs = [ + self._ft.get_weighted_torques( + bead=bead, + rot_axes=np.asarray(rot_axes), + center=np.asarray(center), + force_partitioning=fp, + moment_of_inertia=np.asarray(moi), + axes_manager=axes_manager, + box=box, + ) + ] + + F, T = self._ft.compute_frame_covariance( + force_vecs, torque_vecs + ) + + out_force["poly"][group_id] = F + out_torque["poly"][group_id] = T + if combined and out_ft is not None: + out_ft["poly"][group_id] = self._block_diag(F, T) - else: - raise ValueError(f"Unknown level: {level}") + frame_cov = {"force": out_force, "torque": out_torque} + if combined and out_ft is not None: + frame_cov["forcetorque"] = out_ft - ctx["frame_covariance"] = {"force": out_force, "torque": out_torque} - return ctx["frame_covariance"] + ctx["frame_covariance"] = frame_cov + return frame_cov diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/init_covariance_accumulators.py index c08c3830..79ea740e 100644 --- a/CodeEntropy/levels/nodes/init_covariance_accumulators.py +++ b/CodeEntropy/levels/nodes/init_covariance_accumulators.py @@ -1,20 +1,23 @@ -# CodeEntropy/levels/nodes/init_covariance_accumulators.py +import logging import numpy as np +logger = logging.getLogger(__name__) + + +def _empty_stats(): + return {"n": 0, "mean": None, "M2": None} + class InitCovarianceAccumulatorsNode: """ - Allocate containers for running averages, matching procedural semantics. + Allocate Welford online covariance accumulators (procedural semantics). - force_covariances = {"ua": {}, "res": [None]*n_groups, "poly": [None]*n_groups} - torque_covariances = {"ua": {}, "res": [None]*n_groups, "poly": [None]*n_groups} + After LevelDAG finishes iterating frames, it will "finalize" stats into: + shared_data["force_covariances"] + shared_data["torque_covariances"] - frame_counts = {"ua": {}, "res": np.zeros(n_groups), "poly": np.zeros(n_groups)} - - Also stores: - shared_data["group_id_to_index"] : dict[group_id -> 0..n_groups-1] - shared_data["index_to_group_id"] : list[index -> group_id] + Plus frame counts, and group_id_to_index mapping. """ def run(self, shared_data): @@ -25,8 +28,19 @@ def run(self, shared_data): gid2i = {gid: i for i, gid in enumerate(group_ids)} i2gid = list(group_ids) - force_avg = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} - torque_avg = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} + force_cov = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} + torque_cov = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} + + force_stats = { + "ua": {}, + "res": [_empty_stats() for _ in range(n_groups)], + "poly": [_empty_stats() for _ in range(n_groups)], + } + torque_stats = { + "ua": {}, + "res": [_empty_stats() for _ in range(n_groups)], + "poly": [_empty_stats() for _ in range(n_groups)], + } frame_counts = { "ua": {}, @@ -36,14 +50,22 @@ def run(self, shared_data): shared_data["group_id_to_index"] = gid2i shared_data["index_to_group_id"] = i2gid - shared_data["force_covariances"] = force_avg - shared_data["torque_covariances"] = torque_avg + + shared_data["force_covariances"] = force_cov + shared_data["torque_covariances"] = torque_cov shared_data["frame_counts"] = frame_counts + shared_data["force_stats"] = force_stats + shared_data["torque_stats"] = torque_stats + + logger.warning(f"[InitCovAcc] group_ids={group_ids} gid2i={gid2i}") + return { "group_id_to_index": gid2i, "index_to_group_id": i2gid, - "force_covariances": force_avg, - "torque_covariances": torque_avg, + "force_covariances": force_cov, + "torque_covariances": torque_cov, "frame_counts": frame_counts, + "force_stats": force_stats, + "torque_stats": torque_stats, } From b81e12af9785365feb163c17dda4b8f220569614 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 12 Feb 2026 12:13:21 +0000 Subject: [PATCH 042/101] simplify get axes selection within `FrameAxesNode` --- CodeEntropy/levels/nodes/frame_axes.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/CodeEntropy/levels/nodes/frame_axes.py b/CodeEntropy/levels/nodes/frame_axes.py index e63cfee8..13678e20 100644 --- a/CodeEntropy/levels/nodes/frame_axes.py +++ b/CodeEntropy/levels/nodes/frame_axes.py @@ -46,17 +46,7 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: fragments = u.atoms.fragments for mol_id, mol in enumerate(fragments): - if use_custom: - if hasattr(self._axes_manager, "get_translation_axes"): - A = np.asarray(self._axes_manager.get_translation_axes(mol)) - elif hasattr(self._axes_manager, "translation_axes"): - A = np.asarray(self._axes_manager.translation_axes(mol)) - else: - A = np.asarray(mol.principal_axes()) - else: - A = np.asarray(mol.principal_axes()) - - trans_axes[mol_id] = A + _, trans_axes[mol_id] = self._axes_manager.get_vanilla_axes(mol) frame_ctx["frame_axes"] = {"trans": trans_axes, "custom": use_custom} return frame_ctx["frame_axes"] From 510cd97b8ba97f05e3811d6beb769bf0c36cc5bd Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 13 Feb 2026 16:16:16 +0000 Subject: [PATCH 043/101] include `forcetorque` matrices within reducing step --- CodeEntropy/levels/hierarchy_graph.py | 21 ++++++++++++---- .../nodes/init_covariance_accumulators.py | 24 ++++++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index d4e5d9a3..fdb5fdf0 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -60,7 +60,9 @@ def _add_static( @staticmethod def _inc_mean(old, new, n: int): - return new.copy() if old is None else old + (new - old) / float(n) + if old is None: + return new.copy() if hasattr(new, "copy") else new + return old + (new - old) / float(n) def _reduce_one_frame( self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] @@ -79,7 +81,10 @@ def _reduce_one_frame( f_cov["ua"][key] = self._inc_mean(f_cov["ua"].get(key), F, n) for key, T in t_frame["ua"].items(): - n = counts["ua"][key] + n = counts["ua"].get(key) + if n is None: + counts["ua"][key] = counts["ua"].get(key, 0) + 1 + n = counts["ua"][key] t_cov["ua"][key] = self._inc_mean(t_cov["ua"].get(key), T, n) for gid, F in f_frame["res"].items(): @@ -91,6 +96,9 @@ def _reduce_one_frame( for gid, T in t_frame["res"].items(): gi = gid2i[gid] n = counts["res"][gi] + if n == 0: + counts["res"][gi] += 1 + n = counts["res"][gi] t_cov["res"][gi] = self._inc_mean(t_cov["res"][gi], T, n) for gid, F in f_frame["poly"].items(): @@ -102,11 +110,14 @@ def _reduce_one_frame( for gid, T in t_frame["poly"].items(): gi = gid2i[gid] n = counts["poly"][gi] + if n == 0: + counts["poly"][gi] += 1 + n = counts["poly"][gi] t_cov["poly"][gi] = self._inc_mean(t_cov["poly"][gi], T, n) - if "forcetorque" in frame_out and "forcetorque_covariances" in shared_data: - ft_cov = shared_data["forcetorque_covariances"] - ft_counts = shared_data["forcetorque_frame_counts"] + if "forcetorque" in frame_out and "force_torque_stats" in shared_data: + ft_cov = shared_data["force_torque_stats"] + ft_counts = shared_data["force_torque_counts"] ft_frame = frame_out["forcetorque"] for key, M in ft_frame["ua"].items(): diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/init_covariance_accumulators.py index 79ea740e..80659da1 100644 --- a/CodeEntropy/levels/nodes/init_covariance_accumulators.py +++ b/CodeEntropy/levels/nodes/init_covariance_accumulators.py @@ -11,13 +11,15 @@ def _empty_stats(): class InitCovarianceAccumulatorsNode: """ - Allocate Welford online covariance accumulators (procedural semantics). + Allocate accumulators for per-frame reductions. - After LevelDAG finishes iterating frames, it will "finalize" stats into: + LevelDAG iterates frames and accumulates *means* of per-frame second-moment + matrices into: shared_data["force_covariances"] shared_data["torque_covariances"] + shared_data["force_torque_stats"] (mean of block-diag(F,T) per group) - Plus frame counts, and group_id_to_index mapping. + It also stores counts and group_id_to_index mapping. """ def run(self, shared_data): @@ -41,6 +43,11 @@ def run(self, shared_data): "res": [_empty_stats() for _ in range(n_groups)], "poly": [_empty_stats() for _ in range(n_groups)], } + force_torque_stats = { + "ua": {}, + "res": [None] * n_groups, + "poly": [None] * n_groups, + } frame_counts = { "ua": {}, @@ -48,6 +55,12 @@ def run(self, shared_data): "poly": np.zeros(n_groups, dtype=int), } + force_torque_counts = { + "ua": {}, + "res": np.zeros(n_groups, dtype=int), + "poly": np.zeros(n_groups, dtype=int), + } + shared_data["group_id_to_index"] = gid2i shared_data["index_to_group_id"] = i2gid @@ -58,6 +71,9 @@ def run(self, shared_data): shared_data["force_stats"] = force_stats shared_data["torque_stats"] = torque_stats + shared_data["force_torque_stats"] = force_torque_stats + shared_data["force_torque_counts"] = force_torque_counts + logger.warning(f"[InitCovAcc] group_ids={group_ids} gid2i={gid2i}") return { @@ -68,4 +84,6 @@ def run(self, shared_data): "frame_counts": frame_counts, "force_stats": force_stats, "torque_stats": torque_stats, + "force_torque_stats": force_torque_stats, + "force_torque_counts": force_torque_counts, } From e74551a1e168be876ac0b3f97b6032c5910d6368 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 16 Feb 2026 09:07:31 +0000 Subject: [PATCH 044/101] ensure `FMAT` and `TMAT` matrices are accounted for at all levels --- .../entropy/nodes/vibrational_entropy_node.py | 21 ++++++- CodeEntropy/entropy/vibrational_entropy.py | 12 ++-- CodeEntropy/levels/hierarchy_graph.py | 16 ++++- CodeEntropy/levels/nodes/frame_covariance.py | 59 +++++++++++++++---- .../nodes/init_covariance_accumulators.py | 17 ++++-- 5 files changed, 98 insertions(+), 27 deletions(-) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index fda2a59c..51258d9b 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -44,7 +44,12 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: torque_cov = shared_data["torque_covariances"] combined = bool(getattr(args, "combined_forcetorque", False)) - ft_cov = shared_data.get("forcetorque_covariances") if combined else None + + ft_cov = None + if combined: + ft_cov = shared_data.get("forcetorque_covariances") + if ft_cov is None: + ft_cov = shared_data.get("force_torque_stats") counts = shared_data.get("frame_counts", {}) ua_counts = counts.get("ua", {}) if isinstance(counts, dict) else {} @@ -134,11 +139,23 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: ) elif level == "polymer": + if combined and ft_cov is not None: + gi = gid2i[group_id] + ftmat = ft_cov["poly"][gi] + logger.warning( + f"[VibNode] group={group_id} " + "ftmat_shape={None if ftmat is None else ftmat.shape}" + ) + gi = gid2i[group_id] if combined and ft_cov is not None: ftmat = ft_cov["poly"][gi] if gi < len(ft_cov["poly"]) else None if ftmat is None: + logger.warning( + f"[VibNode] combined=True but ftmat is None for group " + f"{group_id}; falling back to F/T" + ) S_trans, S_rot = 0.0, 0.0 else: ftmat = np.asarray(ftmat) @@ -188,7 +205,7 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: } if data_logger is not None: - if level == "polymer" and combined: + if level == "polymer" and combined and ft_cov is not None: data_logger.add_results_data( group_id, level, "FTmat-Transvibrational", S_trans ) diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index b78ab40b..3c2b7363 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -51,8 +51,10 @@ def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_lev Procedural matching behavior for FTmat: - compute entropy components from the full 6N spectrum - - split into first 3N and last 3N *after sorting frequencies* - - so: FTmat-Trans + FTmat-Rot == total FT entropy + - sort frequencies + - split into first 3N and last 3N after sorting + - ensures FTmat-Trans + FTmat-Rot == total FT entropy + - (and cross F↔T terms affect eigenmodes, as intended) """ matrix = np.asarray(matrix) lambdas = la.eigvals(matrix) @@ -91,10 +93,8 @@ def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_lev rot_part = float(np.sum(S_components[half:])) if not highest_level: - trans_keep = max(0, half - 6) - trans_part = ( - float(np.sum(S_components[6:half])) if trans_keep > 0 else 0.0 - ) + # Only drop within the trans half + trans_part = float(np.sum(S_components[6:half])) if half > 6 else 0.0 return trans_part if matrix_type == "forcetorqueTRANS" else rot_part diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index fdb5fdf0..55ff3336 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -120,18 +120,28 @@ def _reduce_one_frame( ft_counts = shared_data["force_torque_counts"] ft_frame = frame_out["forcetorque"] - for key, M in ft_frame["ua"].items(): + if ft_cov is None: + ft_cov = shared_data.get("force_torque_stats") + if ft_counts is None: + ft_counts = shared_data.get("force_torque_counts") + + if ft_cov is None or ft_counts is None: + return + + ft_frame = frame_out["forcetorque"] + + for key, M in ft_frame.get("ua", {}).items(): ft_counts["ua"][key] = ft_counts["ua"].get(key, 0) + 1 n = ft_counts["ua"][key] ft_cov["ua"][key] = self._inc_mean(ft_cov["ua"].get(key), M, n) - for gid, M in ft_frame["res"].items(): + for gid, M in ft_frame.get("res", {}).items(): gi = gid2i[gid] ft_counts["res"][gi] += 1 n = ft_counts["res"][gi] ft_cov["res"][gi] = self._inc_mean(ft_cov["res"][gi], M, n) - for gid, M in ft_frame["poly"].items(): + for gid, M in ft_frame.get("poly", {}).items(): gi = gid2i[gid] ft_counts["poly"][gi] += 1 n = ft_counts["poly"][gi] diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index 98669b72..01e1ad80 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict +from typing import Any, Dict, List import numpy as np @@ -13,13 +13,36 @@ def __init__(self): self._ft = ForceTorqueManager() @staticmethod - def _block_diag(F: np.ndarray, T: np.ndarray) -> np.ndarray: - nF = F.shape[0] - nT = T.shape[0] - M = np.zeros((nF + nT, nF + nT), dtype=float) - M[:nF, :nF] = F - M[nF:, nF:] = T - return M + def _full_ft_second_moment( + force_vecs: List[np.ndarray], torque_vecs: List[np.ndarray] + ) -> np.ndarray: + """ + Procedural-equivalent FT construction: + + Build a FULL 6N x 6N second-moment matrix from concatenated bead vectors: + [F1, F2, ... FN, T1, T2, ... TN] + where each Fi, Ti is a 3-vector (already projected/weighted). + + This includes the F<->T cross blocks (off-diagonal blocks). + """ + if len(force_vecs) != len(torque_vecs): + raise ValueError( + "force_vecs and torque_vecs must have the same number of beads" + ) + + if len(force_vecs) == 0: + raise ValueError("force_vecs/torque_vecs are empty") + + f = [np.asarray(v, dtype=float).reshape(-1) for v in force_vecs] + t = [np.asarray(v, dtype=float).reshape(-1) for v in torque_vecs] + + if any(v.shape[0] != 3 for v in f + t): + raise ValueError( + "Each force/torque vector must be length 3 after weighting" + ) + + flat = np.concatenate(f + t, axis=0) + return np.outer(flat, flat) def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: if "shared" not in ctx: @@ -69,7 +92,6 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: torque_vecs = [] for ua_i, bead in enumerate(bead_groups): - trans_axes, rot_axes, center, moi = ( axes_manager.get_UA_axes(res.atoms, ua_i) ) @@ -102,7 +124,9 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: out_force["ua"][key] = F out_torque["ua"][key] = T if combined and out_ft is not None: - out_ft["ua"][key] = self._block_diag(F, T) + out_ft["ua"][key] = self._full_ft_second_moment( + force_vecs, torque_vecs + ) if "residue" in level_list: bead_key = (mol_id, "residue") @@ -148,8 +172,11 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: out_force["res"][group_id] = F out_torque["res"][group_id] = T + if combined and out_ft is not None: - out_ft["res"][group_id] = self._block_diag(F, T) + out_ft["res"][group_id] = self._full_ft_second_moment( + force_vecs, torque_vecs + ) if "polymer" in level_list: bead_key = (mol_id, "polymer") @@ -195,8 +222,16 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: out_force["poly"][group_id] = F out_torque["poly"][group_id] = T + if combined and out_ft is not None: - out_ft["poly"][group_id] = self._block_diag(F, T) + out_ft["poly"][group_id] = self._full_ft_second_moment( + force_vecs, torque_vecs + ) + # M = out_ft["poly"][group_id] + # half = M.shape[0] // 2 + # cross_norm = np.linalg.norm(M[:half, half:]) + # logger.warning(f"[FT DEBUG] group={group_id} " + # f"cross_norm={cross_norm:.6e}") frame_cov = {"force": out_force, "torque": out_torque} if combined and out_ft is not None: diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/init_covariance_accumulators.py index 80659da1..086243e0 100644 --- a/CodeEntropy/levels/nodes/init_covariance_accumulators.py +++ b/CodeEntropy/levels/nodes/init_covariance_accumulators.py @@ -13,13 +13,17 @@ class InitCovarianceAccumulatorsNode: """ Allocate accumulators for per-frame reductions. - LevelDAG iterates frames and accumulates *means* of per-frame second-moment - matrices into: + Canonical mean accumulators: shared_data["force_covariances"] shared_data["torque_covariances"] - shared_data["force_torque_stats"] (mean of block-diag(F,T) per group) - It also stores counts and group_id_to_index mapping. + Canonical combined (full 6N x 6N) mean accumulator: + shared_data["forcetorque_covariances"] + shared_data["forcetorque_counts"] + + Backwards-compatible aliases (point to the same objects): + shared_data["force_torque_stats"] -> shared_data["forcetorque_covariances"] + shared_data["force_torque_counts"] -> shared_data["forcetorque_counts"] """ def run(self, shared_data): @@ -74,6 +78,9 @@ def run(self, shared_data): shared_data["force_torque_stats"] = force_torque_stats shared_data["force_torque_counts"] = force_torque_counts + shared_data["forcetorque_covariances"] = force_torque_stats + shared_data["forcetorque_counts"] = force_torque_counts + logger.warning(f"[InitCovAcc] group_ids={group_ids} gid2i={gid2i}") return { @@ -86,4 +93,6 @@ def run(self, shared_data): "torque_stats": torque_stats, "force_torque_stats": force_torque_stats, "force_torque_counts": force_torque_counts, + "forcetorque_covariances": force_torque_stats, + "forcetorque_counts": force_torque_counts, } From b0a7f2c305d9397f83a9950dc9ade1b90b5a9aa5 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 16 Feb 2026 11:37:51 +0000 Subject: [PATCH 045/101] Fix residue axes indexing for multi-molecule DAG runs --- CodeEntropy/axes.py | 11 ++++++++--- CodeEntropy/levels/nodes/frame_covariance.py | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index d698b482..ae4dab9f 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -28,7 +28,7 @@ def __init__(self): self._rot_axes = None self._number_of_beads = None - def get_residue_axes(self, data_container, index): + def get_residue_axes(self, data_container, index, residue=None): """ The translational and rotational axes at the residue level. @@ -45,12 +45,17 @@ def get_residue_axes(self, data_container, index): # TODO refine selection so that it will work for branched polymers index_prev = index - 1 index_next = index + 1 + + if residue is None: + residue = data_container.select_atoms(f"resindex {index}") + if len(residue) == 0: + raise ValueError(f"Empty residue selection for resindex={index}") + + center = residue.atoms.center_of_mass(unwrap=True) atom_set = data_container.select_atoms( f"(resindex {index_prev} or resindex {index_next}) " f"and bonded resid {index}" ) - residue = data_container.select_atoms(f"resindex {index}") - center = residue.atoms.center_of_mass(unwrap=True) if len(atom_set) == 0: # No bonds to other residues diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index 01e1ad80..4774ce00 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -140,9 +140,10 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: for local_res_i, bead in enumerate(bead_groups): res = mol.residues[local_res_i] + trans_axes, rot_axes, center, moi = ( axes_manager.get_residue_axes( - mol, int(res.resindex) + mol, local_res_i, residue=res.atoms ) ) From 95c3d6e2e05d2b59ff174a0c477b1968c1496bf0 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 16 Feb 2026 12:07:44 +0000 Subject: [PATCH 046/101] add correct paths for water entropy calculations --- CodeEntropy/entropy/entropy_manager.py | 5 ++++- CodeEntropy/entropy/water_entropy.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 7592314a..012d69a9 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -6,6 +6,7 @@ from CodeEntropy.config.logging_config import LoggingConfig from CodeEntropy.entropy.entropy_graph import EntropyGraph +from CodeEntropy.entropy.water_entropy import WaterEntropy from CodeEntropy.levels.hierarchy_graph import LevelDAG from CodeEntropy.levels.level_hierarchy import LevelHierarchy @@ -127,9 +128,11 @@ def _handle_water_entropy(self, start, end, step, water_groups): if not water_groups or not self._args.water_entropy: return + water_entropy = WaterEntropy(self._args) + for group_id, atom_indices in water_groups.items(): - self._calculate_water_entropy( + water_entropy._calculate_water_entropy( universe=self._universe, start=start, end=end, diff --git a/CodeEntropy/entropy/water_entropy.py b/CodeEntropy/entropy/water_entropy.py index 7682ba31..d05ab6d6 100644 --- a/CodeEntropy/entropy/water_entropy.py +++ b/CodeEntropy/entropy/water_entropy.py @@ -8,8 +8,9 @@ class WaterEntropy: - def __init__(self): - """""" + def __init__(self, args): + """ """ + self._args = args def _calculate_water_entropy(self, universe, start, end, step, group_id=None): """ From af2d5dfc0b5323da6b8c987f582afd915ef85132 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 18 Feb 2026 12:13:44 +0000 Subject: [PATCH 047/101] =?UTF-8?q?Fix=20FTmat=20parity=20and=20conformati?= =?UTF-8?q?onal=20entropy=20to=20match=20procedural=20implementation:=20-?= =?UTF-8?q?=20Compute=20combined=20force=E2=80=93torque=20covariance=20at?= =?UTF-8?q?=20highest=20level=20(residue=20or=20polymer)=20only=20-=20Alig?= =?UTF-8?q?n=20FrameCovarianceNode=20axes=20and=20force=20partitioning=20w?= =?UTF-8?q?ith=20procedural=20logic=20-=20Fix=20FT=20accumulator=20wiring?= =?UTF-8?q?=20and=20reduction=20across=20frames=20-=20Restore=20procedural?= =?UTF-8?q?=20conformational=20entropy=20behaviour=20(correct=20probabilit?= =?UTF-8?q?y=20normalisation)=20-=20Ensure=20VibrationalEntropyNode=20cons?= =?UTF-8?q?umes=20correct=20FT=20matrices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entropy/configurational_entropy.py | 151 +++++++-------- .../nodes/configurational_entropy_node.py | 91 +++++---- .../entropy/nodes/vibrational_entropy_node.py | 175 +++++++++++------- CodeEntropy/levels/dihedral_tools.py | 155 ++++++++-------- CodeEntropy/levels/hierarchy_graph.py | 25 +-- CodeEntropy/levels/nodes/frame_covariance.py | 174 +++++++++++------ .../nodes/init_covariance_accumulators.py | 60 ++---- 7 files changed, 429 insertions(+), 402 deletions(-) diff --git a/CodeEntropy/entropy/configurational_entropy.py b/CodeEntropy/entropy/configurational_entropy.py index fbb2d926..91293313 100644 --- a/CodeEntropy/entropy/configurational_entropy.py +++ b/CodeEntropy/entropy/configurational_entropy.py @@ -19,123 +19,98 @@ def assign_conformation( self, data_container, dihedral, number_frames, bin_width, start, end, step ): """ - Create a state vector, showing the state in which the input dihedral is - as a function of time. The function creates a histogram from the timeseries of - the dihedral angle values and identifies points of dominant occupancy - (called CONVEX TURNING POINTS). - Based on the identified TPs, states are assigned to each configuration of the - dihedral. - - Args: - data_container (MDAnalysis Universe): data for the molecule/residue unit - dihedral (array): The dihedral angles in the unit - number_frames (int): number of frames in the trajectory - bin_width (int): the width of the histogram bit, default 30 degrees - start (int): starting frame, will default to 0 - end (int): ending frame, will default to -1 (last frame in trajectory) - step (int): spacing between frames, will default to 1 - - Returns: - conformations (array): A timeseries with integer labels describing the - state at each point in time. + Build a conformation/state time series for ONE dihedral using the same + logic as the procedural approach (histogram peaks -> nearest peak index), + but with correct handling of start/end/step. + NOTE: `number_frames` is ignored for sizing; we size to the slice length + to avoid mismatches that cause invalid probabilities later. """ - conformations = np.zeros(number_frames) - phi = np.zeros(number_frames) - - # get the values of the angle for the dihedral - # dihedral angle values have a range from -180 to 180 - indices = list(range(number_frames)) - for timestep_index, _ in zip( - indices, data_container.trajectory[start:end:step] - ): - timestep_index = timestep_index - value = dihedral.value() - # we want postive values in range 0 to 360 to make the peak assignment - # works using the fact that dihedrals have circular symetry - # (i.e. -15 degrees = +345 degrees) + traj_slice = data_container.trajectory[start:end:step] + n = len(traj_slice) + + if n <= 0: + return np.array([], dtype=int) + + phi = np.zeros(n, dtype=float) + + k = 0 + for _ts in traj_slice: + value = float(dihedral.value()) if value < 0: - value += 360 - phi[timestep_index] = value + value += 360.0 + phi[k] = value + k += 1 - # create a histogram using numpy number_bins = int(360 / bin_width) - popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360)) - bin_value = [ - 0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul)) - ] - - # identify "convex turning-points" and populate a list of peaks - # peak : a bin whose neighboring bins have smaller population - # NOTE might have problems if the peak is wide with a flat or sawtooth - # top in which case check you have a sensible bin width - peak_values = [] + popul, bin_edges = np.histogram(phi, bins=number_bins, range=(0.0, 360.0)) + bin_value = 0.5 * (bin_edges[:-1] + bin_edges[1:]) + peak_values = [] for bin_index in range(number_bins): - # if there is no dihedrals in a bin then it cannot be a peak if popul[bin_index] == 0: - pass - # being careful of the last bin - # (dihedrals have circular symmetry, the histogram does not) - elif ( - bin_index == number_bins - 1 - ): # the -1 is because the index starts with 0 not 1 + continue + + if bin_index == number_bins - 1: if ( popul[bin_index] >= popul[bin_index - 1] and popul[bin_index] >= popul[0] ): - peak_values.append(bin_value[bin_index]) + peak_values.append(float(bin_value[bin_index])) else: if ( popul[bin_index] >= popul[bin_index - 1] and popul[bin_index] >= popul[bin_index + 1] ): - peak_values.append(bin_value[bin_index]) + peak_values.append(float(bin_value[bin_index])) - # go through each frame again and assign conformation state - for frame in range(number_frames): - # find the TP that the snapshot is least distant from - distances = [abs(phi[frame] - peak) for peak in peak_values] - conformations[frame] = np.argmin(distances) + if not peak_values: + return np.zeros(n, dtype=int) - logger.debug(f"Final conformations: {conformations}") + peak_values = np.asarray(peak_values, dtype=float) + conformations = np.zeros(n, dtype=int) + for i in range(n): + distances = np.abs(phi[i] - peak_values) + conformations[i] = int(np.argmin(distances)) + + logger.debug(f"Final conformations: {conformations}") return conformations def conformational_entropy_calculation(self, states, number_frames): """ - Function to calculate conformational entropies using eq. (7) in Higham, - S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116, - 1965–1976 / eq. (4) in A. Chakravorty, J. Higham and R. H. Henchman, - J. Chem. Inf. Model., 2020, 60, 5540–5551. - - Uses the adaptive enumeration method (AEM). + Procedural parity: + - probabilities are computed using total_count = len(states) + - number_frames is NOT used as the denominator (it is only metadata) + """ + if states is None: + return 0.0 - Args: - states (array): Conformational states in the molecule - number_frames (int): The number of frames analysed + if isinstance(states, np.ndarray): + states = states.reshape(-1) - Returns: - S_conf_total (float) : conformational entropy - """ + try: + if len(states) == 0: + return 0.0 + except TypeError: + return 0.0 - S_conf_total = 0 + try: + if not any(states): + return 0.0 + except TypeError: + pass - # 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) - S_conf_total += entropy + total_count = int(np.sum(counts)) + if total_count <= 0: + return 0.0 - # multiply by gas constant to get the units J/mol/K - S_conf_total *= -1 * self._GAS_CONST + S_conf_total = 0.0 + for c in counts: + p = float(c) / float(total_count) + S_conf_total += p * np.log(p) + S_conf_total *= -1.0 * self._GAS_CONST logger.debug(f"Total conformational entropy: {S_conf_total}") - - return S_conf_total + return float(S_conf_total) diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 38797ddc..9f0ffb61 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -1,6 +1,8 @@ import logging from typing import Any, Dict +import numpy as np + from CodeEntropy.entropy.configurational_entropy import ConformationalEntropy logger = logging.getLogger(__name__) @@ -8,15 +10,20 @@ class ConfigurationalEntropyNode: """ - Computes conformational entropy from conformational states produced by LevelDAG. - - Expected: - shared_data["conformational_states"]["ua"] - -> dict[(group_id, res_id)] = list/int states - shared_data["conformational_states"]["res"] - -> list indexed by group index OR dict[group_id]=states + Procedural-parity conformational entropy. """ + @staticmethod + def _has_state_data(states) -> bool: + if states is None: + return False + if isinstance(states, np.ndarray): + return bool(np.any(states)) + try: + return any(states) + except TypeError: + return bool(states) + def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: run_manager = shared_data["run_manager"] args = shared_data["args"] @@ -31,78 +38,80 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: group_molecules=shared_data.get("group_molecules"), ) - conf_states = shared_data["conformational_states"] + conf_states = shared_data.get("conformational_states", {}) or {} + states_ua = conf_states.get("ua", {}) or {} + states_res = conf_states.get("res", {}) n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) if n_frames is None: raise KeyError("shared_data must contain n_frames (or number_frames)") + n_frames = int(n_frames) groups = shared_data["groups"] levels = shared_data["levels"] - gid2i = shared_data.get( - "group_id_to_index", {gid: i for i, gid in enumerate(groups.keys())} - ) - - states_ua = conf_states.get("ua", {}) or {} - states_res = conf_states.get("res", {}) + fragments = universe.atoms.fragments results: Dict[int, Dict[str, float]] = {} - fragments = universe.atoms.fragments - for group_id, mol_ids in groups.items(): - mol_id = mol_ids[0] - mol = fragments[mol_id] - level_list = levels[mol_id] - results[group_id] = {"ua": 0.0, "res": 0.0, "poly": 0.0} + if not mol_ids: + continue + + rep_mol_id = mol_ids[0] + rep_mol = fragments[rep_mol_id] + level_list = levels[rep_mol_id] if "united_atom" in level_list: - total = 0.0 - for res_id, res in enumerate(mol.residues): + S_conf_ua = 0.0 + for res_id, res in enumerate(rep_mol.residues): key = (group_id, res_id) states = states_ua.get(key, []) - if not states: - val = 0.0 + + if self._has_state_data(states): + val = float( + ce.conformational_entropy_calculation(states, n_frames) + ) else: - val = ce.conformational_entropy_calculation(states, n_frames) + val = 0.0 - total += val + S_conf_ua += val if data_logger is not None: data_logger.add_residue_data( group_id=group_id, - resname=res.resname, + resname=getattr(res, "resname", "UNK"), level="united_atom", entropy_type="Conformational", frame_count=n_frames, value=val, ) - results[group_id]["ua"] = total + results[group_id]["ua"] = S_conf_ua if data_logger is not None: data_logger.add_results_data( - group_id, "united_atom", "Conformational", total + group_id, "united_atom", "Conformational", S_conf_ua ) if "residue" in level_list: - val = 0.0 - if isinstance(states_res, dict): - s = states_res.get(group_id, []) - if s: - val = ce.conformational_entropy_calculation(s, n_frames) + group_states = states_res.get(group_id, None) else: - gi = gid2i[group_id] - if gi < len(states_res) and states_res[gi]: - val = ce.conformational_entropy_calculation( - states_res[gi], n_frames - ) + group_states = ( + states_res[group_id] if group_id < len(states_res) else None + ) + + if self._has_state_data(group_states): + S_conf_res = float( + ce.conformational_entropy_calculation(group_states, n_frames) + ) + else: + S_conf_res = 0.0 - results[group_id]["res"] = val + results[group_id]["res"] = S_conf_res if data_logger is not None: data_logger.add_results_data( - group_id, "residue", "Conformational", val + group_id, "residue", "Conformational", S_conf_res ) logger.info("[ConfigurationalEntropyNode] Done") diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 51258d9b..ac283e45 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -44,12 +44,7 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: torque_cov = shared_data["torque_covariances"] combined = bool(getattr(args, "combined_forcetorque", False)) - - ft_cov = None - if combined: - ft_cov = shared_data.get("forcetorque_covariances") - if ft_cov is None: - ft_cov = shared_data.get("force_torque_stats") + ft_cov = shared_data.get("forcetorque_covariances") if combined else None counts = shared_data.get("frame_counts", {}) ua_counts = counts.get("ua", {}) if isinstance(counts, dict) else {} @@ -60,22 +55,21 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: vib_results: Dict[int, Dict[str, Dict[str, float]]] = {} for group_id, mol_ids in groups.items(): - mol_id = mol_ids[0] - mol = fragments[mol_id] - level_list = levels[mol_id] - vib_results[group_id] = {} + rep_mol_id = mol_ids[0] + rep_mol = fragments[rep_mol_id] + level_list = levels[rep_mol_id] + for level in level_list: - # highest = level == level_list[-1] + highest = level == level_list[-1] if level == "united_atom": S_trans = 0.0 S_rot = 0.0 - for res_id, res in enumerate(mol.residues): + for res_id, res in enumerate(rep_mol.residues): key = (group_id, res_id) - fmat = force_cov["ua"].get(key) tmat = torque_cov["ua"].get(key) @@ -118,8 +112,52 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: value=val_rot, ) - elif level == "residue": + vib_results[group_id][level] = { + "trans": float(S_trans), + "rot": float(S_rot), + } + + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot + ) + + continue + + if level == "residue": gi = gid2i[group_id] + + if combined and highest and ft_cov is not None: + ftmat = ft_cov["res"][gi] if gi < len(ft_cov["res"]) else None + if ftmat is None: + S_trans, S_rot = 0.0, 0.0 + else: + ftmat = self._mat_ops.filter_zero_rows_columns( + np.asarray(ftmat) + ) + S_trans = ve.vibrational_entropy_calculation( + ftmat, "forcetorqueTRANS", temp, highest_level=True + ) + S_rot = ve.vibrational_entropy_calculation( + ftmat, "forcetorqueROT", temp, highest_level=True + ) + + vib_results[group_id][level] = { + "trans": float(S_trans), + "rot": float(S_rot), + } + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "FTmat-Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "FTmat-Rovibrational", S_rot + ) + continue + fmat = force_cov["res"][gi] if gi < len(force_cov["res"]) else None tmat = ( torque_cov["res"][gi] if gi < len(torque_cov["res"]) else None @@ -130,95 +168,90 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: else: fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) - S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=False + fmat, "force", temp, highest_level=highest ) S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=False + tmat, "torque", temp, highest_level=highest ) - elif level == "polymer": - if combined and ft_cov is not None: - gi = gid2i[group_id] - ftmat = ft_cov["poly"][gi] - logger.warning( - f"[VibNode] group={group_id} " - "ftmat_shape={None if ftmat is None else ftmat.shape}" + vib_results[group_id][level] = { + "trans": float(S_trans), + "rot": float(S_rot), + } + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "Transvibrational", S_trans + ) + data_logger.add_results_data( + group_id, level, "Rovibrational", S_rot ) + continue + if level == "polymer": gi = gid2i[group_id] - if combined and ft_cov is not None: + if combined and highest and ft_cov is not None: ftmat = ft_cov["poly"][gi] if gi < len(ft_cov["poly"]) else None if ftmat is None: - logger.warning( - f"[VibNode] combined=True but ftmat is None for group " - f"{group_id}; falling back to F/T" - ) S_trans, S_rot = 0.0, 0.0 else: - ftmat = np.asarray(ftmat) - ftmat = self._mat_ops.filter_zero_rows_columns(ftmat) - + ftmat = self._mat_ops.filter_zero_rows_columns( + np.asarray(ftmat) + ) S_trans = ve.vibrational_entropy_calculation( ftmat, "forcetorqueTRANS", temp, highest_level=True ) S_rot = ve.vibrational_entropy_calculation( ftmat, "forcetorqueROT", temp, highest_level=True ) - else: - fmat = ( - force_cov["poly"][gi] - if gi < len(force_cov["poly"]) - else None - ) - tmat = ( - torque_cov["poly"][gi] - if gi < len(torque_cov["poly"]) - else None - ) - if fmat is None or tmat is None: - S_trans, S_rot = 0.0, 0.0 - else: - fmat = self._mat_ops.filter_zero_rows_columns( - np.asarray(fmat) + vib_results[group_id][level] = { + "trans": float(S_trans), + "rot": float(S_rot), + } + if data_logger is not None: + data_logger.add_results_data( + group_id, level, "FTmat-Transvibrational", S_trans ) - tmat = self._mat_ops.filter_zero_rows_columns( - np.asarray(tmat) + data_logger.add_results_data( + group_id, level, "FTmat-Rovibrational", S_rot ) + continue - S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=True - ) - S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=True - ) - - else: - raise ValueError(f"Unknown level: {level}") - - vib_results[group_id][level] = { - "trans": float(S_trans), - "rot": float(S_rot), - } + fmat = ( + force_cov["poly"][gi] if gi < len(force_cov["poly"]) else None + ) + tmat = ( + torque_cov["poly"][gi] if gi < len(torque_cov["poly"]) else None + ) - if data_logger is not None: - if level == "polymer" and combined and ft_cov is not None: - data_logger.add_results_data( - group_id, level, "FTmat-Transvibrational", S_trans + if fmat is None or tmat is None: + S_trans, S_rot = 0.0, 0.0 + else: + fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) + tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) + S_trans = ve.vibrational_entropy_calculation( + fmat, "force", temp, highest_level=highest ) - data_logger.add_results_data( - group_id, level, "FTmat-Rovibrational", S_rot + S_rot = ve.vibrational_entropy_calculation( + tmat, "torque", temp, highest_level=highest ) - else: + + vib_results[group_id][level] = { + "trans": float(S_trans), + "rot": float(S_rot), + } + if data_logger is not None: data_logger.add_results_data( group_id, level, "Transvibrational", S_trans ) data_logger.add_results_data( group_id, level, "Rovibrational", S_rot ) + continue + + raise ValueError(f"Unknown level: {level}") logger.info("[VibrationalEntropyNode] Done") return {"vibrational_entropy": vib_results} diff --git a/CodeEntropy/levels/dihedral_tools.py b/CodeEntropy/levels/dihedral_tools.py index 1c5d24df..a7c9dd59 100644 --- a/CodeEntropy/levels/dihedral_tools.py +++ b/CodeEntropy/levels/dihedral_tools.py @@ -64,104 +64,97 @@ def build_conformational_states( title="Starting...", ) - for group_id in groups.keys(): - molecules = groups[group_id] - mol = self._universe_operations.get_molecule_container( - data_container, molecules[0] - ) - num_residues = len(mol.residues) - dihedrals_ua = [[] for _ in range(num_residues)] - peaks_ua = [{} for _ in range(num_residues)] - dihedrals_res = [] - peaks_res = {} - - # Identify dihedral AtomGroups - for level in levels[molecules[0]]: - if level == "united_atom": - for res_id in range(num_residues): - selection1 = mol.residues[res_id].atoms.indices[0] - selection2 = mol.residues[res_id].atoms.indices[-1] - res_container = self._universe_operations.new_U_select_atom( - mol, - f"index {selection1}:" f"{selection2}", - ) - heavy_res = self._universe_operations.new_U_select_atom( - res_container, "prop mass > 1.1" - ) - - dihedrals_ua[res_id] = self._get_dihedrals(heavy_res, level) - - elif level == "residue": - dihedrals_res = self._get_dihedrals(mol, level) - - # Identify peaks - for level in levels[molecules[0]]: - if level == "united_atom": - for res_id in range(num_residues): - if len(dihedrals_ua[res_id]) == 0: - # No dihedrals means no histogram or peaks - peaks_ua[res_id] = [] + for group_id in groups.keys(): + molecules = groups[group_id] + mol = self._universe_operations.get_molecule_container( + data_container, molecules[0] + ) + num_residues = len(mol.residues) + dihedrals_ua = [[] for _ in range(num_residues)] + peaks_ua = [{} for _ in range(num_residues)] + dihedrals_res = [] + peaks_res = {} + + for level in levels[molecules[0]]: + if level == "united_atom": + for res_id in range(num_residues): + selection1 = mol.residues[res_id].atoms.indices[0] + selection2 = mol.residues[res_id].atoms.indices[-1] + res_container = self._universe_operations.new_U_select_atom( + mol, + f"index {selection1}:" f"{selection2}", + ) + heavy_res = self._universe_operations.new_U_select_atom( + res_container, "prop mass > 1.1" + ) + + dihedrals_ua[res_id] = self._get_dihedrals(heavy_res, level) + + elif level == "residue": + dihedrals_res = self._get_dihedrals(mol, level) + + for level in levels[molecules[0]]: + if level == "united_atom": + for res_id in range(num_residues): + if len(dihedrals_ua[res_id]) == 0: + peaks_ua[res_id] = [] + else: + peaks_ua[res_id] = self._identify_peaks( + data_container, + molecules, + dihedrals_ua[res_id], + bin_width, + start, + end, + step, + ) + + elif level == "residue": + if len(dihedrals_res) == 0: + peaks_res = [] else: - peaks_ua[res_id] = self._identify_peaks( + peaks_res = self._identify_peaks( data_container, molecules, - dihedrals_ua[res_id], + dihedrals_res, bin_width, start, end, step, ) - elif level == "residue": - if len(dihedrals_res) == 0: - # No dihedrals means no histogram or peaks - peaks_res = [] - else: - peaks_res = self._identify_peaks( - data_container, - molecules, - dihedrals_res, - bin_width, - start, - end, - step, - ) - - # Assign states for each group - for level in levels[molecules[0]]: - if level == "united_atom": - for res_id in range(num_residues): - key = (group_id, res_id) - if len(dihedrals_ua[res_id]) == 0: - # No conformational states - states_ua[key] = [] + for level in levels[molecules[0]]: + if level == "united_atom": + for res_id in range(num_residues): + key = (group_id, res_id) + if len(dihedrals_ua[res_id]) == 0: + states_ua[key] = [] + else: + states_ua[key] = self._assign_states( + data_container, + molecules, + dihedrals_ua[res_id], + peaks_ua[res_id], + start, + end, + step, + ) + + elif level == "residue": + if len(dihedrals_res) == 0: + states_res[group_id] = [] else: - states_ua[key] = self._assign_states( + states_res[group_id] = self._assign_states( data_container, molecules, - dihedrals_ua[res_id], - peaks_ua[res_id], + dihedrals_res, + peaks_res, start, end, step, ) - elif level == "residue": - if len(dihedrals_res) == 0: - # No conformational states - states_res[group_id] = [] - else: - states_res[group_id] = self._assign_states( - data_container, - molecules, - dihedrals_res, - peaks_res, - start, - end, - step, - ) - - progress.advance(task) + progress.advance(task) return states_ua, states_res diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 55ff3336..7ae06b60 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -64,9 +64,7 @@ def _inc_mean(old, new, n: int): return new.copy() if hasattr(new, "copy") else new return old + (new - old) / float(n) - def _reduce_one_frame( - self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] - ) -> None: + def _reduce_one_frame(self, shared_data, frame_out): f_cov = shared_data["force_covariances"] t_cov = shared_data["torque_covariances"] counts = shared_data["frame_counts"] @@ -115,26 +113,11 @@ def _reduce_one_frame( n = counts["poly"][gi] t_cov["poly"][gi] = self._inc_mean(t_cov["poly"][gi], T, n) - if "forcetorque" in frame_out and "force_torque_stats" in shared_data: - ft_cov = shared_data["force_torque_stats"] - ft_counts = shared_data["force_torque_counts"] - ft_frame = frame_out["forcetorque"] - - if ft_cov is None: - ft_cov = shared_data.get("force_torque_stats") - if ft_counts is None: - ft_counts = shared_data.get("force_torque_counts") - - if ft_cov is None or ft_counts is None: - return - + if "forcetorque" in frame_out: + ft_cov = shared_data["forcetorque_covariances"] + ft_counts = shared_data["forcetorque_counts"] ft_frame = frame_out["forcetorque"] - for key, M in ft_frame.get("ua", {}).items(): - ft_counts["ua"][key] = ft_counts["ua"].get(key, 0) + 1 - n = ft_counts["ua"][key] - ft_cov["ua"][key] = self._inc_mean(ft_cov["ua"].get(key), M, n) - for gid, M in ft_frame.get("res", {}).items(): gi = gid2i[gid] ft_counts["res"][gi] += 1 diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index 4774ce00..a4dc4051 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -1,7 +1,8 @@ import logging -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple import numpy as np +from MDAnalysis.lib.mdamath import make_whole from CodeEntropy.levels.force_torque_manager import ForceTorqueManager @@ -13,36 +14,43 @@ def __init__(self): self._ft = ForceTorqueManager() @staticmethod - def _full_ft_second_moment( - force_vecs: List[np.ndarray], torque_vecs: List[np.ndarray] - ) -> np.ndarray: - """ - Procedural-equivalent FT construction: - - Build a FULL 6N x 6N second-moment matrix from concatenated bead vectors: - [F1, F2, ... FN, T1, T2, ... TN] - where each Fi, Ti is a 3-vector (already projected/weighted). + def _inc_mean(old: np.ndarray | None, new: np.ndarray, n: int) -> np.ndarray: + """Incremental mean over molecules within the same frame.""" + if old is None: + return new.copy() + return old + (new - old) / float(n) - This includes the F<->T cross blocks (off-diagonal blocks). + @staticmethod + def _build_ft_block_procedural(force_vecs, torque_vecs) -> np.ndarray: + """ + Match procedural get_combined_forcetorque_matrices: + - per bead vector is [Fi, Ti] + - subblock(i,j) = outer([Fi,Ti], [Fj,Tj]) + - assemble np.block over beads """ if len(force_vecs) != len(torque_vecs): - raise ValueError( - "force_vecs and torque_vecs must have the same number of beads" - ) + raise ValueError("force_vecs and torque_vecs must match length") - if len(force_vecs) == 0: - raise ValueError("force_vecs/torque_vecs are empty") + n = len(force_vecs) + if n == 0: + raise ValueError("No beads provided for FT matrix build") - f = [np.asarray(v, dtype=float).reshape(-1) for v in force_vecs] - t = [np.asarray(v, dtype=float).reshape(-1) for v in torque_vecs] + bead_vecs: List[np.ndarray] = [] + for Fi, Ti in zip(force_vecs, torque_vecs): + Fi = np.asarray(Fi, dtype=float).reshape(-1) + Ti = np.asarray(Ti, dtype=float).reshape(-1) + if Fi.shape[0] != 3 or Ti.shape[0] != 3: + raise ValueError("Each force/torque must be length 3") + bead_vecs.append(np.concatenate([Fi, Ti], axis=0)) - if any(v.shape[0] != 3 for v in f + t): - raise ValueError( - "Each force/torque vector must be length 3 after weighting" - ) + blocks = [[None] * n for _ in range(n)] + for i in range(n): + for j in range(i, n): + sub = np.outer(bead_vecs[i], bead_vecs[j]) + blocks[i][j] = sub + blocks[j][i] = sub.T - flat = np.concatenate(f + t, axis=0) - return np.outer(flat, flat) + return np.block(blocks) def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: if "shared" not in ctx: @@ -58,9 +66,10 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: fp = args.force_partitioning combined = bool(getattr(args, "combined_forcetorque", False)) + customised_axes = bool(getattr(args, "customised_axes", False)) axes_manager = shared.get("axes_manager", None) - box = None + try: box = np.asarray(u.dimensions[:3], dtype=float) except Exception: @@ -68,9 +77,15 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: fragments = u.atoms.fragments - out_force = {"ua": {}, "res": {}, "poly": {}} - out_torque = {"ua": {}, "res": {}, "poly": {}} - out_ft = {"ua": {}, "res": {}, "poly": {}} if combined else None + out_force: Dict[str, Dict[Any, np.ndarray]] = {"ua": {}, "res": {}, "poly": {}} + out_torque: Dict[str, Dict[Any, np.ndarray]] = {"ua": {}, "res": {}, "poly": {}} + out_ft: Dict[str, Dict[Any, np.ndarray]] | None = ( + {"ua": {}, "res": {}, "poly": {}} if combined else None + ) + + ua_molcount: Dict[Tuple[int, int], int] = {} + res_molcount: Dict[int, int] = {} + poly_molcount: Dict[int, int] = {} for group_id, mol_ids in groups.items(): for mol_id in mol_ids: @@ -121,12 +136,15 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: ) key = (group_id, local_res_i) - out_force["ua"][key] = F - out_torque["ua"][key] = T - if combined and out_ft is not None: - out_ft["ua"][key] = self._full_ft_second_moment( - force_vecs, torque_vecs - ) + + n = ua_molcount.get(key, 0) + 1 + out_force["ua"][key] = self._inc_mean( + out_force["ua"].get(key), F, n + ) + out_torque["ua"][key] = self._inc_mean( + out_torque["ua"].get(key), T, n + ) + ua_molcount[key] = n if "residue" in level_list: bead_key = (mol_id, "residue") @@ -134,24 +152,40 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: if bead_idx_list: bead_groups = [u.atoms[idx] for idx in bead_idx_list] if not any(len(bg) == 0 for bg in bead_groups): - force_vecs = [] torque_vecs = [] - for local_res_i, bead in enumerate(bead_groups): - res = mol.residues[local_res_i] + highest = "residue" == level_list[-1] - trans_axes, rot_axes, center, moi = ( - axes_manager.get_residue_axes( - mol, local_res_i, residue=res.atoms + for local_res_i, bead in enumerate(bead_groups): + if customised_axes and axes_manager is not None: + res = mol.residues[local_res_i] + trans_axes, rot_axes, center, moi = ( + axes_manager.get_residue_axes( + mol, local_res_i, residue=res.atoms + ) ) - ) + else: + make_whole(mol.atoms) + make_whole(bead) + trans_axes = mol.atoms.principal_axes() + if axes_manager is not None: + rot_axes, moi = axes_manager.get_vanilla_axes( + bead + ) + else: + rot_axes = np.real(bead.principal_axes()) + eigvals, _ = np.linalg.eig( + bead.moment_of_inertia(unwrap=True) + ) + moi = sorted(eigvals, reverse=True) + center = bead.center_of_mass(unwrap=True) force_vecs.append( self._ft.get_weighted_forces( bead=bead, trans_axes=np.asarray(trans_axes), - highest_level=False, + highest_level=highest, force_partitioning=fp, ) ) @@ -171,13 +205,22 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: force_vecs, torque_vecs ) - out_force["res"][group_id] = F - out_torque["res"][group_id] = T + n = res_molcount.get(group_id, 0) + 1 + out_force["res"][group_id] = self._inc_mean( + out_force["res"].get(group_id), F, n + ) + out_torque["res"][group_id] = self._inc_mean( + out_torque["res"].get(group_id), T, n + ) + res_molcount[group_id] = n - if combined and out_ft is not None: - out_ft["res"][group_id] = self._full_ft_second_moment( + if combined and highest and out_ft is not None: + M = self._build_ft_block_procedural( force_vecs, torque_vecs ) + out_ft["res"][group_id] = self._inc_mean( + out_ft["res"].get(group_id), M, n + ) if "polymer" in level_list: bead_key = (mol_id, "polymer") @@ -187,21 +230,28 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: if not any(len(bg) == 0 for bg in bead_groups): bead = bead_groups[0] + highest = "polymer" == level_list[-1] + if axes_manager is not None: rot_axes, moi = axes_manager.get_vanilla_axes(bead) - trans_axes = rot_axes + trans_axes = mol.atoms.principal_axes() center = bead.center_of_mass(unwrap=True) else: - trans_axes = bead.principal_axes() - rot_axes = trans_axes + make_whole(mol.atoms) + make_whole(bead) + trans_axes = mol.atoms.principal_axes() + rot_axes = np.real(bead.principal_axes()) + eigvals, _ = np.linalg.eig( + bead.moment_of_inertia(unwrap=True) + ) + moi = sorted(eigvals, reverse=True) center = bead.center_of_mass(unwrap=True) - moi = np.linalg.eigvals(bead.moment_of_inertia()) force_vecs = [ self._ft.get_weighted_forces( bead=bead, trans_axes=np.asarray(trans_axes), - highest_level=True, + highest_level=highest, force_partitioning=fp, ) ] @@ -221,18 +271,22 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: force_vecs, torque_vecs ) - out_force["poly"][group_id] = F - out_torque["poly"][group_id] = T + n = poly_molcount.get(group_id, 0) + 1 + out_force["poly"][group_id] = self._inc_mean( + out_force["poly"].get(group_id), F, n + ) + out_torque["poly"][group_id] = self._inc_mean( + out_torque["poly"].get(group_id), T, n + ) + poly_molcount[group_id] = n - if combined and out_ft is not None: - out_ft["poly"][group_id] = self._full_ft_second_moment( + if combined and highest and out_ft is not None: + M = self._build_ft_block_procedural( force_vecs, torque_vecs ) - # M = out_ft["poly"][group_id] - # half = M.shape[0] // 2 - # cross_norm = np.linalg.norm(M[:half, half:]) - # logger.warning(f"[FT DEBUG] group={group_id} " - # f"cross_norm={cross_norm:.6e}") + out_ft["poly"][group_id] = self._inc_mean( + out_ft["poly"].get(group_id), M, n + ) frame_cov = {"force": out_force, "torque": out_torque} if combined and out_ft is not None: diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/init_covariance_accumulators.py index 086243e0..daa43b43 100644 --- a/CodeEntropy/levels/nodes/init_covariance_accumulators.py +++ b/CodeEntropy/levels/nodes/init_covariance_accumulators.py @@ -1,14 +1,11 @@ import logging +from typing import Any, Dict import numpy as np logger = logging.getLogger(__name__) -def _empty_stats(): - return {"n": 0, "mean": None, "M2": None} - - class InitCovarianceAccumulatorsNode: """ Allocate accumulators for per-frame reductions. @@ -16,17 +13,18 @@ class InitCovarianceAccumulatorsNode: Canonical mean accumulators: shared_data["force_covariances"] shared_data["torque_covariances"] + shared_data["forcetorque_covariances"] # 6N x 6N mean (highest level only) - Canonical combined (full 6N x 6N) mean accumulator: - shared_data["forcetorque_covariances"] + Counters: + shared_data["frame_counts"] shared_data["forcetorque_counts"] - Backwards-compatible aliases (point to the same objects): + Backwards-compatible aliases: shared_data["force_torque_stats"] -> shared_data["forcetorque_covariances"] shared_data["force_torque_counts"] -> shared_data["forcetorque_counts"] """ - def run(self, shared_data): + def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: groups = shared_data["groups"] group_ids = list(groups.keys()) n_groups = len(group_ids) @@ -37,30 +35,17 @@ def run(self, shared_data): force_cov = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} torque_cov = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} - force_stats = { - "ua": {}, - "res": [_empty_stats() for _ in range(n_groups)], - "poly": [_empty_stats() for _ in range(n_groups)], - } - torque_stats = { - "ua": {}, - "res": [_empty_stats() for _ in range(n_groups)], - "poly": [_empty_stats() for _ in range(n_groups)], - } - force_torque_stats = { - "ua": {}, - "res": [None] * n_groups, - "poly": [None] * n_groups, - } - frame_counts = { "ua": {}, "res": np.zeros(n_groups, dtype=int), "poly": np.zeros(n_groups, dtype=int), } - force_torque_counts = { - "ua": {}, + forcetorque_cov = { + "res": [None] * n_groups, + "poly": [None] * n_groups, + } + forcetorque_counts = { "res": np.zeros(n_groups, dtype=int), "poly": np.zeros(n_groups, dtype=int), } @@ -72,16 +57,13 @@ def run(self, shared_data): shared_data["torque_covariances"] = torque_cov shared_data["frame_counts"] = frame_counts - shared_data["force_stats"] = force_stats - shared_data["torque_stats"] = torque_stats - - shared_data["force_torque_stats"] = force_torque_stats - shared_data["force_torque_counts"] = force_torque_counts + shared_data["forcetorque_covariances"] = forcetorque_cov + shared_data["forcetorque_counts"] = forcetorque_counts - shared_data["forcetorque_covariances"] = force_torque_stats - shared_data["forcetorque_counts"] = force_torque_counts + shared_data["force_torque_stats"] = forcetorque_cov + shared_data["force_torque_counts"] = forcetorque_counts - logger.warning(f"[InitCovAcc] group_ids={group_ids} gid2i={gid2i}") + logger.info(f"[InitCovAcc] group_ids={group_ids} gid2i={gid2i}") return { "group_id_to_index": gid2i, @@ -89,10 +71,8 @@ def run(self, shared_data): "force_covariances": force_cov, "torque_covariances": torque_cov, "frame_counts": frame_counts, - "force_stats": force_stats, - "torque_stats": torque_stats, - "force_torque_stats": force_torque_stats, - "force_torque_counts": force_torque_counts, - "forcetorque_covariances": force_torque_stats, - "forcetorque_counts": force_torque_counts, + "forcetorque_covariances": forcetorque_cov, + "forcetorque_counts": forcetorque_counts, + "force_torque_stats": forcetorque_cov, + "force_torque_counts": forcetorque_counts, } From 36e796268df5bad616ab613d1cd5d3775e7bfe11 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 18 Feb 2026 17:29:47 +0000 Subject: [PATCH 048/101] update all files within `entropy` folder to use Google Doc-Strings and follow SOLID principles --- .../entropy/configurational_entropy.py | 332 ++++++++--- CodeEntropy/entropy/entropy_graph.py | 121 +++- CodeEntropy/entropy/entropy_manager.py | 433 ++++++++------ .../entropy/nodes/aggregate_entropy_node.py | 88 ++- .../nodes/configurational_entropy_node.py | 285 +++++++--- .../entropy/nodes/vibrational_entropy_node.py | 527 +++++++++++------- CodeEntropy/entropy/orientational_entropy.py | 199 +++++-- CodeEntropy/entropy/vibrational_entropy.py | 305 ++++++++-- CodeEntropy/entropy/water_entropy.py | 311 +++++++---- 9 files changed, 1814 insertions(+), 787 deletions(-) diff --git a/CodeEntropy/entropy/configurational_entropy.py b/CodeEntropy/entropy/configurational_entropy.py index 91293313..9825ff71 100644 --- a/CodeEntropy/entropy/configurational_entropy.py +++ b/CodeEntropy/entropy/configurational_entropy.py @@ -1,116 +1,286 @@ +"""Conformational entropy utilities. + +This module provides: + * Assigning discrete conformational states for a single dihedral time series. + * Computing conformational entropy from a sequence of state labels. + +The public surface area is intentionally small to keep responsibilities clear. +""" + +from __future__ import annotations + import logging +from dataclasses import dataclass +from typing import Any, Optional import numpy as np logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class ConformationAssignmentConfig: + """Configuration for assigning conformational states from a dihedral. + + Attributes: + bin_width: Histogram bin width in degrees for peak detection. + start: Inclusive start frame index for trajectory slicing. + end: Exclusive end frame index for trajectory slicing. + step: Stride for trajectory slicing (must be positive). + """ + + bin_width: int + start: int + end: int + step: int + + class ConformationalEntropy: - def __init__(self, run_manager, args, universe, data_logger, group_molecules): + """Assign dihedral conformational states and compute conformational entropy. + + This class contains two independent responsibilities: + 1) `assign_conformation`: Map a single dihedral angle time series to discrete + state labels by detecting histogram peaks and assigning the nearest peak. + 2) `conformational_entropy_calculation`: Compute Shannon entropy of the + state distribution (in J/mol/K). + + Notes: + `number_frames` is accepted by `conformational_entropy_calculation` for + compatibility with calling sites that track frame counts, but the entropy + is computed from the observed state counts (i.e., `len(states)`), which is + the correct normalization for the sampled distribution. + """ + + _GAS_CONST: float = 8.3144598484848 + + def __init__( + self, + run_manager: Any, + args: Any, + universe: Any, + data_logger: Any, + group_molecules: Any, + ) -> None: + """Initialize the conformational entropy helper. + + Args: + run_manager: Workflow run manager. + args: Parsed CLI/config arguments. + universe: MDAnalysis Universe (or compatible container). + data_logger: Optional logger/collector for results. + group_molecules: Grouping helper used elsewhere in the workflow. + """ self._run_manager = run_manager self._args = args self._universe = universe self._data_logger = data_logger self._group_molecules = group_molecules - self._GAS_CONST = 8.3144598484848 - def assign_conformation( - self, data_container, dihedral, number_frames, bin_width, start, end, step - ): - """ - Build a conformation/state time series for ONE dihedral using the same - logic as the procedural approach (histogram peaks -> nearest peak index), - but with correct handling of start/end/step. + self, + data_container: Any, + dihedral: Any, + number_frames: int, + bin_width: int, + start: int, + end: int, + step: int, + ) -> np.ndarray: + """Assign discrete conformational states for a single dihedral. - NOTE: `number_frames` is ignored for sizing; we size to the slice length - to avoid mismatches that cause invalid probabilities later. + The dihedral angle time series is: + 1) Collected across the trajectory slice [start:end:step]. + 2) Converted to [0, 360) degrees. + 3) Histogrammed using `bin_width`. + 4) Peaks are identified as bins with locally maximal population. + 5) Each frame is assigned the index of the nearest peak. + + Args: + data_container: MDAnalysis Universe/AtomGroup with a trajectory. + dihedral: Object providing `value()` for the current frame dihedral. + number_frames: Provided for call-site compatibility; not used for sizing. + bin_width: Histogram bin width in degrees. + start: Inclusive start frame index. + end: Exclusive end frame index. + step: Stride for trajectory slicing. + + Returns: + Array of integer state labels of length equal to the trajectory slice. + Returns an empty array if the slice is empty. + + Raises: + ValueError: If `bin_width` or `step` are invalid. """ - traj_slice = data_container.trajectory[start:end:step] - n = len(traj_slice) + _ = number_frames # kept for compatibility; sizing follows the slice length. + + config = ConformationAssignmentConfig( + bin_width=int(bin_width), + start=int(start), + end=int(end), + step=int(step), + ) + self._validate_assignment_config(config) - if n <= 0: + traj_slice = data_container.trajectory[config.start : config.end : config.step] + n_slice = len(traj_slice) + if n_slice <= 0: return np.array([], dtype=int) - phi = np.zeros(n, dtype=float) + phi = self._collect_dihedral_angles(traj_slice, dihedral) + peak_values = self._find_histogram_peaks(phi, config.bin_width) + + if peak_values.size == 0: + # No peaks means no distinguishable states; assign everything to 0. + return np.zeros(n_slice, dtype=int) + + states = self._assign_nearest_peaks(phi, peak_values) + logger.debug("Final conformations: %s", states) + return states + + def conformational_entropy_calculation( + self, states: Any, number_frames: int + ) -> float: + """Compute conformational entropy for a sequence of state labels. + + Entropy is computed as: + S = -R * sum_i p_i * ln(p_i) + where p_i is the observed probability of state i in `states`. - k = 0 - for _ts in traj_slice: + Args: + states: Sequence/array of discrete state labels. Empty/None yields 0.0. + number_frames: Frame count metadata. + + Returns: + Conformational entropy in J/mol/K. + """ + _ = number_frames # accepted as metadata; distribution uses observed counts. + + arr = self._to_1d_array(states) + if arr is None or arr.size == 0: + return 0.0 + + # If states contain only falsy values (e.g., all zeros) this is still valid: + # entropy would be 0 because only one state is present. + values, counts = np.unique(arr, return_counts=True) + total_count = int(np.sum(counts)) + if total_count <= 0 or values.size <= 1: + return 0.0 + + probs = counts.astype(float) / float(total_count) + # Guard against log(0) (shouldn't happen because counts>0), but keep robust. + probs = probs[probs > 0.0] + + s_conf = -self._GAS_CONST * float(np.sum(probs * np.log(probs))) + logger.debug("Total conformational entropy: %s", s_conf) + return s_conf + + @staticmethod + def _validate_assignment_config(config: ConformationAssignmentConfig) -> None: + """Validate conformation assignment configuration. + + Args: + config: Assignment configuration. + + Raises: + ValueError: If configuration values are invalid. + """ + if config.step <= 0: + raise ValueError("step must be a positive integer") + if config.bin_width <= 0 or config.bin_width > 360: + raise ValueError("bin_width must be in the range (0, 360]") + if 360 % config.bin_width != 0: + # Not strictly required, but prevents uneven bins and edge-case confusion. + logger.warning( + "bin_width=%s does not evenly divide 360; histogram bins will be " + "uneven.", + config.bin_width, + ) + + @staticmethod + def _collect_dihedral_angles(traj_slice: Any, dihedral: Any) -> np.ndarray: + """Collect dihedral angles for each frame in the trajectory slice. + + Args: + traj_slice: Slice of a trajectory iterable where iterating advances frames. + dihedral: Object with `value()` returning the dihedral in degrees. + + Returns: + Array of dihedral values mapped into [0, 360). + """ + phi = np.zeros(len(traj_slice), dtype=float) + for i, _ts in enumerate(traj_slice): value = float(dihedral.value()) - if value < 0: + if value < 0.0: value += 360.0 - phi[k] = value - k += 1 + phi[i] = value + return phi + + @staticmethod + def _find_histogram_peaks(phi: np.ndarray, bin_width: int) -> np.ndarray: + """Identify peak bin centers from a histogram of dihedral angles. + + A peak is defined as a bin whose population is greater than or equal to + its immediate neighbors (with circular handling at the final bin). + Args: + phi: Dihedral angles in degrees, in [0, 360). + bin_width: Histogram bin width in degrees. + + Returns: + 1D array of peak bin center values (degrees). Empty if no peaks found. + """ number_bins = int(360 / bin_width) popul, bin_edges = np.histogram(phi, bins=number_bins, range=(0.0, 360.0)) - bin_value = 0.5 * (bin_edges[:-1] + bin_edges[1:]) + bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) - peak_values = [] - for bin_index in range(number_bins): - if popul[bin_index] == 0: + peaks: list[float] = [] + for idx in range(number_bins): + if popul[idx] == 0: continue - if bin_index == number_bins - 1: - if ( - popul[bin_index] >= popul[bin_index - 1] - and popul[bin_index] >= popul[0] - ): - peak_values.append(float(bin_value[bin_index])) - else: - if ( - popul[bin_index] >= popul[bin_index - 1] - and popul[bin_index] >= popul[bin_index + 1] - ): - peak_values.append(float(bin_value[bin_index])) - - if not peak_values: - return np.zeros(n, dtype=int) - - peak_values = np.asarray(peak_values, dtype=float) - - conformations = np.zeros(n, dtype=int) - for i in range(n): - distances = np.abs(phi[i] - peak_values) - conformations[i] = int(np.argmin(distances)) - - logger.debug(f"Final conformations: {conformations}") - return conformations - - def conformational_entropy_calculation(self, states, number_frames): - """ - Procedural parity: - - probabilities are computed using total_count = len(states) - - number_frames is NOT used as the denominator (it is only metadata) - """ - if states is None: - return 0.0 + left = popul[idx - 1] if idx > 0 else popul[number_bins - 1] + right = popul[idx + 1] if idx < number_bins - 1 else popul[0] - if isinstance(states, np.ndarray): - states = states.reshape(-1) + if popul[idx] >= left and popul[idx] >= right: + peaks.append(float(bin_centers[idx])) - try: - if len(states) == 0: - return 0.0 - except TypeError: - return 0.0 + return np.asarray(peaks, dtype=float) - try: - if not any(states): - return 0.0 - except TypeError: - pass + @staticmethod + def _assign_nearest_peaks(phi: np.ndarray, peak_values: np.ndarray) -> np.ndarray: + """Assign each phi value to the index of its nearest peak. - values, counts = np.unique(states, return_counts=True) - total_count = int(np.sum(counts)) - if total_count <= 0: - return 0.0 + Args: + phi: Dihedral angles in degrees. + peak_values: Peak centers (degrees). - S_conf_total = 0.0 - for c in counts: - p = float(c) / float(total_count) - S_conf_total += p * np.log(p) + Returns: + Integer state labels aligned with `phi`. + """ + # Vectorized nearest-peak assignment + # shape: (n_frames, n_peaks) + distances = np.abs(phi[:, None] - peak_values[None, :]) + return np.argmin(distances, axis=1).astype(int) + + @staticmethod + def _to_1d_array(states: Any) -> Optional[np.ndarray]: + """Convert a state sequence into a 1D numpy array. + + Args: + states: Input sequence/array. + + Returns: + 1D numpy array, or None if input is not usable. + """ + if states is None: + return None + + if isinstance(states, np.ndarray): + arr = states.reshape(-1) + else: + try: + arr = np.asarray(list(states)).reshape(-1) + except TypeError: + return None - S_conf_total *= -1.0 * self._GAS_CONST - logger.debug(f"Total conformational entropy: {S_conf_total}") - return float(S_conf_total) + return arr diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/entropy_graph.py index 80cd7b39..9081c9b2 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/entropy_graph.py @@ -1,5 +1,21 @@ +"""Entropy graph orchestration. + +This module defines `EntropyGraph`, a small directed acyclic graph (DAG) that +executes entropy calculation nodes in dependency order. + +The graph is intentionally simple: + * Vibrational entropy + * Configurational entropy + * Aggregation of results + +The nodes themselves encapsulate the detailed calculations. +""" + +from __future__ import annotations + import logging -from typing import Any, Dict, Optional +from dataclasses import dataclass +from typing import Any, Dict import networkx as nx @@ -12,39 +28,96 @@ logger = logging.getLogger(__name__) -class EntropyGraph: +SharedData = Dict[str, Any] + + +@dataclass(frozen=True) +class GraphNodeSpec: + """Specification for a node within the entropy graph. + + Attributes: + name: Unique node name. + node: Node instance. Must implement `run(shared_data, **kwargs)`. + deps: Optional list of node names that must run before this node. """ - Entropy DAG (simple, stable): - vibrational_entropy - configurational_entropy - aggregate_entropy + + name: str + node: Any + deps: tuple[str, ...] = () + + +class EntropyGraph: + """Build and execute the entropy calculation DAG. + + The graph is built once via `build()` and executed via `execute()`. + + Examples: + graph = EntropyGraph().build() + results = graph.execute(shared_data) """ - def __init__(self): - self.graph = nx.DiGraph() - self.nodes: Dict[str, Any] = {} + def __init__(self) -> None: + """Initialize an empty entropy graph.""" + self._graph: nx.DiGraph = nx.DiGraph() + self._nodes: Dict[str, Any] = {} def build(self) -> "EntropyGraph": - self._add("vibrational_entropy", VibrationalEntropyNode()) - self._add("configurational_entropy", ConfigurationalEntropyNode()) - self._add( - "aggregate_entropy", - AggregateEntropyNode(), - deps=["vibrational_entropy", "configurational_entropy"], + """Populate the graph with the standard entropy workflow. + + Returns: + Self for fluent chaining. + """ + specs = ( + GraphNodeSpec("vibrational_entropy", VibrationalEntropyNode()), + GraphNodeSpec("configurational_entropy", ConfigurationalEntropyNode()), + GraphNodeSpec( + "aggregate_entropy", + AggregateEntropyNode(), + deps=("vibrational_entropy", "configurational_entropy"), + ), ) + + for spec in specs: + self._add_node(spec) + return self - def _add(self, name: str, node: Any, deps: Optional[list[str]] = None) -> None: - self.nodes[name] = node - self.graph.add_node(name) - for d in deps or []: - self.graph.add_edge(d, name) + def execute(self, shared_data: SharedData) -> Dict[str, Any]: + """Execute the entropy graph in topological order. + + Args: + shared_data: Mutable shared data dictionary passed to each node. - def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: + Returns: + Dictionary containing the merged outputs of all nodes (only including + outputs that are dict-like). + + Raises: + KeyError: If a node name is missing from the internal node registry. + """ results: Dict[str, Any] = {} - for node_name in nx.topological_sort(self.graph): - logger.info(f"[EntropyGraph] node: {node_name}") - out = self.nodes[node_name].run(shared_data) + for node_name in nx.topological_sort(self._graph): + node = self._nodes[node_name] + logger.info("[EntropyGraph] node: %s", node_name) + out = node.run(shared_data) if isinstance(out, dict): results.update(out) return results + + def _add_node(self, spec: GraphNodeSpec) -> None: + """Add a node and its dependencies to the graph. + + Args: + spec: Node specification. + + Raises: + ValueError: If a duplicate node name is added. + """ + if spec.name in self._nodes: + raise ValueError(f"Duplicate node name: {spec.name}") + + self._nodes[spec.name] = spec.node + self._graph.add_node(spec.name) + + for dep in spec.deps: + self._graph.add_edge(dep, spec.name) diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/entropy_manager.py index 012d69a9..383c428f 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/entropy_manager.py @@ -1,6 +1,25 @@ +"""Entropy manager orchestration. + +This module defines `EntropyManager`, which coordinates the end-to-end entropy +workflow: + * Determine trajectory bounds and frame count. + * Build a reduced universe based on atom selection. + * Identify molecule groups and hierarchy levels. + * Optionally compute water entropy and adjust selection. + * Execute the level DAG (matrix/state preparation). + * Execute the entropy graph (entropy calculations and aggregation). + * Finalize and persist results. + +The manager intentionally delegates calculations to dedicated components. +""" + +from __future__ import annotations + import logging import math from collections import defaultdict +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Tuple import pandas as pd @@ -13,33 +32,55 @@ logger = logging.getLogger(__name__) console = LoggingConfig.get_console() +SharedData = Dict[str, Any] -class EntropyManager: + +@dataclass(frozen=True) +class TrajectorySlice: + """Trajectory slicing parameters. + + Attributes: + start: Inclusive start frame index. + end: Exclusive end frame index (or a concrete index derived from args). + step: Step size between frames. + n_frames: Number of frames in the slice. """ - Manages entropy calculations at multiple molecular levels, based on a - molecular dynamics trajectory. + + start: int + end: int + step: int + n_frames: int + + +class EntropyManager: + """Coordinate entropy calculations across structural levels. + + This class is responsible for orchestration and IO-level concerns (selection, + grouping, running graphs, and finalizing results). Domain calculations live in + dedicated components (LevelDAG, EntropyGraph, WaterEntropy, etc.). """ def __init__( self, - run_manager, - args, - universe, - data_logger, - group_molecules, - dihedral_analysis, - universe_operations, - ): - """ - Initializes the EntropyManager with required components. + run_manager: Any, + args: Any, + universe: Any, + data_logger: Any, + group_molecules: Any, + dihedral_analysis: Any, + universe_operations: Any, + ) -> None: + """Initialize the entropy workflow manager. Args: - run_manager: Manager for universe and selection operations. - args: Argument namespace containing user parameters. - universe: MDAnalysis universe representing the simulation system. - data_logger: Logger for storing and exporting entropy data. - group_molecules: includes the grouping functions for averaging over - molecules. + run_manager: Manager for universe IO and unit conversions. + args: Parsed CLI/user arguments. + universe: MDAnalysis Universe representing the simulation system. + data_logger: Collector for per-molecule and per-residue outputs. + group_molecules: Component that groups molecules for averaging. + dihedral_analysis: Component used to compute conformational states. + (Stored for completeness; computation is typically triggered by nodes.) + universe_operations: Adapter providing common universe operations. """ self._run_manager = run_manager self._args = args @@ -48,46 +89,67 @@ def __init__( self._group_molecules = group_molecules self._dihedral_analysis = dihedral_analysis self._universe_operations = universe_operations - self._GAS_CONST = 8.3144598484848 - def execute(self): - start, end, step = self._get_trajectory_bounds() - n_frames = self._get_number_frames(start, end, step) + def execute(self) -> None: + """Run the full entropy workflow and emit results. - console.print(f"Analyzing a total of {n_frames} frames in this calculation.") - - reduced_universe = self._get_reduced_universe() + This method orchestrates the complete pipeline, populates shared data, + and triggers the DAG/graph executions. Final results are logged and saved + via `DataLogger`. + """ + traj = self._build_trajectory_slice() + console.print( + f"Analyzing a total of {traj.n_frames} frames in this calculation." + ) - level_hierarchy = LevelHierarchy() - number_molecules, levels = level_hierarchy.select_levels(reduced_universe) + reduced_universe = self._build_reduced_universe() + levels = self._detect_levels(reduced_universe) groups = self._group_molecules.grouping_molecules( reduced_universe, self._args.grouping ) - logger.info(f"Number of molecule groups: {len(groups)}") - - water_atoms = self._universe.select_atoms("water") - water_resids = {res.resid for res in water_atoms.residues} + logger.info("Number of molecule groups: %d", len(groups)) - water_groups = { - gid: g - for gid, g in groups.items() - if any( - res.resid in water_resids - for mol in [self._universe.atoms.fragments[i] for i in g] - for res in mol.residues - ) - } - nonwater_groups = { - gid: g for gid, g in groups.items() if gid not in water_groups - } + nonwater_groups, water_groups = self._split_water_groups(groups) if self._args.water_entropy and water_groups: - self._handle_water_entropy(start, end, step, water_groups) + self._compute_water_entropy(traj, water_groups) else: + # If water entropy isn't computed, include water in the remaining groups. nonwater_groups.update(water_groups) - shared_data = { + shared_data = self._build_shared_data( + reduced_universe=reduced_universe, + levels=levels, + groups=nonwater_groups, + traj=traj, + ) + + self._run_level_dag(shared_data) + self._run_entropy_graph(shared_data) + + self._finalize_molecule_results() + self._data_logger.log_tables() + + def _build_shared_data( + self, + reduced_universe: Any, + levels: Any, + groups: Mapping[int, Any], + traj: TrajectorySlice, + ) -> SharedData: + """Build the shared_data dict used by nodes and graphs. + + Args: + reduced_universe: Universe after applying selection. + levels: Level definition per molecule id. + groups: Mapping of group id -> list of molecule ids. + traj: Trajectory slice parameters. + + Returns: + Shared data dictionary for DAG/graph execution. + """ + shared_data: SharedData = { "entropy_manager": self, "run_manager": self._run_manager, "data_logger": self._data_logger, @@ -95,181 +157,196 @@ def execute(self): "universe": self._universe, "reduced_universe": reduced_universe, "levels": levels, - "groups": nonwater_groups, - "start": start, - "end": end, - "step": step, - "n_frames": n_frames, + "groups": dict(groups), + "start": traj.start, + "end": traj.end, + "step": traj.step, + "n_frames": traj.n_frames, } + return shared_data - logger.info(f"shared_data: {shared_data}") + def _run_level_dag(self, shared_data: SharedData) -> None: + """Execute the structural/level DAG. + Args: + shared_data: Shared data dict that will be mutated by the DAG. + """ LevelDAG(self._universe_operations).build().execute(shared_data) + def _run_entropy_graph(self, shared_data: SharedData) -> None: + """Execute the entropy calculation graph and merge results into shared_data. + + Args: + shared_data: Shared data dict that will be mutated by the graph. + """ entropy_results = EntropyGraph().build().execute(shared_data) shared_data.update(entropy_results) + logger.info("entropy_results: %s", entropy_results) - logger.info(f"entropy_results: {entropy_results}") + def _build_trajectory_slice(self) -> TrajectorySlice: + """Compute trajectory slicing parameters from args. - self._finalize_molecule_results() - self._data_logger.log_tables() + Returns: + A TrajectorySlice describing the frames to analyze. + """ + start, end, step = self._get_trajectory_bounds() + n_frames = self._get_number_frames(start, end, step) + return TrajectorySlice(start=start, end=end, step=step, n_frames=n_frames) + + def _get_trajectory_bounds(self) -> Tuple[int, int, int]: + """Return start, end, and step frame indices from args. - def _handle_water_entropy(self, start, end, step, water_groups): + Returns: + Tuple of (start, end, step). """ - Compute water entropy for each water group, log data, and update selection - string to exclude water from further analysis. + start = self._args.start or 0 + end = len(self._universe.trajectory) if self._args.end == -1 else self._args.end + step = self._args.step or 1 + return start, end, step + + def _get_number_frames(self, start: int, end: int, step: int) -> int: + """Compute the number of frames in a trajectory slice. Args: - start (int): Start frame index - end (int): End frame index - step (int): Step size - water_groups (dict): {group_id: [atom indices]} for water + start: Inclusive start frame index. + end: Exclusive end frame index. + step: Step between frames. + + Returns: + Number of frames processed. """ - if not water_groups or not self._args.water_entropy: - return + return math.floor((end - start) / step) - water_entropy = WaterEntropy(self._args) + def _build_reduced_universe(self) -> Any: + """Apply atom selection and return the reduced universe. - for group_id, atom_indices in water_groups.items(): + If `selection_string` is "all", the original universe is returned. - water_entropy._calculate_water_entropy( - universe=self._universe, - start=start, - end=end, - step=step, - group_id=group_id, - ) + Returns: + MDAnalysis Universe (original or reduced). + """ + selection = self._args.selection_string + if selection == "all": + return self._universe - self._args.selection_string = ( - self._args.selection_string + " and not water" - if self._args.selection_string != "all" - else "not water" - ) + reduced = self._universe_operations.new_U_select_atom(self._universe, selection) + name = f"{len(reduced.trajectory)}_frame_dump_atom_selection" + self._run_manager.write_universe(reduced, name) + return reduced - logger.debug(f"WaterEntropy: molecule_data: {self._data_logger.molecule_data}") - logger.debug(f"WaterEntropy: residue_data: {self._data_logger.residue_data}") + def _detect_levels(self, reduced_universe: Any) -> Any: + """Detect hierarchy levels for each molecule in the reduced universe. - def _initialize_molecules(self): - """ - Prepare the reduced universe and determine molecule-level configurations. + Args: + reduced_universe: Reduced MDAnalysis Universe. Returns: - tuple: A tuple containing: - - reduced_atom (Universe): The reduced atom selection. - - number_molecules (int): Number of molecules in the system. - - levels (list): List of entropy levels per molecule. - - groups (dict): Groups for averaging over molecules. + Levels structure as returned by `LevelHierarchy.select_levels`. """ - # Based on the selection string, create a new MDAnalysis universe - reduced_atom = self._get_reduced_universe() - level_hierarchy = LevelHierarchy() + _number_molecules, levels = level_hierarchy.select_levels(reduced_universe) + return levels - # Count the molecules and identify the length scale levels for each one - number_molecules, levels = level_hierarchy.select_levels(reduced_atom) - - # Group the molecules for averaging - grouping = self._args.grouping - groups = self._group_molecules.grouping_molecules(reduced_atom, grouping) - - return reduced_atom, number_molecules, levels, groups + def _split_water_groups( + self, groups: Mapping[int, Any] + ) -> Tuple[Dict[int, Any], Dict[int, Any]]: + """Partition molecule groups into water and non-water groups. - def _get_trajectory_bounds(self): - """ - Returns the start, end, and step frame indices based on input arguments. + Args: + groups: Mapping of group id -> molecule ids. Returns: - Tuple of (start, end, step) frame indices. + Tuple of (nonwater_groups, water_groups). """ - start = self._args.start or 0 - end = len(self._universe.trajectory) if self._args.end == -1 else self._args.end - step = self._args.step or 1 + water_atoms = self._universe.select_atoms("water") + water_resids = {res.resid for res in water_atoms.residues} - return start, end, step + water_groups = { + gid: mol_ids + for gid, mol_ids in groups.items() + if any( + res.resid in water_resids + for mol in [self._universe.atoms.fragments[i] for i in mol_ids] + for res in mol.residues + ) + } + nonwater_groups = { + gid: g for gid, g in groups.items() if gid not in water_groups + } + return nonwater_groups, water_groups - def _get_number_frames(self, start, end, step): - """ - Calculates the total number of trajectory frames used in the calculation. + def _compute_water_entropy( + self, traj: TrajectorySlice, water_groups: Mapping[int, Any] + ) -> None: + """Compute water entropy for each water group and adjust selection string. Args: - start (int): Start frame index. - end (int): End frame index. If -1, it refers to the end of the trajectory. - step (int): Frame step size. - - Returns: - int: Total number of frames considered. + traj: Trajectory slice parameters. + water_groups: Mapping of group id -> molecule ids for waters. """ - return math.floor((end - start) / step) + if not water_groups or not self._args.water_entropy: + return - def _get_reduced_universe(self): - """ - Applies atom selection based on the user's input. + water_entropy = WaterEntropy(self._args) - Returns: - MDAnalysis.Universe: Selected subset of the system. - """ - # If selection string is "all" the universe does not change - if self._args.selection_string == "all": - return self._universe + for group_id in water_groups.keys(): + # WaterEntropy currently exposes a concrete API; keep this manager + # as an orchestrator and avoid duplicating internals here. + water_entropy._calculate_water_entropy( + universe=self._universe, + start=traj.start, + end=traj.end, + step=traj.step, + group_id=group_id, + ) - # Otherwise create a new (smaller) universe based on the selection - u = self._universe - selection_string = self._args.selection_string - reduced = self._universe_operations.new_U_select_atom(u, selection_string) - name = f"{len(reduced.trajectory)}_frame_dump_atom_selection" - self._run_manager.write_universe(reduced, name) + # Exclude water from subsequent analysis when water entropy has been computed. + self._args.selection_string = ( + f"{self._args.selection_string} and not water" + if self._args.selection_string != "all" + else "not water" + ) - return reduced + logger.debug("WaterEntropy: molecule_data=%s", self._data_logger.molecule_data) + logger.debug("WaterEntropy: residue_data=%s", self._data_logger.residue_data) - def _finalize_molecule_results(self): - """ - Aggregates and logs total entropy and frame counts per molecule. + def _finalize_molecule_results(self) -> None: + """Aggregate group totals and persist results to JSON. + + Computes total entropy per group and appends "Group Total" rows to the + molecule results table, then writes molecule and residue tables to the + configured output file via the data logger. """ - entropy_by_molecule = defaultdict(float) - for ( - mol_id, - level, - entropy_type, - result, - ) in self._data_logger.molecule_data: - if level != "Group Total": - try: - entropy_by_molecule[mol_id] += float(result) - except ValueError: - logger.warning(f"Skipping invalid entry: {mol_id}, {result}") - - for mol_id in entropy_by_molecule.keys(): - total_entropy = entropy_by_molecule[mol_id] + entropy_by_group = defaultdict(float) + + for group_id, level, _etype, result in self._data_logger.molecule_data: + if level == "Group Total": + continue + try: + entropy_by_group[group_id] += float(result) + except (TypeError, ValueError): + logger.warning("Skipping invalid entry: %s, %s", group_id, result) + for group_id, total in entropy_by_group.items(): self._data_logger.molecule_data.append( - ( - mol_id, - "Group Total", - "Group Total Entropy", - total_entropy, - ) + (group_id, "Group Total", "Group Total Entropy", total) ) + molecule_df = pd.DataFrame( + self._data_logger.molecule_data, + columns=["Group ID", "Level", "Type", "Result (J/mol/K)"], + ) + residue_df = pd.DataFrame( + self._data_logger.residue_data, + columns=[ + "Group ID", + "Residue Name", + "Level", + "Type", + "Frame Count", + "Result (J/mol/K)", + ], + ) self._data_logger.save_dataframes_as_json( - pd.DataFrame( - self._data_logger.molecule_data, - columns=[ - "Group ID", - "Level", - "Type", - "Result (J/mol/K)", - ], - ), - pd.DataFrame( - self._data_logger.residue_data, - columns=[ - "Group ID", - "Residue Name", - "Level", - "Type", - "Frame Count", - "Result (J/mol/K)", - ], - ), - self._args.output_file, + molecule_df, residue_df, self._args.output_file ) diff --git a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py index ed5c7f79..6855ab12 100644 --- a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py +++ b/CodeEntropy/entropy/nodes/aggregate_entropy_node.py @@ -1,11 +1,85 @@ -from typing import Any, Dict +"""Aggregates entropy outputs produced by upstream DAG nodes.""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Dict, Mapping, MutableMapping, Optional + +EntropyResults = Dict[str, Any] + + +@dataclass(frozen=True, slots=True) class AggregateEntropyNode: - def run(self, shared_data: Dict[str, Any], **_) -> Dict[str, Any]: - out = { - "vibrational_entropy": shared_data.get("vibrational_entropy"), - "configurational_entropy": shared_data.get("configurational_entropy"), + """Aggregate entropy results into a single shared output object. + + This node is intentionally small and single-purpose: + it gathers previously-computed entropy components from `shared_data` + and writes a canonical `shared_data["entropy_results"]` mapping. + + Attributes: + vibrational_key: Key in `shared_data` where vibrational entropy is stored. + configurational_key: Key in `shared_data` where configurational entropy is + stored. + output_key: Key in `shared_data` where the aggregated mapping is written. + """ + + vibrational_key: str = "vibrational_entropy" + configurational_key: str = "configurational_entropy" + output_key: str = "entropy_results" + + def run( + self, shared_data: MutableMapping[str, Any], **_: Any + ) -> Dict[str, EntropyResults]: + """Run the aggregation step. + + Args: + shared_data: Shared workflow state. Must contain (or may contain) keys + for vibrational and configurational entropy results. + + Returns: + A dict containing a single key, `"entropy_results"`, which maps to the + aggregated results dict. + + Side Effects: + Writes the aggregated results to `shared_data[self.output_key]`. + + Notes: + This node does not validate the shapes/types of upstream results. + Validation should live with the producer nodes (single responsibility). + """ + results = self._collect_entropy_results(shared_data) + shared_data[self.output_key] = results + return {self.output_key: results} + + def _collect_entropy_results( + self, shared_data: Mapping[str, Any] + ) -> EntropyResults: + """Collect entropy results from shared data. + + Args: + shared_data: Shared workflow state. + + Returns: + A mapping with keys `"vibrational_entropy"` and `"configurational_entropy"`. + """ + return { + "vibrational_entropy": self._get_optional( + shared_data, self.vibrational_key + ), + "configurational_entropy": self._get_optional( + shared_data, self.configurational_key + ), } - shared_data["entropy_results"] = out - return {"entropy_results": out} + + @staticmethod + def _get_optional(shared_data: Mapping[str, Any], key: str) -> Optional[Any]: + """Fetch an optional value from shared data. + + Args: + shared_data: Shared workflow state. + key: Key to fetch. + + Returns: + The value if present, otherwise None. + """ + return shared_data.get(key) diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational_entropy_node.py index 9f0ffb61..0a716e91 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational_entropy_node.py @@ -1,5 +1,19 @@ +"""Node for computing configurational entropy from conformational states.""" + +from __future__ import annotations + import logging -from typing import Any, Dict +from typing import ( + Any, + Dict, + Iterable, + Mapping, + MutableMapping, + Optional, + Sequence, + Tuple, + Union, +) import numpy as np @@ -7,50 +21,43 @@ logger = logging.getLogger(__name__) +GroupId = int +ResidueId = int +StateKey = Tuple[GroupId, ResidueId] +StateSequence = Union[Sequence[Any], np.ndarray] + class ConfigurationalEntropyNode: - """ - Procedural-parity conformational entropy. - """ + """Compute configurational entropy using precomputed conformational states. - @staticmethod - def _has_state_data(states) -> bool: - if states is None: - return False - if isinstance(states, np.ndarray): - return bool(np.any(states)) - try: - return any(states) - except TypeError: - return bool(states) + This node reads conformational state assignments from ``shared_data`` and + computes entropy contributions at different structural levels. - def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: - run_manager = shared_data["run_manager"] - args = shared_data["args"] - universe = shared_data["reduced_universe"] - data_logger = shared_data.get("data_logger") + Results are written back into ``shared_data["configurational_entropy"]``. + """ - ce = ConformationalEntropy( - run_manager=run_manager, - args=args, - universe=universe, - data_logger=data_logger, - group_molecules=shared_data.get("group_molecules"), - ) + def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any]: + """Execute configurational entropy calculation. - conf_states = shared_data.get("conformational_states", {}) or {} - states_ua = conf_states.get("ua", {}) or {} - states_res = conf_states.get("res", {}) + Args: + shared_data: Shared workflow state dictionary. - n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) - if n_frames is None: - raise KeyError("shared_data must contain n_frames (or number_frames)") - n_frames = int(n_frames) + Returns: + Dictionary containing configurational entropy results. + Raises: + KeyError: If required keys are missing. + """ + n_frames = self._get_n_frames(shared_data) groups = shared_data["groups"] levels = shared_data["levels"] - fragments = universe.atoms.fragments + universe = shared_data["reduced_universe"] + data_logger = shared_data.get("data_logger") + states_ua, states_res = self._get_state_containers(shared_data) + ce = self._build_entropy_engine(shared_data) + + fragments = universe.atoms.fragments results: Dict[int, Dict[str, float]] = {} for group_id, mol_ids in groups.items(): @@ -63,56 +70,176 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: level_list = levels[rep_mol_id] if "united_atom" in level_list: - S_conf_ua = 0.0 - for res_id, res in enumerate(rep_mol.residues): - key = (group_id, res_id) - states = states_ua.get(key, []) - - if self._has_state_data(states): - val = float( - ce.conformational_entropy_calculation(states, n_frames) - ) - else: - val = 0.0 - - S_conf_ua += val - - if data_logger is not None: - data_logger.add_residue_data( - group_id=group_id, - resname=getattr(res, "resname", "UNK"), - level="united_atom", - entropy_type="Conformational", - frame_count=n_frames, - value=val, - ) - - results[group_id]["ua"] = S_conf_ua - if data_logger is not None: - data_logger.add_results_data( - group_id, "united_atom", "Conformational", S_conf_ua - ) + ua_total = self._compute_ua_entropy_for_group( + ce=ce, + group_id=group_id, + residues=rep_mol.residues, + states_ua=states_ua, + n_frames=n_frames, + data_logger=data_logger, + ) + results[group_id]["ua"] = ua_total if "residue" in level_list: - if isinstance(states_res, dict): - group_states = states_res.get(group_id, None) - else: - group_states = ( - states_res[group_id] if group_id < len(states_res) else None - ) - - if self._has_state_data(group_states): - S_conf_res = float( - ce.conformational_entropy_calculation(group_states, n_frames) - ) - else: - S_conf_res = 0.0 + res_val = self._compute_residue_entropy_for_group( + ce=ce, + group_id=group_id, + states_res=states_res, + n_frames=n_frames, + ) + results[group_id]["res"] = res_val - results[group_id]["res"] = S_conf_res if data_logger is not None: data_logger.add_results_data( - group_id, "residue", "Conformational", S_conf_res + group_id, "residue", "Conformational", res_val ) - logger.info("[ConfigurationalEntropyNode] Done") + shared_data["configurational_entropy"] = results + logger.info("[ConfigurationalEntropyNode] Completed") + return {"configurational_entropy": results} + + def _build_entropy_engine( + self, shared_data: Mapping[str, Any] + ) -> ConformationalEntropy: + """Create the entropy calculation engine. + + Args: + shared_data: Shared workflow state. + + Returns: + ConformationalEntropy instance. + """ + return ConformationalEntropy( + run_manager=shared_data["run_manager"], + args=shared_data["args"], + universe=shared_data["reduced_universe"], + data_logger=shared_data.get("data_logger"), + group_molecules=shared_data.get("group_molecules"), + ) + + def _get_state_containers(self, shared_data: Mapping[str, Any]) -> Tuple[ + Dict[StateKey, StateSequence], + Union[Dict[GroupId, StateSequence], Sequence[Optional[StateSequence]]], + ]: + """Retrieve conformational state containers. + + Args: + shared_data: Shared workflow state. + + Returns: + Tuple of united atom and residue state containers. + """ + conf_states = shared_data.get("conformational_states", {}) or {} + return conf_states.get("ua", {}) or {}, conf_states.get("res", {}) + + def _get_n_frames(self, shared_data: Mapping[str, Any]) -> int: + """Return the number of frames analysed. + + Args: + shared_data: Shared workflow state. + + Returns: + Number of frames. + + Raises: + KeyError: If frame count is missing. + """ + n_frames = shared_data.get("n_frames", shared_data.get("number_frames")) + if n_frames is None: + raise KeyError("shared_data must contain n_frames or number_frames") + return int(n_frames) + + def _compute_ua_entropy_for_group( + self, + *, + ce: ConformationalEntropy, + group_id: int, + residues: Iterable[Any], + states_ua: Mapping[StateKey, StateSequence], + n_frames: int, + data_logger: Optional[Any], + ) -> float: + """Compute united atom entropy for a group. + + Args: + ce: Entropy calculator. + group_id: Group identifier. + residues: Residue iterable. + states_ua: Mapping of states. + n_frames: Frame count. + data_logger: Optional logger. + + Returns: + Total entropy for united atom level. + """ + total = 0.0 + + for res_id, res in enumerate(residues): + states = states_ua.get((group_id, res_id)) + val = self._entropy_or_zero(ce, states, n_frames) + total += val + + if data_logger is not None: + data_logger.add_residue_data( + group_id=group_id, + resname=getattr(res, "resname", "UNK"), + level="united_atom", + entropy_type="Conformational", + frame_count=n_frames, + value=val, + ) + + if data_logger is not None: + data_logger.add_results_data( + group_id, "united_atom", "Conformational", total + ) + + return total + + def _compute_residue_entropy_for_group( + self, + *, + ce: ConformationalEntropy, + group_id: int, + states_res: Union[Dict[int, StateSequence], Sequence[Optional[StateSequence]]], + n_frames: int, + ) -> float: + """Compute residue-level entropy for a group.""" + group_states = self._get_group_states(states_res, group_id) + return self._entropy_or_zero(ce, group_states, n_frames) + + def _entropy_or_zero( + self, + ce: ConformationalEntropy, + states: Optional[StateSequence], + n_frames: int, + ) -> float: + """Return entropy value or zero if no state data exists.""" + if not self._has_state_data(states): + return 0.0 + return float(ce.conformational_entropy_calculation(states, n_frames)) + + @staticmethod + def _get_group_states( + states_res: Union[Dict[int, StateSequence], Sequence[Optional[StateSequence]]], + group_id: int, + ) -> Optional[StateSequence]: + """Fetch group states from container.""" + if isinstance(states_res, dict): + return states_res.get(group_id) + if group_id < len(states_res): + return states_res[group_id] + return None + + @staticmethod + def _has_state_data(states: Optional[StateSequence]) -> bool: + """Check if state container has usable data.""" + if states is None: + return False + if isinstance(states, np.ndarray): + return bool(np.any(states)) + try: + return any(states) + except TypeError: + return bool(states) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index ac283e45..09386614 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -1,5 +1,10 @@ +"""Node for computing vibrational entropy from covariance matrices.""" + +from __future__ import annotations + import logging -from typing import Any, Dict +from dataclasses import dataclass +from typing import Any, Dict, Mapping, MutableMapping, Optional, Tuple import numpy as np @@ -9,53 +14,73 @@ logger = logging.getLogger(__name__) -def _build_gid2i(shared_data: Dict[str, Any]) -> Dict[int, int]: - gid2i = shared_data.get("group_id_to_index") - if isinstance(gid2i, dict) and gid2i: - return gid2i - groups = shared_data["groups"] - return {gid: i for i, gid in enumerate(groups.keys())} +GroupId = int +ResidueId = int +GroupIndex = int +CovKey = Tuple[GroupId, ResidueId] + + +@dataclass(frozen=True) +class _EntropyPair: + """Container for paired translational and rotational entropy values.""" + + trans: float + rot: float class VibrationalEntropyNode: - def __init__(self): + """Compute vibrational entropy from force/torque (and optional FT) covariances. + + This node reads covariance matrices from ``shared_data`` and computes entropy + contributions for each group and level (e.g., united_atom, residue, polymer). + + If combined force/torque matrices are enabled and available, FT matrices are + used for the highest level at each group/level, otherwise separate force and + torque matrices are used. + + Results are written back into ``shared_data["vibrational_entropy"]``. + """ + + def __init__(self) -> None: self._mat_ops = MatrixOperations() - def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: - run_manager = shared_data["run_manager"] - args = shared_data["args"] - universe = shared_data["reduced_universe"] - data_logger = shared_data.get("data_logger") - group_molecules = shared_data.get("group_molecules") - - ve = VibrationalEntropy( - run_manager=run_manager, - args=args, - universe=universe, - data_logger=data_logger, - group_molecules=group_molecules, - ) + def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any]: + """Execute vibrational entropy calculation. + + Args: + shared_data: Shared workflow state dictionary. + + Returns: + Dictionary containing vibrational entropy results. + + Raises: + KeyError: If required keys are missing. + ValueError: If an unknown level is encountered. + """ + ve = self._build_entropy_engine(shared_data) + temp = shared_data["args"].temperature - temp = args.temperature groups = shared_data["groups"] levels = shared_data["levels"] + fragments = shared_data["reduced_universe"].atoms.fragments + + gid2i = self._get_group_id_to_index(shared_data) force_cov = shared_data["force_covariances"] torque_cov = shared_data["torque_covariances"] - combined = bool(getattr(args, "combined_forcetorque", False)) + combined = bool(getattr(shared_data["args"], "combined_forcetorque", False)) ft_cov = shared_data.get("forcetorque_covariances") if combined else None - counts = shared_data.get("frame_counts", {}) - ua_counts = counts.get("ua", {}) if isinstance(counts, dict) else {} - - gid2i = _build_gid2i(shared_data) - fragments = universe.atoms.fragments + ua_frame_counts = self._get_ua_frame_counts(shared_data) + data_logger = shared_data.get("data_logger") - vib_results: Dict[int, Dict[str, Dict[str, float]]] = {} + results: Dict[int, Dict[str, Dict[str, float]]] = {} for group_id, mol_ids in groups.items(): - vib_results[group_id] = {} + results[group_id] = {} + if not mol_ids: + continue rep_mol_id = mol_ids[0] rep_mol = fragments[rep_mol_id] @@ -65,193 +90,287 @@ def run(self, shared_data: Dict[str, Any], **_kwargs) -> Dict[str, Any]: highest = level == level_list[-1] if level == "united_atom": - S_trans = 0.0 - S_rot = 0.0 - - for res_id, res in enumerate(rep_mol.residues): - key = (group_id, res_id) - fmat = force_cov["ua"].get(key) - tmat = torque_cov["ua"].get(key) - - if fmat is None or tmat is None: - val_trans, val_rot = 0.0, 0.0 - else: - fmat = self._mat_ops.filter_zero_rows_columns( - np.asarray(fmat) - ) - tmat = self._mat_ops.filter_zero_rows_columns( - np.asarray(tmat) - ) - - val_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=False - ) - val_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=False - ) - - S_trans += val_trans - S_rot += val_rot - - if data_logger is not None: - fc = ua_counts.get(key, shared_data.get("n_frames", 0)) - data_logger.add_residue_data( - group_id=group_id, - resname=res.resname, - level="united_atom", - entropy_type="Transvibrational", - frame_count=fc, - value=val_trans, - ) - data_logger.add_residue_data( - group_id=group_id, - resname=res.resname, - level="united_atom", - entropy_type="Rovibrational", - frame_count=fc, - value=val_rot, - ) - - vib_results[group_id][level] = { - "trans": float(S_trans), - "rot": float(S_rot), - } - - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) - + pair = self._compute_united_atom_entropy( + ve=ve, + temp=temp, + group_id=group_id, + residues=rep_mol.residues, + force_ua=force_cov["ua"], + torque_ua=torque_cov["ua"], + ua_frame_counts=ua_frame_counts, + data_logger=data_logger, + n_frames_default=shared_data.get("n_frames", 0), + ) + self._store_results(results, group_id, level, pair) + self._log_molecule_level_results( + data_logger, group_id, level, pair, use_ft_labels=False + ) continue - if level == "residue": + if level in ("residue", "polymer"): gi = gid2i[group_id] if combined and highest and ft_cov is not None: - ftmat = ft_cov["res"][gi] if gi < len(ft_cov["res"]) else None - if ftmat is None: - S_trans, S_rot = 0.0, 0.0 - else: - ftmat = self._mat_ops.filter_zero_rows_columns( - np.asarray(ftmat) - ) - S_trans = ve.vibrational_entropy_calculation( - ftmat, "forcetorqueTRANS", temp, highest_level=True - ) - S_rot = ve.vibrational_entropy_calculation( - ftmat, "forcetorqueROT", temp, highest_level=True - ) - - vib_results[group_id][level] = { - "trans": float(S_trans), - "rot": float(S_rot), - } - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "FTmat-Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "FTmat-Rovibrational", S_rot - ) - continue - - fmat = force_cov["res"][gi] if gi < len(force_cov["res"]) else None - tmat = ( - torque_cov["res"][gi] if gi < len(torque_cov["res"]) else None - ) + ft_key = "res" if level == "residue" else "poly" + ftmat = self._get_indexed_matrix(ft_cov.get(ft_key, []), gi) - if fmat is None or tmat is None: - S_trans, S_rot = 0.0, 0.0 - else: - fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) - tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) - S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=highest + pair = self._compute_ft_entropy( + ve=ve, + temp=temp, + ftmat=ftmat, ) - S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=highest + self._store_results(results, group_id, level, pair) + self._log_molecule_level_results( + data_logger, group_id, level, pair, use_ft_labels=True ) + continue - vib_results[group_id][level] = { - "trans": float(S_trans), - "rot": float(S_rot), - } - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) + cov_key = "res" if level == "residue" else "poly" + fmat = self._get_indexed_matrix(force_cov.get(cov_key, []), gi) + tmat = self._get_indexed_matrix(torque_cov.get(cov_key, []), gi) + + pair = self._compute_force_torque_entropy( + ve=ve, + temp=temp, + fmat=fmat, + tmat=tmat, + highest=highest, + ) + self._store_results(results, group_id, level, pair) + self._log_molecule_level_results( + data_logger, group_id, level, pair, use_ft_labels=False + ) continue - if level == "polymer": - gi = gid2i[group_id] + raise ValueError(f"Unknown level: {level}") - if combined and highest and ft_cov is not None: - ftmat = ft_cov["poly"][gi] if gi < len(ft_cov["poly"]) else None - if ftmat is None: - S_trans, S_rot = 0.0, 0.0 - else: - ftmat = self._mat_ops.filter_zero_rows_columns( - np.asarray(ftmat) - ) - S_trans = ve.vibrational_entropy_calculation( - ftmat, "forcetorqueTRANS", temp, highest_level=True - ) - S_rot = ve.vibrational_entropy_calculation( - ftmat, "forcetorqueROT", temp, highest_level=True - ) - - vib_results[group_id][level] = { - "trans": float(S_trans), - "rot": float(S_rot), - } - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "FTmat-Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "FTmat-Rovibrational", S_rot - ) - continue + shared_data["vibrational_entropy"] = results + logger.info("[VibrationalEntropyNode] Completed") + return {"vibrational_entropy": results} + + def _build_entropy_engine( + self, shared_data: Mapping[str, Any] + ) -> VibrationalEntropy: + """Create the entropy calculation engine. + + Args: + shared_data: Shared workflow state. + + Returns: + VibrationalEntropy instance. + """ + return VibrationalEntropy( + run_manager=shared_data["run_manager"], + args=shared_data["args"], + universe=shared_data["reduced_universe"], + data_logger=shared_data.get("data_logger"), + group_molecules=shared_data.get("group_molecules"), + ) - fmat = ( - force_cov["poly"][gi] if gi < len(force_cov["poly"]) else None - ) - tmat = ( - torque_cov["poly"][gi] if gi < len(torque_cov["poly"]) else None - ) + def _get_group_id_to_index(self, shared_data: Mapping[str, Any]) -> Dict[int, int]: + """Get mapping from group id to contiguous index. - if fmat is None or tmat is None: - S_trans, S_rot = 0.0, 0.0 - else: - fmat = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) - tmat = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) - S_trans = ve.vibrational_entropy_calculation( - fmat, "force", temp, highest_level=highest - ) - S_rot = ve.vibrational_entropy_calculation( - tmat, "torque", temp, highest_level=highest - ) + Args: + shared_data: Shared workflow state. - vib_results[group_id][level] = { - "trans": float(S_trans), - "rot": float(S_rot), - } - if data_logger is not None: - data_logger.add_results_data( - group_id, level, "Transvibrational", S_trans - ) - data_logger.add_results_data( - group_id, level, "Rovibrational", S_rot - ) - continue + Returns: + Mapping of group id -> index. + """ + gid2i = shared_data.get("group_id_to_index") + if isinstance(gid2i, dict) and gid2i: + return gid2i + groups = shared_data["groups"] + return {gid: i for i, gid in enumerate(groups.keys())} - raise ValueError(f"Unknown level: {level}") + def _get_ua_frame_counts(self, shared_data: Mapping[str, Any]) -> Dict[CovKey, int]: + """Get per-residue frame counts for united atom computations. - logger.info("[VibrationalEntropyNode] Done") - return {"vibrational_entropy": vib_results} + Args: + shared_data: Shared workflow state. + + Returns: + Mapping (group_id, res_id) -> frame count. + """ + counts = shared_data.get("frame_counts", {}) + if isinstance(counts, dict): + ua_counts = counts.get("ua", {}) + if isinstance(ua_counts, dict): + return ua_counts + return {} + + def _compute_united_atom_entropy( + self, + *, + ve: VibrationalEntropy, + temp: float, + group_id: int, + residues: Any, + force_ua: Mapping[CovKey, Any], + torque_ua: Mapping[CovKey, Any], + ua_frame_counts: Mapping[CovKey, int], + data_logger: Optional[Any], + n_frames_default: int, + ) -> _EntropyPair: + """Compute united atom vibrational entropy for a group. + + Args: + ve: Vibrational entropy engine. + temp: Temperature used for entropy calculation. + group_id: Group identifier. + residues: Residues iterable for the representative molecule. + force_ua: UA-level force covariance matrices. + torque_ua: UA-level torque covariance matrices. + ua_frame_counts: UA per-residue frame counts. + data_logger: Optional data logger for residue-level output. + n_frames_default: Default frame count if per-residue count is missing. + + Returns: + Total translational and rotational entropy for UA level. + """ + s_trans_total = 0.0 + s_rot_total = 0.0 + + for res_id, res in enumerate(residues): + key = (group_id, res_id) + fmat = force_ua.get(key) + tmat = torque_ua.get(key) + + pair = self._compute_force_torque_entropy( + ve=ve, + temp=temp, + fmat=fmat, + tmat=tmat, + highest=False, + ) + + s_trans_total += pair.trans + s_rot_total += pair.rot + + if data_logger is not None: + frame_count = ua_frame_counts.get(key, int(n_frames_default or 0)) + data_logger.add_residue_data( + group_id=group_id, + resname=getattr(res, "resname", "UNK"), + level="united_atom", + entropy_type="Transvibrational", + frame_count=frame_count, + value=pair.trans, + ) + data_logger.add_residue_data( + group_id=group_id, + resname=getattr(res, "resname", "UNK"), + level="united_atom", + entropy_type="Rovibrational", + frame_count=frame_count, + value=pair.rot, + ) + + return _EntropyPair(trans=float(s_trans_total), rot=float(s_rot_total)) + + def _compute_force_torque_entropy( + self, + *, + ve: VibrationalEntropy, + temp: float, + fmat: Any, + tmat: Any, + highest: bool, + ) -> _EntropyPair: + """Compute entropy from separate force and torque covariance matrices. + + Args: + ve: Vibrational entropy engine. + temp: Temperature. + fmat: Force covariance matrix. + tmat: Torque covariance matrix. + highest: Whether this is the highest level. + + Returns: + Translational and rotational entropy pair. + """ + if fmat is None or tmat is None: + return _EntropyPair(trans=0.0, rot=0.0) + + f = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) + t = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) + + s_trans = ve.vibrational_entropy_calculation( + f, "force", temp, highest_level=highest + ) + s_rot = ve.vibrational_entropy_calculation( + t, "torque", temp, highest_level=highest + ) + return _EntropyPair(trans=float(s_trans), rot=float(s_rot)) + + def _compute_ft_entropy( + self, + *, + ve: VibrationalEntropy, + temp: float, + ftmat: Any, + ) -> _EntropyPair: + """Compute entropy from a combined force/torque covariance matrix. + + Args: + ve: Vibrational entropy engine. + temp: Temperature. + ftmat: Combined force/torque covariance matrix. + + Returns: + Translational and rotational entropy pair. + """ + if ftmat is None: + return _EntropyPair(trans=0.0, rot=0.0) + + ft = self._mat_ops.filter_zero_rows_columns(np.asarray(ftmat)) + + s_trans = ve.vibrational_entropy_calculation( + ft, "forcetorqueTRANS", temp, highest_level=True + ) + s_rot = ve.vibrational_entropy_calculation( + ft, "forcetorqueROT", temp, highest_level=True + ) + return _EntropyPair(trans=float(s_trans), rot=float(s_rot)) + + @staticmethod + def _store_results( + results: Dict[int, Dict[str, Dict[str, float]]], + group_id: int, + level: str, + pair: _EntropyPair, + ) -> None: + """Store computed entropy pair into results dict.""" + results[group_id][level] = {"trans": pair.trans, "rot": pair.rot} + + @staticmethod + def _log_molecule_level_results( + data_logger: Optional[Any], + group_id: int, + level: str, + pair: _EntropyPair, + *, + use_ft_labels: bool, + ) -> None: + """Log molecule-level results, if a data logger is present.""" + if data_logger is None: + return + + if use_ft_labels: + data_logger.add_results_data( + group_id, level, "FTmat-Transvibrational", pair.trans + ) + data_logger.add_results_data( + group_id, level, "FTmat-Rovibrational", pair.rot + ) + return + + data_logger.add_results_data(group_id, level, "Transvibrational", pair.trans) + data_logger.add_results_data(group_id, level, "Rovibrational", pair.rot) + + @staticmethod + def _get_indexed_matrix(mats: Any, index: int) -> Any: + """Safely fetch a matrix from a list-like container.""" + try: + return mats[index] if index < len(mats) else None + except TypeError: + return None diff --git a/CodeEntropy/entropy/orientational_entropy.py b/CodeEntropy/entropy/orientational_entropy.py index 74a6e425..094360ed 100644 --- a/CodeEntropy/entropy/orientational_entropy.py +++ b/CodeEntropy/entropy/orientational_entropy.py @@ -1,77 +1,160 @@ +"""Orientational entropy calculations. + +This module defines `OrientationalEntropy`, which computes orientational entropy +from a neighbor-count mapping. + +The current implementation supports non-water neighbors. Water-specific behavior +can be implemented later behind an interface so the core calculation remains +stable and testable. +""" + +from __future__ import annotations + import logging import math +from dataclasses import dataclass +from typing import Any, Mapping import numpy as np logger = logging.getLogger(__name__) +_GAS_CONST_J_PER_MOL_K = 8.3144598484848 -class OrientationalEntropy: + +@dataclass(frozen=True) +class OrientationalEntropyResult: + """Result of an orientational entropy calculation. + + Attributes: + total: Total orientational entropy (J/mol/K). """ - Performs orientational entropy calculations using molecular dynamics data. + + total: float + + +class OrientationalEntropy: + """Compute orientational entropy from neighbor counts. + + This class is intentionally small and focused: it provides a single public + method that converts a mapping of neighbor species to neighbor counts into + an orientational entropy value. + + Notes: + The manager-like constructor signature is kept for compatibility with + the rest of the codebase, but the calculation itself does not depend on + those objects. """ - def __init__(self, run_manager, args, universe, data_logger, group_molecules): + def __init__( + self, + run_manager: Any, + args: Any, + universe: Any, + data_logger: Any, + group_molecules: Any, + gas_constant: float = _GAS_CONST_J_PER_MOL_K, + ) -> None: + """Initialize the orientational entropy calculator. + + Args: + run_manager: Run manager (currently unused by this class). + args: User arguments (currently unused by this class). + universe: MDAnalysis Universe (currently unused by this class). + data_logger: Data logger (currently unused by this class). + group_molecules: Grouping helper (currently unused by this class). + gas_constant: Gas constant in J/(mol*K). """ - Initializes the OrientationalEntropy manager with all required components and - sets the gas constant used in orientational entropy calculations. + self._run_manager = run_manager + self._args = args + self._universe = universe + self._data_logger = data_logger + self._group_molecules = group_molecules + self._gas_constant = float(gas_constant) + + def calculate(self, neighbours: Mapping[str, int]) -> OrientationalEntropyResult: + """Calculate orientational entropy from neighbor counts. + + For each neighbor species (except water), the number of orientations is + estimated as: + + Ω = sqrt(Nc^3 * π) + + and the entropy contribution is: + + S = R * ln(Ω) + + where Nc is the neighbor count and R is the gas constant. + + Args: + neighbours: Mapping of neighbor species name to count. + + Returns: + OrientationalEntropyResult containing the total entropy in J/mol/K. """ + total = 0.0 + for species, count in neighbours.items(): + if self._is_water(species): + # Water handling can be added later (e.g., via a strategy). + logger.debug( + "Skipping water species %s in orientational entropy.", species + ) + continue + + contribution = self._entropy_contribution(count) + logger.debug( + "Orientational entropy contribution for %s: %s", species, contribution + ) + total += contribution + + logger.debug("Final orientational entropy total: %s", total) + return OrientationalEntropyResult(total=float(total)) + + @staticmethod + def _is_water(species: str) -> bool: + """Return True if the species should be treated as water. - def orientational_entropy_calculation(self, neighbours_dict): + Args: + species: Species identifier. + + Returns: + True if the species is considered water. """ - Function to calculate orientational entropies from eq. (10) in J. Higham, - S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116, - 3 1965–1976. Number of orientations, Ω, is calculated using eq. (8) in - J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, - 2018, 116, 3 1965–1976. - - σ is assumed to be 1 for the molecules we're concerned with and hence, - max {1, (Nc^3*π)^(1/2)} will always be (Nc^3*π)^(1/2). - - TODO future release - function for determing symmetry and symmetry numbers - maybe? - - Input - ----- - neighbours_dict : dictionary - dictionary of neighbours for the molecule - - should contain the type of neighbour molecule and the number of neighbour - molecules of that species - - Returns - ------- - S_or_total : float - orientational entropy + return species in {"H2O", "WAT", "HOH"} + + def _entropy_contribution(self, neighbour_count: int) -> float: + """Compute the entropy contribution for a single neighbor count. + + Args: + neighbour_count: Number of neighbors (Nc). + + Returns: + Entropy contribution in J/mol/K. + + Raises: + ValueError: If neighbour_count is negative. """ + if neighbour_count < 0: + raise ValueError(f"neighbour_count must be >= 0, got {neighbour_count}") - # Replaced molecule with neighbour as this is what the for loop uses - S_or_total = 0 - for neighbour in neighbours_dict: # we are going through neighbours - if neighbour in ["H2O"]: # water molecules - call POSEIDON functions - pass # TODO temporary until function is written - 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 *= self._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 + if neighbour_count == 0: + return 0.0 + + omega = self._omega(neighbour_count) + # omega should always be > 0 when neighbour_count > 0, but guard anyway. + if omega <= 0.0: + return 0.0 + + return self._gas_constant * math.log(omega) - logger.debug(f"Final total orientational entropy: {S_or_total}") + @staticmethod + def _omega(neighbour_count: int) -> float: + """Compute the number of orientations Ω. - return S_or_total + Args: + neighbour_count: Number of neighbors (Nc). + + Returns: + Ω (unitless). + """ + return float(np.sqrt((neighbour_count**3) * math.pi)) diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index 3c2b7363..3f52c1e7 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -1,101 +1,298 @@ +"""Vibrational entropy calculations. + +This module provides `VibrationalEntropy`, which computes vibrational entropy +from force, torque, or combined force-torque covariance matrices. + +The implementation is intentionally split into small, single-purpose methods: +- Eigenvalue extraction + unit conversion +- Frequency calculation with robust filtering +- Entropy component computation +- Mode selection / summation rules based on matrix type +""" + +from __future__ import annotations + import logging +from dataclasses import dataclass +from typing import Any, Literal, Tuple import numpy as np from numpy import linalg as la logger = logging.getLogger(__name__) +MatrixType = Literal["force", "torque", "forcetorqueTRANS", "forcetorqueROT"] -class VibrationalEntropy: + +@dataclass(frozen=True) +class VibrationalEntropyResult: + """Result of a vibrational entropy computation. + + Attributes: + total: Computed entropy value (J/mol/K) for the requested matrix type. + n_modes: Number of vibrational modes used (after filtering eigenvalues). """ - Performs vibrational entropy calculations using molecular trajectory data. + + total: float + n_modes: int + + +class VibrationalEntropy: + """Compute vibrational entropy from covariance matrices. + + This class focuses only on vibrational entropy math and relies on `run_manager` + for unit conversions (eigenvalue unit conversion and kT conversion). """ - def __init__(self, run_manager, args, universe, data_logger, group_molecules): + def __init__( + self, + run_manager: Any, + args: Any, + universe: Any, + data_logger: Any, + group_molecules: Any, + planck_const: float = 6.62607004081818e-34, + gas_const: float = 8.3144598484848, + ) -> None: + """Initialize the vibrational entropy calculator. + + Args: + run_manager: Provides thermodynamic conversions (e.g., kT in Joules) and + eigenvalue unit conversion. + args: User args (kept for compatibility; not required for math here). + universe: MDAnalysis Universe (kept for compatibility). + data_logger: Data logger (kept for compatibility). + group_molecules: Grouping helper (kept for compatibility). + planck_const: Planck constant (J*s). + gas_const: Gas constant (J/(mol*K)). + """ self._run_manager = run_manager self._args = args self._universe = universe self._data_logger = data_logger self._group_molecules = group_molecules - self._PLANCK_CONST = 6.62607004081818e-34 - self._GAS_CONST = 8.3144598484848 + self._planck_const = float(planck_const) + self._gas_const = float(gas_const) + + def vibrational_entropy_calculation( + self, + matrix: np.ndarray, + matrix_type: MatrixType, + temp: float, + highest_level: bool, + ) -> float: + """Compute vibrational entropy for the given covariance matrix. + + Supported matrix types: + - "force": 3N x 3N force covariance. + - "torque": 3N x 3N torque covariance. + - "forcetorqueTRANS": 6N x 6N combined covariance (translational part). + - "forcetorqueROT": 6N x 6N combined covariance (rotational part). + + Mode handling: + - Frequencies are computed from eigenvalues, filtered to valid values, + then sorted ascending. + - For "force": + - If highest_level, include all modes. + - Otherwise, drop the lowest 6 modes. + - For "torque": include all modes. + - For combined "forcetorque*": + - Split the sorted spectrum into two halves (first 3N, last 3N). + - If not highest_level, drop the lowest 6 modes only within the + translational half. + + Args: + matrix: Covariance matrix (shape depends on matrix_type). + matrix_type: Type of covariance matrix. + temp: Temperature in Kelvin. + highest_level: Whether this is the highest level in the hierarchy. + + Returns: + Vibrational entropy value in J/mol/K. + + Raises: + ValueError: If matrix_type is unknown. + """ + components = self._entropy_components(matrix, temp) + total = self._sum_components(components, matrix_type, highest_level) + return float(total) + + def _entropy_components(self, matrix: np.ndarray, temp: float) -> np.ndarray: + """Compute per-mode entropy components from a covariance matrix. + + Args: + matrix: Covariance matrix. + temp: Temperature in Kelvin. + + Returns: + Array of entropy components (J/mol/K) for each valid mode. + """ + lambdas = self._matrix_eigenvalues(matrix) + lambdas = self._convert_lambda_units(lambdas) + + freqs = self._frequencies_from_lambdas(lambdas, temp) + if freqs.size == 0: + return np.array([], dtype=float) + + freqs = np.sort(freqs) + return self._entropy_components_from_frequencies(freqs, temp) - def frequency_calculation(self, lambdas, temp): - pi = np.pi - kT = self._run_manager.get_KT2J(temp) + @staticmethod + def _matrix_eigenvalues(matrix: np.ndarray) -> np.ndarray: + """Compute eigenvalues of a matrix. - lambdas = np.array(lambdas) + Args: + matrix: Input matrix. + + Returns: + Eigenvalues as a NumPy array. + """ + matrix = np.asarray(matrix, dtype=float) + return la.eigvals(matrix) + + def _convert_lambda_units(self, lambdas: np.ndarray) -> np.ndarray: + """Convert eigenvalues into SI units using run_manager. + + Args: + lambdas: Eigenvalues. + + Returns: + Converted eigenvalues. + """ + return self._run_manager.change_lambda_units(lambdas) + + def _frequencies_from_lambdas(self, lambdas: np.ndarray, temp: float) -> np.ndarray: + """Convert eigenvalues to frequencies with robust filtering. + + Filters out eigenvalues that are complex, non-positive, or near-zero to + avoid invalid frequencies and unstable entropies. + + Args: + lambdas: Eigenvalues (post unit conversion). + temp: Temperature in Kelvin. + + Returns: + Frequencies in Hz. + """ + lambdas = np.asarray(lambdas) lambdas = np.real_if_close(lambdas, tol=1000) valid_mask = ( np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-7)) ) - if len(lambdas) > np.count_nonzero(valid_mask): + + removed = int(len(lambdas) - np.count_nonzero(valid_mask)) + if removed: logger.warning( - f"{len(lambdas) - np.count_nonzero(valid_mask)} " - f"invalid eigenvalues excluded (complex, non-positive, or near-zero)." + "%d invalid eigenvalues excluded (complex, non-positive, " + "or near-zero).", + removed, ) - lambdas = lambdas[valid_mask].real - frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT) - return frequencies + lambdas = np.asarray(lambdas[valid_mask].real, dtype=float) + if lambdas.size == 0: + return np.array([], dtype=float) - def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_level): - """ - Supports matrix_type: - - "force" (3N x 3N) - - "torque" (3N x 3N) - - "forcetorqueTRANS" (6N x 6N -> translational part) - - "forcetorqueROT" (6N x 6N -> rotational part) - - Procedural matching behavior for FTmat: - - compute entropy components from the full 6N spectrum - - sort frequencies - - split into first 3N and last 3N after sorting - - ensures FTmat-Trans + FTmat-Rot == total FT entropy - - (and cross F↔T terms affect eigenmodes, as intended) + kT = float(self._run_manager.get_KT2J(temp)) + pi = float(np.pi) + return (1.0 / (2.0 * pi)) * np.sqrt(lambdas / kT) + + def _entropy_components_from_frequencies( + self, frequencies: np.ndarray, temp: float + ) -> np.ndarray: + """Compute per-mode entropy components from frequencies. + + Args: + frequencies: Frequencies (Hz), sorted ascending. + temp: Temperature in Kelvin. + + Returns: + Per-mode entropy components in J/mol/K. """ - matrix = np.asarray(matrix) - lambdas = la.eigvals(matrix) - lambdas = self._run_manager.change_lambda_units(lambdas) + kT = float(self._run_manager.get_KT2J(temp)) + exponent = (self._planck_const * frequencies) / kT - freqs = self.frequency_calculation(lambdas, temp) - freqs = np.sort(freqs) + # Numerically stable enough for typical ranges; callers filter eigenvalues. + exp_pos = np.exp(exponent) + exp_neg = np.exp(-exponent) + + components = exponent / (exp_pos - 1.0) - np.log(1.0 - exp_neg) + return components * self._gas_const - kT = self._run_manager.get_KT2J(temp) - exponent = self._PLANCK_CONST * freqs / kT - power_positive = np.exp(exponent) - power_negative = np.exp(-exponent) + @staticmethod + def _split_halves(components: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Split a component array into two equal halves. - S_components = exponent / (power_positive - 1.0) - np.log(1.0 - power_negative) - S_components *= self._GAS_CONST + Args: + components: Array with an even length. + + Returns: + Tuple of (first_half, second_half). If odd-length, returns + (components, empty). + + Notes: + For combined force-torque matrices (6N x 6N), the valid number of modes + should be 6N. After sorting, we split into two halves of size 3N. + """ + n = int(components.size) + if n % 2 != 0: + return components, np.array([], dtype=float) + half = n // 2 + return components[:half], components[half:] - n_modes = len(S_components) + def _sum_components( + self, + components: np.ndarray, + matrix_type: MatrixType, + highest_level: bool, + ) -> float: + """Sum entropy components according to the matrix type and level rules. + + Args: + components: Per-mode entropy components. + matrix_type: Type selector. + highest_level: Whether this is the highest level. + + Returns: + Summed entropy value. + """ + if components.size == 0: + return 0.0 if matrix_type == "force": - if highest_level: - return float(np.sum(S_components)) - return float(np.sum(S_components[6:])) + return float( + np.sum(components) if highest_level else np.sum(components[6:]) + ) if matrix_type == "torque": - return float(np.sum(S_components)) + return float(np.sum(components)) if matrix_type in ("forcetorqueTRANS", "forcetorqueROT"): - if n_modes % 2 != 0: + first, second = self._split_halves(components) + if second.size == 0: + # Odd number of modes; fallback to total. logger.warning( - f"FTmat has odd number of modes ({n_modes}); cannot cleanly split." + "Combined FT spectrum has odd number of modes (%d); " + "returning total.", + components.size, ) - return float(np.sum(S_components)) + return float(np.sum(components)) - half = n_modes // 2 # == 3N - trans_part = float(np.sum(S_components[:half])) - rot_part = float(np.sum(S_components[half:])) + trans_components = first + rot_components = second if not highest_level: - # Only drop within the trans half - trans_part = float(np.sum(S_components[6:half])) if half > 6 else 0.0 + trans_components = ( + trans_components[6:] + if trans_components.size > 6 + else np.array([], dtype=float) + ) - return trans_part if matrix_type == "forcetorqueTRANS" else rot_part + return ( + float(np.sum(trans_components)) + if matrix_type == "forcetorqueTRANS" + else float(np.sum(rot_components)) + ) raise ValueError(f"Unknown matrix_type: {matrix_type}") diff --git a/CodeEntropy/entropy/water_entropy.py b/CodeEntropy/entropy/water_entropy.py index d05ab6d6..366b9000 100644 --- a/CodeEntropy/entropy/water_entropy.py +++ b/CodeEntropy/entropy/water_entropy.py @@ -1,4 +1,14 @@ +"""Water entropy aggregation. + +This module wraps the waterEntropy routines and maps their +outputs into the project `DataLogger` format. +""" + +from __future__ import annotations + import logging +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Optional, Tuple import numpy as np import waterEntropy.recipes.interfacial_solvent as GetSolvent @@ -6,131 +16,228 @@ logger = logging.getLogger(__name__) -class WaterEntropy: +@dataclass(frozen=True) +class WaterEntropyInputs: + """Inputs for water entropy computation. + + Attributes: + universe: MDAnalysis Universe containing the system. + start: Start frame index (inclusive). + end: End frame index (exclusive, or -1 depending on caller convention). + step: Frame stride. + temperature: Temperature in Kelvin. + group_id: Group ID used for logging. + """ + + universe: Any + start: int + end: int + step: int + temperature: float + group_id: Optional[int] = None - def __init__(self, args): - """ """ - self._args = args - def _calculate_water_entropy(self, universe, start, end, step, group_id=None): +class WaterEntropy: + """Compute and log water entropy contributions. + + This class calls the external `waterEntropy` routine to compute: + - orientational entropy per residue + - translational vibrational entropy + - rotational vibrational entropy + + Then it logs residue-level entries and adds a group label. + """ + + def __init__( + self, + args: Any, + data_logger: Any, + solver: Callable[..., Tuple[dict, Any, Any, Any, Any]] = ( + GetSolvent.get_interfacial_water_orient_entropy + ), + ) -> None: + """Initialize the water entropy calculator. + + Args: + args: Argument namespace; must include `temperature`. + data_logger: Logger used to record residue and group results. + solver: Callable compatible with + `get_interfacial_water_orient_entropy + (universe, start, end, step, temperature, parallel=True)`. + Dependency injection allows unit testing without the external package. """ - Calculate and aggregate the entropy of water molecules in a simulation. - - This function computes orientational, translational, and rotational - entropy components for all water molecules, aggregates them per residue, - and maps all waters to a single group ID. It also logs the total results - and labels the water group in the data logger. - - Parameters - ---------- - universe : MDAnalysis.Universe - The simulation universe containing water molecules. - start : int - The starting frame for analysis. - end : int - The ending frame for analysis. - step : int - Frame interval for analysis. - group_id : int or str, optional - The group ID to which all water molecules will be assigned. + self._args = args + self._data_logger = data_logger + self._solver = solver + + def calculate_and_log( + self, + universe: Any, + start: int, + end: int, + step: int, + group_id: Optional[int] = None, + ) -> None: + """Compute water entropy and write results to the data logger. + + Args: + universe: MDAnalysis Universe containing water. + start: Start frame index. + end: End frame index. + step: Frame stride. + group_id: Group ID to assign all water contributions to. """ - Sorient_dict, covariances, vibrations, _, water_count = ( - GetSolvent.get_interfacial_water_orient_entropy( - universe, start, end, step, self._args.temperature, parallel=True - ) + inputs = WaterEntropyInputs( + universe=universe, + start=start, + end=end, + step=step, + temperature=float(self._args.temperature), + group_id=group_id, ) + self._calculate_and_log_from_inputs(inputs) - self._calculate_water_orientational_entropy(Sorient_dict, group_id) - self._calculate_water_vibrational_translational_entropy( - vibrations, group_id, covariances - ) - self._calculate_water_vibrational_rotational_entropy( - vibrations, group_id, covariances + def _calculate_and_log_from_inputs(self, inputs: WaterEntropyInputs) -> None: + """Run the solver and log all returned entropy components.""" + Sorient_dict, covariances, vibrations, _unused, _water_count = self._run_solver( + inputs ) - water_selection = universe.select_atoms("resname WAT") - actual_water_residues = len(water_selection.residues) - residue_names = { - resname - for res_dict in Sorient_dict.values() - for resname in res_dict.keys() - if resname.upper() in water_selection.residues.resnames - } + self._log_orientational_entropy(Sorient_dict, inputs.group_id) + self._log_translational_entropy(vibrations, covariances, inputs.group_id) + self._log_rotational_entropy(vibrations, covariances, inputs.group_id) + self._log_group_label(inputs.universe, Sorient_dict, inputs.group_id) - residue_group = "_".join(sorted(residue_names)) if residue_names else "WAT" - self._data_logger.add_group_label( - group_id, residue_group, actual_water_residues, len(water_selection.atoms) - ) + def _run_solver(self, inputs: WaterEntropyInputs): + """Call the external solver. + + Args: + inputs: WaterEntropyInputs. - def _calculate_water_orientational_entropy(self, Sorient_dict, group_id): + Returns: + Tuple of solver outputs. """ - Aggregate orientational entropy for all water molecules into a single group. - - Parameters - ---------- - Sorient_dict : dict - Dictionary containing orientational entropy values per residue. - group_id : int or str - The group ID to which the water residues belong. - covariances : object - Covariance object. + logger.info( + "[WaterEntropy] Computing water entropy (start=%s, end=%s, step=%s)", + inputs.start, + inputs.end, + inputs.step, + ) + return self._solver( + inputs.universe, + inputs.start, + inputs.end, + inputs.step, + inputs.temperature, + parallel=True, + ) + + def _log_orientational_entropy( + self, Sorient_dict: Mapping[Any, Mapping[str, Any]], group_id: Optional[int] + ) -> None: + """Log orientational entropy entries. + + Args: + Sorient_dict: Mapping of residue ids to {resname: [entropy, count]}. + group_id: Group ID to assign logs to. """ - for resid, resname_dict in Sorient_dict.items(): + for _resid, resname_dict in Sorient_dict.items(): for resname, values in resname_dict.items(): if isinstance(values, list) and len(values) == 2: - Sor, count = values + entropy, count = values self._data_logger.add_residue_data( - group_id, resname, "Water", "Orientational", count, Sor + group_id, resname, "Water", "Orientational", count, entropy ) - def _calculate_water_vibrational_translational_entropy( - self, vibrations, group_id, covariances - ): - """ - Aggregate translational vibrational entropy for all water molecules. - - Parameters - ---------- - vibrations : object - Object containing translational entropy data (vibrations.translational_S). - group_id : int or str - The group ID for the water residues. - covariances : object - Covariance object. + def _log_translational_entropy( + self, vibrations: Any, covariances: Any, group_id: Optional[int] + ) -> None: + """Log translational vibrational entropy entries. + + Args: + vibrations: Solver vibrations object with `translational_S`. + covariances: Solver covariances object with `counts`. + group_id: Group ID to assign logs to. """ + translational = getattr(vibrations, "translational_S", {}) or {} + counts = getattr(covariances, "counts", {}) or {} + + for (solute_id, _), entropy in translational.items(): + value = ( + float(np.sum(entropy)) + if isinstance(entropy, (list, np.ndarray)) + else float(entropy) + ) + count = counts.get((solute_id, "WAT"), 1) + resname = self._solute_id_to_resname(solute_id) + self._data_logger.add_residue_data( + group_id, resname, "Water", "Transvibrational", count, value + ) - for (solute_id, _), entropy in vibrations.translational_S.items(): - if isinstance(entropy, (list, np.ndarray)): - entropy = float(np.sum(entropy)) + def _log_rotational_entropy( + self, vibrations: Any, covariances: Any, group_id: Optional[int] + ) -> None: + """Log rotational vibrational entropy entries. - count = covariances.counts.get((solute_id, "WAT"), 1) - resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id + Args: + vibrations: Solver vibrations object with `rotational_S`. + covariances: Solver covariances object with `counts`. + group_id: Group ID to assign logs to. + """ + rotational = getattr(vibrations, "rotational_S", {}) or {} + counts = getattr(covariances, "counts", {}) or {} + + for (solute_id, _), entropy in rotational.items(): + value = ( + float(np.sum(entropy)) + if isinstance(entropy, (list, np.ndarray)) + else float(entropy) + ) + count = counts.get((solute_id, "WAT"), 1) + resname = self._solute_id_to_resname(solute_id) self._data_logger.add_residue_data( - group_id, resname, "Water", "Transvibrational", count, entropy + group_id, resname, "Water", "Rovibrational", count, value ) - def _calculate_water_vibrational_rotational_entropy( - self, vibrations, group_id, covariances - ): - """ - Aggregate rotational vibrational entropy for all water molecules. - - Parameters - ---------- - vibrations : object - Object containing rotational entropy data (vibrations.rotational_S). - group_id : int or str - The group ID for the water residues. - covariances : object - Covariance object. + def _log_group_label( + self, + universe: Any, + Sorient_dict: Mapping[Any, Mapping[str, Any]], + group_id: Optional[int], + ) -> None: + """Log a group label summarizing the water entries. + + Args: + universe: MDAnalysis Universe. + Sorient_dict: Orientational entropy dict used to infer residue names. + group_id: Group ID. """ - for (solute_id, _), entropy in vibrations.rotational_S.items(): - if isinstance(entropy, (list, np.ndarray)): - entropy = float(np.sum(entropy)) + water_selection = universe.select_atoms("resname WAT") + actual_water_residues = len(water_selection.residues) + + water_resnames = set(water_selection.residues.resnames) + residue_names = { + resname + for res_dict in Sorient_dict.values() + for resname in res_dict.keys() + if str(resname).upper() in {str(r).upper() for r in water_resnames} + } - count = covariances.counts.get((solute_id, "WAT"), 1) + residue_group = "_".join(sorted(residue_names)) if residue_names else "WAT" + self._data_logger.add_group_label( + group_id, residue_group, actual_water_residues, len(water_selection.atoms) + ) - resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id - self._data_logger.add_residue_data( - group_id, resname, "Water", "Rovibrational", count, entropy - ) + @staticmethod + def _solute_id_to_resname(solute_id: str) -> str: + """Convert a solver solute_id to a residue-like name. + + Args: + solute_id: Identifier returned by the solver. + + Returns: + Residue name string. + """ + if "_" in str(solute_id): + return str(solute_id).rsplit("_", 1)[0] + return str(solute_id) From 9dc348901a5e40577b50b7b3aaaf66eee0dc8be2 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 18 Feb 2026 17:58:17 +0000 Subject: [PATCH 049/101] update all files within `group_molecules` folder to use Google Doc-Strings --- .../group_molecules/group_molecules.py | 228 +++++++++++++----- 1 file changed, 163 insertions(+), 65 deletions(-) diff --git a/CodeEntropy/group_molecules/group_molecules.py b/CodeEntropy/group_molecules/group_molecules.py index 417f293e..7a0265cf 100644 --- a/CodeEntropy/group_molecules/group_molecules.py +++ b/CodeEntropy/group_molecules/group_molecules.py @@ -1,106 +1,204 @@ +"""Utilities for grouping molecules for entropy analysis. + +This module provides strategies for grouping molecular fragments from an +MDAnalysis Universe into deterministic groups used for statistical averaging +during entropy calculations. + +Grouping strategies are designed to be stable and reproducible so that group +IDs remain consistent across runs given the same input system. + +Available strategies: + - each: Every molecule is treated as its own group. + - molecules: Molecules are grouped by chemical signature + (atom count and atom names in order). +""" + import logging +from dataclasses import dataclass +from typing import Callable, Dict, List, Mapping, Sequence, Tuple logger = logging.getLogger(__name__) +GroupId = int +MoleculeId = int +MoleculeGroups = Dict[GroupId, List[MoleculeId]] +Signature = Tuple[int, Tuple[str, ...]] -class GroupMolecules: + +@dataclass(frozen=True) +class GroupingConfig: + """Configuration for molecule grouping. + + Attributes: + strategy: Grouping strategy name. Supported values are: + - "each": each molecule gets its own group. + - "molecules": group molecules by chemical signature + (atom count + atom names in order). """ - Groups molecules for averaging. + + strategy: str + + +class GroupMolecules: + """Build groups of molecules for averaging. + + This class provides strategies for grouping molecule fragments from an + MDAnalysis Universe. Groups are returned as a mapping: + + group_id -> [molecule_id, molecule_id, ...] + + Group IDs are deterministic and stable. + + Supported strategies: + - "each": Every molecule is its own group. + - "molecules": Molecules are grouped by chemical signature + (atom count, atom names in order). The group ID is the first molecule + index observed for that signature. """ - def __init__(self): - """ - Initializes the class with relevant information. + def grouping_molecules(self, universe, grouping: str) -> MoleculeGroups: + """Group molecules according to a selected strategy. - """ - self._molecule_groups = None + Args: + universe: MDAnalysis Universe containing atoms and fragments. + grouping: Strategy name ("each" or "molecules"). + + Returns: + A dict mapping group IDs to molecule indices. - def grouping_molecules(self, universe, grouping): + Raises: + ValueError: If `grouping` is not a supported strategy. """ - Grouping molecules by desired level of detail. + config = GroupingConfig(strategy=grouping) + grouper = self._get_strategy(config.strategy) + groups = grouper(universe) + + self._log_summary(groups) + return groups + + def _get_strategy(self, strategy: str) -> Callable[[object], MoleculeGroups]: + """Resolve a strategy name to a grouping implementation. Args: - universe: MDAnalysis univers object for the system of interest. - grouping (str): how to group molecules for averaging + strategy: Strategy name. Returns: - molecule_groups (dict): molecule indices for each group. + Callable that accepts a Universe and returns molecule groups. + + Raises: + ValueError: If the strategy is unknown. """ + strategies: Mapping[str, Callable[[object], MoleculeGroups]] = { + "each": self._group_each, + "molecules": self._group_by_signature, + } + + try: + return strategies[strategy] + except KeyError as exc: + raise ValueError(f"Unknown grouping strategy: {strategy!r}") from exc - molecule_groups = {} + def _group_each(self, universe) -> MoleculeGroups: + """Create one group per molecule. - if grouping == "each": - molecule_groups = self._by_none(universe) + Args: + universe: MDAnalysis Universe. - if grouping == "molecules": - molecule_groups = self._by_molecules(universe) + Returns: + Dict where each molecule id maps to a singleton list [molecule id]. + """ + n_molecules = self._num_molecules(universe) + return {mol_id: [mol_id] for mol_id in range(n_molecules)} - number_groups = len(molecule_groups) + def _group_by_signature(self, universe) -> MoleculeGroups: + """Group molecules by chemical signature with stable group IDs. - logger.info(f"Number of molecule groups: {number_groups}") - logger.debug(f"Molecule groups are: {molecule_groups}") + Signature is defined as: + (atom_count, atom_names_in_order) - return molecule_groups + Group ID selection is stable and matches the previous behavior: + the first molecule index encountered for a signature is the group ID. - def _by_none(self, universe): + Args: + universe: MDAnalysis Universe. + + Returns: + Dict mapping representative molecule id -> list of all molecule ids + sharing the same signature. """ - Don't group molecules. Every molecule is in its own group. + fragments = self._fragments(universe) + + signature_to_rep: Dict[Signature, MoleculeId] = {} + groups: MoleculeGroups = {} + + for mol_id, fragment in enumerate(fragments): + signature = self._signature(fragment) + rep_id = self._representative_id(signature_to_rep, signature, mol_id) + groups.setdefault(rep_id, []).append(mol_id) + + return groups + + def _num_molecules(self, universe) -> int: + """Return number of molecule fragments. Args: - universe: MDAnalysis universe + universe: MDAnalysis Universe. Returns: - molecule_groups (dict): molecule indices for each group. + Number of fragments (molecules). """ + return len(self._fragments(universe)) + + def _fragments(self, universe) -> Sequence[object]: + """Return universe fragments (molecules). - # fragments is MDAnalysis terminology for molecules - number_molecules = len(universe.atoms.fragments) + Args: + universe: MDAnalysis Universe. - molecule_groups = {} + Returns: + Sequence of fragments. + """ + return universe.atoms.fragments - for molecule_i in range(number_molecules): - molecule_groups[molecule_i] = [molecule_i] + def _signature(self, fragment) -> Signature: + """Build a chemical signature for a fragment. - return molecule_groups + Args: + fragment: MDAnalysis AtomGroup representing a fragment. - def _by_molecules(self, universe): + Returns: + A tuple of (atom_count, atom_names_in_order). """ - Group molecules by chemical type. - Based on number of atoms and atom names. + names = tuple(fragment.names) + return (len(names), names) + + def _representative_id( + self, + signature_to_rep: Dict[Signature, MoleculeId], + signature: Signature, + candidate_id: MoleculeId, + ) -> MoleculeId: + """Return stable representative id for a signature. Args: - universe: MDAnalysis universe + signature_to_rep: Cache mapping signature -> representative id. + signature: Chemical signature of current molecule. + candidate_id: Current molecule id. Returns: - molecule_groups (dict): molecule indices for each group. + Representative id for this signature (first seen molecule id). """ + rep_id = signature_to_rep.get(signature) + if rep_id is None: + signature_to_rep[signature] = candidate_id + return candidate_id + return rep_id + + def _log_summary(self, groups: MoleculeGroups) -> None: + """Log grouping summary. - # fragments is MDAnalysis terminology for molecules - number_molecules = len(universe.atoms.fragments) - fragments = universe.atoms.fragments - - molecule_groups = {} - - for molecule_i in range(number_molecules): - names_i = fragments[molecule_i].names - number_atoms_i = len(names_i) - - for molecule_j in range(number_molecules): - names_j = fragments[molecule_j].names - number_atoms_j = len(names_j) - - # If molecule_i has the same number of atoms and same - # atom names as molecule_j, then index i is added to group j - # The index of molecule_j is the group key, the keys are - # all integers, but may not be consecutive numbers. - if number_atoms_i == number_atoms_j and all( - i == j for i, j in zip(names_i, names_j) - ): - if molecule_j in molecule_groups.keys(): - molecule_groups[molecule_j].append(molecule_i) - else: - molecule_groups[molecule_j] = [] - molecule_groups[molecule_j].append(molecule_i) - break - - return molecule_groups + Args: + groups: Group mapping to summarize. + """ + logger.info("Number of molecule groups: %d", len(groups)) + logger.debug("Molecule groups: %s", groups) From 36a6be1ec98a4c452fb12e6d49bb9637de79817f Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:06:08 +0000 Subject: [PATCH 050/101] update all files within `levels` folder to use Google Doc-Strings --- CodeEntropy/levels/dihedral_tools.py | 559 ++++++++------ CodeEntropy/levels/force_torque_manager.py | 271 +++++-- CodeEntropy/levels/frame_dag.py | 124 ++- CodeEntropy/levels/hierarchy_graph.py | 198 +++-- CodeEntropy/levels/level_hierarchy.py | 170 ++-- CodeEntropy/levels/matrix_operations.py | 133 ++-- CodeEntropy/levels/mda_universe_operations.py | 195 +++-- CodeEntropy/levels/nodes/build_beads.py | 263 +++++-- CodeEntropy/levels/nodes/compute_dihedrals.py | 116 ++- CodeEntropy/levels/nodes/detect_levels.py | 66 +- CodeEntropy/levels/nodes/detect_molecules.py | 117 ++- CodeEntropy/levels/nodes/frame_axes.py | 151 +++- CodeEntropy/levels/nodes/frame_covariance.py | 729 ++++++++++++------ .../nodes/init_covariance_accumulators.py | 205 ++++- 14 files changed, 2301 insertions(+), 996 deletions(-) diff --git a/CodeEntropy/levels/dihedral_tools.py b/CodeEntropy/levels/dihedral_tools.py index a7c9dd59..4d3003a6 100644 --- a/CodeEntropy/levels/dihedral_tools.py +++ b/CodeEntropy/levels/dihedral_tools.py @@ -1,4 +1,12 @@ +"""Dihedral state assignment for conformational entropy. + +This module converts dihedral angle time series into discrete conformational +state labels. The resulting state labels are used downstream to compute +conformational entropy. +""" + import logging +from typing import Dict, List, Tuple import numpy as np from MDAnalysis.analysis.dihedrals import Dihedral @@ -12,52 +20,55 @@ logger = logging.getLogger(__name__) +UAKey = Tuple[int, int] + class DihedralAnalysis: - """ - Functions for finding dihedral angles and analysing them to get the - states needed for the conformational entropy functions. - """ + """Build conformational state labels from dihedral angles.""" def __init__(self, universe_operations=None): - """ - Initialise with placeholders. + """Initializes the analysis helper. + + Args: + universe_operations: Object providing helper methods: + - get_molecule_container(data_container, molecule_id) + - new_U_select_atom(atomgroup, selection_string) """ self._universe_operations = universe_operations - self.data_container = None - self.states_ua = None - self.states_res = None def build_conformational_states( self, data_container, levels, groups, - start, - end, - step, - bin_width, + start: int, + end: int, + step: int, + bin_width: float, ): - """ - Build the conformational states descriptors based on dihedral angles - needed for the calculation of the conformational entropy. + """Build conformational state labels for UA and residue levels. + + Args: + data_container: MDAnalysis universe containing the system. + levels: Mapping of molecule_id -> list of enabled levels. + groups: Mapping of group_id -> list of molecule_ids. + start: Start frame index (currently not applied in legacy sampling). + end: End frame index (currently not applied in legacy sampling). + step: Step size (currently not applied in legacy sampling). + bin_width: Histogram bin width (degrees). + + Returns: + Tuple of: + states_ua: Dict[(group_id, res_id)] -> list of state labels. + states_res: List indexed by group_id -> list of state labels. """ number_groups = len(groups) - states_ua = {} - states_res = [None] * number_groups - - total_items = sum( - len(levels[mol_id]) for mols in groups.values() for mol_id in mols - ) + states_ua: Dict[UAKey, List[str]] = {} + states_res: List[List[str]] = [None] * number_groups - with Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) as progress: + total_items = self._count_total_items(levels=levels, groups=groups) + with self._progress_bar(total_items) as progress: task = progress.add_task( "[green]Building Conformational States...", total=total_items, @@ -66,176 +77,190 @@ def build_conformational_states( for group_id in groups.keys(): molecules = groups[group_id] + if not molecules: + progress.advance(task) + continue + mol = self._universe_operations.get_molecule_container( data_container, molecules[0] ) - num_residues = len(mol.residues) - dihedrals_ua = [[] for _ in range(num_residues)] - peaks_ua = [{} for _ in range(num_residues)] - dihedrals_res = [] - peaks_res = {} - - for level in levels[molecules[0]]: - if level == "united_atom": - for res_id in range(num_residues): - selection1 = mol.residues[res_id].atoms.indices[0] - selection2 = mol.residues[res_id].atoms.indices[-1] - res_container = self._universe_operations.new_U_select_atom( - mol, - f"index {selection1}:" f"{selection2}", - ) - heavy_res = self._universe_operations.new_U_select_atom( - res_container, "prop mass > 1.1" - ) - - dihedrals_ua[res_id] = self._get_dihedrals(heavy_res, level) - - elif level == "residue": - dihedrals_res = self._get_dihedrals(mol, level) - - for level in levels[molecules[0]]: - if level == "united_atom": - for res_id in range(num_residues): - if len(dihedrals_ua[res_id]) == 0: - peaks_ua[res_id] = [] - else: - peaks_ua[res_id] = self._identify_peaks( - data_container, - molecules, - dihedrals_ua[res_id], - bin_width, - start, - end, - step, - ) - - elif level == "residue": - if len(dihedrals_res) == 0: - peaks_res = [] - else: - peaks_res = self._identify_peaks( - data_container, - molecules, - dihedrals_res, - bin_width, - start, - end, - step, - ) - - for level in levels[molecules[0]]: - if level == "united_atom": - for res_id in range(num_residues): - key = (group_id, res_id) - if len(dihedrals_ua[res_id]) == 0: - states_ua[key] = [] - else: - states_ua[key] = self._assign_states( - data_container, - molecules, - dihedrals_ua[res_id], - peaks_ua[res_id], - start, - end, - step, - ) - - elif level == "residue": - if len(dihedrals_res) == 0: - states_res[group_id] = [] - else: - states_res[group_id] = self._assign_states( - data_container, - molecules, - dihedrals_res, - peaks_res, - start, - end, - step, - ) + + dihedrals_ua, dihedrals_res = self._collect_dihedrals_for_group( + mol=mol, + level_list=levels[molecules[0]], + ) + + peaks_ua, peaks_res = self._collect_peaks_for_group( + data_container=data_container, + molecules=molecules, + dihedrals_ua=dihedrals_ua, + dihedrals_res=dihedrals_res, + bin_width=bin_width, + start=start, + end=end, + step=step, + level_list=levels[molecules[0]], + ) + + self._assign_states_for_group( + data_container=data_container, + group_id=group_id, + molecules=molecules, + dihedrals_ua=dihedrals_ua, + peaks_ua=peaks_ua, + dihedrals_res=dihedrals_res, + peaks_res=peaks_res, + start=start, + end=end, + step=step, + level_list=levels[molecules[0]], + states_ua=states_ua, + states_res=states_res, + ) progress.advance(task) return states_ua, states_res - def _get_dihedrals(self, data_container, level): + def _collect_dihedrals_for_group(self, mol, level_list): + """Collect UA and residue dihedral AtomGroups for a group. + + Args: + mol: Representative molecule AtomGroup. + level_list: List of enabled hierarchy levels. + + Returns: + Tuple: + dihedrals_ua: List of per-residue dihedral AtomGroups. + dihedrals_res: List of residue-level dihedral AtomGroups. + """ + num_residues = len(mol.residues) + dihedrals_ua: List[List] = [[] for _ in range(num_residues)] + dihedrals_res: List = [] + + for level in level_list: + if level == "united_atom": + for res_id in range(num_residues): + heavy_res = self._select_heavy_residue(mol, res_id) + dihedrals_ua[res_id] = self._get_dihedrals(heavy_res, level) + + elif level == "residue": + dihedrals_res = self._get_dihedrals(mol, level) + + return dihedrals_ua, dihedrals_res + + def _select_heavy_residue(self, mol, res_id: int): + """Select heavy atoms in a residue by residue index. + + Args: + mol: Representative molecule AtomGroup. + res_id: Residue index. + + Returns: + AtomGroup containing heavy atoms in the residue selection. """ - Define the set of dihedrals for use in the conformational entropy function. - If united atom level, the dihedrals are defined from the heavy atoms - (4 bonded atoms for 1 dihedral). - If residue level, use the bonds between residues to cast dihedrals. - Note: not using improper dihedrals only ones with 4 atoms/residues - in a linear arrangement. + selection1 = mol.residues[res_id].atoms.indices[0] + selection2 = mol.residues[res_id].atoms.indices[-1] + + res_container = self._universe_operations.new_U_select_atom( + mol, f"index {selection1}:{selection2}" + ) + return self._universe_operations.new_U_select_atom( + res_container, "prop mass > 1.1" + ) + + def _get_dihedrals(self, data_container, level: str): + """Return dihedral AtomGroups for a container at a given level. Args: - data_container (MDAnalysis.Universe): system information - level (str): level of the hierarchy (should be residue or polymer) + data_container: MDAnalysis container (AtomGroup/Universe). + level: Either "united_atom" or "residue". Returns: - dihedrals (array): set of dihedrals + List of AtomGroups (each representing a dihedral definition). """ - # Start with empty array - dihedrals = [] atom_groups = [] - # if united atom level, read dihedrals from MDAnalysis universe if level == "united_atom": dihedrals = data_container.dihedrals - num_dihedrals = len(dihedrals) - for index in range(num_dihedrals): - atom_groups.append(dihedrals[index].atoms) + for d in dihedrals: + atom_groups.append(d.atoms) - # if residue level, looking for dihedrals involving residues if level == "residue": num_residues = len(data_container.residues) - logger.debug(f"Number Residues: {num_residues}") - if num_residues < 4: - logger.debug("no residue level dihedrals") - - else: - # find bonds between residues N-3:N-2 and N-1:N + if num_residues >= 4: for residue in range(4, num_residues + 1): - # Using MDAnalysis selection, - # assuming only one covalent bond between neighbouring residues - # TODO not written for branched polymers - atom_string = ( - "resindex " - + str(residue - 4) - + " and bonded resindex " - + str(residue - 3) + atom1 = data_container.select_atoms( + f"resindex {residue - 4} and bonded resindex {residue - 3}" ) - atom1 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 3) - + " and bonded resindex " - + str(residue - 4) + atom2 = data_container.select_atoms( + f"resindex {residue - 3} and bonded resindex {residue - 4}" ) - atom2 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 2) - + " and bonded resindex " - + str(residue - 1) + atom3 = data_container.select_atoms( + f"resindex {residue - 2} and bonded resindex {residue - 1}" ) - atom3 = data_container.select_atoms(atom_string) - - atom_string = ( - "resindex " - + str(residue - 1) - + " and bonded resindex " - + str(residue - 2) + atom4 = data_container.select_atoms( + f"resindex {residue - 1} and bonded resindex {residue - 2}" ) - atom4 = data_container.select_atoms(atom_string) + atom_groups.append(atom1 + atom2 + atom3 + atom4) - atom_group = atom1 + atom2 + atom3 + atom4 - atom_groups.append(atom_group) + logger.debug("Level: %s, Dihedrals: %s", level, atom_groups) + return atom_groups - logger.debug(f"Level: {level}, Dihedrals: {atom_groups}") + def _collect_peaks_for_group( + self, + data_container, + molecules, + dihedrals_ua, + dihedrals_res, + bin_width, + start, + end, + step, + level_list, + ): + """Compute histogram peaks for UA and residue dihedral sets. - return atom_groups + Returns: + Tuple: + peaks_ua: list of peaks per residue + (each item is list-of-peaks per dihedral) + peaks_res: list-of-peaks per dihedral for residue level (or []) + """ + peaks_ua = [{} for _ in range(len(dihedrals_ua))] + peaks_res = {} + + for level in level_list: + if level == "united_atom": + for res_id in range(len(dihedrals_ua)): + if len(dihedrals_ua[res_id]) == 0: + peaks_ua[res_id] = [] + else: + peaks_ua[res_id] = self._identify_peaks( + data_container=data_container, + molecules=molecules, + dihedrals=dihedrals_ua[res_id], + bin_width=bin_width, + start=start, + end=end, + step=step, + ) + + elif level == "residue": + if len(dihedrals_res) == 0: + peaks_res = [] + else: + peaks_res = self._identify_peaks( + data_container=data_container, + molecules=molecules, + dihedrals=dihedrals_res, + bin_width=bin_width, + start=start, + end=end, + step=step, + ) + + return peaks_ua, peaks_res def _identify_peaks( self, @@ -247,74 +272,130 @@ def _identify_peaks( end, step, ): - """ - Build a histogram of the dihedral data and identify the peaks. - This is to give the information needed for the adaptive method - of identifying dihedral states. + """Identify histogram peaks ("convex turning points") for each dihedral. + + Important: + This function intentionally preserves the legacy behavior: + it samples over the full trajectory length for each molecule + and does not apply start/end/step to the Dihedral run. + + Args: + data_container: MDAnalysis universe. + molecules: Molecule ids in the group. + dihedrals: Dihedral AtomGroups. + bin_width: Histogram bin width (degrees). + start: Unused in legacy sampling. + end: Unused in legacy sampling. + step: Unused in legacy sampling. + + Returns: + List of peaks per dihedral (peak_values[dihedral_index] -> list of peaks). """ peak_values = [] * len(dihedrals) + for dihedral_index in range(len(dihedrals)): phi = [] - # get the values of the angle for the dihedral - # loop over all molecules in the averaging group - # dihedral angle values have a range from -180 to 180 + for molecule in molecules: mol = self._universe_operations.get_molecule_container( data_container, molecule ) number_frames = len(mol.trajectory) + dihedral_results = Dihedral(dihedrals).run() + for timestep in range(number_frames): value = dihedral_results.results.angles[timestep][dihedral_index] - - # We want postive values in range 0 to 360 to make - # the peak assignment. - # works using the fact that dihedrals have circular symetry - # (i.e. -15 degrees = +345 degrees) if value < 0: value += 360 phi.append(value) - # create a histogram using numpy number_bins = int(360 / bin_width) popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360)) bin_value = [ 0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul)) ] - # identify "convex turning-points" and populate a list of peaks - # peak : a bin whose neighboring bins have smaller population - # NOTE might have problems if the peak is wide with a flat or - # sawtooth top in which case check you have a sensible bin width - - peaks = [] - for bin_index in range(number_bins): - # if there is no dihedrals in a bin then it cannot be a peak - if popul[bin_index] == 0: - pass - # being careful of the last bin - # (dihedrals have circular symmetry, the histogram does not) - elif ( - bin_index == number_bins - 1 - ): # the -1 is because the index starts with 0 not 1 - if ( - popul[bin_index] >= popul[bin_index - 1] - and popul[bin_index] >= popul[0] - ): - peaks.append(bin_value[bin_index]) - else: - if ( - popul[bin_index] >= popul[bin_index - 1] - and popul[bin_index] >= popul[bin_index + 1] - ): - peaks.append(bin_value[bin_index]) - + peaks = self._find_histogram_peaks(popul=popul, bin_value=bin_value) peak_values.append(peaks) - logger.debug(f"Dihedral: {dihedral_index}, Peak Values: {peak_values}") + logger.debug("Dihedral: %s, Peak Values: %s", dihedral_index, peak_values) return peak_values + @staticmethod + def _find_histogram_peaks(popul, bin_value): + """Return convex turning-point peaks from a histogram.""" + number_bins = len(popul) + peaks = [] + + for bin_index in range(number_bins): + if popul[bin_index] == 0: + continue + + if bin_index == number_bins - 1: + if ( + popul[bin_index] >= popul[bin_index - 1] + and popul[bin_index] >= popul[0] + ): + peaks.append(bin_value[bin_index]) + else: + if ( + popul[bin_index] >= popul[bin_index - 1] + and popul[bin_index] >= popul[bin_index + 1] + ): + peaks.append(bin_value[bin_index]) + + return peaks + + def _assign_states_for_group( + self, + data_container, + group_id, + molecules, + dihedrals_ua, + peaks_ua, + dihedrals_res, + peaks_res, + start, + end, + step, + level_list, + states_ua, + states_res, + ): + """Assign UA and residue states for a group into output containers.""" + for level in level_list: + if level == "united_atom": + for res_id in range(len(dihedrals_ua)): + key = (group_id, res_id) + if len(dihedrals_ua[res_id]) == 0: + states_ua[key] = [] + else: + states_ua[key] = self._assign_states( + data_container=data_container, + molecules=molecules, + dihedrals=dihedrals_ua[res_id], + peaks=peaks_ua[res_id], + start=start, + end=end, + step=step, + ) + + elif level == "residue": + if len(dihedrals_res) == 0: + states_res[group_id] = [] + else: + states_res[group_id] = self._assign_states( + data_container=data_container, + molecules=molecules, + dihedrals=dihedrals_res, + peaks=peaks_res, + start=start, + end=end, + step=step, + ) + def _assign_states( self, data_container, @@ -325,45 +406,48 @@ def _assign_states( end, step, ): - """ - Turn the dihedral values into conformations based on the peaks - from the histogram. - Then combine these to form states for each molecule. + """Assign discrete state labels for the provided dihedrals. + + Important: + This function intentionally preserves the legacy behavior: + it samples over the full trajectory length for each molecule + and does not apply start/end/step to the Dihedral run. + + Args: + data_container: MDAnalysis universe. + molecules: Molecule ids in the group. + dihedrals: Dihedral AtomGroups. + peaks: Peaks per dihedral. + start: Unused in legacy sampling. + end: Unused in legacy sampling. + step: Unused in legacy sampling. + + Returns: + List of state labels (strings). """ states = None - # get the values of the angle for the dihedral - # dihedral angle values have a range from -180 to 180 for molecule in molecules: conformations = [] mol = self._universe_operations.get_molecule_container( data_container, molecule ) number_frames = len(mol.trajectory) + dihedral_results = Dihedral(dihedrals).run() + for dihedral_index in range(len(dihedrals)): conformation = [] for timestep in range(number_frames): value = dihedral_results.results.angles[timestep][dihedral_index] - - # We want postive values in range 0 to 360 to make - # the peak assignment. - # works using the fact that dihedrals have circular symetry - # (i.e. -15 degrees = +345 degrees) if value < 0: value += 360 - # Find the turning point/peak that the snapshot is closest to. distances = [abs(value - peak) for peak in peaks[dihedral_index]] conformation.append(np.argmin(distances)) - logger.debug( - f"Dihedral: {dihedral_index} Conformations: {conformation}" - ) conformations.append(conformation) - # for all the dihedrals available concatenate the label of each - # dihedral into the state for that frame mol_states = [ state for state in ( @@ -380,6 +464,21 @@ def _assign_states( else: states.extend(mol_states) - logger.debug(f"States: {states}") - + logger.debug("States: %s", states) return states + + @staticmethod + def _count_total_items(levels, groups) -> int: + """Count total progress items.""" + return sum(len(levels[mol_id]) for mols in groups.values() for mol_id in mols) + + @staticmethod + def _progress_bar(total_items: int) -> Progress: + """Create a Rich progress bar.""" + return Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.fields[title]}", justify="right"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), + TimeElapsedColumn(), + ) diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py index 8342e571..e2bb9686 100644 --- a/CodeEntropy/levels/force_torque_manager.py +++ b/CodeEntropy/levels/force_torque_manager.py @@ -1,84 +1,190 @@ +"""Force/torque weighting and per-frame second-moment construction. + +This module provides utilities for transforming atomic forces into bead-level +generalized forces (translation) and torques (rotation), and for assembling +per-frame second-moment matrices used downstream in entropy calculations. +""" + +from __future__ import annotations + import logging -from typing import Any, List, Optional, Tuple +from dataclasses import dataclass +from typing import Any, Optional, Sequence, Tuple import numpy as np logger = logging.getLogger(__name__) +Vector3 = np.ndarray +Matrix = np.ndarray + + +@dataclass(frozen=True) +class TorqueInputs: + """Container for torque computation inputs. + + Attributes: + rot_axes: Rotation matrix mapping lab-frame vectors into the bead frame, + shape (3, 3). + center: Reference center for torque arm vectors, shape (3,). + force_partitioning: Scaling factor applied to forces before torque + accumulation. + moment_of_inertia: Principal moments (aligned with rot_axes), shape (3,). + axes_manager: Optional object that provides: + get_vector(center, positions, box) -> displacement vectors (PBC-aware). + box: Optional periodic box passed to axes_manager.get_vector. + """ + + rot_axes: Matrix + center: Vector3 + force_partitioning: float + moment_of_inertia: Vector3 + axes_manager: Optional[Any] = None + box: Optional[np.ndarray] = None + class ForceTorqueManager: - def __init__(self): - pass + """Computes weighted generalized forces/torques and per-frame second moments.""" def get_weighted_forces( self, - bead, - trans_axes: np.ndarray, + bead: Any, + trans_axes: Matrix, highest_level: bool, force_partitioning: float, - ) -> np.ndarray: - """ - Procedural-equivalent translational force: - sum( trans_axes @ atom.force ) over bead atoms - optionally scale by force_partitioning if highest_level - divide by sqrt(bead mass) - """ - forces_trans = np.zeros((3,), dtype=float) - - for atom in bead.atoms: - forces_local = np.matmul(trans_axes, atom.force) - forces_trans += forces_local + ) -> Vector3: + """Compute a mass-weighted translational generalized force. - if highest_level: - forces_trans = force_partitioning * forces_trans + Args: + bead: MDAnalysis AtomGroup-like bead with .atoms and .total_mass(). + Each atom must provide .force (shape (3,)). + trans_axes: Transform matrix for translational forces, shape (3, 3). + highest_level: If True, apply force_partitioning scaling. + force_partitioning: Scaling factor applied when highest_level is True. - mass = bead.total_mass() - if mass <= 0: - raise ValueError(f"Invalid mass value: {mass}") + Returns: + Mass-weighted generalized force vector, shape (3,). - return forces_trans / np.sqrt(mass) + Raises: + ValueError: If mass is non-positive or trans_axes shape is invalid. + """ + return self._compute_weighted_force( + bead=bead, + trans_axes=trans_axes, + apply_partitioning=highest_level, + force_partitioning=force_partitioning, + ) def get_weighted_torques( self, - bead, - rot_axes: np.ndarray, - center: np.ndarray, + bead: Any, + rot_axes: Matrix, + center: Vector3, force_partitioning: float, - moment_of_inertia: np.ndarray, + moment_of_inertia: Vector3, axes_manager: Optional[Any], box: Optional[np.ndarray], - ) -> np.ndarray: + ) -> Vector3: + """Compute a moment-weighted generalized torque. + + Args: + bead: MDAnalysis AtomGroup-like bead with .positions and .forces (N,3). + rot_axes: Rotation matrix into bead frame, shape (3,3). + center: Reference center for displacement vectors, shape (3,). + force_partitioning: Scaling factor applied to forces before torque sum. + moment_of_inertia: Principal moments aligned with rot_axes, shape (3,). + axes_manager: Optional PBC displacement provider. + box: Periodic box passed to axes_manager when used. + + Returns: + Weighted torque vector, shape (3,). + + Raises: + ValueError: If shapes are invalid. """ - Procedural-equivalent rotational torque: - coords = axes_manager.get_vector(center, bead.positions, box) (PBC) - rotate coords/forces into rot_axes - scale forces by force_partitioning - torque = sum( cross(r, f) ) - divide componentwise by sqrt(principal moments) + inputs = TorqueInputs( + rot_axes=np.asarray(rot_axes, dtype=float), + center=np.asarray(center, dtype=float).reshape(3), + force_partitioning=float(force_partitioning), + moment_of_inertia=np.asarray(moment_of_inertia), + axes_manager=axes_manager, + box=box, + ) + return self._compute_weighted_torque(bead=bead, inputs=inputs) + + def compute_frame_covariance( + self, + force_vecs: Sequence[Vector3], + torque_vecs: Sequence[Vector3], + ) -> Tuple[Matrix, Matrix]: + """Compute per-frame second-moment matrices for force/torque vectors. + + Note: + This returns outer(x, x) where x is the concatenation of all bead + vectors in the frame. + + Args: + force_vecs: Sequence of per-bead force vectors (3,). + torque_vecs: Sequence of per-bead torque vectors (3,). + + Returns: + Tuple (F, T) where each is a (3N, 3N) second-moment matrix. """ - if ( - axes_manager is not None - and hasattr(axes_manager, "get_vector") - and box is not None - ): - translated = axes_manager.get_vector(center, bead.positions, box) - else: - translated = bead.positions - center + return self._compute_frame_second_moments(force_vecs, torque_vecs) - rotated_coords = np.tensordot(translated, rot_axes.T, axes=1) - rotated_forces = np.tensordot(bead.forces, rot_axes.T, axes=1) + def _compute_weighted_force( + self, + bead: Any, + trans_axes: Matrix, + *, + apply_partitioning: bool, + force_partitioning: float, + ) -> Vector3: + """Implementation of translational generalized force computation.""" + trans_axes = np.asarray(trans_axes, dtype=float) + if trans_axes.shape != (3, 3): + raise ValueError(f"trans_axes must be (3,3), got {trans_axes.shape}") - rotated_forces *= force_partitioning + forces_trans = np.zeros((3,), dtype=float) + for atom in bead.atoms: + forces_trans += trans_axes @ np.asarray(atom.force, dtype=float) + + if apply_partitioning: + forces_trans *= float(force_partitioning) - torques = np.cross(rotated_coords, rotated_forces) - torques = np.sum(torques, axis=0) + mass = float(bead.total_mass()) + if mass <= 0.0: + raise ValueError(f"Invalid bead mass: {mass}. Mass must be positive.") + + return forces_trans / np.sqrt(mass) - moi = np.asarray(moment_of_inertia) + def _compute_weighted_torque(self, bead: Any, inputs: TorqueInputs) -> Vector3: + """Implementation of rotational generalized torque computation.""" + rot_axes = np.asarray(inputs.rot_axes, dtype=float) + if rot_axes.shape != (3, 3): + raise ValueError(f"rot_axes must be (3,3), got {rot_axes.shape}") + + moi = np.asarray(inputs.moment_of_inertia) moi = np.real_if_close(moi, tol=1000) moi = np.asarray(moi, dtype=float).reshape(-1) if moi.size != 3: raise ValueError(f"moment_of_inertia must be (3,), got {moi.shape}") + translated = self._displacements_relative_to_center( + center=np.asarray(inputs.center, dtype=float).reshape(3), + positions=np.asarray(bead.positions, dtype=float), + axes_manager=inputs.axes_manager, + box=inputs.box, + ) + + rotated_coords = np.tensordot(translated, rot_axes.T, axes=1) + rotated_forces = np.tensordot( + np.asarray(bead.forces, dtype=float), rot_axes.T, axes=1 + ) + rotated_forces *= float(inputs.force_partitioning) + + torques = np.sum(np.cross(rotated_coords, rotated_forces), axis=0) + weighted = np.zeros((3,), dtype=float) for d in range(3): if np.isclose(torques[d], 0.0): @@ -89,32 +195,59 @@ def get_weighted_torques( return weighted + def _compute_frame_second_moments( + self, + force_vectors: Sequence[Vector3], + torque_vectors: Sequence[Vector3], + ) -> Tuple[Matrix, Matrix]: + """Build outer products for concatenated force/torque vectors.""" + f = self._outer_second_moment(force_vectors) + t = self._outer_second_moment(torque_vectors) + return f, t + @staticmethod - def _outer_second_moment(vectors: List[np.ndarray]) -> np.ndarray: + def _displacements_relative_to_center( + *, + center: Vector3, + positions: np.ndarray, + axes_manager: Optional[Any], + box: Optional[np.ndarray], + ) -> np.ndarray: """ - Procedural-style per-frame "covariance" (actually second moment): - If x is concatenated (3N,) vector of bead forces/torques, - return outer(x, x) -> (3N,3N) + Compute displacement vectors from center to positions (optionally PBC-aware). + """ + if ( + axes_manager is not None + and hasattr(axes_manager, "get_vector") + and box is not None + ): + return axes_manager.get_vector(center, positions, box) + return positions - center + + @staticmethod + def _outer_second_moment(vectors: Sequence[Vector3]) -> Matrix: + """Compute outer(flat, flat) for concatenated 3-vectors. + + Args: + vectors: Sequence of vectors of shape (3,). + + Returns: + Second-moment matrix with shape (3N, 3N). Returns (0,0) if empty. + + Raises: + ValueError: If any vector is not length 3. """ if not vectors: return np.zeros((0, 0), dtype=float) - flat = np.concatenate( - [ - np.asarray(v, dtype=float).reshape( - 3, + parts = [] + for v in vectors: + arr = np.asarray(v, dtype=float).reshape(-1) + if arr.size != 3: + raise ValueError( + f"Expected vector of length 3, got shape {np.asarray(v).shape}" ) - for v in vectors - ], - axis=0, - ) - return np.outer(flat, flat) + parts.append(arr) - def compute_frame_covariance( - self, - force_vecs: List[np.ndarray], - torque_vecs: List[np.ndarray], - ) -> Tuple[np.ndarray, np.ndarray]: - F = self._outer_second_moment(force_vecs) - T = self._outer_second_moment(torque_vecs) - return F, T + flat = np.concatenate(parts, axis=0) + return np.outer(flat, flat) diff --git a/CodeEntropy/levels/frame_dag.py b/CodeEntropy/levels/frame_dag.py index 47fbb84e..cdbf64a6 100644 --- a/CodeEntropy/levels/frame_dag.py +++ b/CodeEntropy/levels/frame_dag.py @@ -1,4 +1,15 @@ +"""Frame-local DAG execution. + +This module defines the frame-scoped DAG used during the MAP stage of the +hierarchy workflow. Each frame is processed independently to produce +frame-local outputs (e.g., axes and covariance data), which are later reduced +outside this DAG. +""" + +from __future__ import annotations + import logging +from dataclasses import dataclass from typing import Any, Dict, Optional import networkx as nx @@ -9,48 +20,103 @@ logger = logging.getLogger(__name__) -class FrameDAG: +@dataclass +class FrameContext: + """Container for per-frame execution context. + + Attributes: + shared: Shared workflow data (mutated across the full workflow). + frame_index: Absolute trajectory frame index being processed. + frame_axes: Frame-local axes output produced by FrameAxesNode. + frame_covariance: Frame-local covariance output produced by FrameCovarianceNode. + data: Additional frame-local scratch space for nodes, if needed. """ - Frame-local DAG (MAP stage). - - Contract: - - execute_frame(shared_data, frame_index) builds a frame_ctx - - frame_ctx ALWAYS contains: - frame_ctx["shared"] -> the shared_data dict - frame_ctx["frame_index"] -> absolute trajectory frame index - - nodes write only frame-local outputs: - frame_ctx["frame_axes"], frame_ctx["frame_covariance"] - - reduction/averaging happens outside this DAG (in LevelDAG) + + shared: Dict[str, Any] + frame_index: int + frame_axes: Any = None + frame_covariance: Any = None + data: Dict[str, Any] = None + + +class FrameDAG: + """Execute a frame-local directed acyclic graph. + + The graph is run once per trajectory frame. Nodes may read shared inputs from + `ctx["shared"]` and must write only frame-local outputs into the frame context. + + Expected node outputs: + - "frame_axes" + - "frame_covariance" """ - def __init__(self, universe_operations=None): + def __init__(self, universe_operations: Optional[Any] = None) -> None: + """Initialise a FrameDAG. + + Args: + universe_operations: Optional adapter providing universe operations used + by frame-level nodes (e.g., selections / molecule containers). + """ self._universe_operations = universe_operations - self.graph = nx.DiGraph() - self.nodes: Dict[str, Any] = {} + self._graph = nx.DiGraph() + self._nodes: Dict[str, Any] = {} def build(self) -> "FrameDAG": + """Build the default frame DAG topology. + + Returns: + Self, to allow fluent chaining. + """ self._add("frame_axes", FrameAxesNode(self._universe_operations)) self._add("frame_covariance", FrameCovarianceNode(), deps=["frame_axes"]) return self + def execute_frame(self, shared_data: Dict[str, Any], frame_index: int) -> Any: + """Execute the frame DAG for a single trajectory frame. + + Args: + shared_data: Shared workflow data dict. + frame_index: Absolute trajectory frame index. + + Returns: + Frame-local covariance payload produced by FrameCovarianceNode. + """ + ctx = self._make_frame_ctx(shared_data=shared_data, frame_index=frame_index) + + for node_name in nx.topological_sort(self._graph): + logger.debug("[FrameDAG] running %s @ frame=%s", node_name, frame_index) + self._nodes[node_name].run(ctx) + + return ctx["frame_covariance"] + def _add(self, name: str, node: Any, deps: Optional[list[str]] = None) -> None: - self.nodes[name] = node - self.graph.add_node(name) - for d in deps or []: - self.graph.add_edge(d, name) + """Register a node and its dependencies in the DAG.""" + self._nodes[name] = node + self._graph.add_node(name) + for dep in deps or []: + self._graph.add_edge(dep, name) - def execute_frame( - self, shared_data: Dict[str, Any], frame_index: int + @staticmethod + def _make_frame_ctx( + shared_data: Dict[str, Any], frame_index: int ) -> Dict[str, Any]: - frame_ctx: Dict[str, Any] = dict(shared_data) - frame_ctx["shared"] = shared_data - frame_ctx["frame_index"] = frame_index + """Create a frame context dictionary for node execution. - frame_ctx["frame_axes"] = None - frame_ctx["frame_covariance"] = None + Notes: + - The context includes a reference to `shared_data` via "shared". + - The context is intentionally frame-scoped and should not be used as + a replacement for shared workflow state. - for node_name in nx.topological_sort(self.graph): - logger.debug(f"[FrameDAG] running {node_name} @ frame={frame_index}") - self.nodes[node_name].run(frame_ctx) + Args: + shared_data: Shared workflow data dict. + frame_index: Absolute trajectory frame index. - return frame_ctx["frame_covariance"] + Returns: + Frame context dict with required keys. + """ + return { + "shared": shared_data, + "frame_index": frame_index, + "frame_axes": None, + "frame_covariance": None, + } diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 7ae06b60..477b4968 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -1,3 +1,19 @@ +"""Hierarchy-level DAG orchestration and reduction. + +This module defines the `LevelDAG`, which coordinates two stages of the hierarchy +workflow: + +1) Static stage (runs once): + - Detect molecules and available resolution levels. + - Build beads for each (molecule, level) definition. + - Initialise accumulators used during per-frame reduction. + - Compute conformational state descriptors required later by entropy nodes. + +2) Frame stage (runs for each trajectory frame): + - Execute the `FrameDAG` to produce frame-local covariance outputs. + - Reduce frame-local outputs into running (incremental) means. +""" + from __future__ import annotations import logging @@ -19,21 +35,47 @@ class LevelDAG: - def __init__(self, universe_operations=None): + """Execute hierarchy detection, per-frame covariance calculation, and reduction. + + The LevelDAG is responsible for: + - Running a static DAG (once) to prepare shared inputs. + - Running a per-frame DAG (for each frame) to compute frame-local outputs. + - Reducing frame-local outputs into shared running means. + + The reduction performed here is an incremental mean across frames (and across + molecules within a group when frame nodes average within-frame first). + """ + + def __init__(self, universe_operations: Optional[Any] = None) -> None: + """Initialise a LevelDAG. + + Args: + universe_operations: Optional adapter providing universe operations. + Passed to the FrameDAG and the conformational-state node. + """ self._universe_operations = universe_operations - self.static_graph = nx.DiGraph() - self.static_nodes: Dict[str, Any] = {} + self._static_graph = nx.DiGraph() + self._static_nodes: Dict[str, Any] = {} - self.frame_dag = FrameDAG(universe_operations=universe_operations) + self._frame_dag = FrameDAG(universe_operations=universe_operations) def build(self) -> "LevelDAG": + """Build the static and frame DAG topology. + + Returns: + Self, to allow fluent chaining. + """ self._add_static("detect_molecules", DetectMoleculesNode()) self._add_static("detect_levels", DetectLevelsNode(), deps=["detect_molecules"]) self._add_static("build_beads", BuildBeadsNode(), deps=["detect_levels"]) + # Produces a frame axes manager stored in shared_data (node name is explicit + # to avoid ambiguity with per-frame axes). self._add_static( - "frame_axes_manager", FrameAxesNode(), deps=["detect_molecules"] + "frame_axes_manager", + FrameAxesNode(), + deps=["detect_molecules"], ) self._add_static( @@ -47,24 +89,79 @@ def build(self) -> "LevelDAG": deps=["detect_levels"], ) - self.frame_dag.build() + self._frame_dag.build() return self + def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: + """Execute the full hierarchy workflow and mutate shared_data. + + Args: + shared_data: Shared workflow data dict that will be mutated by the DAG. + + Returns: + The mutated shared_data dict. + """ + self._run_static_stage(shared_data) + self._run_frame_stage(shared_data) + return shared_data + + def _run_static_stage(self, shared_data: Dict[str, Any]) -> None: + """Run all static nodes in dependency order.""" + for node_name in nx.topological_sort(self._static_graph): + logger.info("[LevelDAG] static node: %s", node_name) + self._static_nodes[node_name].run(shared_data) + def _add_static( self, name: str, node: Any, deps: Optional[list[str]] = None ) -> None: - self.static_nodes[name] = node - self.static_graph.add_node(name) - for d in deps or []: - self.static_graph.add_edge(d, name) + """Register a static node and its dependencies in the static DAG.""" + self._static_nodes[name] = node + self._static_graph.add_node(name) + for dep in deps or []: + self._static_graph.add_edge(dep, name) + + def _run_frame_stage(self, shared_data: Dict[str, Any]) -> None: + """Run the frame DAG for each selected trajectory frame and reduce outputs.""" + u = shared_data["reduced_universe"] + start, end, step = shared_data["start"], shared_data["end"], shared_data["step"] + + for ts in u.trajectory[start:end:step]: + frame_index = ts.frame + frame_out = self._frame_dag.execute_frame(shared_data, frame_index) + self._reduce_one_frame(shared_data, frame_out) @staticmethod - def _inc_mean(old, new, n: int): + def _incremental_mean(old: Any, new: Any, n: int) -> Any: + """Compute an incremental mean. + + Args: + old: Previous running mean (or None for first sample). + new: New sample to incorporate. + n: 1-based sample count after adding `new`. + + Returns: + Updated running mean. + """ if old is None: return new.copy() if hasattr(new, "copy") else new return old + (new - old) / float(n) - def _reduce_one_frame(self, shared_data, frame_out): + def _reduce_one_frame( + self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] + ) -> None: + """Reduce one frame's covariance outputs into shared running means. + + Args: + shared_data: Shared workflow data dict containing accumulators. + frame_out: Frame-local covariance outputs produced by FrameDAG. + """ + self._reduce_force_and_torque(shared_data, frame_out) + self._reduce_forcetorque(shared_data, frame_out) + + def _reduce_force_and_torque( + self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] + ) -> None: + """Reduce force/torque covariance outputs into shared accumulators.""" f_cov = shared_data["force_covariances"] t_cov = shared_data["torque_covariances"] counts = shared_data["frame_counts"] @@ -76,73 +173,60 @@ def _reduce_one_frame(self, shared_data, frame_out): for key, F in f_frame["ua"].items(): counts["ua"][key] = counts["ua"].get(key, 0) + 1 n = counts["ua"][key] - f_cov["ua"][key] = self._inc_mean(f_cov["ua"].get(key), F, n) + f_cov["ua"][key] = self._incremental_mean(f_cov["ua"].get(key), F, n) for key, T in t_frame["ua"].items(): - n = counts["ua"].get(key) - if n is None: + if key not in counts["ua"]: counts["ua"][key] = counts["ua"].get(key, 0) + 1 - n = counts["ua"][key] - t_cov["ua"][key] = self._inc_mean(t_cov["ua"].get(key), T, n) + n = counts["ua"][key] + t_cov["ua"][key] = self._incremental_mean(t_cov["ua"].get(key), T, n) for gid, F in f_frame["res"].items(): gi = gid2i[gid] counts["res"][gi] += 1 n = counts["res"][gi] - f_cov["res"][gi] = self._inc_mean(f_cov["res"][gi], F, n) + f_cov["res"][gi] = self._incremental_mean(f_cov["res"][gi], F, n) for gid, T in t_frame["res"].items(): gi = gid2i[gid] - n = counts["res"][gi] - if n == 0: + if counts["res"][gi] == 0: counts["res"][gi] += 1 - n = counts["res"][gi] - t_cov["res"][gi] = self._inc_mean(t_cov["res"][gi], T, n) + n = counts["res"][gi] + t_cov["res"][gi] = self._incremental_mean(t_cov["res"][gi], T, n) for gid, F in f_frame["poly"].items(): gi = gid2i[gid] counts["poly"][gi] += 1 n = counts["poly"][gi] - f_cov["poly"][gi] = self._inc_mean(f_cov["poly"][gi], F, n) + f_cov["poly"][gi] = self._incremental_mean(f_cov["poly"][gi], F, n) for gid, T in t_frame["poly"].items(): gi = gid2i[gid] - n = counts["poly"][gi] - if n == 0: + if counts["poly"][gi] == 0: counts["poly"][gi] += 1 - n = counts["poly"][gi] - t_cov["poly"][gi] = self._inc_mean(t_cov["poly"][gi], T, n) - - if "forcetorque" in frame_out: - ft_cov = shared_data["forcetorque_covariances"] - ft_counts = shared_data["forcetorque_counts"] - ft_frame = frame_out["forcetorque"] - - for gid, M in ft_frame.get("res", {}).items(): - gi = gid2i[gid] - ft_counts["res"][gi] += 1 - n = ft_counts["res"][gi] - ft_cov["res"][gi] = self._inc_mean(ft_cov["res"][gi], M, n) - - for gid, M in ft_frame.get("poly", {}).items(): - gi = gid2i[gid] - ft_counts["poly"][gi] += 1 - n = ft_counts["poly"][gi] - ft_cov["poly"][gi] = self._inc_mean(ft_cov["poly"][gi], M, n) + n = counts["poly"][gi] + t_cov["poly"][gi] = self._incremental_mean(t_cov["poly"][gi], T, n) - def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: - for node_name in nx.topological_sort(self.static_graph): - logger.info(f"[LevelDAG] static node: {node_name}") - self.static_nodes[node_name].run(shared_data) + def _reduce_forcetorque( + self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] + ) -> None: + """Reduce combined force-torque covariance outputs into shared accumulators.""" + if "forcetorque" not in frame_out: + return - u = shared_data["reduced_universe"] - start, end, step = shared_data["start"], shared_data["end"], shared_data["step"] + ft_cov = shared_data["forcetorque_covariances"] + ft_counts = shared_data["forcetorque_counts"] + gid2i = shared_data["group_id_to_index"] + ft_frame = frame_out["forcetorque"] - for ts in u.trajectory[start:end:step]: - frame_index = ts.frame - frame_out = self.frame_dag.execute_frame( - shared_data, frame_index=frame_index - ) - self._reduce_one_frame(shared_data, frame_out) + for gid, M in ft_frame.get("res", {}).items(): + gi = gid2i[gid] + ft_counts["res"][gi] += 1 + n = ft_counts["res"][gi] + ft_cov["res"][gi] = self._incremental_mean(ft_cov["res"][gi], M, n) - return shared_data + for gid, M in ft_frame.get("poly", {}).items(): + gi = gid2i[gid] + ft_counts["poly"][gi] += 1 + n = ft_counts["poly"][gi] + ft_cov["poly"][gi] = self._incremental_mean(ft_cov["poly"][gi], M, n) diff --git a/CodeEntropy/levels/level_hierarchy.py b/CodeEntropy/levels/level_hierarchy.py index 5b0afcdc..9e736294 100644 --- a/CodeEntropy/levels/level_hierarchy.py +++ b/CodeEntropy/levels/level_hierarchy.py @@ -1,98 +1,144 @@ +"""Hierarchy level selection and bead construction. + +This module defines `LevelHierarchy`, which is responsible for: + 1) Determining which hierarchy levels apply to each molecule. + 2) Constructing "beads" (AtomGroups) for a given molecule at a given level. + +Notes: +- The "residue" bead construction must use residues attached to the provided + AtomGroup/container. Using `resindex` selection strings is unsafe because + `resindex` is global to the Universe and can produce empty/incorrect beads + when operating on per-molecule containers beyond the first molecule. +""" + +from __future__ import annotations + import logging +from typing import List, Tuple logger = logging.getLogger(__name__) class LevelHierarchy: - """ """ + """Determine applicable hierarchy levels and build beads for each level. - def __init__(self): - """ - Initializes the LevelHierarchy with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ + A "level" represents a resolution scale used throughout the entropy workflow: + - united_atom: heavy-atom-centered beads (plus bonded hydrogens) + - residue: residue beads + - polymer: whole-molecule bead - def select_levels(self, data_container): - """ - Identify the number of molecules and which levels (united atom, residue, - polymer) should be used for each molecule. + This class intentionally does not perform any entropy calculations. It only + provides structural information (levels and beads). + """ + + def select_levels(self, data_container) -> Tuple[int, List[List[str]]]: + """Select applicable hierarchy levels for each molecule in the container. + + A molecule is always assigned the `united_atom` level. + + Additional levels are included if: + - `residue`: the heavy-atom subset has more than one atom. + - `polymer`: the heavy-atom subset spans more than one residue. Args: - data_container: MDAnalysis Universe for the system. + data_container: An MDAnalysis Universe (or compatible object) with + `atoms.fragments` available. Returns: - number_molecules (int) - levels (list[list[str]]) + A tuple of: + - number_molecules: Number of molecular fragments. + - levels: List where `levels[mol_id]` is a list of level names + (strings) for that molecule in increasing coarseness. """ number_molecules = len(data_container.atoms.fragments) - logger.debug(f"The number of molecules is {number_molecules}.") + logger.debug("The number of molecules is %d.", number_molecules) fragments = data_container.atoms.fragments - levels = [[] for _ in range(number_molecules)] + levels: List[List[str]] = [[] for _ in range(number_molecules)] - for molecule in range(number_molecules): - levels[molecule].append("united_atom") + for mol_id in range(number_molecules): + levels[mol_id].append("united_atom") - atoms_in_fragment = fragments[molecule].select_atoms("prop mass > 1.1") - number_residues = len(atoms_in_fragment.residues) - - if len(atoms_in_fragment) > 1: - levels[molecule].append("residue") + heavy_atoms = fragments[mol_id].select_atoms("prop mass > 1.1") + if len(heavy_atoms) > 1: + levels[mol_id].append("residue") + number_residues = len(heavy_atoms.residues) if number_residues > 1: - levels[molecule].append("polymer") + levels[mol_id].append("polymer") - logger.debug(f"levels {levels}") + logger.debug("Selected levels: %s", levels) return number_molecules, levels - def get_beads(self, data_container, level): - """ - Define beads depending on the hierarchy level. - - IMPORTANT FIX: - - For "residue", DO NOT use "resindex i" selection strings. - resindex is global to the universe and will often produce empty beads - for molecules beyond the first one. - - Instead, directly use the residues belonging to the data_container. + def get_beads(self, data_container, level: str) -> List: + """Build beads for a given container at a given hierarchy level. Args: - data_container: MDAnalysis AtomGroup (typically a molecule/fragment or - residue.atoms) level (str): "polymer", "residue", or "united_atom" + data_container: An MDAnalysis AtomGroup representing a molecule or + other container that has `.select_atoms(...)` and `.residues`. + level: One of {"united_atom", "residue", "polymer"}. Returns: - list_of_beads: list[AtomGroup] + A list of MDAnalysis AtomGroups representing beads at that level. + + Raises: + ValueError: If `level` is not a supported hierarchy level. """ if level == "polymer": return [data_container.select_atoms("all")] if level == "residue": - list_of_beads = [] - for res in data_container.residues: - bead = res.atoms - list_of_beads.append(bead) - logger.debug(f"Residue beads: {[len(b) for b in list_of_beads]}") - return list_of_beads + return self._build_residue_beads(data_container) if level == "united_atom": - list_of_beads = [] - heavy_atoms = data_container.select_atoms("prop mass > 1.1") - - if len(heavy_atoms) == 0: - list_of_beads.append(data_container.select_atoms("all")) - else: - for atom in heavy_atoms: - atom_group = ( - "index " - + str(atom.index) - + " or ((prop mass <= 1.1) and bonded index " - + str(atom.index) - + ")" - ) - bead = data_container.select_atoms(atom_group) - list_of_beads.append(bead) - - logger.debug(f"United-atom beads: {[len(b) for b in list_of_beads]}") - return list_of_beads + return self._build_united_atom_beads(data_container) raise ValueError(f"Unknown level: {level}") + + # ------------------------------------------------------------------ + # Bead builders (single responsibility, testable) + # ------------------------------------------------------------------ + + def _build_residue_beads(self, data_container) -> List: + """Build one bead per residue using the container's residues. + + Args: + data_container: MDAnalysis AtomGroup with `.residues`. + + Returns: + List of residue AtomGroups. + """ + beads = [res.atoms for res in data_container.residues] + logger.debug("Residue beads sizes: %s", [len(b) for b in beads]) + return beads + + def _build_united_atom_beads(self, data_container) -> List: + """Build united-atom beads from heavy atoms and their bonded hydrogens. + + For each heavy atom, a bead is defined as: + - that heavy atom, plus + - any bonded atoms with mass <= 1.1 (hydrogen-like). + + If no heavy atoms exist in the container, the entire container becomes + a single bead. + + Args: + data_container: MDAnalysis AtomGroup representing a molecule. + + Returns: + List of bead AtomGroups. + """ + heavy_atoms = data_container.select_atoms("prop mass > 1.1") + if len(heavy_atoms) == 0: + return [data_container.select_atoms("all")] + + beads = [] + for atom in heavy_atoms: + selection = ( + f"index {atom.index} " + f"or ((prop mass <= 1.1) and bonded index {atom.index})" + ) + beads.append(data_container.select_atoms(selection)) + + logger.debug("United-atom bead sizes: %s", [len(b) for b in beads]) + return beads diff --git a/CodeEntropy/levels/matrix_operations.py b/CodeEntropy/levels/matrix_operations.py index c6c3b0f6..801ad13d 100644 --- a/CodeEntropy/levels/matrix_operations.py +++ b/CodeEntropy/levels/matrix_operations.py @@ -1,3 +1,16 @@ +"""Matrix utilities used across covariance and entropy calculations. + +This module contains small, focused helpers for matrix construction and cleanup. +All functions are pure (no side effects beyond logging) and operate on NumPy +arrays. + +Key behaviors: +- `create_submatrix` computes a 3x3 outer-product block for two 3-vectors. +- `filter_zero_rows_columns` removes rows/columns that are all (near) zero. +""" + +from __future__ import annotations + import logging import numpy as np @@ -6,85 +19,89 @@ class MatrixOperations: - """ """ + """Utility operations for small matrix manipulations.""" - def __init__(self): - """ - Initializes the MatrixOperations with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. - """ + def create_submatrix(self, data_i: np.ndarray, data_j: np.ndarray) -> np.ndarray: + """Create a 3x3 covariance-style submatrix from two 3-vectors. - def create_submatrix(self, data_i, data_j): - """ - Function for making covariance matrices. + This computes the outer product of `data_i` and `data_j`: - Args - ----- - data_i : values for bead i - data_j : values for bead j + submatrix = outer(data_i, data_j) - Returns - ------ - submatrix : 3x3 matrix for the covariance between i and j - """ + Args: + data_i: Vector of shape (3,) representing bead i values. + data_j: Vector of shape (3,) representing bead j values. - # Start with 3 by 3 matrix of zeros - submatrix = np.zeros((3, 3)) + Returns: + A (3, 3) NumPy array corresponding to the outer product. - # For each frame calculate the outer product (cross product) of the data from - # the two beads and add the result to the submatrix - outer_product_matrix = np.outer(data_i, data_j) - submatrix = np.add(submatrix, outer_product_matrix) + Raises: + ValueError: If either input cannot be reshaped to (3,). + """ + v_i = np.asarray(data_i, dtype=float).reshape(-1) + v_j = np.asarray(data_j, dtype=float).reshape(-1) - logger.debug(f"Submatrix: {submatrix}") + if v_i.shape[0] != 3 or v_j.shape[0] != 3: + raise ValueError( + f"Expected 3-vectors for outer product, got {v_i.shape} " + f"and {v_j.shape}." + ) + submatrix = np.outer(v_i, v_j) + logger.debug("Submatrix: %s", submatrix) return submatrix - def filter_zero_rows_columns(self, arg_matrix): - """ - function for removing rows and columns that contain only zeros from a matrix + def filter_zero_rows_columns( + self, matrix: np.ndarray, atol: float = 0.0 + ) -> np.ndarray: + """Remove rows and columns that are entirely (near) zero. + + A row (or column) is removed if all entries are close to zero according + to `np.isclose(..., atol=atol)`. Args: - arg_matrix : matrix + matrix: Input 2D array. + atol: Absolute tolerance used to determine "zero". Defaults to 0.0. Returns: - arg_matrix : the reduced size matrix + A new matrix with all-zero rows and columns removed. If no such rows + or columns exist, returns a view/copy of the original with consistent + NumPy typing. + + Raises: + ValueError: If `matrix` is not 2D. """ + mat = np.asarray(matrix, dtype=float) + if mat.ndim != 2: + raise ValueError(f"Expected a 2D matrix, got ndim={mat.ndim}.") - # record the initial size - init_shape = np.shape(arg_matrix) + init_shape = mat.shape - zero_indices = list( - filter( - lambda row: np.all(np.isclose(arg_matrix[row, :], 0.0)), - np.arange(np.shape(arg_matrix)[0]), - ) - ) - all_indices = np.ones((np.shape(arg_matrix)[0]), dtype=bool) - all_indices[zero_indices] = False - arg_matrix = arg_matrix[all_indices, :] - - all_indices = np.ones((np.shape(arg_matrix)[1]), dtype=bool) - zero_indices = list( - filter( - lambda col: np.all(np.isclose(arg_matrix[:, col], 0.0)), - np.arange(np.shape(arg_matrix)[1]), - ) - ) - all_indices[zero_indices] = False - arg_matrix = arg_matrix[:, all_indices] + row_mask = self._nonzero_row_mask(mat, atol=atol) + mat = mat[row_mask, :] - # get the final shape - final_shape = np.shape(arg_matrix) + col_mask = self._nonzero_col_mask(mat, atol=atol) + mat = mat[:, col_mask] + final_shape = mat.shape if init_shape != final_shape: logger.debug( - "A shape change has occurred ({},{}) -> ({}, {})".format( - *init_shape, *final_shape - ) + "Matrix shape changed %s -> %s after removing zero rows/cols.", + init_shape, + final_shape, ) - logger.debug(f"arg_matrix: {arg_matrix}") + logger.debug("Filtered matrix: %s", mat) + return mat + + @staticmethod + def _nonzero_row_mask(matrix: np.ndarray, atol: float) -> np.ndarray: + """Return a boolean mask selecting rows that are not all (near) zero.""" + is_zero_row = np.all(np.isclose(matrix, 0.0, atol=atol), axis=1) + return ~is_zero_row - return arg_matrix + @staticmethod + def _nonzero_col_mask(matrix: np.ndarray, atol: float) -> np.ndarray: + """Return a boolean mask selecting columns that are not all (near) zero.""" + is_zero_col = np.all(np.isclose(matrix, 0.0, atol=atol), axis=0) + return ~is_zero_col diff --git a/CodeEntropy/levels/mda_universe_operations.py b/CodeEntropy/levels/mda_universe_operations.py index 306dfd3a..115072a4 100644 --- a/CodeEntropy/levels/mda_universe_operations.py +++ b/CodeEntropy/levels/mda_universe_operations.py @@ -1,4 +1,14 @@ +"""MDAnalysis universe utilities. + +This module provides helper functions for creating and manipulating MDAnalysis +`Universe` objects used throughout the CodeEntropy workflow. +""" + +from __future__ import annotations + import logging +from dataclasses import dataclass +from typing import Optional import MDAnalysis as mda from MDAnalysis.analysis.base import AnalysisFromFunction @@ -7,43 +17,66 @@ logger = logging.getLogger(__name__) -class UniverseOperations: +@dataclass(frozen=True) +class TrajectorySlice: + """Frame slicing configuration for trajectory selection. + + Attributes: + start: Starting frame index (inclusive). If None, defaults to 0. + end: Ending frame index (exclusive). If None, defaults to len(trajectory). + step: Step between frames. Must be >= 1. """ - Functions to create and manipulate MDAnalysis Universe objects. + + start: Optional[int] = None + end: Optional[int] = None + step: int = 1 + + +class UniverseOperations: + """Utility methods for creating and manipulating MDAnalysis universes. + + This class focuses on a small set of responsibilities: + - Build reduced universes by selecting frames or atoms. + - Extract a single molecule (fragment) as its own universe. + - Merge coordinates and forces from separate trajectories into one universe. + + Notes: + These methods return new `MDAnalysis.Universe` objects backed by in-memory + trajectories. This makes downstream operations deterministic and avoids + side effects on the original universe. """ - def __init__(self): - """ - Initialise class - """ - self._universe = None - - def new_U_select_frame(self, u, start=None, end=None, step=1): - """Create a reduced universe by dropping frames according to - user selection. - - Parameters - ---------- - u : MDAnalyse.Universe - A Universe object will all topology, dihedrals,coordinates and force - information - start : int or None, Optional, default: None - Frame id to start analysis. Default None will start from frame 0 - end : int or None, Optional, default: None - Frame id to end analysis. Default None will end at last frame - step : int, Optional, default: 1 - Steps between frame. - - Returns - ------- - u2 : MDAnalysis.Universe - reduced universe + def new_U_select_frame( + self, + u: mda.Universe, + start: Optional[int] = None, + end: Optional[int] = None, + step: int = 1, + ) -> mda.Universe: + """Create a reduced universe by slicing frames. + + Args: + u: Source universe containing topology, coordinates, forces, and dimensions. + start: Starting frame index (inclusive). If None, defaults to 0. + end: Ending frame index (exclusive). If None, defaults to len(u.trajectory). + step: Step between frames. Must be >= 1. + + Returns: + A new universe containing the same atoms as `u` but only the selected frames + + Raises: + ValueError: If `step` is less than 1. """ + if step < 1: + raise ValueError("step must be >= 1") + if start is None: start = 0 if end is None: end = len(u.trajectory) + select_atom = u.select_atoms("all", updating=True) + coordinates = ( AnalysisFromFunction(lambda ag: ag.positions.copy(), select_atom) .run() @@ -59,33 +92,32 @@ def new_U_select_frame(self, u, start=None, end=None, step=1): .run() .results["timeseries"][start:end:step] ) + u2 = mda.Merge(select_atom) u2.load_new( - coordinates, format=MemoryReader, forces=forces, dimensions=dimensions + coordinates, + format=MemoryReader, + forces=forces, + dimensions=dimensions, ) - logger.debug(f"MDAnalysis.Universe - reduced universe: {u2}") + logger.debug("Created reduced universe by frames: %s", u2) return u2 - def new_U_select_atom(self, u, select_string="all"): - """Create a reduced universe by dropping atoms according to - user selection. - - Parameters - ---------- - u : MDAnalyse.Universe - A Universe object will all topology, dihedrals,coordinates and force - information - select_string : str, Optional, default: 'all' - MDAnalysis.select_atoms selection string. + def new_U_select_atom( + self, u: mda.Universe, select_string: str = "all" + ) -> mda.Universe: + """Create a reduced universe by selecting a subset of atoms. - Returns - ------- - u2 : MDAnalysis.Universe - reduced universe + Args: + u: Source universe containing topology, coordinates, forces, and dimensions. + select_string: MDAnalysis selection string. + Returns: + A new universe containing only the selected atoms across all frames. """ select_atom = u.select_atoms(select_string, updating=True) + coordinates = ( AnalysisFromFunction(lambda ag: ag.positions.copy(), select_atom) .run() @@ -101,51 +133,70 @@ def new_U_select_atom(self, u, select_string="all"): .run() .results["timeseries"] ) + u2 = mda.Merge(select_atom) u2.load_new( - coordinates, format=MemoryReader, forces=forces, dimensions=dimensions + coordinates, + format=MemoryReader, + forces=forces, + dimensions=dimensions, ) - logger.debug(f"MDAnalysis.Universe - reduced universe: {u2}") + logger.debug("Created reduced universe by atoms: %s", u2) return u2 - def get_molecule_container(self, universe, molecule_id): - """ - Extracts the atom group corresponding to a single molecule from the universe. + def get_molecule_container( + self, universe: mda.Universe, molecule_id: int + ) -> mda.Universe: + """Extract a single molecule (fragment) from a universe. Args: - universe (MDAnalysis.Universe): The reduced universe. - molecule_id (int): Index of the molecule to extract. + universe: Universe containing the system. + molecule_id: Index of the fragment (molecule) to extract. Returns: - MDAnalysis.Universe: Universe containing only the selected molecule. + A new universe containing only the atoms from the specified fragment. + + Raises: + IndexError: If `molecule_id` is out of range. + ValueError: If the fragment has no atoms. """ - # Identify the atoms in the molecule - frag = universe.atoms.fragments[molecule_id] - selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}" + fragments = universe.atoms.fragments + frag = fragments[molecule_id] + if len(frag) == 0: + raise ValueError(f"Fragment {molecule_id} is empty.") + selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}" return self.new_U_select_atom(universe, selection_string) - def merge_forces(self, tprfile, trrfile, forcefile, fileformat=None, kcal=False): - """ - Creates a universe by merging the coordinates and forces from - different input files. + def merge_forces( + self, + tprfile: str, + trrfile: str, + forcefile: str, + fileformat: Optional[str] = None, + kcal: bool = False, + ) -> mda.Universe: + """Merge coordinates and forces trajectories into a single universe. Args: - tprfile : Topology input file - trrfile : Coordinate trajectory file - forcefile : Force trajectory file - format : Optional string for MDAnalysis identifying the file format - kcal : Optional Boolean for when the forces are in kcal not kJ + tprfile: Topology input file. + trrfile: Coordinate trajectory file. + forcefile: Force trajectory file. + fileformat: Optional MDAnalysis format string (e.g., "TRR"). + kcal: If True, convert forces from kcal to kJ by multiplying by 4.184. Returns: - MDAnalysis Universe object - """ + A universe where coordinates come from `trrfile` and forces come from + `forcefile`. - logger.debug(f"Loading Universe with {trrfile}") + Raises: + ValueError: If the coordinate and force trajectories are incompatible. + """ + logger.debug("Loading coordinates universe: %s", trrfile) u = mda.Universe(tprfile, trrfile, format=fileformat) - logger.debug(f"Loading Universe with {forcefile}") + logger.debug("Loading forces universe: %s", forcefile) u_force = mda.Universe(tprfile, forcefile, format=fileformat) select_atom = u.select_atoms("all") @@ -157,11 +208,10 @@ def merge_forces(self, tprfile, trrfile, forcefile, fileformat=None, kcal=False) .results["timeseries"] ) forces = ( - AnalysisFromFunction(lambda ag: ag.positions.copy(), select_atom_force) + AnalysisFromFunction(lambda ag: ag.forces.copy(), select_atom_force) .run() .results["timeseries"] ) - dimensions = ( AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) .run() @@ -169,8 +219,7 @@ def merge_forces(self, tprfile, trrfile, forcefile, fileformat=None, kcal=False) ) if kcal: - # Convert from kcal to kJ - forces *= 4.184 + forces = forces * 4.184 logger.debug("Merging forces with coordinates universe.") new_universe = mda.Merge(select_atom) diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/build_beads.py index 6d4f8a4a..7efb538e 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/build_beads.py @@ -1,6 +1,19 @@ +"""Build bead (AtomGroup index) definitions for each molecule and hierarchy level. + +This module defines the `BuildBeadsNode`, a static DAG node that constructs bead +definitions once, in reduced-universe index space. These bead definitions are +used by later frame-level nodes (e.g., covariance construction) without needing +to re-run selection logic every frame. + +Beads are stored as arrays of atom indices (in the reduced universe). +""" + +from __future__ import annotations + import logging from collections import defaultdict -from typing import Any, Dict, List +from dataclasses import dataclass +from typing import Any, DefaultDict, Dict, List, MutableMapping, Tuple import numpy as np @@ -8,88 +21,212 @@ logger = logging.getLogger(__name__) +BeadKey = Tuple[int, str] | Tuple[int, str, int] +BeadsMap = Dict[BeadKey, List[np.ndarray]] -class BuildBeadsNode: + +@dataclass(frozen=True) +class UnitedAtomBead: + """A united-atom bead associated with a residue bucket. + + Attributes: + residue_id: Local residue index within the molecule (0..n_residues-1). + atom_indices: Atom indices (reduced-universe index space) belonging to the bead. """ - Build bead definitions ONCE, in reduced_universe index space. - shared_data["beads"] dict keys: - (mol_id, "united_atom", res_id) -> list[np.ndarray] # UA beads grouped by residue - (mol_id, "residue") -> list[np.ndarray] - (mol_id, "polymer") -> list[np.ndarray] + residue_id: int + atom_indices: np.ndarray - IMPORTANT: - UA beads are generated at the MOLECULE level (mol) to preserve procedural ordering - (molecule-heavy-atom ordinal), then assigned into residue buckets. + +class BuildBeadsNode: + """Build bead definitions once, in reduced-universe index space. + + Output contract: + Writes `shared_data["beads"]` with keys: + - (mol_id, "united_atom", res_id) -> list[np.ndarray] + - (mol_id, "residue") -> list[np.ndarray] + - (mol_id, "polymer") -> list[np.ndarray] + + Notes: + United-atom beads are generated at the molecule level (preserving the + underlying ordering provided by `LevelHierarchy.get_beads`) and then + grouped into residue buckets based on the heavy atom that defines the bead. """ - def __init__(self): - self._hier = LevelHierarchy() + def __init__(self, hierarchy: LevelHierarchy | None = None) -> None: + """Initialize the node. + + Args: + hierarchy: Optional `LevelHierarchy` dependency. If not provided, + a default instance is created. + """ + self._hier = hierarchy or LevelHierarchy() def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: + """Build bead definitions for all molecules and levels. + + Args: + shared_data: Shared data dictionary. Requires: + - "reduced_universe": MDAnalysis.Universe + - "levels": list[list[str]] + + Returns: + Dict containing the "beads" mapping (also written into shared_data). + + Raises: + KeyError: If required keys are missing from `shared_data`. + """ u = shared_data["reduced_universe"] - levels = shared_data["levels"] + levels: List[List[str]] = shared_data["levels"] - beads: Dict[Any, List[np.ndarray]] = {} + beads: BeadsMap = {} fragments = u.atoms.fragments for mol_id, level_list in enumerate(levels): mol = fragments[mol_id] if "united_atom" in level_list: - ua_beads_mol = self._hier.get_beads(mol, "united_atom") - - buckets: Dict[int, List[np.ndarray]] = defaultdict(list) - - for i, b in enumerate(ua_beads_mol): - if len(b) == 0: - logger.warning( - f"[BuildBeadsNode] EMPTY UA bead: mol={mol_id} bead_i={i}" - ) - continue - - heavy = b.select_atoms("prop mass > 1.1") - if len(heavy) == 0: - res_id = 0 - else: - heavy_resindex = int(heavy[0].resindex) - res_id = None - for local_i, res in enumerate(mol.residues): - if int(res.resindex) == heavy_resindex: - res_id = local_i - break - if res_id is None: - res_id = 0 - - buckets[res_id].append(b.indices.copy()) - - for res_id, res in enumerate(mol.residues): - kept = buckets.get(res_id, []) - beads[(mol_id, "united_atom", res_id)] = kept + self._add_united_atom_beads(beads=beads, mol_id=mol_id, mol=mol) if "residue" in level_list: - res_beads = self._hier.get_beads(mol, "residue") - kept = [] - for i, b in enumerate(res_beads): - if len(b) == 0: - continue - kept.append(b.indices.copy()) - beads[(mol_id, "residue")] = kept - - if len(kept) == 0: - logger.error( - f"[BuildBeadsNode] NO residue beads kept for mol={mol_id}. " - "This will force residue entropy to 0.0." - ) + self._add_residue_beads(beads=beads, mol_id=mol_id, mol=mol) if "polymer" in level_list: - poly_beads = self._hier.get_beads(mol, "polymer") - kept = [] - for i, b in enumerate(poly_beads): - if len(b) == 0: - continue - kept.append(b.indices.copy()) - beads[(mol_id, "polymer")] = kept + self._add_polymer_beads(beads=beads, mol_id=mol_id, mol=mol) shared_data["beads"] = beads return {"beads": beads} + + def _add_united_atom_beads( + self, beads: MutableMapping[BeadKey, List[np.ndarray]], mol_id: int, mol + ) -> None: + """Compute and store united-atom beads grouped into residue buckets. + + Args: + beads: Output bead mapping mutated in-place. + mol_id: Molecule (fragment) index. + mol: MDAnalysis AtomGroup representing the molecule. + """ + ua_beads = self._hier.get_beads(mol, "united_atom") + + buckets: DefaultDict[int, List[np.ndarray]] = defaultdict(list) + for bead_i, bead in enumerate(ua_beads): + atom_indices = self._validate_bead_indices( + bead, mol_id=mol_id, level="united_atom", bead_i=bead_i + ) + if atom_indices is None: + continue + + residue_id = self._infer_local_residue_id(mol=mol, bead=bead) + buckets[residue_id].append(atom_indices) + + for local_res_id in range(len(mol.residues)): + beads[(mol_id, "united_atom", local_res_id)] = buckets.get(local_res_id, []) + + def _add_residue_beads( + self, beads: MutableMapping[BeadKey, List[np.ndarray]], mol_id: int, mol + ) -> None: + """Compute and store residue beads. + + Args: + beads: Output bead mapping mutated in-place. + mol_id: Molecule (fragment) index. + mol: MDAnalysis AtomGroup representing the molecule. + """ + res_beads = self._hier.get_beads(mol, "residue") + kept: List[np.ndarray] = [] + + for bead_i, bead in enumerate(res_beads): + atom_indices = self._validate_bead_indices( + bead, mol_id=mol_id, level="residue", bead_i=bead_i + ) + if atom_indices is None: + continue + kept.append(atom_indices) + + beads[(mol_id, "residue")] = kept + + if len(kept) == 0: + logger.error( + "[BuildBeadsNode] No residue beads kept for mol=%s. Residue-level " + "entropy may be 0.0.", + mol_id, + ) + + def _add_polymer_beads( + self, beads: MutableMapping[BeadKey, List[np.ndarray]], mol_id: int, mol + ) -> None: + """Compute and store polymer beads. + + Args: + beads: Output bead mapping mutated in-place. + mol_id: Molecule (fragment) index. + mol: MDAnalysis AtomGroup representing the molecule. + """ + poly_beads = self._hier.get_beads(mol, "polymer") + kept: List[np.ndarray] = [] + + for bead_i, bead in enumerate(poly_beads): + atom_indices = self._validate_bead_indices( + bead, mol_id=mol_id, level="polymer", bead_i=bead_i + ) + if atom_indices is None: + continue + kept.append(atom_indices) + + beads[(mol_id, "polymer")] = kept + + @staticmethod + def _validate_bead_indices( + bead, mol_id: int, level: str, bead_i: int + ) -> np.ndarray | None: + """Return a bead's atom indices, or None if the bead is empty. + + Args: + bead: MDAnalysis AtomGroup representing the bead. + mol_id: Molecule id used only for logging context. + level: Level name used only for logging context. + bead_i: Bead index used only for logging context. + + Returns: + A copy of the bead indices as a NumPy array, or None if the bead is empty. + """ + if len(bead) == 0: + logger.warning( + "[BuildBeadsNode] Empty bead skipped: mol=%s level=%s bead_i=%s", + mol_id, + level, + bead_i, + ) + return None + return bead.indices.copy() + + @staticmethod + def _infer_local_residue_id(mol, bead) -> int: + """Infer the local residue bucket for a united-atom bead. + + Strategy: + - Select heavy atoms in the bead (mass > 1.1). + - Use the first heavy atom's `resindex` (universe-level). + - Map that universe-level `resindex` back to the molecule's local residue + index by scanning `mol.residues`. + + Args: + mol: Molecule AtomGroup. + bead: United-atom bead AtomGroup. + + Returns: + Local residue index in [0, len(mol.residues) - 1]. Falls back to 0 if + mapping cannot be determined. + """ + heavy = bead.select_atoms("prop mass > 1.1") + if len(heavy) == 0: + return 0 + + target_resindex = int(heavy[0].resindex) + for local_i, res in enumerate(mol.residues): + if int(res.resindex) == target_resindex: + return local_i + + # Conservative fallback: bucket into residue 0 rather than dropping. + return 0 diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/compute_dihedrals.py index 30a98f5c..7675da46 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/compute_dihedrals.py @@ -1,36 +1,116 @@ +"""Compute conformational states for configurational entropy calculations. + +This module defines a static DAG node that scans the trajectory and builds +conformational state descriptors (united-atom and residue level). The resulting +states are stored in `shared_data` for later use by configurational entropy +calculations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + from CodeEntropy.levels.dihedral_tools import DihedralAnalysis +SharedData = Dict[str, Any] +ConformationalStates = Dict[str, Any] -class ComputeConformationalStatesNode: + +@dataclass(frozen=True) +class ConformationalStateConfig: + """Configuration for conformational state construction. + + Attributes: + start: Start frame index (inclusive). + end: End frame index (exclusive). + step: Frame stride. + bin_width: Histogram bin width in degrees. """ - Static node (runs once). Internally scans the trajectory to build conformational - states. + + start: int + end: int + step: int + bin_width: int + + +class ComputeConformationalStatesNode: + """Static node that computes conformational states from trajectory dihedrals. + Produces: - shared_data["conformational_states"] = {"ua": states_ua, "res": states_res} + shared_data["conformational_states"] = {"ua": states_ua, "res": states_res} + + Where: + - states_ua is a dict keyed by (group_id, local_residue_id) + - states_res is a list-like structure indexed by group_id (or equivalent) """ - def __init__(self, universe_operations): - self._dih = DihedralAnalysis(universe_operations=universe_operations) + def __init__(self, universe_operations: Any) -> None: + """Initialize the node. + + Args: + universe_operations: Object providing universe selection utilities used + by `DihedralAnalysis`. + """ + self._dihedral_analysis = DihedralAnalysis( + universe_operations=universe_operations + ) + + def run(self, shared_data: SharedData) -> Dict[str, ConformationalStates]: + """Compute conformational states and store them in shared_data. + + Args: + shared_data: Shared data dictionary. Requires: + - "reduced_universe" + - "levels" + - "groups" + - "start", "end", "step" + - "args" with attribute "bin_width" - def run(self, shared_data): + Returns: + Dict containing "conformational_states" (also written into shared_data). + + Raises: + KeyError: If required keys are missing. + AttributeError: If `shared_data["args"]` lacks `bin_width`. + """ u = shared_data["reduced_universe"] levels = shared_data["levels"] groups = shared_data["groups"] - start = shared_data["start"] - end = shared_data["end"] - step = shared_data["step"] - bin_width = shared_data["args"].bin_width + cfg = self._build_config(shared_data) - states_ua, states_res = self._dih.build_conformational_states( + states_ua, states_res = self._dihedral_analysis.build_conformational_states( data_container=u, levels=levels, groups=groups, - start=start, - end=end, - step=step, - bin_width=bin_width, + start=cfg.start, + end=cfg.end, + step=cfg.step, + bin_width=cfg.bin_width, ) - shared_data["conformational_states"] = {"ua": states_ua, "res": states_res} - return {"conformational_states": shared_data["conformational_states"]} + conformational_states: ConformationalStates = { + "ua": states_ua, + "res": states_res, + } + shared_data["conformational_states"] = conformational_states + return {"conformational_states": conformational_states} + + @staticmethod + def _build_config(shared_data: SharedData) -> ConformationalStateConfig: + """Extract and validate configuration from shared_data. + + Args: + shared_data: Shared data dictionary. + + Returns: + ConformationalStateConfig with normalized integer fields. + """ + start = int(shared_data["start"]) + end = int(shared_data["end"]) + step = int(shared_data["step"]) + bin_width = int(shared_data["args"].bin_width) + return ConformationalStateConfig( + start=start, end=end, step=step, bin_width=bin_width + ) diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index 595a2c65..3b518e4e 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -1,13 +1,65 @@ +"""Detect hierarchy levels present for each molecule in the reduced universe. + +This module defines a static DAG node responsible for determining which +hierarchical levels (united_atom, residue, polymer) apply to each molecule. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Tuple + from CodeEntropy.levels.level_hierarchy import LevelHierarchy +SharedData = Dict[str, Any] +Levels = List[List[str]] + class DetectLevelsNode: - def __init__(self): - self._hier = LevelHierarchy() + """Static node that determines hierarchy levels per molecule. + + Produces: + shared_data["levels"] + shared_data["number_molecules"] + """ + + def __init__(self) -> None: + """Initialize the node with a LevelHierarchy helper.""" + self._hierarchy = LevelHierarchy() + + def run(self, shared_data: SharedData) -> Dict[str, Any]: + """Detect levels and store results in shared_data. + + Args: + shared_data: Shared data dictionary. Requires: + - "reduced_universe" + + Returns: + Dict containing: + - "levels": List of levels per molecule. + - "number_molecules": Total molecule count. + + Raises: + KeyError: If required keys are missing. + """ + universe = shared_data["reduced_universe"] + + number_molecules, levels = self._detect_levels(universe) - def run(self, shared_data): - u = shared_data["reduced_universe"] - n_mol, levels = self._hier.select_levels(u) shared_data["levels"] = levels - shared_data["number_molecules"] = n_mol - return {"levels": levels, "number_molecules": n_mol} + shared_data["number_molecules"] = number_molecules + + return { + "levels": levels, + "number_molecules": number_molecules, + } + + def _detect_levels(self, universe: Any) -> Tuple[int, Levels]: + """Delegate level detection to LevelHierarchy. + + Args: + universe: Reduced MDAnalysis universe. + + Returns: + Tuple of molecule count and levels list. + """ + return self._hierarchy.select_levels(universe) diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index 4e0366f2..1ac8bf08 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -1,40 +1,113 @@ +"""Detect molecules and build grouping definitions for the reduced universe. + +This module defines a static DAG node responsible for ensuring a reduced +universe is available and generating molecule groupings using the configured +grouping strategy. +""" + +from __future__ import annotations + import logging +from typing import Any, Dict from CodeEntropy.group_molecules.group_molecules import GroupMolecules logger = logging.getLogger(__name__) +SharedData = Dict[str, Any] + class DetectMoleculesNode: - """ - Establish shared_data['reduced_universe'] and shared_data['groups']. + """Static node that establishes molecule groups. - Assumptions (matches what you've been running): - - shared_data already contains 'universe' - - reduced_universe is either already present or is the same as universe - - grouping uses your existing GroupMolecules implementation + Produces: + shared_data["reduced_universe"] + shared_data["groups"] + shared_data["number_molecules"] """ - def __init__(self): - self._group = GroupMolecules() + def __init__(self) -> None: + """Initialize the node with a molecule grouping helper.""" + self._grouping = GroupMolecules() - def run(self, shared_data): - u = shared_data.get("reduced_universe", None) - if u is None: - u = shared_data.get("universe", None) - if u is None: - raise KeyError("shared_data must contain 'universe'") - shared_data["reduced_universe"] = u + def run(self, shared_data: SharedData) -> Dict[str, Any]: + """Detect molecules and create grouping definitions. - args = shared_data["args"] - grouping = getattr(args, "grouping", "each") + Args: + shared_data: Shared data dictionary. Requires: + - "universe" + - "args" + + Returns: + Dict containing: + - "groups": Molecule grouping dictionary. + - "number_molecules": Total molecule count. + + Raises: + KeyError: If required keys are missing. + """ + universe = self._ensure_reduced_universe(shared_data) + + grouping_strategy = self._get_grouping_strategy(shared_data) + + groups = self._grouping.grouping_molecules(universe, grouping_strategy) + number_molecules = self._count_molecules(universe) - groups = self._group.grouping_molecules(u, grouping) shared_data["groups"] = groups - shared_data["number_molecules"] = len(u.atoms.fragments) + shared_data["number_molecules"] = number_molecules logger.info( - f"[DetectMoleculesNode] {shared_data['number_molecules']} " - "molecules detected (reduced_universe)" + "[DetectMoleculesNode] %s molecules detected (reduced_universe)", + number_molecules, ) - return {"groups": groups, "number_molecules": shared_data["number_molecules"]} + + return { + "groups": groups, + "number_molecules": number_molecules, + } + + def _ensure_reduced_universe(self, shared_data: SharedData) -> Any: + """Ensure reduced_universe exists in shared_data. + + Args: + shared_data: Shared data dictionary. + + Returns: + Reduced universe object. + + Raises: + KeyError: If no universe is available. + """ + universe = shared_data.get("reduced_universe") + + if universe is None: + universe = shared_data.get("universe") + if universe is None: + raise KeyError("shared_data must contain 'universe'") + shared_data["reduced_universe"] = universe + + return universe + + def _get_grouping_strategy(self, shared_data: SharedData) -> str: + """Extract grouping strategy from args. + + Args: + shared_data: Shared data dictionary. + + Returns: + Grouping strategy string. + """ + args = shared_data["args"] + return getattr(args, "grouping", "each") + + @staticmethod + def _count_molecules(universe: Any) -> int: + """Count molecules in the universe. + + Args: + universe: MDAnalysis universe. + + Returns: + Number of molecular fragments. + """ + return len(universe.atoms.fragments) diff --git a/CodeEntropy/levels/nodes/frame_axes.py b/CodeEntropy/levels/nodes/frame_axes.py index 13678e20..582c8de4 100644 --- a/CodeEntropy/levels/nodes/frame_axes.py +++ b/CodeEntropy/levels/nodes/frame_axes.py @@ -1,7 +1,14 @@ +"""Frame-local axes calculation. + +This module defines a frame DAG node that computes per-molecule translational +axes for a single trajectory frame and exposes an AxesManager for downstream +nodes that need consistent axes and PBC-aware vectors. +""" + from __future__ import annotations import logging -from typing import Any, Dict +from typing import Any, Dict, Mapping, MutableMapping, Tuple import numpy as np @@ -10,43 +17,143 @@ logger = logging.getLogger(__name__) +SharedData = MutableMapping[str, Any] +FrameContext = MutableMapping[str, Any] + class FrameAxesNode: - """ - Produces per-frame translational axes for each molecule. - Also exports the AxesManager into shared_data so torque can use PBC vectors. + """Compute per-frame translational axes for each molecule. + + This node operates in two modes: + + 1) Frame-DAG mode: + - Input is a frame context dict containing: + ctx["shared"] -> shared_data dict + ctx["frame_index"] -> absolute trajectory frame index + - Writes frame-local result to: + ctx["frame_axes"] + + 2) Static mode: + - Input is the shared_data dict directly. + - Uses shared_data["frame_index"] if present, otherwise defaults to 0. + - Returns the computed axes payload (and also provides it in a synthetic + frame context). + + Produces: + - shared_data["axes_manager"]: AxesManager instance for downstream nodes. + - frame_ctx["frame_axes"]: Dict containing: + { + "trans": {mol_id: np.ndarray shape (3, 3)}, + "custom": bool + } """ - def __init__(self, universe_operations: UniverseOperations | None = None): + def __init__(self, universe_operations: UniverseOperations | None = None) -> None: + """Initialize the node. + + Args: + universe_operations: Helper for universe operations. If None, a default + UniverseOperations instance is created. + """ self._universe_operations = universe_operations or UniverseOperations() self._axes_manager = AxesManager() def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: + """Run the axes calculation for a single frame. + + Args: + ctx: Either a frame context dict (Frame-DAG mode) or shared_data (static + mode). + + Returns: + A dict with translational axes and whether customized axes are enabled. + + Raises: + KeyError: If required data is missing. + """ + frame_ctx, shared, frame_index = self._resolve_context(ctx) + + universe = self._get_universe(shared) + use_custom_axes = self._use_custom_axes(shared) + + # Expose the AxesManager for downstream nodes (e.g., torque with PBC vectors). + shared["axes_manager"] = self._axes_manager + + # Ensure the universe is positioned at the correct frame before reading coords. + universe.trajectory[frame_index] + + trans_axes = self._compute_trans_axes(universe) + + result = {"trans": trans_axes, "custom": use_custom_axes} + frame_ctx["frame_axes"] = result + return result + + def _resolve_context( + self, ctx: Dict[str, Any] + ) -> Tuple[FrameContext, SharedData, int]: + """Resolve whether `ctx` is a frame context or shared_data. + + Args: + ctx: Frame context dict or shared_data dict. + + Returns: + Tuple of (frame_ctx, shared_data, frame_index). + """ if "shared" in ctx: - frame_ctx = ctx - shared = frame_ctx["shared"] - frame_index = frame_ctx["frame_index"] - else: - shared = ctx - frame_index = shared.get("frame_index", shared.get("time_index", 0)) - frame_ctx = {"shared": shared, "frame_index": frame_index} - - u = shared.get("reduced_universe", shared.get("universe")) - if u is None: + shared = ctx["shared"] + frame_index = int(ctx["frame_index"]) + return ctx, shared, frame_index + + shared = ctx + frame_index = int(shared.get("frame_index", shared.get("time_index", 0))) + frame_ctx: FrameContext = {"shared": shared, "frame_index": frame_index} + return frame_ctx, shared, frame_index + + @staticmethod + def _get_universe(shared: Mapping[str, Any]) -> Any: + """Fetch the universe to operate on. + + Args: + shared: Shared data mapping. + + Returns: + MDAnalysis universe-like object. + + Raises: + KeyError: If neither reduced_universe nor universe is present. + """ + universe = shared.get("reduced_universe") or shared.get("universe") + if universe is None: raise KeyError("shared_data must contain 'reduced_universe' or 'universe'") + return universe + @staticmethod + def _use_custom_axes(shared: Mapping[str, Any]) -> bool: + """Determine whether customized axes are enabled. + + Args: + shared: Shared data mapping. + + Returns: + True if customized axes are enabled. + """ args = shared["args"] - use_custom = bool(getattr(args, "customised_axes", False)) + return bool(getattr(args, "customised_axes", False)) - shared["axes_manager"] = self._axes_manager + def _compute_trans_axes(self, universe: Any) -> Dict[int, np.ndarray]: + """Compute translational axes for each molecule in the universe. - u.trajectory[frame_index] + Args: + universe: MDAnalysis universe-like object. + Returns: + Mapping from molecule id to translational axes (3x3). + """ trans_axes: Dict[int, np.ndarray] = {} - fragments = u.atoms.fragments + fragments = universe.atoms.fragments for mol_id, mol in enumerate(fragments): - _, trans_axes[mol_id] = self._axes_manager.get_vanilla_axes(mol) + _, axes = self._axes_manager.get_vanilla_axes(mol) + trans_axes[mol_id] = np.asarray(axes, dtype=float) - frame_ctx["frame_axes"] = {"trans": trans_axes, "custom": use_custom} - return frame_ctx["frame_axes"] + return trans_axes diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index a4dc4051..4a590467 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -1,5 +1,25 @@ +"""Frame-level covariance (second-moment) construction. + +This module computes per-frame second-moment matrices for force and torque +vectors at each hierarchy level (united_atom, residue, polymer). Results are +incrementally averaged across molecules within a group for the current frame. + +Responsibilities: +- Build bead-level force/torque vectors using ForceTorqueManager. +- Construct per-frame force/torque second moments (outer products). +- Optionally construct combined force-torque block matrices. +- Average per-frame matrices across molecules in the same group. + +Not responsible for: +- Defining groups/levels/beads mapping (provided via shared context). +- Axis construction policy (delegated to axes_manager when present). +- Accumulating across frames (handled by the higher-level reducer). +""" + +from __future__ import annotations + import logging -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import numpy as np from MDAnalysis.lib.mdamath import make_whole @@ -9,77 +29,49 @@ logger = logging.getLogger(__name__) -class FrameCovarianceNode: - def __init__(self): - self._ft = ForceTorqueManager() +FrameCtx = Dict[str, Any] +Matrix = np.ndarray - @staticmethod - def _inc_mean(old: np.ndarray | None, new: np.ndarray, n: int) -> np.ndarray: - """Incremental mean over molecules within the same frame.""" - if old is None: - return new.copy() - return old + (new - old) / float(n) - @staticmethod - def _build_ft_block_procedural(force_vecs, torque_vecs) -> np.ndarray: - """ - Match procedural get_combined_forcetorque_matrices: - - per bead vector is [Fi, Ti] - - subblock(i,j) = outer([Fi,Ti], [Fj,Tj]) - - assemble np.block over beads - """ - if len(force_vecs) != len(torque_vecs): - raise ValueError("force_vecs and torque_vecs must match length") - - n = len(force_vecs) - if n == 0: - raise ValueError("No beads provided for FT matrix build") +class FrameCovarianceNode: + """Build per-frame covariance-like (second-moment) matrices for each group.""" - bead_vecs: List[np.ndarray] = [] - for Fi, Ti in zip(force_vecs, torque_vecs): - Fi = np.asarray(Fi, dtype=float).reshape(-1) - Ti = np.asarray(Ti, dtype=float).reshape(-1) - if Fi.shape[0] != 3 or Ti.shape[0] != 3: - raise ValueError("Each force/torque must be length 3") - bead_vecs.append(np.concatenate([Fi, Ti], axis=0)) + def __init__(self) -> None: + self._ft = ForceTorqueManager() - blocks = [[None] * n for _ in range(n)] - for i in range(n): - for j in range(i, n): - sub = np.outer(bead_vecs[i], bead_vecs[j]) - blocks[i][j] = sub - blocks[j][i] = sub.T + def run(self, ctx: FrameCtx) -> Dict[str, Any]: + """Compute and store per-frame force/torque (and optional FT) matrices. - return np.block(blocks) + Args: + ctx: Frame context dict expected to include: + - "shared": dict containing reduced_universe, groups, levels, beads, + args + - may include axes_manager - def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: - if "shared" not in ctx: - raise KeyError("FrameCovarianceNode expects ctx['shared'].") + Returns: + The frame covariance payload also stored at ctx["frame_covariance"]. - shared = ctx["shared"] + Raises: + KeyError: If ctx is missing required fields. + """ + shared = self._get_shared(ctx) u = shared["reduced_universe"] - groups = shared["groups"] levels = shared["levels"] beads = shared["beads"] args = shared["args"] - fp = args.force_partitioning + fp = float(args.force_partitioning) combined = bool(getattr(args, "combined_forcetorque", False)) customised_axes = bool(getattr(args, "customised_axes", False)) + axes_manager = shared.get("axes_manager") - axes_manager = shared.get("axes_manager", None) - - try: - box = np.asarray(u.dimensions[:3], dtype=float) - except Exception: - box = None - + box = self._try_get_box(u) fragments = u.atoms.fragments - out_force: Dict[str, Dict[Any, np.ndarray]] = {"ua": {}, "res": {}, "poly": {}} - out_torque: Dict[str, Dict[Any, np.ndarray]] = {"ua": {}, "res": {}, "poly": {}} - out_ft: Dict[str, Dict[Any, np.ndarray]] | None = ( + out_force: Dict[str, Dict[Any, Matrix]] = {"ua": {}, "res": {}, "poly": {}} + out_torque: Dict[str, Dict[Any, Matrix]] = {"ua": {}, "res": {}, "poly": {}} + out_ft: Optional[Dict[str, Dict[Any, Matrix]]] = ( {"ua": {}, "res": {}, "poly": {}} if combined else None ) @@ -93,200 +85,56 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: level_list = levels[mol_id] if "united_atom" in level_list: - for local_res_i, res in enumerate(mol.residues): - bead_key = (mol_id, "united_atom", local_res_i) - bead_idx_list = beads.get(bead_key, []) - if not bead_idx_list: - continue - - bead_groups = [u.atoms[idx] for idx in bead_idx_list] - if any(len(bg) == 0 for bg in bead_groups): - continue - - force_vecs = [] - torque_vecs = [] - - for ua_i, bead in enumerate(bead_groups): - trans_axes, rot_axes, center, moi = ( - axes_manager.get_UA_axes(res.atoms, ua_i) - ) - - force_vecs.append( - self._ft.get_weighted_forces( - bead=bead, - trans_axes=np.asarray(trans_axes), - highest_level=False, - force_partitioning=fp, - ) - ) - torque_vecs.append( - self._ft.get_weighted_torques( - bead=bead, - rot_axes=np.asarray(rot_axes), - center=np.asarray(center), - force_partitioning=fp, - moment_of_inertia=np.asarray(moi), - axes_manager=axes_manager, - box=box, - ) - ) - - F, T = self._ft.compute_frame_covariance( - force_vecs, torque_vecs - ) - - key = (group_id, local_res_i) - - n = ua_molcount.get(key, 0) + 1 - out_force["ua"][key] = self._inc_mean( - out_force["ua"].get(key), F, n - ) - out_torque["ua"][key] = self._inc_mean( - out_torque["ua"].get(key), T, n - ) - ua_molcount[key] = n + self._process_united_atom( + u=u, + mol=mol, + mol_id=mol_id, + group_id=group_id, + beads=beads, + axes_manager=axes_manager, + box=box, + force_partitioning=fp, + out_force=out_force, + out_torque=out_torque, + molcount=ua_molcount, + ) if "residue" in level_list: - bead_key = (mol_id, "residue") - bead_idx_list = beads.get(bead_key, []) - if bead_idx_list: - bead_groups = [u.atoms[idx] for idx in bead_idx_list] - if not any(len(bg) == 0 for bg in bead_groups): - force_vecs = [] - torque_vecs = [] - - highest = "residue" == level_list[-1] - - for local_res_i, bead in enumerate(bead_groups): - if customised_axes and axes_manager is not None: - res = mol.residues[local_res_i] - trans_axes, rot_axes, center, moi = ( - axes_manager.get_residue_axes( - mol, local_res_i, residue=res.atoms - ) - ) - else: - make_whole(mol.atoms) - make_whole(bead) - trans_axes = mol.atoms.principal_axes() - if axes_manager is not None: - rot_axes, moi = axes_manager.get_vanilla_axes( - bead - ) - else: - rot_axes = np.real(bead.principal_axes()) - eigvals, _ = np.linalg.eig( - bead.moment_of_inertia(unwrap=True) - ) - moi = sorted(eigvals, reverse=True) - center = bead.center_of_mass(unwrap=True) - - force_vecs.append( - self._ft.get_weighted_forces( - bead=bead, - trans_axes=np.asarray(trans_axes), - highest_level=highest, - force_partitioning=fp, - ) - ) - torque_vecs.append( - self._ft.get_weighted_torques( - bead=bead, - rot_axes=np.asarray(rot_axes), - center=np.asarray(center), - force_partitioning=fp, - moment_of_inertia=np.asarray(moi), - axes_manager=axes_manager, - box=box, - ) - ) - - F, T = self._ft.compute_frame_covariance( - force_vecs, torque_vecs - ) - - n = res_molcount.get(group_id, 0) + 1 - out_force["res"][group_id] = self._inc_mean( - out_force["res"].get(group_id), F, n - ) - out_torque["res"][group_id] = self._inc_mean( - out_torque["res"].get(group_id), T, n - ) - res_molcount[group_id] = n - - if combined and highest and out_ft is not None: - M = self._build_ft_block_procedural( - force_vecs, torque_vecs - ) - out_ft["res"][group_id] = self._inc_mean( - out_ft["res"].get(group_id), M, n - ) + self._process_residue( + u=u, + mol=mol, + mol_id=mol_id, + group_id=group_id, + beads=beads, + axes_manager=axes_manager, + box=box, + customised_axes=customised_axes, + force_partitioning=fp, + is_highest=("residue" == level_list[-1]), + out_force=out_force, + out_torque=out_torque, + out_ft=out_ft, + molcount=res_molcount, + combined=combined, + ) if "polymer" in level_list: - bead_key = (mol_id, "polymer") - bead_idx_list = beads.get(bead_key, []) - if bead_idx_list: - bead_groups = [u.atoms[idx] for idx in bead_idx_list] - if not any(len(bg) == 0 for bg in bead_groups): - bead = bead_groups[0] - - highest = "polymer" == level_list[-1] - - if axes_manager is not None: - rot_axes, moi = axes_manager.get_vanilla_axes(bead) - trans_axes = mol.atoms.principal_axes() - center = bead.center_of_mass(unwrap=True) - else: - make_whole(mol.atoms) - make_whole(bead) - trans_axes = mol.atoms.principal_axes() - rot_axes = np.real(bead.principal_axes()) - eigvals, _ = np.linalg.eig( - bead.moment_of_inertia(unwrap=True) - ) - moi = sorted(eigvals, reverse=True) - center = bead.center_of_mass(unwrap=True) - - force_vecs = [ - self._ft.get_weighted_forces( - bead=bead, - trans_axes=np.asarray(trans_axes), - highest_level=highest, - force_partitioning=fp, - ) - ] - torque_vecs = [ - self._ft.get_weighted_torques( - bead=bead, - rot_axes=np.asarray(rot_axes), - center=np.asarray(center), - force_partitioning=fp, - moment_of_inertia=np.asarray(moi), - axes_manager=axes_manager, - box=box, - ) - ] - - F, T = self._ft.compute_frame_covariance( - force_vecs, torque_vecs - ) - - n = poly_molcount.get(group_id, 0) + 1 - out_force["poly"][group_id] = self._inc_mean( - out_force["poly"].get(group_id), F, n - ) - out_torque["poly"][group_id] = self._inc_mean( - out_torque["poly"].get(group_id), T, n - ) - poly_molcount[group_id] = n - - if combined and highest and out_ft is not None: - M = self._build_ft_block_procedural( - force_vecs, torque_vecs - ) - out_ft["poly"][group_id] = self._inc_mean( - out_ft["poly"].get(group_id), M, n - ) + self._process_polymer( + u=u, + mol=mol, + mol_id=mol_id, + group_id=group_id, + beads=beads, + axes_manager=axes_manager, + box=box, + force_partitioning=fp, + is_highest=("polymer" == level_list[-1]), + out_force=out_force, + out_torque=out_torque, + out_ft=out_ft, + molcount=poly_molcount, + combined=combined, + ) frame_cov = {"force": out_force, "torque": out_torque} if combined and out_ft is not None: @@ -294,3 +142,396 @@ def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: ctx["frame_covariance"] = frame_cov return frame_cov + + def _process_united_atom( + self, + *, + u: Any, + mol: Any, + mol_id: int, + group_id: int, + beads: Dict[Any, List[Any]], + axes_manager: Any, + box: Optional[np.ndarray], + force_partitioning: float, + out_force: Dict[str, Dict[Any, Matrix]], + out_torque: Dict[str, Dict[Any, Matrix]], + molcount: Dict[Tuple[int, int], int], + ) -> None: + """Compute UA-level per-residue force/torque second moments for one molecule.""" + for local_res_i, res in enumerate(mol.residues): + bead_key = (mol_id, "united_atom", local_res_i) + bead_idx_list = beads.get(bead_key, []) + if not bead_idx_list: + continue + + bead_groups = [u.atoms[idx] for idx in bead_idx_list] + if any(len(bg) == 0 for bg in bead_groups): + continue + + force_vecs, torque_vecs = self._build_ua_vectors( + bead_groups=bead_groups, + residue_atoms=res.atoms, + axes_manager=axes_manager, + box=box, + force_partitioning=force_partitioning, + ) + + F, T = self._ft.compute_frame_covariance(force_vecs, torque_vecs) + + key = (group_id, local_res_i) + n = molcount.get(key, 0) + 1 + out_force["ua"][key] = self._inc_mean(out_force["ua"].get(key), F, n) + out_torque["ua"][key] = self._inc_mean(out_torque["ua"].get(key), T, n) + molcount[key] = n + + def _process_residue( + self, + *, + u: Any, + mol: Any, + mol_id: int, + group_id: int, + beads: Dict[Any, List[Any]], + axes_manager: Any, + box: Optional[np.ndarray], + customised_axes: bool, + force_partitioning: float, + is_highest: bool, + out_force: Dict[str, Dict[Any, Matrix]], + out_torque: Dict[str, Dict[Any, Matrix]], + out_ft: Optional[Dict[str, Dict[Any, Matrix]]], + molcount: Dict[int, int], + combined: bool, + ) -> None: + """ + Compute residue-level force/torque (and optional FT) moments for one molecule. + """ + bead_key = (mol_id, "residue") + bead_idx_list = beads.get(bead_key, []) + if not bead_idx_list: + return + + bead_groups = [u.atoms[idx] for idx in bead_idx_list] + if any(len(bg) == 0 for bg in bead_groups): + return + + force_vecs, torque_vecs = self._build_residue_vectors( + mol=mol, + bead_groups=bead_groups, + axes_manager=axes_manager, + box=box, + customised_axes=customised_axes, + force_partitioning=force_partitioning, + is_highest=is_highest, + ) + + F, T = self._ft.compute_frame_covariance(force_vecs, torque_vecs) + + n = molcount.get(group_id, 0) + 1 + out_force["res"][group_id] = self._inc_mean( + out_force["res"].get(group_id), F, n + ) + out_torque["res"][group_id] = self._inc_mean( + out_torque["res"].get(group_id), T, n + ) + molcount[group_id] = n + + if combined and is_highest and out_ft is not None: + M = self._build_ft_block(force_vecs, torque_vecs) + out_ft["res"][group_id] = self._inc_mean(out_ft["res"].get(group_id), M, n) + + def _process_polymer( + self, + *, + u: Any, + mol: Any, + mol_id: int, + group_id: int, + beads: Dict[Any, List[Any]], + axes_manager: Any, + box: Optional[np.ndarray], + force_partitioning: float, + is_highest: bool, + out_force: Dict[str, Dict[Any, Matrix]], + out_torque: Dict[str, Dict[Any, Matrix]], + out_ft: Optional[Dict[str, Dict[Any, Matrix]]], + molcount: Dict[int, int], + combined: bool, + ) -> None: + """ + Compute polymer-level force/torque (and optional FT) moments for one molecule. + """ + bead_key = (mol_id, "polymer") + bead_idx_list = beads.get(bead_key, []) + if not bead_idx_list: + return + + bead_groups = [u.atoms[idx] for idx in bead_idx_list] + if any(len(bg) == 0 for bg in bead_groups): + return + + bead = bead_groups[0] + + trans_axes, rot_axes, center, moi = self._get_polymer_axes( + mol=mol, bead=bead, axes_manager=axes_manager + ) + + force_vecs = [ + self._ft.get_weighted_forces( + bead=bead, + trans_axes=np.asarray(trans_axes), + highest_level=is_highest, + force_partitioning=force_partitioning, + ) + ] + torque_vecs = [ + self._ft.get_weighted_torques( + bead=bead, + rot_axes=np.asarray(rot_axes), + center=np.asarray(center), + force_partitioning=force_partitioning, + moment_of_inertia=np.asarray(moi), + axes_manager=axes_manager, + box=box, + ) + ] + + F, T = self._ft.compute_frame_covariance(force_vecs, torque_vecs) + + n = molcount.get(group_id, 0) + 1 + out_force["poly"][group_id] = self._inc_mean( + out_force["poly"].get(group_id), F, n + ) + out_torque["poly"][group_id] = self._inc_mean( + out_torque["poly"].get(group_id), T, n + ) + molcount[group_id] = n + + if combined and is_highest and out_ft is not None: + M = self._build_ft_block(force_vecs, torque_vecs) + out_ft["poly"][group_id] = self._inc_mean( + out_ft["poly"].get(group_id), M, n + ) + + def _build_ua_vectors( + self, + *, + bead_groups: List[Any], + residue_atoms: Any, + axes_manager: Any, + box: Optional[np.ndarray], + force_partitioning: float, + ) -> Tuple[List[np.ndarray], List[np.ndarray]]: + """Build force/torque vectors for UA beads belonging to a single residue.""" + force_vecs: List[np.ndarray] = [] + torque_vecs: List[np.ndarray] = [] + + for ua_i, bead in enumerate(bead_groups): + trans_axes, rot_axes, center, moi = axes_manager.get_UA_axes( + residue_atoms, ua_i + ) + + force_vecs.append( + self._ft.get_weighted_forces( + bead=bead, + trans_axes=np.asarray(trans_axes), + highest_level=False, + force_partitioning=force_partitioning, + ) + ) + torque_vecs.append( + self._ft.get_weighted_torques( + bead=bead, + rot_axes=np.asarray(rot_axes), + center=np.asarray(center), + force_partitioning=force_partitioning, + moment_of_inertia=np.asarray(moi), + axes_manager=axes_manager, + box=box, + ) + ) + + return force_vecs, torque_vecs + + def _build_residue_vectors( + self, + *, + mol: Any, + bead_groups: List[Any], + axes_manager: Any, + box: Optional[np.ndarray], + customised_axes: bool, + force_partitioning: float, + is_highest: bool, + ) -> Tuple[List[np.ndarray], List[np.ndarray]]: + """Build force/torque vectors for residue-level beads of one molecule.""" + force_vecs: List[np.ndarray] = [] + torque_vecs: List[np.ndarray] = [] + + for local_res_i, bead in enumerate(bead_groups): + trans_axes, rot_axes, center, moi = self._get_residue_axes( + mol=mol, + bead=bead, + local_res_i=local_res_i, + axes_manager=axes_manager, + customised_axes=customised_axes, + ) + + force_vecs.append( + self._ft.get_weighted_forces( + bead=bead, + trans_axes=np.asarray(trans_axes), + highest_level=is_highest, + force_partitioning=force_partitioning, + ) + ) + torque_vecs.append( + self._ft.get_weighted_torques( + bead=bead, + rot_axes=np.asarray(rot_axes), + center=np.asarray(center), + force_partitioning=force_partitioning, + moment_of_inertia=np.asarray(moi), + axes_manager=axes_manager, + box=box, + ) + ) + + return force_vecs, torque_vecs + + def _get_residue_axes( + self, + *, + mol: Any, + bead: Any, + local_res_i: int, + axes_manager: Any, + customised_axes: bool, + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Get translation/rotation axes, center and MOI for a residue bead.""" + if customised_axes and axes_manager is not None: + res = mol.residues[local_res_i] + return axes_manager.get_residue_axes(mol, local_res_i, residue=res.atoms) + + make_whole(mol.atoms) + make_whole(bead) + + trans_axes = mol.atoms.principal_axes() + + if axes_manager is not None: + rot_axes, moi = axes_manager.get_vanilla_axes(bead) + else: + rot_axes = np.real(bead.principal_axes()) + eigvals, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) + moi = sorted(eigvals, reverse=True) + + center = bead.center_of_mass(unwrap=True) + return ( + np.asarray(trans_axes), + np.asarray(rot_axes), + np.asarray(center), + np.asarray(moi), + ) + + def _get_polymer_axes( + self, + *, + mol: Any, + bead: Any, + axes_manager: Any, + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Get translation/rotation axes, center and MOI for a polymer bead.""" + make_whole(mol.atoms) + make_whole(bead) + + trans_axes = mol.atoms.principal_axes() + + if axes_manager is not None: + rot_axes, moi = axes_manager.get_vanilla_axes(bead) + else: + rot_axes = np.real(bead.principal_axes()) + eigvals, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) + moi = sorted(eigvals, reverse=True) + + center = bead.center_of_mass(unwrap=True) + return ( + np.asarray(trans_axes), + np.asarray(rot_axes), + np.asarray(center), + np.asarray(moi), + ) + + @staticmethod + def _get_shared(ctx: FrameCtx) -> Dict[str, Any]: + """Fetch shared context from a frame context dict.""" + if "shared" not in ctx: + raise KeyError("FrameCovarianceNode expects ctx['shared'].") + return ctx["shared"] + + @staticmethod + def _try_get_box(u: Any) -> Optional[np.ndarray]: + """Extract a (3,) box vector from an MDAnalysis universe when available.""" + try: + return np.asarray(u.dimensions[:3], dtype=float) + except Exception: + return None + + @staticmethod + def _inc_mean(old: Optional[np.ndarray], new: np.ndarray, n: int) -> np.ndarray: + """Compute an incremental mean (streaming average). + + Args: + old: Existing mean matrix or None. + new: New sample matrix. + n: 1-indexed number of samples incorporated into the mean. + + Returns: + Updated mean matrix. + """ + if old is None: + return new.copy() + return old + (new - old) / float(n) + + @staticmethod + def _build_ft_block( + force_vecs: List[np.ndarray], torque_vecs: List[np.ndarray] + ) -> np.ndarray: + """Build a combined force-torque block matrix for a frame. + + For each bead i, create a 6-vector [Fi, Ti]. The block matrix is built + from outer products of these 6-vectors. + + Args: + force_vecs: List of force vectors, each shape (3,). + torque_vecs: List of torque vectors, each shape (3,). + + Returns: + Block matrix of shape (6N, 6N). + + Raises: + ValueError: If vector sizes are invalid. + """ + if len(force_vecs) != len(torque_vecs): + raise ValueError("force_vecs and torque_vecs must have the same length.") + + n = len(force_vecs) + if n == 0: + raise ValueError("No bead vectors available to build an FT matrix.") + + bead_vecs: List[np.ndarray] = [] + for Fi, Ti in zip(force_vecs, torque_vecs): + Fi = np.asarray(Fi, dtype=float).reshape(-1) + Ti = np.asarray(Ti, dtype=float).reshape(-1) + if Fi.size != 3 or Ti.size != 3: + raise ValueError("Each force/torque vector must be length 3.") + bead_vecs.append(np.concatenate([Fi, Ti], axis=0)) + + blocks: List[List[np.ndarray]] = [[None] * n for _ in range(n)] + for i in range(n): + for j in range(i, n): + sub = np.outer(bead_vecs[i], bead_vecs[j]) + blocks[i][j] = sub + blocks[j][i] = sub.T + + return np.block(blocks) diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/init_covariance_accumulators.py index daa43b43..3a7aef69 100644 --- a/CodeEntropy/levels/nodes/init_covariance_accumulators.py +++ b/CodeEntropy/levels/nodes/init_covariance_accumulators.py @@ -1,37 +1,127 @@ +"""Initialize covariance accumulators. + +This module defines a LevelDAG static node that allocates all per-frame reduction +accumulators (means) and counters used by downstream frame processing. + +The node owns only initialization concerns (single responsibility): +- create group-id <-> index mappings +- allocate force/torque covariance mean containers +- allocate optional combined force-torque (FT) mean containers +- allocate per-level frame counters + +The structure created here is treated as the canonical storage layout for the +rest of the pipeline. +""" + +from __future__ import annotations + import logging -from typing import Any, Dict +from dataclasses import dataclass +from typing import Any, Dict, List, MutableMapping import numpy as np logger = logging.getLogger(__name__) +SharedData = MutableMapping[str, Any] + + +@dataclass(frozen=True) +class GroupIndex: + """Bidirectional mapping between group ids and contiguous indices.""" + + group_id_to_index: Dict[int, int] + index_to_group_id: List[int] + + +@dataclass(frozen=True) +class CovarianceAccumulators: + """Container for covariance mean accumulators and frame counters.""" + + force_covariances: Dict[str, Any] + torque_covariances: Dict[str, Any] + frame_counts: Dict[str, Any] + forcetorque_covariances: Dict[str, Any] + forcetorque_counts: Dict[str, Any] + class InitCovarianceAccumulatorsNode: - """ - Allocate accumulators for per-frame reductions. + """Allocate accumulators and counters for per-frame reductions. + + Produces the following keys in `shared_data`: Canonical mean accumulators: - shared_data["force_covariances"] - shared_data["torque_covariances"] - shared_data["forcetorque_covariances"] # 6N x 6N mean (highest level only) + - force_covariances: {"ua": dict, "res": list, "poly": list} + - torque_covariances: {"ua": dict, "res": list, "poly": list} + - forcetorque_covariances: {"res": list, "poly": list} (6N x 6N means) Counters: - shared_data["frame_counts"] - shared_data["forcetorque_counts"] + - frame_counts: {"ua": dict, "res": np.ndarray[int], "poly": np.ndarray[int]} + - forcetorque_counts: {"res": np.ndarray[int], "poly": np.ndarray[int]} + + Group index mapping: + - group_id_to_index: {group_id: index} + - index_to_group_id: [group_id_by_index] - Backwards-compatible aliases: - shared_data["force_torque_stats"] -> shared_data["forcetorque_covariances"] - shared_data["force_torque_counts"] -> shared_data["forcetorque_counts"] + Backwards-compatible aliases (kept for older consumers): + - force_torque_stats -> forcetorque_covariances + - force_torque_counts -> forcetorque_counts """ def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: + """Initialize and attach all accumulator structures into shared_data. + + Args: + shared_data: Shared pipeline dictionary. Must contain "groups". + + Returns: + A dict of keys written into shared_data. + + Raises: + KeyError: If "groups" is missing from shared_data. + """ groups = shared_data["groups"] - group_ids = list(groups.keys()) - n_groups = len(group_ids) + group_index = self._build_group_index(groups) + + accumulators = self._build_accumulators( + n_groups=len(group_index.index_to_group_id) + ) + + self._attach_to_shared_data(shared_data, group_index, accumulators) + self._attach_backwards_compatible_aliases(shared_data) + + logger.info( + "[InitCovAcc] group_ids=%s gid2i=%s", + group_index.index_to_group_id, + group_index.group_id_to_index, + ) + return self._build_return_payload(shared_data) + + @staticmethod + def _build_group_index(groups: Dict[int, Any]) -> GroupIndex: + """Build group id <-> index mappings. + + Args: + groups: Mapping of group id to group members. + + Returns: + GroupIndex mapping object. + """ + group_ids = list(groups.keys()) gid2i = {gid: i for i, gid in enumerate(group_ids)} - i2gid = list(group_ids) + return GroupIndex(group_id_to_index=gid2i, index_to_group_id=list(group_ids)) + @staticmethod + def _build_accumulators(n_groups: int) -> CovarianceAccumulators: + """Allocate empty covariance means and counters. + + Args: + n_groups: Number of molecule groups. + + Returns: + CovarianceAccumulators containing allocated containers. + """ force_cov = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} torque_cov = {"ua": {}, "res": [None] * n_groups, "poly": [None] * n_groups} @@ -41,38 +131,69 @@ def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: "poly": np.zeros(n_groups, dtype=int), } - forcetorque_cov = { - "res": [None] * n_groups, - "poly": [None] * n_groups, - } + forcetorque_cov = {"res": [None] * n_groups, "poly": [None] * n_groups} forcetorque_counts = { "res": np.zeros(n_groups, dtype=int), "poly": np.zeros(n_groups, dtype=int), } - shared_data["group_id_to_index"] = gid2i - shared_data["index_to_group_id"] = i2gid - - shared_data["force_covariances"] = force_cov - shared_data["torque_covariances"] = torque_cov - shared_data["frame_counts"] = frame_counts - - shared_data["forcetorque_covariances"] = forcetorque_cov - shared_data["forcetorque_counts"] = forcetorque_counts - - shared_data["force_torque_stats"] = forcetorque_cov - shared_data["force_torque_counts"] = forcetorque_counts - - logger.info(f"[InitCovAcc] group_ids={group_ids} gid2i={gid2i}") - + return CovarianceAccumulators( + force_covariances=force_cov, + torque_covariances=torque_cov, + frame_counts=frame_counts, + forcetorque_covariances=forcetorque_cov, + forcetorque_counts=forcetorque_counts, + ) + + @staticmethod + def _attach_to_shared_data( + shared_data: SharedData, group_index: GroupIndex, acc: CovarianceAccumulators + ) -> None: + """Attach canonical structures to shared_data. + + Args: + shared_data: Shared pipeline dictionary. + group_index: GroupIndex object. + acc: CovarianceAccumulators object. + """ + shared_data["group_id_to_index"] = group_index.group_id_to_index + shared_data["index_to_group_id"] = group_index.index_to_group_id + + shared_data["force_covariances"] = acc.force_covariances + shared_data["torque_covariances"] = acc.torque_covariances + shared_data["frame_counts"] = acc.frame_counts + + shared_data["forcetorque_covariances"] = acc.forcetorque_covariances + shared_data["forcetorque_counts"] = acc.forcetorque_counts + + @staticmethod + def _attach_backwards_compatible_aliases(shared_data: SharedData) -> None: + """Attach backwards-compatible aliases. + + Args: + shared_data: Shared pipeline dictionary. + """ + shared_data["force_torque_stats"] = shared_data["forcetorque_covariances"] + shared_data["force_torque_counts"] = shared_data["forcetorque_counts"] + + @staticmethod + def _build_return_payload(shared_data: SharedData) -> Dict[str, Any]: + """Build the return payload containing initialized keys. + + Args: + shared_data: Shared pipeline dictionary. + + Returns: + Dict of keys to values that were set in shared_data. + """ return { - "group_id_to_index": gid2i, - "index_to_group_id": i2gid, - "force_covariances": force_cov, - "torque_covariances": torque_cov, - "frame_counts": frame_counts, - "forcetorque_covariances": forcetorque_cov, - "forcetorque_counts": forcetorque_counts, - "force_torque_stats": forcetorque_cov, - "force_torque_counts": forcetorque_counts, + "group_id_to_index": shared_data["group_id_to_index"], + "index_to_group_id": shared_data["index_to_group_id"], + "force_covariances": shared_data["force_covariances"], + "torque_covariances": shared_data["torque_covariances"], + "frame_counts": shared_data["frame_counts"], + "forcetorque_covariances": shared_data["forcetorque_covariances"], + "forcetorque_counts": shared_data["forcetorque_counts"], + "force_torque_stats": shared_data["force_torque_stats"], + "force_torque_counts": shared_data["force_torque_counts"], } From 62b9a528723f7f5db179c488316c7bcd94e9e82a Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:06:44 +0000 Subject: [PATCH 051/101] update the `main.py` to use Google Doc-Strings --- CodeEntropy/main.py | 41 ++++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/CodeEntropy/main.py b/CodeEntropy/main.py index 99e0d9ec..3f9c3fbe 100644 --- a/CodeEntropy/main.py +++ b/CodeEntropy/main.py @@ -1,28 +1,51 @@ +"""Command-line entry point for CodeEntropy. + +This module provides the program entry point used to run the multiscale cell +correlation entropy workflow. + +The entry point is intentionally small and only responsible for: + 1) Creating a job folder. + 2) Constructing a :class:`~CodeEntropy.config.run.RunManager`. + 3) Executing the entropy workflow. + 4) Handling fatal errors with a non-zero exit code. + +All scientific computation and I/O orchestration lives in RunManager and the +workflow components it coordinates. +""" + +from __future__ import annotations + import logging -import sys from CodeEntropy.config.run import RunManager logger = logging.getLogger(__name__) -def main(): - """ +def main() -> None: + """Run the entropy workflow. + Main function for calculating the entropy of a system using the multiscale cell correlation method. - """ - # Setup initial services + This function is the CLI entry point. It creates the output/job folder, then + delegates to :class:`~CodeEntropy.config.run.RunManager` to execute the full + workflow. + + Raises: + SystemExit: Exits with status code 1 on any unhandled exception. + """ folder = RunManager.create_job_folder() try: run_manager = RunManager(folder=folder) run_manager.run_entropy_workflow() - except Exception as e: - logger.critical(f"Fatal error during entropy calculation: {e}", exc_info=True) - sys.exit(1) + except Exception as exc: + logger.critical( + "Fatal error during entropy calculation: %s", exc, exc_info=True + ) + raise SystemExit(1) from exc if __name__ == "__main__": - main() # pragma: no cover From 56435e5a28cc705502484a5f95a562f1bf8a3694 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:08:16 +0000 Subject: [PATCH 052/101] update `axis.py` to use Google Doc-Strings --- CodeEntropy/axes.py | 570 ++++++++++++++++++++++++++------------------ 1 file changed, 342 insertions(+), 228 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index ae4dab9f..12cd34df 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -1,4 +1,14 @@ +"""Axes utilities for entropy calculations. + +This module contains the :class:`AxesManager`, a geometry-focused helper used by +the entropy pipeline to compute translational and rotational axes, centres, and +moments of inertia at different hierarchy levels (residue / united-atom). +""" + +from __future__ import annotations + import logging +from typing import Sequence, Tuple import numpy as np from MDAnalysis.lib.mdamath import make_whole @@ -7,20 +17,41 @@ class AxesManager: - """ - Manages the structural and dynamic levels involved in entropy calculations. This - includes selecting relevant levels, computing axes for translation and rotation, - and handling bead-based representations of molecular systems. Provides utility - methods to extract averaged positions, convert coordinates to spherical systems, - compute weighted forces and torques, and manipulate matrices used in entropy - analysis. + """Compute translation/rotation axes and inertia utilities used by entropy. + + Manages the structural and dynamic levels involved in entropy calculations. + This includes selecting relevant levels, computing axes for translation and + rotation, and handling bead-based representations of molecular systems. + + Provides utility methods to: + - extract averaged positions, + - convert coordinates to spherical systems (future/legacy scope), + - compute axes used to rotate forces around, + - compute custom moments of inertia, + - manipulate vectors under periodic boundary conditions (PBC), + - construct custom moment-of-inertia tensors and principal axes. + + Notes: + This class deliberately does **not**: + - compute weighted forces/torques (that belongs in ForceTorqueManager), + - build covariances, + - compute entropies. """ - def __init__(self): - """ - Initializes the AxesManager with placeholders for level-related data, - including translational and rotational axes, number of beads, and a - general-purpose data container. + def __init__(self) -> None: + """Initialize the AxesManager. + + The original implementation stored a few placeholders for level-related + data (axes, bead counts, etc.). In the current design, AxesManager is a + stateless helper, but we keep the attributes for compatibility and + debugging/extension. + + Attributes: + data_container: Optional container used by legacy workflows. + _levels: Optional levels list (legacy/placeholder). + _trans_axes: Optional cached translation axes (legacy/placeholder). + _rot_axes: Optional cached rotation axes (legacy/placeholder). + _number_of_beads: Optional bead count (legacy/placeholder). """ self.data_container = None self._levels = None @@ -28,19 +59,42 @@ def __init__(self): self._rot_axes = None self._number_of_beads = None - def get_residue_axes(self, data_container, index, residue=None): - """ + def get_residue_axes(self, data_container, index: int, residue=None): + """Compute residue-level translational and rotational axes. + The translational and rotational axes at the residue level. + - Identify the residue (either provided or selected by `resindex index`). + - Determine whether the residue is bonded to neighbouring residues + (previous/next in sequence) using MDAnalysis bonded selections. + - If there are *no* bonds to other residues: + * Use a custom principal axes, from a moment-of-inertia (MOI) tensor + that uses positions of heavy atoms only, but including masses of + heavy atom + bonded hydrogens. + * Set translational axes equal to rotational axes (as per the original + code convention). + - If bonded to other residues: + * Use default axes and MOI (MDAnalysis principal axes / inertia). + Args: - data_container (MDAnalysis.Universe): the molecule and trajectory data - index (int): residue index + data_container (MDAnalysis.Universe or AtomGroup): + Molecule and trajectory data (the fragment/molecule container). + index (int): + Residue index (resindex) within `data_container`. + residue (MDAnalysis.AtomGroup, optional): + If provided, this residue selection will be used rather than + selecting again. Returns: - trans_axes : translational axes (3,3) - rot_axes : rotational axes (3,3) - center: center of mass (3,) - moment_of_inertia: moment of inertia (3,) + Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + - trans_axes: Translational axes array of shape (3, 3). + - rot_axes: Rotational axes array of shape (3, 3). + - center: Center of mass array of shape (3,). + - moment_of_inertia: Principal moments array of shape (3,). + + Raises: + ValueError: + If the residue selection is empty. """ # TODO refine selection so that it will work for branched polymers index_prev = index - 1 @@ -58,23 +112,21 @@ def get_residue_axes(self, data_container, index, residue=None): ) if len(atom_set) == 0: - # No bonds to other residues - # Use a custom principal axes, from a MOI tensor - # that uses positions of heavy atoms only, but including masses - # of heavy atom + bonded hydrogens - UAs = residue.select_atoms("mass 2 to 999") - UA_masses = self.get_UA_masses(residue) - moment_of_inertia_tensor = self.get_moment_of_inertia_tensor( - center, UAs.positions, UA_masses, data_container.dimensions[:3] - ) - rot_axes, moment_of_inertia = self.get_custom_principal_axes( - moment_of_inertia_tensor - ) - trans_axes = ( - rot_axes # set trans axes to same as rot axes as per Jon's code + # No bonds to other residues. + # Use a custom principal axes, from a MOI tensor that uses positions of + # heavy atoms only, but including masses of heavy atom + bonded H. + uas = residue.select_atoms("mass 2 to 999") + ua_masses = self.get_UA_masses(residue) + moi_tensor = self.get_moment_of_inertia_tensor( + center_of_mass=center, + positions=uas.positions, + masses=ua_masses, + dimensions=data_container.dimensions[:3], ) + rot_axes, moment_of_inertia = self.get_custom_principal_axes(moi_tensor) + trans_axes = rot_axes # per original convention else: - # if bonded to other residues, use default axes and MOI + # If bonded to other residues, use default axes and MOI. make_whole(data_container.atoms) trans_axes = data_container.atoms.principal_axes() rot_axes, moment_of_inertia = self.get_vanilla_axes(residue) @@ -82,61 +134,85 @@ def get_residue_axes(self, data_container, index, residue=None): return trans_axes, rot_axes, center, moment_of_inertia - def get_UA_axes(self, data_container, index): - """ + def get_UA_axes(self, data_container, index: int): + """Compute united-atom-level translational and rotational axes. + The translational and rotational axes at the united-atom level. + This preserves the original behaviour and its rationale: + + - Translational axes: + Use the same custom principal-axes approach as residue level: + compute a custom MOI tensor using heavy-atom coordinates but UA masses + (heavy + bonded H masses), then compute the principal axes from it. + + - Rotational axes: + Identify heavy atoms in the residue/molecule of interest and choose + the `index`-th heavy atom (where index corresponds to the bead index). + Use bonded topology around that heavy atom to determine UA rotational + axes (see :meth:`get_bonded_axes`). + Args: - data_container (MDAnalysis.Universe): the molecule and trajectory data - index (int): residue index + data_container (MDAnalysis.Universe or AtomGroup): + Molecule and trajectory data. + index (int): + Bead index (ordinal among heavy atoms). Returns: - trans_axes : translational axes (3,3) - rot_axes : rotational axes (3,3) - center: center of mass (3,) - moment_of_inertia: moment of inertia (3,) + Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + - trans_axes: Translational axes (3, 3). + - rot_axes: Rotational axes (3, 3). + - center: Rotation centre (3,) (heavy atom position). + - moment_of_inertia: (3,) moments for the UA around rot_axes. + + Raises: + IndexError: + If `index` does not correspond to an existing heavy atom. + ValueError: + If bonded-axis construction fails. """ - index = int(index) - # use the same customPI trans axes as the residue level - UAs = data_container.select_atoms("mass 2 to 999") - UA_masses = self.get_UA_masses(data_container.atoms) + # Translational axes: same customPI approach as residue level. + uas = data_container.select_atoms("mass 2 to 999") + ua_masses = self.get_UA_masses(data_container.atoms) center = data_container.atoms.center_of_mass(unwrap=True) - moment_of_inertia_tensor = self.get_moment_of_inertia_tensor( - center, UAs.positions, UA_masses, data_container.dimensions[:3] - ) - trans_axes, _moment_of_inertia = self.get_custom_principal_axes( - moment_of_inertia_tensor + moi_tensor = self.get_moment_of_inertia_tensor( + center_of_mass=center, + positions=uas.positions, + masses=ua_masses, + dimensions=data_container.dimensions[:3], ) + trans_axes, _ = self.get_custom_principal_axes(moi_tensor) - # look for heavy atoms in residue of interest + # Rotational axes: choose the nth heavy atom where n is bead index. heavy_atoms = data_container.select_atoms("prop mass > 1.1") - heavy_atom_indices = [] - for atom in heavy_atoms: - heavy_atom_indices.append(atom.index) - # we find the nth heavy atom - # where n is the bead index - heavy_atom_index = heavy_atom_indices[index] + heavy_atom_indices = [atom.index for atom in heavy_atoms] + heavy_atom_index = heavy_atom_indices[index] # may raise IndexError heavy_atom = data_container.select_atoms(f"index {heavy_atom_index}") center = heavy_atom.positions[0] rot_axes, moment_of_inertia = self.get_bonded_axes( - data_container, heavy_atom[0], data_container.dimensions[:3] + system=data_container, + atom=heavy_atom[0], + dimensions=data_container.dimensions[:3], ) + if rot_axes is None or moment_of_inertia is None: + raise ValueError("Unable to compute bonded axes for UA bead.") - logger.debug(f"Translational Axes: {trans_axes}") - logger.debug(f"Rotational Axes: {rot_axes}") - logger.debug(f"Center: {center}") - logger.debug(f"Moment of Inertia: {moment_of_inertia}") + logger.debug("Translational Axes: %s", trans_axes) + logger.debug("Rotational Axes: %s", rot_axes) + logger.debug("Center: %s", center) + logger.debug("Moment of Inertia: %s", moment_of_inertia) return trans_axes, rot_axes, center, moment_of_inertia - def get_bonded_axes(self, system, atom, dimensions): - """ - For a given heavy atom, use its bonded atoms to get the axes - for rotating forces around. Few cases for choosing united atom axes, - which are dependent on the bonds to the atom: + def get_bonded_axes(self, system, atom, dimensions: np.ndarray): + r"""Compute UA rotational axes from bonded topology around a heavy atom. + + For a given heavy atom, use its bonded atoms to get the axes for rotating + forces around. Few cases for choosing united atom axes, which are dependent + on the bonds to the atom: :: @@ -164,84 +240,98 @@ def get_bonded_axes(self, system, atom, dimensions): - case4: use vector XR1 as axis1, and XR2 to calculate axis2 - case5: get the sum of all XR normalised vectors as axis1, then use vector - R1R2 to calculate axis2 + R1R2 to calculate axis2 axis3 is always the cross product of axis1 and axis2. Args: - system: mdanalysis instance of all atoms in current frame - atom: mdanalysis instance of a heavy atom - dimensions: dimensions of the simulation box (3,) + system: + MDAnalysis selection containing all atoms in current frame. + atom: + MDAnalysis Atom for the heavy atom. + dimensions: + Simulation box dimensions (3,). Returns: - custom_axes: custom axes for the UA, (3,3) array - custom_moment_of_inertia + Tuple[np.ndarray | None, np.ndarray | None]: + - custom_axes: Custom axes (3, 3), or None if atom is not heavy. + - custom_moment_of_inertia: (3,) moment of inertia around axes. + + Notes: + If custom_moment_of_inertia is not produced by the chosen method, it is + computed using :meth:`get_custom_moment_of_inertia` with the heavy atom + as COM (matching original behaviour). """ # check atom is a heavy atom if not atom.mass > 1.1: - return None - # set default values + return None, None + custom_moment_of_inertia = None custom_axes = None - # find the heavy bonded atoms and light bonded atoms heavy_bonded, light_bonded = self.find_bonded_atoms(atom.index, system) - UA = atom + light_bonded - UA_all = atom + heavy_bonded + light_bonded + ua = atom + light_bonded + ua_all = atom + heavy_bonded + light_bonded - # now find which atoms to select to find the axes for rotating forces: # case1 if len(heavy_bonded) == 0: - custom_axes, custom_moment_of_inertia = self.get_vanilla_axes(UA_all) + custom_axes, custom_moment_of_inertia = self.get_vanilla_axes(ua_all) + # case2 if len(heavy_bonded) == 1 and len(light_bonded) == 0: custom_axes = self.get_custom_axes( - atom.position, [heavy_bonded[0].position], np.zeros(3), dimensions + a=atom.position, + b_list=[heavy_bonded[0].position], + c=np.zeros(3), + dimensions=dimensions, ) + # case3 if len(heavy_bonded) == 1 and len(light_bonded) >= 1: custom_axes = self.get_custom_axes( - atom.position, - [heavy_bonded[0].position], - light_bonded[0].position, - dimensions, + a=atom.position, + b_list=[heavy_bonded[0].position], + c=light_bonded[0].position, + dimensions=dimensions, ) - # case4, not used in Jon's 2019 paper code, use case5 instead + + # case4 (not used in original 2019 code; case5 used instead) # case5 if len(heavy_bonded) >= 2: custom_axes = self.get_custom_axes( - atom.position, - heavy_bonded.positions, - heavy_bonded[1].position, - dimensions, + a=atom.position, + b_list=heavy_bonded.positions, + c=heavy_bonded[1].position, + dimensions=dimensions, ) + if custom_axes is None: + return None, None + if custom_moment_of_inertia is None: - # find moment of inertia using custom axes and atom position as COM custom_moment_of_inertia = self.get_custom_moment_of_inertia( - UA, custom_axes, atom.position, dimensions + UA=ua, + custom_rotation_axes=custom_axes, + center_of_mass=atom.position, + dimensions=dimensions, ) - # get the moment of inertia from the custom axes - if custom_axes is not None: - # flip axes to face correct way wrt COM - custom_axes = self.get_flipped_axes( - UA, custom_axes, atom.position, dimensions - ) + # flip axes to face correct way wrt COM + custom_axes = self.get_flipped_axes(ua, custom_axes, atom.position, dimensions) return custom_axes, custom_moment_of_inertia def find_bonded_atoms(self, atom_idx: int, system): - """ - for a given atom, find its bonded heavy and H atoms + """Find bonded heavy and hydrogen atoms for a given atom. Args: - atom_idx: atom index to find bonded heavy atom for - system: mdanalysis instance of all atoms in current frame + atom_idx: Atom index to find bonded atoms for. + system: MDAnalysis selection containing all atoms in current frame. Returns: - bonded_heavy_atoms: MDAnalysis instance of bonded heavy atoms - bonded_H_atoms: MDAnalysis instance of bonded hydrogen atoms + Tuple[AtomGroup, AtomGroup]: + - bonded_heavy_atoms: bonded heavy atoms (mass 2 to 999) + - bonded_H_atoms: bonded hydrogen atoms (mass 1 to 1.1) """ bonded_atoms = system.select_atoms(f"bonded index {atom_idx}") bonded_heavy_atoms = bonded_atoms.select_atoms("mass 2 to 999") @@ -249,71 +339,64 @@ def find_bonded_atoms(self, atom_idx: int, system): return bonded_heavy_atoms, bonded_H_atoms def get_vanilla_axes(self, molecule): - """ - Compute the principal axes and sorted moments of inertia for a molecule. + """Get principal axes and sorted principal moments (vanilla method). - This method computes the translationally invariant principal axes and - corresponding moments of inertia for a molecular selection using the - default MDAnalysis routines. The molecule is first made whole to ensure - correct handling of periodic boundary conditions. + Compute the principal axes and moments of inertia for a molecule using + MDAnalysis built-in functionality. - The moments of inertia are obtained by diagonalising the moment of inertia - tensor and are returned sorted from largest to smallest magnitude. + The original description is preserved: + - The molecule is made whole to ensure correct handling of PBC. + - The moments are obtained by diagonalising the moment of inertia tensor. + - Eigenvalues are returned sorted from largest to smallest magnitude. Args: molecule (MDAnalysis.core.groups.AtomGroup): - AtomGroup representing the molecule or bead for which the axes - and moments of inertia are to be computed. + AtomGroup representing the molecule/bead. Returns: Tuple[np.ndarray, np.ndarray]: - A tuple containing: - - - principal_axes (np.ndarray): - Array of shape ``(3, 3)`` whose rows correspond to the - principal axes of the molecule. - - moment_of_inertia (np.ndarray): - Array of shape ``(3,)`` containing the moments of inertia - sorted in descending order. + - principal_axes: (3, 3) axes. + - moment_of_inertia: (3,) moments sorted descending by |value|. """ - moment_of_inertia = molecule.moment_of_inertia(unwrap=True) + moment_of_inertia_tensor = molecule.moment_of_inertia(unwrap=True) make_whole(molecule.atoms) principal_axes = molecule.principal_axes() - eigenvalues, _eigenvectors = np.linalg.eig(moment_of_inertia) - - # Sort eigenvalues from largest to smallest magnitude + eigenvalues, _ = np.linalg.eig(moment_of_inertia_tensor) order = np.argsort(np.abs(eigenvalues))[::-1] moment_of_inertia = eigenvalues[order] return principal_axes, moment_of_inertia def get_custom_axes( - self, a: np.ndarray, b_list: list, c: np.ndarray, dimensions: np.ndarray - ): - r""" + self, + a: np.ndarray, + b_list: Sequence[np.ndarray], + c: np.ndarray, + dimensions: np.ndarray, + ) -> np.ndarray: + r"""Compute custom rotation axes from bonded vectors (PBC-aware). + For atoms a, b_list and c, calculate the axis to rotate forces around: - - axis1: use the normalised vector ab as axis1. If there is more than one bonded - heavy atom (HA), average over all the normalised vectors calculated from b_list - and use this as axis1). b_list contains all the bonded heavy atom - coordinates. + - axis1: use the normalised vector ab as axis1. If there is more than one + bonded heavy atom (HA), average over all the normalised vectors + calculated from b_list and use this as axis1). b_list contains all the + bonded heavy atom coordinates. - axis2: use the cross product of normalised vector ac and axis1 as axis2. - If there are more than two bonded heavy atoms, then use normalised vector - b[0]c to cross product with axis1, this gives the axis perpendicular - (represented by |_ symbol below) to axis1. + If there are more than two bonded heavy atoms, then use normalised vector + b[0]c to cross product with axis1, this gives the axis perpendicular + (represented by |_ symbol below) to axis1. - axis3: the cross product of axis1 and axis2, which is perpendicular to - axis1 and axis2. + axis1 and axis2. Args: - a: central united-atom coordinates (3,) - b_list: list of heavy bonded atom positions (3,N) - c: atom coordinates of either a second heavy atom or a hydrogen atom - if there are no other bonded heavy atoms in b_list (where N=1 in b_list) - (3,) - dimensions: dimensions of the simulation box (3,) + a: Central united-atom coordinates (3,). + b_list: Positions of heavy bonded atoms. + c: Coordinates of a second heavy atom or a hydrogen atom. + dimensions: Simulation box dimensions (3,). :: @@ -323,16 +406,21 @@ def get_custom_axes( b c Returns: - custom_axes: (3,3) array of the axes used to rotate forces + np.ndarray: (3, 3) array of the axes used to rotate forces. + + Raises: + ValueError: If axes cannot be normalized due to degeneracy. """ - unscaled_axis1 = np.zeros(3) - # average of all heavy atom covalent bond vectors for axis1 + unscaled_axis1 = np.zeros(3, dtype=float) for b in b_list: ab_vector = self.get_vector(a, b, dimensions) unscaled_axis1 += ab_vector + + if np.allclose(unscaled_axis1, 0.0): + raise ValueError("Degenerate axis1: summed bonded vectors are zero.") + if len(b_list) >= 2: - # use the first heavy bonded atom as atom a - ac_vector = self.get_vector(c, b_list[0], dimensions) + ac_vector = self.get_vector(c, np.asarray(b_list)[0], dimensions) else: ac_vector = self.get_vector(c, a, dimensions) @@ -340,11 +428,13 @@ def get_custom_axes( unscaled_axis3 = np.cross(unscaled_axis2, unscaled_axis1) unscaled_custom_axes = np.array( - (unscaled_axis1, unscaled_axis2, unscaled_axis3) + (unscaled_axis1, unscaled_axis2, unscaled_axis3), dtype=float ) mod = np.sqrt(np.sum(unscaled_custom_axes**2, axis=1)) - scaled_custom_axes = unscaled_custom_axes / mod[:, np.newaxis] + if np.any(np.isclose(mod, 0.0)): + raise ValueError("Degenerate custom axes: cannot normalize (zero norm).") + scaled_custom_axes = unscaled_custom_axes / mod[:, np.newaxis] return scaled_custom_axes def get_custom_moment_of_inertia( @@ -353,160 +443,184 @@ def get_custom_moment_of_inertia( custom_rotation_axes: np.ndarray, center_of_mass: np.ndarray, dimensions: np.ndarray, - ): - """ + ) -> np.ndarray: + """Compute moment of inertia around custom axes for a UA. + Get the moment of inertia (specifically used for the united atom level) - from a set of rotation axes and a given center of mass - (COM is usually the heavy atom position in a UA). + from a set of rotation axes and a given center of mass (COM is usually the + heavy atom position in a UA). + + Original behaviour preserved: + - Uses PBC-aware translated coordinates. + - Sums contributions from each atom: |axis x r|^2 * mass. + - Removes the lowest MOI degree of freedom if the UA only has a single + bonded H (i.e. UA has 2 atoms total). Args: - UA: MDAnalysis instance of a united-atom - custom_rotation_axes: (3,3) arrray of rotation axes - center_of_mass: (3,) center of mass for collection of atoms N + UA: MDAnalysis AtomGroup for the UA (heavy + bonded H atoms). + custom_rotation_axes: (3, 3) array of rotation axes. + center_of_mass: (3,) COM for the UA (typically HA position). + dimensions: (3,) simulation box dimensions. Returns: - custom_moment_of_inertia: (3,) array for moment of inertia + np.ndarray: (3,) moment of inertia array. """ translated_coords = self.get_vector(center_of_mass, UA.positions, dimensions) - custom_moment_of_inertia = np.zeros(3) + custom_moment_of_inertia = np.zeros(3, dtype=float) + for coord, mass in zip(translated_coords, UA.masses): axis_component = np.sum( np.cross(custom_rotation_axes, coord) ** 2 * mass, axis=1 ) custom_moment_of_inertia += axis_component - # Remove lowest MOI degree of freedom if UA only has a single bonded H if len(UA) == 2: - order = custom_moment_of_inertia.argsort()[::-1] # decending order - custom_moment_of_inertia[order[-1]] = 0 + order = custom_moment_of_inertia.argsort()[::-1] # descending order + custom_moment_of_inertia[order[-1]] = 0.0 return custom_moment_of_inertia - def get_flipped_axes(self, UA, custom_axes, center_of_mass, dimensions): - """ + def get_flipped_axes( + self, + UA, + custom_axes: np.ndarray, + center_of_mass: np.ndarray, + dimensions: np.ndarray, + ): + """Flip custom axes to a consistent direction with respect to the UA. + For a given set of custom axes, ensure the axes are pointing in the - correct direction wrt the heavy atom position and the chosen center - of mass. + correct direction with respect to the heavy atom position and the chosen + center of mass. Args: - UA: MDAnalysis instance of a united-atom - custom_axes: (3,3) array of the rotation axes - center_of_mass: (3,) array for center of mass (usually HA position) - dimensions: (3,) array of system box dimensions. + UA: MDAnalysis AtomGroup for the UA. + custom_axes: (3, 3) array of rotation axes. + center_of_mass: (3,) COM reference (usually HA position). + dimensions: (3,) simulation box dimensions. + + Returns: + np.ndarray: (3, 3) array of flipped/normalized axes. """ - # sorting out PIaxes for MoI for UA fragment + rr_axis = self.get_vector(UA[0].position, center_of_mass, dimensions) - # get dot product of Paxis1 and CoM->atom1 vect - # will just be [0,0,0] - RRaxis = self.get_vector(UA[0].position, center_of_mass, dimensions) + axis_norm = np.sqrt(np.sum(custom_axes**2, axis=1)) + custom_axes_flipped = custom_axes / axis_norm[:, np.newaxis] - # flip each Paxis if its pointing out of UA - custom_axis = np.sum(custom_axes**2, axis=1) - custom_axes_flipped = custom_axes / custom_axis**0.5 for i in range(3): - dotProd1 = np.dot(custom_axes_flipped[i], RRaxis) - custom_axes_flipped[i] = np.where( - dotProd1 < 0, -custom_axes_flipped[i], custom_axes_flipped[i] - ) + dot_prod = float(np.dot(custom_axes_flipped[i], rr_axis)) + if dot_prod < 0.0: + custom_axes_flipped[i] *= -1.0 + return custom_axes_flipped def get_vector(self, a: np.ndarray, b: np.ndarray, dimensions: np.ndarray): - """ + """Compute PBC-wrapped displacement vector(s). + For vector of two coordinates over periodic boundary conditions (PBCs). Args: - a: (N,3) array of atom cooordinates - b: (3,) array of atom cooordinates - dimensions: (3,) array of system box dimensions. + a: (3,) or (N, 3) array of coordinates. + b: (3,) or (N, 3) array of coordinates. + dimensions: (3,) simulation box dimensions. Returns: - delta_wrapped: (N,3) array of the vector + np.ndarray: Wrapped displacement vector(s) with broadcasted shape. """ delta = b - a delta -= dimensions * np.round(delta / dimensions) - return delta def get_moment_of_inertia_tensor( self, center_of_mass: np.ndarray, positions: np.ndarray, - masses: list, - dimensions: np.array, + masses: Sequence[float], + dimensions: np.ndarray, ) -> np.ndarray: - """ + """Compute a custom moment of inertia tensor. + Calculate a custom moment of inertia tensor. E.g., for cases where the mass list will contain masses of UAs rather than - individual atoms and the postions will be those for the UAs only + individual atoms and the positions will be those for the UAs only (excluding the H atoms coordinates). Args: - center_of_mass: a (3,) array of the chosen center of mass - positions: a (N,3) array of point positions - masses: a (N,) list of point masses + center_of_mass: (3,) chosen centre for the tensor. + positions: (N, 3) point positions. + masses: (N,) point masses corresponding to positions. + dimensions: (3,) simulation box dimensions. Returns: - moment_of_inertia_tensor: a (3,3) moment of inertia tensor + np.ndarray: (3, 3) moment of inertia tensor. """ r = self.get_vector(center_of_mass, positions, dimensions) r2 = np.sum(r**2, axis=1) - moment_of_inertia_tensor = np.eye(3) * np.sum(masses * r2) - moment_of_inertia_tensor -= np.einsum("i,ij,ik->jk", masses, r, r) + + masses_arr = np.asarray(list(masses), dtype=float) + moment_of_inertia_tensor = np.eye(3) * np.sum(masses_arr * r2) + moment_of_inertia_tensor -= np.einsum("i,ij,ik->jk", masses_arr, r, r) return moment_of_inertia_tensor def get_custom_principal_axes( self, moment_of_inertia_tensor: np.ndarray - ) -> tuple[np.ndarray, np.ndarray]: - """ - Principal axes and centre of axes from the ordered eigenvalues - and eigenvectors of a moment of inertia tensor. This function allows for - a custom moment of inertia tensor to be used, which isn't possible with - the built-in MDAnalysis principal_axes() function. + ) -> Tuple[np.ndarray, np.ndarray]: + """Compute principal axes and moments from a custom MOI tensor. + + Principal axes and centre of axes from the ordered eigenvalues and + eigenvectors of a moment of inertia tensor. This function allows for a + custom moment of inertia tensor to be used, which isn't possible with the + built-in MDAnalysis principal_axes() function. + + Original behaviour preserved: + - Eigenvalues are sorted by descending absolute magnitude. + - Eigenvectors are transposed so axes are returned as rows. + - Z axis is flipped to enforce the same handedness convention as the + original implementation. Args: - moment_of_inertia_tensor: a (3,3) array of a custom moment of - inertia tensor + moment_of_inertia_tensor: (3, 3) custom inertia tensor. Returns: - principal_axes: a (3,3) array for the principal axes - moment_of_inertia: a (3,) array of the principal axes center + Tuple[np.ndarray, np.ndarray]: + - principal_axes: (3, 3) principal axes (rows). + - moment_of_inertia: (3,) principal moments. """ eigenvalues, eigenvectors = np.linalg.eig(moment_of_inertia_tensor) - order = abs(eigenvalues).argsort()[::-1] # decending order - transposed = np.transpose(eigenvectors) # turn columns to rows + order = np.abs(eigenvalues).argsort()[::-1] # descending order + transposed = np.transpose(eigenvectors) # columns -> rows moment_of_inertia = eigenvalues[order] principal_axes = transposed[order] - # point z axis in correct direction, as per Jon's code + # point z axis in correct direction, as per original code cross_xy = np.cross(principal_axes[0], principal_axes[1]) - dot_z = np.dot(cross_xy, principal_axes[2]) + dot_z = float(np.dot(cross_xy, principal_axes[2])) if dot_z < 0: principal_axes[2] *= -1 return principal_axes, moment_of_inertia def get_UA_masses(self, molecule) -> list[float]: - """ - For a given molecule, return a list of masses of UAs - (combination of the heavy atoms + bonded hydrogen atoms. This list is used to - get the moment of inertia tensor for molecules larger than one UA. + """Return united-atom (UA) masses for a molecule. + + For a given molecule, return a list of masses of UAs (combination of the + heavy atoms + bonded hydrogen atoms). This list is used to get the moment + of inertia tensor for molecules larger than one UA. Args: - molecule: mdanalysis instance of molecule + molecule: MDAnalysis AtomGroup representing the molecule. Returns: - UA_masses: list of masses for each UA in a molecule + list[float]: UA masses for each heavy atom. """ - UA_masses = [] + ua_masses: list[float] = [] for atom in molecule: if atom.mass > 1.1: - UA_mass = atom.mass + ua_mass = float(atom.mass) bonded_atoms = molecule.select_atoms(f"bonded index {atom.index}") - bonded_H_atoms = bonded_atoms.select_atoms("mass 1 to 1.1") - for H in bonded_H_atoms: - UA_mass += H.mass - UA_masses.append(UA_mass) - else: - continue - return UA_masses + bonded_h_atoms = bonded_atoms.select_atoms("mass 1 to 1.1") + for h in bonded_h_atoms: + ua_mass += float(h.mass) + ua_masses.append(ua_mass) + return ua_masses From c0f5ec92ef2be984adc9685ef1b3fe840a68591f Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:14:49 +0000 Subject: [PATCH 053/101] update `arg_config_manager.py` to use Google Doc-Strings --- CodeEntropy/config/arg_config_manager.py | 518 +++++++++++++++-------- 1 file changed, 334 insertions(+), 184 deletions(-) diff --git a/CodeEntropy/config/arg_config_manager.py b/CodeEntropy/config/arg_config_manager.py index be066423..aeb4f046 100644 --- a/CodeEntropy/config/arg_config_manager.py +++ b/CodeEntropy/config/arg_config_manager.py @@ -1,234 +1,375 @@ +"""Configuration and CLI argument management for CodeEntropy. + +This module provides: + +1) A declarative argument specification (`ARG_SPECS`) used to build an + ``argparse.ArgumentParser``. +2) A `ConfigManager` that: + - loads YAML configuration (if present), + - merges YAML values with CLI values (CLI wins), + - adjusts logging verbosity, + - validates a subset of runtime inputs against the trajectory. + +Notes: +- Boolean arguments are parsed via `str2bool` to support YAML/CLI interop and + common string forms like "true"/"false". +""" + +from __future__ import annotations + import argparse import glob import logging import os +from dataclasses import dataclass +from typing import Any, Dict, Optional, Set 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", - }, - "force_file": { - "type": str, - "default": None, - "help": "Optional path to force file if forces are not in trajectory file", - }, - "file_format": { - "type": str, - "default": None, - "help": "String for file format as recognised by MDAnalysis", - }, - "kcal_force_units": { - "type": bool, - "default": False, - "help": "Set this to True if you have a separate force file with kcal units.", - }, - "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": ( + +@dataclass(frozen=True) +class ArgSpec: + """Argument specification used to build an argparse parser. + + Attributes: + help: Help text shown in CLI usage. + default: Default value if not provided via CLI or YAML. + type: Python type for parsing (e.g., int, float, str, bool). If bool, + `ConfigManager.str2bool` will be used. + action: Optional argparse action (e.g., "store_true"). + nargs: Optional nargs spec (e.g., "+"). + """ + + help: str + default: Any = None + type: Any = None + action: Optional[str] = None + nargs: Optional[str] = None + + +ARG_SPECS: Dict[str, ArgSpec] = { + "top_traj_file": ArgSpec( + type=str, + nargs="+", + help="Path to structure/topology file followed by trajectory file", + ), + "force_file": ArgSpec( + type=str, + default=None, + help="Optional path to force file if forces are not in trajectory file", + ), + "file_format": ArgSpec( + type=str, + default=None, + help="String for file format as recognised by MDAnalysis", + ), + "kcal_force_units": ArgSpec( + type=bool, + default=False, + help="Set this to True if you have a separate force file with kcal units.", + ), + "selection_string": ArgSpec( + type=str, + default="all", + help="Selection string for CodeEntropy", + ), + "start": ArgSpec( + type=int, + default=0, + help="Start analysing the trajectory from this frame index", + ), + "end": ArgSpec( + type=int, + default=-1, + help=( "Stop analysing the trajectory at this frame index. This is " - "the frame index of the last frame to be included, so for example" + "the frame index of the last frame to be included, so for example " "if start=0 and end=500 there would be 501 frames analysed. The " "default -1 will include the last frame." ), - "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", - }, - "output_file": { - "type": str, - "help": ( + ), + "step": ArgSpec( + type=int, + default=1, + help="Interval between two consecutive frames to be read index", + ), + "bin_width": ArgSpec( + type=int, + default=30, + help="Bin width in degrees for making the histogram", + ), + "temperature": ArgSpec( + type=float, + default=298.0, + help="Temperature for entropy calculation (K)", + ), + "verbose": ArgSpec( + action="store_true", + help="Enable verbose output", + ), + "output_file": ArgSpec( + type=str, + default="output_file.json", + help=( "Name of the output file to write results to (filename only). Defaults " "to output_file.json" ), - "default": "output_file.json", - }, - "force_partitioning": {"type": float, "help": "Force partitioning", "default": 0.5}, - "water_entropy": { - "type": bool, - "help": "If set to False, disables the calculation of water entropy", - "default": True, - }, - "grouping": { - "type": str, - "help": "How to group molecules for averaging", - "default": "molecules", - }, - "combined_forcetorque": { - "type": bool, - "help": """Use combined force-torque matrix for residue - level vibrational entropies""", - "default": True, - }, - "customised_axes": { - "type": bool, - "help": """Use bonded axes to rotate forces for UA - level vibrational entropies""", - "default": True, - }, + ), + "force_partitioning": ArgSpec( + type=float, + default=0.5, + help="Force partitioning", + ), + "water_entropy": ArgSpec( + type=bool, + default=True, + help="If set to False, disables the calculation of water entropy", + ), + "grouping": ArgSpec( + type=str, + default="molecules", + help="How to group molecules for averaging", + ), + "combined_forcetorque": ArgSpec( + type=bool, + default=True, + help="Use combined force-torque matrix for residue level vibrational entropies", + ), + "customised_axes": ArgSpec( + type=bool, + default=True, + help="Use bonded axes to rotate forces for UA level vibrational entropies", + ), } class ConfigManager: - def __init__(self): - self.arg_map = arg_map + """Load, merge, and validate CodeEntropy configuration. + + This class provides a consistent interface for: + - YAML config discovery/loading + - CLI parser construction + - merging YAML values with CLI values (CLI wins) + - setting logging verbosity + - validating trajectory-related numeric parameters + """ - def load_config(self, file_path): - """Load YAML configuration file from the given directory.""" - yaml_files = glob.glob(os.path.join(file_path, "*.yaml")) + def __init__(self, arg_specs: Optional[Dict[str, ArgSpec]] = None) -> None: + """Initialize the manager. + Args: + arg_specs: Optional override for argument specs. If omitted, uses + `ARG_SPECS`. + """ + self._arg_specs = dict(arg_specs or ARG_SPECS) + + def load_config(self, directory_path: str) -> Dict[str, Any]: + """Load the first YAML config file found in a directory. + + The current behavior matches your existing workflow: + - searches for ``*.yaml`` in `directory_path`, + - loads the first match, + - returns ``{"run1": {}}`` if none found or file is empty/invalid. + + Args: + directory_path: Directory to search for YAML files. + + Returns: + A configuration dictionary. + """ + yaml_files = glob.glob(os.path.join(directory_path, "*.yaml")) if not yaml_files: return {"run1": {}} + config_path = yaml_files[0] try: - with open(yaml_files[0], "r") as file: - config = yaml.safe_load(file) - logger.info(f"Loaded configuration from: {yaml_files[0]}") - if config is None: - config = {"run1": {}} - except Exception as e: - logger.error(f"Failed to load config file: {e}") - config = {"run1": {}} - - return config - - def str2bool(self, value): - """ - Convert a string or boolean input into a boolean value. + with open(config_path, "r", encoding="utf-8") as file: + config = yaml.safe_load(file) or {"run1": {}} + logger.info("Loaded configuration from: %s", config_path) + return config + except Exception as exc: + logger.error("Failed to load config file: %s", exc) + return {"run1": {}} + + @staticmethod + def str2bool(value: Any) -> bool: + """Convert a string or boolean input into a boolean. - Accepts common string representations of boolean values such as: - - True values: "true", "t", "yes", "1" - - False values: "false", "f", "no", "0" + Accepts common string representations: + - True values: "true", "t", "yes", "1" + - False values: "false", "f", "no", "0" If the input is already a boolean, it is returned as-is. - Raises: - argparse.ArgumentTypeError: If the input cannot be interpreted as a boolean. Args: - value (str or bool): The input value to convert. + value: Input value to convert. Returns: - bool: The corresponding boolean value. + The corresponding boolean. + + Raises: + argparse.ArgumentTypeError: If the input cannot be interpreted as a boolean. """ if isinstance(value, bool): return value - value = value.lower() - if value in {"true", "t", "yes", "1"}: + if not isinstance(value, str): + raise argparse.ArgumentTypeError("Boolean value expected (True/False).") + + lowered = value.lower() + if lowered in {"true", "t", "yes", "1"}: return True - elif value in {"false", "f", "no", "0"}: + if lowered in {"false", "f", "no", "0"}: return False - else: - raise argparse.ArgumentTypeError("Boolean value expected (True/False).") + raise argparse.ArgumentTypeError("Boolean value expected (True/False).") - def setup_argparse(self): - """Setup argument parsing dynamically based on arg_map.""" + def setup_argparse(self) -> argparse.ArgumentParser: + """Build an ArgumentParser from argument specs. + + Returns: + An argparse.ArgumentParser configured with all supported flags. + """ parser = argparse.ArgumentParser( description="CodeEntropy: Entropy calculation with MCC method." ) - for arg, properties in self.arg_map.items(): - help_text = properties.get("help", "") - default = properties.get("default", None) + for name, spec in self._arg_specs.items(): + arg_name = f"--{name}" + + if spec.action is not None: + parser.add_argument(arg_name, action=spec.action, help=spec.help) + continue - if properties.get("type") == bool: + if spec.type is bool: parser.add_argument( - f"--{arg}", + arg_name, type=self.str2bool, - default=default, - help=f"{help_text} (default: {default})", + default=spec.default, + help=f"{spec.help} (default: {spec.default})", ) - else: - kwargs = {k: v for k, v in properties.items() if k != "help"} - parser.add_argument(f"--{arg}", **kwargs, help=help_text) + continue + + kwargs: Dict[str, Any] = {} + if spec.type is not None: + kwargs["type"] = spec.type + if spec.default is not None: + kwargs["default"] = spec.default + if spec.nargs is not None: + kwargs["nargs"] = spec.nargs + + parser.add_argument(arg_name, help=spec.help, **kwargs) return parser - def merge_configs(self, args, run_config): - """Merge CLI arguments with YAML configuration and adjust logging level.""" + def merge_configs( + self, args: argparse.Namespace, run_config: Optional[Dict[str, Any]] + ) -> argparse.Namespace: + """Merge CLI arguments with YAML configuration and adjust logging level. + + Merge rule: + - CLI explicitly-provided values take precedence. + - YAML values fill in missing values. + - Defaults fill in anything still unset. + + Args: + args: Parsed CLI arguments. + run_config: Dict of YAML values for a specific run, or None. + + Returns: + The mutated argparse.Namespace with merged values. + + Raises: + TypeError: If `run_config` is not a dict or None. + """ if run_config is None: run_config = {} - if not isinstance(run_config, dict): raise TypeError("run_config must be a dictionary or None.") - # Convert argparse Namespace to dictionary args_dict = vars(args) - # Reconstruct parser and check which arguments were explicitly provided via CLI parser = self.setup_argparse() default_args = parser.parse_args([]) default_dict = vars(default_args) - cli_provided_args = { - key for key, value in args_dict.items() if value != default_dict.get(key) - } + cli_provided = self._detect_cli_overrides(args_dict, default_dict) + + self._apply_yaml_defaults(args, run_config, cli_provided) + self._ensure_defaults(args) + self._apply_logging_level(bool(getattr(args, "verbose", False))) + + return args + + @staticmethod + def _detect_cli_overrides( + args_dict: Dict[str, Any], default_dict: Dict[str, Any] + ) -> Set[str]: + """Detect which args were explicitly overridden in the CLI. + + Args: + args_dict: Parsed arg values. + default_dict: Parser defaults. + + Returns: + Set of argument names that differ from defaults. + """ + return {k for k, v in args_dict.items() if v != default_dict.get(k)} - # Step 1: Apply YAML values if CLI didn't explicitly set the argument + def _apply_yaml_defaults( + self, + args: argparse.Namespace, + run_config: Dict[str, Any], + cli_provided: Set[str], + ) -> None: + """Apply YAML values onto args for keys not provided by CLI. + + Args: + args: Parsed CLI arguments (mutated in-place). + run_config: YAML dict for this run. + cli_provided: Keys explicitly set via CLI. + """ for key, yaml_value in run_config.items(): - if yaml_value is not None and key not in cli_provided_args: - logger.debug(f"Using YAML value for {key}: {yaml_value}") + if yaml_value is None or key in cli_provided: + continue + if key in self._arg_specs: + logger.debug("Using YAML value for %s: %s", key, yaml_value) setattr(args, key, yaml_value) - # Step 2: Ensure all arguments have at least their default values - for key, params in self.arg_map.items(): + def _ensure_defaults(self, args: argparse.Namespace) -> None: + """Ensure all known args have defaults if still unset. + + Args: + args: Parsed arg namespace (mutated in-place). + """ + for key, spec in self._arg_specs.items(): if getattr(args, key, None) is None: - setattr(args, key, params.get("default")) - - # Step 3: Ensure CLI arguments always take precedence - for key in self.arg_map.keys(): - cli_value = args_dict.get(key) - 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) + setattr(args, key, spec.default) + + @staticmethod + def _apply_logging_level(verbose: bool) -> None: + """Adjust logging levels for this module's logger and its handlers. + + Args: + verbose: Whether to enable DEBUG logging. + """ + level = logging.DEBUG if verbose else logging.INFO + logger.setLevel(level) + for handler in logger.handlers: + handler.setLevel(level) + if verbose: 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 + def input_parameters_validation(self, u: Any, args: argparse.Namespace) -> None: + """Validate user inputs against sensible runtime constraints. - def input_parameters_validation(self, u, args): - """Check the validity of the user inputs against sensible values""" + Args: + u: MDAnalysis universe (or compatible) with a `trajectory`. + args: Parsed/merged arguments. + Raises: + ValueError: If a parameter is invalid. + """ self._check_input_start(u, args) self._check_input_end(u, args) self._check_input_step(args) @@ -236,51 +377,60 @@ def input_parameters_validation(self, u, args): self._check_input_temperature(args) self._check_input_force_partitioning(args) - def _check_input_start(self, u, args): - """Check that the input does not exceed the length of the trajectory.""" - if args.start > len(u.trajectory): + @staticmethod + def _check_input_start(u: Any, args: argparse.Namespace) -> None: + """Check that the start index does not exceed the trajectory length.""" + traj_len = len(u.trajectory) + if args.start > traj_len: raise ValueError( - f"Invalid 'start' value: {args.start}. It exceeds the trajectory length" - " of {len(u.trajectory)}." + f"Invalid 'start' value: {args.start}. It exceeds the trajectory " + f"length of {traj_len}." ) - def _check_input_end(self, u, args): + @staticmethod + def _check_input_end(u: Any, args: argparse.Namespace) -> None: """Check that the end index does not exceed the trajectory length.""" - if args.end > len(u.trajectory): + traj_len = len(u.trajectory) + if args.end > traj_len: raise ValueError( - f"Invalid 'end' value: {args.end}. It exceeds the trajectory length of" - " {len(u.trajectory)}." + f"Invalid 'end' value: {args.end}. It exceeds the trajectory length of " + f"{traj_len}." ) - def _check_input_step(self, args): - """Check that the step value is non-negative.""" + @staticmethod + def _check_input_step(args: argparse.Namespace) -> None: + """Warn if the step value is negative.""" if args.step < 0: logger.warning( - f"Negative 'step' value provided: {args.step}. This may lead to" - " unexpected behavior." + "Negative 'step' value provided: %s. This may lead to unexpected " + "behavior.", + args.step, ) - def _check_input_bin_width(self, args): + @staticmethod + def _check_input_bin_width(args: argparse.Namespace) -> None: """Check that the bin width is within the valid range [0, 360].""" if args.bin_width < 0 or args.bin_width > 360: raise ValueError( - f"Invalid 'bin_width': {args.bin_width}. It must be between 0 and 360" - " degrees." + f"Invalid 'bin_width': {args.bin_width}. It must be between 0 and 360 " + f"degrees." ) - def _check_input_temperature(self, args): + @staticmethod + def _check_input_temperature(args: argparse.Namespace) -> None: """Check that the temperature is non-negative.""" if args.temperature < 0: raise ValueError( - f"Invalid 'temperature': {args.temperature}. Temperature cannot be" - " below 0." + f"Invalid 'temperature': {args.temperature}. Temperature cannot be " + f"below 0." ) - def _check_input_force_partitioning(self, args): + def _check_input_force_partitioning(self, args: argparse.Namespace) -> None: """Warn if force partitioning is not set to the default value.""" - default_value = arg_map["force_partitioning"]["default"] + default_value = self._arg_specs["force_partitioning"].default if args.force_partitioning != default_value: logger.warning( - f"'force_partitioning' is set to {args.force_partitioning}," - f" which differs from the default {default_value}." + "'force_partitioning' is set to %s, which differs from the default %s.", + args.force_partitioning, + default_value, ) From 9b7483a14a838fb232cc5d7bdb27fdf20d2941ff Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:16:37 +0000 Subject: [PATCH 054/101] update `data_logger.py` to use Google Doc-Strings --- CodeEntropy/config/data_logger.py | 211 +++++++++++++++++++++--------- 1 file changed, 151 insertions(+), 60 deletions(-) diff --git a/CodeEntropy/config/data_logger.py b/CodeEntropy/config/data_logger.py index 4345e115..bd9777af 100644 --- a/CodeEntropy/config/data_logger.py +++ b/CodeEntropy/config/data_logger.py @@ -1,6 +1,18 @@ +"""Utilities for logging entropy results and exporting data. + +This module provides the DataLogger class, which is responsible for: + +- Collecting molecule-level entropy results +- Collecting residue-level entropy results +- Storing group metadata labels +- Rendering rich tables to the console +- Exporting results to JSON +""" + import json import logging import re +from typing import Any, Dict, List, Optional, Tuple import numpy as np from rich.console import Console @@ -8,102 +20,181 @@ from CodeEntropy.config.logging_config import LoggingConfig -# Set up logger logger = logging.getLogger(__name__) console = LoggingConfig.get_console() class DataLogger: - def __init__(self, console=None): - self.console = console or Console() - self.molecule_data = [] - self.residue_data = [] - self.group_labels = {} - - def save_dataframes_as_json(self, molecule_df, residue_df, output_file): - """Save multiple DataFrames into a single JSON file with separate keys""" + """Collect, format, and output entropy calculation results.""" + + def __init__(self, console: Optional[Console] = None) -> None: + """Initialize the logger. + + Args: + console: Optional Rich Console instance. If None, the global + console from LoggingConfig is used. + """ + self.console: Console = console or Console() + self.molecule_data: List[Tuple[Any, Any, Any, Any]] = [] + self.residue_data: List[List[Any]] = [] + self.group_labels: Dict[Any, Dict[str, Any]] = {} + + def save_dataframes_as_json( + self, molecule_df, residue_df, output_file: str + ) -> None: + """Save molecule and residue DataFrames into a JSON file. + + Args: + molecule_df: Pandas DataFrame containing molecule results. + residue_df: Pandas DataFrame containing residue results. + output_file: Path to JSON output file. + """ data = { "molecule_data": molecule_df.to_dict(orient="records"), "residue_data": residue_df.to_dict(orient="records"), } - # Write JSON data to file with open(output_file, "w") as out: json.dump(data, out, indent=4) - def clean_residue_name(self, resname): - """Ensures residue names are stripped and cleaned before being stored""" + @staticmethod + def clean_residue_name(resname: Any) -> str: + """Clean residue name by removing dash-like characters. + + Args: + resname: Residue name input. + + Returns: + Cleaned residue name string. + """ return re.sub(r"[-–—]", "", str(resname)) - def add_results_data(self, group_id, level, entropy_type, value): - """Add data for molecule-level entries""" + def add_results_data( + self, + group_id: Any, + level: str, + entropy_type: str, + value: Any, + ) -> None: + """Add molecule-level entropy result. + + Args: + group_id: Group identifier. + level: Hierarchy level (e.g., united_atom, residue). + entropy_type: Entropy category. + value: Computed entropy value. + """ self.molecule_data.append((group_id, level, entropy_type, value)) def add_residue_data( - self, group_id, resname, level, entropy_type, frame_count, value - ): - """Add data for residue-level entries""" + self, + group_id: Any, + resname: str, + level: str, + entropy_type: str, + frame_count: Any, + value: Any, + ) -> None: + """Add residue-level entropy result. + + Args: + group_id: Group identifier. + resname: Residue name. + level: Hierarchy level. + entropy_type: Entropy category. + frame_count: Frame count or array. + value: Computed entropy value. + """ resname = self.clean_residue_name(resname) + if isinstance(frame_count, np.ndarray): frame_count = frame_count.tolist() + self.residue_data.append( [group_id, resname, level, entropy_type, frame_count, value] ) - def add_group_label(self, group_id, label, residue_count=None, atom_count=None): - """Store a mapping from group ID to a descriptive label and metadata""" + def add_group_label( + self, + group_id: Any, + label: str, + residue_count: Optional[int] = None, + atom_count: Optional[int] = None, + ) -> None: + """Store metadata label for a group. + + Args: + group_id: Group identifier. + label: Descriptive label. + residue_count: Optional residue count. + atom_count: Optional atom count. + """ self.group_labels[group_id] = { "label": label, "residue_count": residue_count, "atom_count": atom_count, } - def log_tables(self): - """Display rich tables in terminal""" + def log_tables(self) -> None: + """Render all collected data as Rich tables.""" - if self.molecule_data: - table = Table( - title="Molecule Entropy Results", show_lines=True, expand=True - ) - table.add_column("Group ID", justify="center", style="bold cyan") - table.add_column("Level", justify="center", style="magenta") - table.add_column("Type", justify="center", style="green") - table.add_column("Result (J/mol/K)", justify="center", style="yellow") + self._log_molecule_table() + self._log_residue_table() + self._log_group_label_table() + + def _log_molecule_table(self) -> None: + """Render molecule entropy table.""" + if not self.molecule_data: + return + + table = Table(title="Molecule Entropy Results", show_lines=True, expand=True) + table.add_column("Group ID", justify="center", style="bold cyan") + table.add_column("Level", justify="center", style="magenta") + table.add_column("Type", justify="center", style="green") + table.add_column("Result (J/mol/K)", justify="center", style="yellow") + + for row in self.molecule_data: + table.add_row(*[str(cell) for cell in row]) - for row in self.molecule_data: - table.add_row(*[str(cell) for cell in row]) + console.print(table) - console.print(table) + def _log_residue_table(self) -> None: + """Render residue entropy table.""" + if not self.residue_data: + return - if self.residue_data: - table = Table(title="Residue Entropy Results", show_lines=True, expand=True) - table.add_column("Group ID", justify="center", style="bold cyan") - table.add_column("Residue Name", justify="center", style="cyan") - table.add_column("Level", justify="center", style="magenta") - table.add_column("Type", justify="center", style="green") - table.add_column("Count", justify="center", style="green") - table.add_column("Result (J/mol/K)", justify="center", style="yellow") + table = Table(title="Residue Entropy Results", show_lines=True, expand=True) + table.add_column("Group ID", justify="center", style="bold cyan") + table.add_column("Residue Name", justify="center", style="cyan") + table.add_column("Level", justify="center", style="magenta") + table.add_column("Type", justify="center", style="green") + table.add_column("Count", justify="center", style="green") + table.add_column("Result (J/mol/K)", justify="center", style="yellow") - for row in self.residue_data: - table.add_row(*[str(cell) for cell in row]) + for row in self.residue_data: + table.add_row(*[str(cell) for cell in row]) - console.print(table) + console.print(table) - if self.group_labels: - label_table = Table( - title="Group ID to Residue Label Mapping", show_lines=True, expand=True + def _log_group_label_table(self) -> None: + """Render group label metadata table.""" + if not self.group_labels: + return + + table = Table( + title="Group ID to Residue Label Mapping", show_lines=True, expand=True + ) + table.add_column("Group ID", justify="center", style="bold cyan") + table.add_column("Residue Label", justify="center", style="green") + table.add_column("Residue Count", justify="center", style="magenta") + table.add_column("Atom Count", justify="center", style="yellow") + + for group_id, info in self.group_labels.items(): + table.add_row( + str(group_id), + info["label"], + str(info.get("residue_count", "")), + str(info.get("atom_count", "")), ) - label_table.add_column("Group ID", justify="center", style="bold cyan") - label_table.add_column("Residue Label", justify="center", style="green") - label_table.add_column("Residue Count", justify="center", style="magenta") - label_table.add_column("Atom Count", justify="center", style="yellow") - - for group_id, info in self.group_labels.items(): - label_table.add_row( - str(group_id), - info["label"], - str(info.get("residue_count", "")), - str(info.get("atom_count", "")), - ) - - console.print(label_table) + + console.print(table) From 529f92cef54846db126012c00ee0674904663c43 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:23:37 +0000 Subject: [PATCH 055/101] update `logging_config.py` to use Google style Doc-strings --- CodeEntropy/config/logging_config.py | 222 +++++++++++++++++---------- 1 file changed, 139 insertions(+), 83 deletions(-) diff --git a/CodeEntropy/config/logging_config.py b/CodeEntropy/config/logging_config.py index aea5f893..3c5f44a7 100644 --- a/CodeEntropy/config/logging_config.py +++ b/CodeEntropy/config/logging_config.py @@ -1,68 +1,91 @@ +"""Logging configuration utilities for CodeEntropy. + +This module configures consistent logging across the project with: + +- Rich console output (with tracebacks) for human-readable terminal logs +- File handlers for main logs, error-only logs, command logs, and MDAnalysis logs +- A singleton Rich Console instance with recording enabled, so terminal output + can be exported to disk at the end of a run + +The design keeps responsibilities separated: +- ErrorFilter: filter logic only +- LoggingConfig: handler creation, logger wiring, and exporting recorded output +""" + +from __future__ import annotations + import logging import os +from typing import Dict, Optional from rich.console import Console from rich.logging import RichHandler class ErrorFilter(logging.Filter): - """ - Logging filter that only allows records with level ERROR or higher. + """Allow only ERROR and CRITICAL log records. - This ensures that the attached handler only processes error and critical logs, - filtering out all lower level messages such as DEBUG and INFO. + This filter is intended for the error file handler so that the file contains + only high-severity records and does not include DEBUG/INFO/WARNING output. """ - def filter(self, record): + def filter(self, record: logging.LogRecord) -> bool: + """Return True if the record should be logged. + + Args: + record: The log record being evaluated. + + Returns: + True if record.levelno >= logging.ERROR, otherwise False. + """ return record.levelno >= logging.ERROR class LoggingConfig: - """ - Configures logging with Rich console output and multiple file handlers. - Provides a single Rich Console instance that records all output for later export. + """Configure project logging with Rich console output and file handlers. + + This class wires a set of handlers onto the root logger and a few named + loggers. It also provides a singleton Rich Console instance with recording + enabled so that all console output can be exported to a text file later. Attributes: - _console (Console): Shared Rich Console instance with output recording enabled. - log_dir (str): Directory path to store log files. - level (int): Logging level (e.g., logging.INFO). - console (Console): The Rich Console instance used for output and logging. - handlers (dict): Dictionary of logging handlers for console and files. + log_dir: Directory where log files are written. + level: Base logging level for the root logger and file handlers. + console: Shared Rich Console instance used by RichHandler. + handlers: Mapping of handler name to handler instance. """ - _console = None # Shared Console with recording enabled + _console: Optional[Console] = None @classmethod - def get_console(cls): - """ - Get or create a singleton Rich Console instance with recording enabled. + def get_console(cls) -> Console: + """Get or create the singleton Rich Console with recording enabled. Returns: - Console: Rich Console instance that prints to terminal and records output. + A Rich Console instance that prints to terminal and records output. """ if cls._console is None: - # Create console that records output for later export cls._console = Console(record=True) return cls._console - def __init__(self, folder, level=logging.INFO): - """ - Initialize the logging configuration. + def __init__(self, folder: str, level: int = logging.INFO) -> None: + """Initialize logging configuration. Args: - folder (str): Base folder where 'logs' directory will be created. - level (int): Logging level (default: logging.INFO). + folder: Base folder where the 'logs' directory will be created. + level: Logging level for the root logger and most file handlers. """ self.log_dir = os.path.join(folder, "logs") os.makedirs(self.log_dir, exist_ok=True) - self.level = level - # Use the single recorded console instance + self.level = level self.console = self.get_console() + self.handlers: Dict[str, logging.Handler] = {} self._setup_handlers() - def _setup_handlers(self): + def _setup_handlers(self) -> None: + """Create handlers and assign formatters/levels/filters.""" paths = { "main": os.path.join(self.log_dir, "program.log"), "error": os.path.join(self.log_dir, "program.err"), @@ -74,91 +97,124 @@ def _setup_handlers(self): "%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" ) - self.handlers = { - "rich": RichHandler( - console=self.console, - markup=True, - rich_tracebacks=True, - show_time=True, - show_level=True, - show_path=False, - ), - "main": logging.FileHandler(paths["main"]), - "error": logging.FileHandler(paths["error"]), - "command": logging.FileHandler(paths["command"]), - "mdanalysis": logging.FileHandler(paths["mdanalysis"]), - } + rich_handler = RichHandler( + console=self.console, + markup=True, + rich_tracebacks=True, + show_time=True, + show_level=True, + show_path=False, + ) + rich_handler.setLevel(logging.INFO) - self.handlers["rich"].setLevel(logging.INFO) - self.handlers["main"].setLevel(self.level) - self.handlers["error"].setLevel(logging.ERROR) - self.handlers["command"].setLevel(logging.INFO) - self.handlers["mdanalysis"].setLevel(self.level) + main_handler = logging.FileHandler(paths["main"]) + main_handler.setLevel(self.level) + main_handler.setFormatter(formatter) - for name, handler in self.handlers.items(): - if name != "rich": - handler.setFormatter(formatter) + error_handler = logging.FileHandler(paths["error"]) + error_handler.setLevel(logging.ERROR) + error_handler.setFormatter(formatter) + error_handler.addFilter(ErrorFilter()) - # Add filter to error handler to ensure only ERROR and above are logged - self.handlers["error"].addFilter(ErrorFilter()) + command_handler = logging.FileHandler(paths["command"]) + command_handler.setLevel(logging.INFO) + command_handler.setFormatter(formatter) - def setup_logging(self): - """ - Configure the root logger and specific loggers with the prepared handlers. + mdanalysis_handler = logging.FileHandler(paths["mdanalysis"]) + mdanalysis_handler.setLevel(self.level) + mdanalysis_handler.setFormatter(formatter) + + self.handlers = { + "rich": rich_handler, + "main": main_handler, + "error": error_handler, + "command": command_handler, + "mdanalysis": mdanalysis_handler, + } + + def setup_logging(self) -> logging.Logger: + """Attach configured handlers to the appropriate loggers. + + This method: + - Attaches rich/main/error handlers to the root logger + - Attaches command handler to the 'commands' logger (non-propagating) + - Attaches MDAnalysis handler to the 'MDAnalysis' logger (non-propagating) Returns: - logging.Logger: Logger instance for the current module (__name__). + A logger for the current module. """ root = logging.getLogger() root.setLevel(self.level) - root.addHandler(self.handlers["rich"]) - root.addHandler(self.handlers["main"]) - root.addHandler(self.handlers["error"]) - logging.getLogger("commands").addHandler(self.handlers["command"]) - logging.getLogger("commands").setLevel(logging.INFO) - logging.getLogger("commands").propagate = False + self._add_handler_once(root, self.handlers["rich"]) + self._add_handler_once(root, self.handlers["main"]) + self._add_handler_once(root, self.handlers["error"]) + + commands_logger = logging.getLogger("commands") + commands_logger.setLevel(logging.INFO) + commands_logger.propagate = False + self._add_handler_once(commands_logger, self.handlers["command"]) - logging.getLogger("MDAnalysis").addHandler(self.handlers["mdanalysis"]) - logging.getLogger("MDAnalysis").setLevel(self.level) - logging.getLogger("MDAnalysis").propagate = False + mda_logger = logging.getLogger("MDAnalysis") + mda_logger.setLevel(self.level) + mda_logger.propagate = False + self._add_handler_once(mda_logger, self.handlers["mdanalysis"]) return logging.getLogger(__name__) - def update_logging_level(self, log_level): + @staticmethod + def _add_handler_once(logger_obj: logging.Logger, handler: logging.Handler) -> None: + """Attach a handler to a logger only if it isn't already attached. + + Args: + logger_obj: Logger to modify. + handler: Handler to attach. """ - Update the logging level for the root logger and specific sub-loggers. + if handler not in logger_obj.handlers: + logger_obj.addHandler(handler) + + def update_logging_level(self, log_level: int) -> None: + """Update logging levels for root and named loggers. + + Notes: + - FileHandlers are set to the new log_level. + - RichHandler is kept at INFO (or higher) for cleaner console output. Args: - log_level (int): New logging level (e.g., logging.DEBUG, logging.WARNING). + log_level: New logging level (e.g., logging.DEBUG). """ root_logger = logging.getLogger() root_logger.setLevel(log_level) - for handler in root_logger.handlers: + + self._set_logger_handlers_level(root_logger, log_level) + + for logger_name in ("commands", "MDAnalysis"): + named_logger = logging.getLogger(logger_name) + named_logger.setLevel(log_level) + self._set_logger_handlers_level(named_logger, log_level) + + @staticmethod + def _set_logger_handlers_level(logger_obj: logging.Logger, log_level: int) -> None: + """Apply level rules to all handlers on a logger. + + Args: + logger_obj: Logger whose handlers should be updated. + log_level: Target logging level for file handlers. + """ + for handler in logger_obj.handlers: if isinstance(handler, logging.FileHandler): handler.setLevel(log_level) else: - # Keep RichHandler at INFO or higher for nicer console output handler.setLevel(logging.INFO) - for logger_name in ["commands", "MDAnalysis"]: - logger = logging.getLogger(logger_name) - logger.setLevel(log_level) - for handler in logger.handlers: - if isinstance(handler, logging.FileHandler): - handler.setLevel(log_level) - else: - handler.setLevel(logging.INFO) - - def save_console_log(self, filename="program_output.txt"): - """ - Save all recorded console output to a text file. + def save_console_log(self, filename: str = "program_output.txt") -> None: + """Save recorded console output to a file. Args: - filename (str): Name of the file to write console output to. - Defaults to 'program_output.txt' in the logs directory. + filename: Output filename inside the log directory. """ output_path = os.path.join(self.log_dir, filename) os.makedirs(self.log_dir, exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: f.write(self.console.export_text()) From cab144058c9fe1d3550ba438fc8320caedf7759c Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 09:29:40 +0000 Subject: [PATCH 056/101] update `run.py` to use Google style Doc-strings --- CodeEntropy/config/run.py | 249 ++++++++++++++++++++++---------------- 1 file changed, 147 insertions(+), 102 deletions(-) diff --git a/CodeEntropy/config/run.py b/CodeEntropy/config/run.py index 8df7d14c..6013aba0 100644 --- a/CodeEntropy/config/run.py +++ b/CodeEntropy/config/run.py @@ -1,6 +1,24 @@ +"""Run orchestration for CodeEntropy. + +This module provides the RunManager, which is responsible for: +- Creating a new job folder for each run +- Loading YAML configuration and merging it with CLI arguments +- Setting up logging and displaying a Rich splash screen +- Building the MDAnalysis Universe (including optional force merging) +- Wiring dependencies and executing the EntropyManager workflow +- Providing physical-constants helpers used by entropy calculations + +Notes on design: +- RunManager focuses on orchestration and simple utilities only. +- Computational logic lives in EntropyManager and the level/entropy DAG modules. +""" + +from __future__ import annotations + import logging import os import pickle +from typing import Any, Dict, Optional import MDAnalysis as mda import requests @@ -27,78 +45,80 @@ class RunManager: - """ - Handles the setup and execution of entropy analysis runs, including configuration - loading, logging, and access to physical constants used in calculations. + """Coordinate setup and execution of entropy analysis runs. + + Responsibilities: + - Bootstrapping: job folder, logging, splash screen + - Configuration: YAML loading + CLI parsing + merge and validation + - Universe creation: MDAnalysis Universe (optionally merging forces) + - Dependency wiring and execution: EntropyManager + - Utilities used by downstream modules: constants and unit conversions + + Attributes: + folder: Working directory for the current job (e.g., job001). """ - def __init__(self, folder): - """ - Initializes the RunManager with the working folder and sets up configuration, - data logging, and logging systems. Also defines physical constants used in - entropy calculations. + _N_AVOGADRO = 6.0221415e23 + _DEF_TEMPER = 298 + + def __init__(self, folder: str) -> None: + """Initialize a RunManager for a given working folder. + + This sets up configuration helpers, data logging, and logging configuration. + It also defines physical constants used in entropy calculations. + + Args: + folder: Job folder path where logs and outputs will be written. """ self.folder = folder self._config_manager = ConfigManager() self._data_logger = DataLogger() self._logging_config = LoggingConfig(folder) - self._N_AVOGADRO = 6.0221415e23 - self._DEF_TEMPER = 298 @property - def N_AVOGADRO(self): - """Returns Avogadro's number used in entropy calculations.""" + def N_AVOGADRO(self) -> float: + """Return Avogadro's number used in entropy calculations.""" return self._N_AVOGADRO @property - def DEF_TEMPER(self): - """Returns the default temperature (in Kelvin) used in the analysis.""" + def DEF_TEMPER(self) -> float: + """Return the default temperature (K) used in the analysis.""" return self._DEF_TEMPER @staticmethod - def create_job_folder(): - """ - Create a new job folder with an incremented job number based on existing - folders. + def create_job_folder() -> str: + """Create a new job folder (job###) in the current working directory. + + The method searches existing folders that start with "job" and picks the next + integer suffix. If none exist, it creates job001. + + Returns: + The full path to the newly created job folder. """ - # Get the current working directory current_dir = os.getcwd() - - # Get a list of existing folders that start with "job" existing_folders = [f for f in os.listdir(current_dir) if f.startswith("job")] - # Extract numbers from existing folder names job_numbers = [] for folder in existing_folders: try: - # Assuming folder names are in the format "jobXXX" - job_number = int(folder[3:]) # Get the number part after "job" - job_numbers.append(job_number) + job_numbers.append(int(folder[3:])) except ValueError: - continue # Ignore any folder names that don't follow the pattern - - # If no folders exist, start with job001 - if not job_numbers: - next_job_number = 1 - else: - next_job_number = max(job_numbers) + 1 + continue - # Create the new job folder name + next_job_number = 1 if not job_numbers else max(job_numbers) + 1 new_job_folder = f"job{next_job_number:03d}" - - # Create the full path to the new folder new_folder_path = os.path.join(current_dir, new_job_folder) - - # Create the directory os.makedirs(new_folder_path, exist_ok=True) - # Return the path of the newly created folder return new_folder_path - def load_citation_data(self): - """ - Load CITATION.cff from GitHub into memory. - Return empty dict if offline. + def load_citation_data(self) -> Optional[Dict[str, Any]]: + """Load CITATION.cff from GitHub. + + If the request fails (offline, blocked, etc.), returns None. + + Returns: + Parsed CITATION.cff content as a dict, or None if unavailable. """ url = ( "https://raw.githubusercontent.com/CCPBioSim/" @@ -111,16 +131,14 @@ def load_citation_data(self): except requests.exceptions.RequestException: return None - def show_splash(self): - """Render splash screen with optional citation metadata.""" + def show_splash(self) -> None: + """Render a Rich splash screen with optional citation metadata.""" citation = self.load_citation_data() if citation: - # ASCII Title ascii_title = text2art(citation.get("title", "CodeEntropy")) ascii_render = Align.center(Text(ascii_title, style="bold white")) - # Metadata version = citation.get("version", "?") release_date = citation.get("date-released", "?") url = citation.get("url", citation.get("repository-code", "")) @@ -130,7 +148,6 @@ def show_splash(self): ) url_text = Align.center(Text(url, style="blue underline")) - # Description block abstract = citation.get("abstract", "No description available.") description_title = Align.center( Text("Description", style="bold magenta underline") @@ -139,7 +156,6 @@ def show_splash(self): Padding(Text(abstract, style="white", justify="left"), (0, 4)) ) - # Contributors table contributors_title = Align.center( Text("Contributors", style="bold magenta underline") ) @@ -159,7 +175,6 @@ def show_splash(self): contributors_table = Align.center(Padding(author_table, (0, 4))) - # Full layout splash_content = Group( ascii_render, Rule(style="cyan"), @@ -173,13 +188,9 @@ def show_splash(self): contributors_table, ) else: - # ASCII Title ascii_title = text2art("CodeEntropy") ascii_render = Align.center(Text(ascii_title, style="bold white")) - - splash_content = Group( - ascii_render, - ) + splash_content = Group(ascii_render) splash_panel = Panel( splash_content, @@ -192,9 +203,13 @@ def show_splash(self): console.print(splash_panel) - def print_args_table(self, args): - table = Table(title="Run Configuration", expand=True) + def print_args_table(self, args: Any) -> None: + """Print a Rich table of the run configuration arguments. + Args: + args: argparse Namespace or object with attributes for configuration. + """ + table = Table(title="Run Configuration", expand=True) table.add_column("Argument", style="cyan", no_wrap=True) table.add_column("Value", style="magenta") @@ -203,75 +218,63 @@ def print_args_table(self, args): console.print(table) - def run_entropy_workflow(self): - """ - Runs the entropy analysis workflow by setting up logging, loading configuration - files, parsing arguments, and executing the analysis for each configured run. - Initializes the MDAnalysis Universe and supporting managers, and logs all - relevant inputs and commands. + def run_entropy_workflow(self) -> None: + """Run the end-to-end entropy workflow. + + This method: + - Sets up logging and prints the splash screen + - Loads YAML config from CWD and parses CLI args + - Merges args with YAML per-run config + - Builds the MDAnalysis Universe (with optional force merging) + - Validates user parameters + - Constructs dependencies and executes EntropyManager + - Saves recorded console output to a log file + + Raises: + Exception: Re-raises any exception after logging with traceback. """ try: - logger = self._logging_config.setup_logging() + run_logger = self._logging_config.setup_logging() self.show_splash() current_directory = os.getcwd() - config = self._config_manager.load_config(current_directory) + parser = self._config_manager.setup_argparse() args, _ = parser.parse_known_args() args.output_file = os.path.join(self.folder, args.output_file) for run_name, run_config in config.items(): if not isinstance(run_config, dict): - logger.warning( - f"Run configuration for {run_name} is not a dictionary." + run_logger.warning( + "Run configuration for %s is not a dictionary.", run_name ) continue args = self._config_manager.merge_configs(args, run_config) - log_level = logging.DEBUG if args.verbose else logging.INFO + log_level = ( + logging.DEBUG if getattr(args, "verbose", False) else logging.INFO + ) self._logging_config.update_logging_level(log_level) command = " ".join(os.sys.argv) logging.getLogger("commands").info(command) - if not getattr(args, "top_traj_file", None): - raise ValueError("Missing 'top_traj_file' argument.") - if not getattr(args, "selection_string", None): - raise ValueError("Missing 'selection_string' argument.") + self._validate_required_args(args) self.print_args_table(args) - # Load MDAnalysis Universe - tprfile = args.top_traj_file[0] - trrfile = args.top_traj_file[1:] - forcefile = args.force_file - fileformat = args.file_format - kcal_units = args.kcal_force_units - - # Create shared UniverseOperations instance universe_operations = UniverseOperations() - - if forcefile is None: - logger.debug(f"Loading Universe with {tprfile} and {trrfile}") - u = mda.Universe(tprfile, trrfile, format=fileformat) - else: - u = universe_operations.merge_forces( - tprfile, trrfile, forcefile, fileformat, kcal_units - ) + u = self._build_universe(args, universe_operations) self._config_manager.input_parameters_validation(u, args) - # Create GroupMolecules instance group_molecules = GroupMolecules() - - # Create shared DihedralAnalysis with injected universe_operations dihedral_analysis = DihedralAnalysis( universe_operations=universe_operations ) - # Inject all dependencies into EntropyManager entropy_manager = EntropyManager( run_manager=self, args=args, @@ -281,17 +284,58 @@ def run_entropy_workflow(self): dihedral_analysis=dihedral_analysis, universe_operations=universe_operations, ) - entropy_manager.execute() self._logging_config.save_console_log() except Exception as e: - logger.error(f"RunManager encountered an error: {e}", exc_info=True) + logger.error("RunManager encountered an error: %s", e, exc_info=True) raise - def write_universe(self, u, name="default"): - """Write a universe to working directories as pickle + @staticmethod + def _validate_required_args(args: Any) -> None: + """Validate presence of required arguments. + + Args: + args: argparse Namespace or similar. + + Raises: + ValueError: If required arguments are missing. + """ + if not getattr(args, "top_traj_file", None): + raise ValueError("Missing 'top_traj_file' argument.") + if not getattr(args, "selection_string", None): + raise ValueError("Missing 'selection_string' argument.") + + @staticmethod + def _build_universe( + args: Any, universe_operations: UniverseOperations + ) -> mda.Universe: + """Create an MDAnalysis Universe from args. + + Args: + args: Parsed arguments containing topology/trajectory and force settings. + universe_operations: UniverseOperations utility instance. + + Returns: + An MDAnalysis Universe ready for analysis. + """ + tprfile = args.top_traj_file[0] + trrfile = args.top_traj_file[1:] + forcefile = args.force_file + fileformat = args.file_format + kcal_units = args.kcal_force_units + + if forcefile is None: + logger.debug("Loading Universe with %s and %s", tprfile, trrfile) + return mda.Universe(tprfile, trrfile, format=fileformat) + + return universe_operations.merge_forces( + tprfile, trrfile, forcefile, fileformat, kcal_units + ) + + def write_universe(self, u: mda.Universe, name: str = "default") -> str: + """Write a universe to disk as a pickle. Parameters ---------- @@ -307,11 +351,12 @@ def write_universe(self, u, name="default"): filename of saved universe """ filename = f"{name}.pkl" - pickle.dump(u, open(filename, "wb")) + with open(filename, "wb") as f: + pickle.dump(u, f) return name - def read_universe(self, path): - """read a universe to working directories as pickle + def read_universe(self, path: str) -> mda.Universe: + """Read a universe from disk (pickle). Parameters ---------- @@ -324,15 +369,15 @@ def read_universe(self, path): A Universe object will all topology, dihedrals,coordinates and force information. """ - u = pickle.load(open(path, "rb")) - return u + with open(path, "rb") as f: + return pickle.load(f) - def change_lambda_units(self, arg_lambdas): + def change_lambda_units(self, arg_lambdas: Any) -> Any: """Unit of lambdas : kJ2 mol-2 A-2 amu-1 change units of lambda to J/s2""" # return arg_lambdas * N_AVOGADRO * N_AVOGADRO * AMU2KG * 1e-26 return arg_lambdas * 1e29 / self.N_AVOGADRO - def get_KT2J(self, arg_temper): + def get_KT2J(self, arg_temper: float) -> float: """A temperature dependent KT to Joule conversion""" return 4.11e-21 * arg_temper / self.DEF_TEMPER From 009e64d57883d870b11fd81b9dada19f42cf8872 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 10:44:49 +0000 Subject: [PATCH 057/101] fix(universe): restore legacy force merge behaviour with silent fallback --- CodeEntropy/levels/mda_universe_operations.py | 305 ++++++++++-------- 1 file changed, 175 insertions(+), 130 deletions(-) diff --git a/CodeEntropy/levels/mda_universe_operations.py b/CodeEntropy/levels/mda_universe_operations.py index 115072a4..1a0c4271 100644 --- a/CodeEntropy/levels/mda_universe_operations.py +++ b/CodeEntropy/levels/mda_universe_operations.py @@ -1,50 +1,30 @@ -"""MDAnalysis universe utilities. +""" +MDAnalysis universe utilities. -This module provides helper functions for creating and manipulating MDAnalysis -`Universe` objects used throughout the CodeEntropy workflow. +This module contains helpers for creating reduced MDAnalysis `Universe` objects by +sub-selecting frames and/or atoms, and for building a `Universe` that combines +coordinates from one trajectory with forces sourced from a second trajectory. """ from __future__ import annotations import logging -from dataclasses import dataclass from typing import Optional import MDAnalysis as mda from MDAnalysis.analysis.base import AnalysisFromFunction from MDAnalysis.coordinates.memory import MemoryReader +from MDAnalysis.exceptions import NoDataError logger = logging.getLogger(__name__) -@dataclass(frozen=True) -class TrajectorySlice: - """Frame slicing configuration for trajectory selection. - - Attributes: - start: Starting frame index (inclusive). If None, defaults to 0. - end: Ending frame index (exclusive). If None, defaults to len(trajectory). - step: Step between frames. Must be >= 1. - """ - - start: Optional[int] = None - end: Optional[int] = None - step: int = 1 - - class UniverseOperations: - """Utility methods for creating and manipulating MDAnalysis universes. - - This class focuses on a small set of responsibilities: - - Build reduced universes by selecting frames or atoms. - - Extract a single molecule (fragment) as its own universe. - - Merge coordinates and forces from separate trajectories into one universe. + """Functions to create and manipulate MDAnalysis Universe objects.""" - Notes: - These methods return new `MDAnalysis.Universe` objects backed by in-memory - trajectories. This makes downstream operations deterministic and avoids - side effects on the original universe. - """ + def __init__(self) -> None: + """Initialise the operations helper.""" + self._universe = None def new_U_select_frame( self, @@ -53,23 +33,26 @@ def new_U_select_frame( end: Optional[int] = None, step: int = 1, ) -> mda.Universe: - """Create a reduced universe by slicing frames. - - Args: - u: Source universe containing topology, coordinates, forces, and dimensions. - start: Starting frame index (inclusive). If None, defaults to 0. - end: Ending frame index (exclusive). If None, defaults to len(u.trajectory). - step: Step between frames. Must be >= 1. - - Returns: - A new universe containing the same atoms as `u` but only the selected frames - - Raises: - ValueError: If `step` is less than 1. + """Create a reduced universe by dropping frames according to user selection. + + Parameters + ---------- + u: + A Universe object with topology, coordinates and (optionally) forces. + start: + Frame index to start analysis. If None, defaults to 0. + end: + Frame index to stop analysis (Python slicing semantics). If None, defaults + to the full trajectory length. + step: + Step size between frames. + + Returns + ------- + mda.Universe: + A reduced universe containing the selected frames, with coordinates, + forces (if present) and unit cell dimensions loaded into memory. """ - if step < 1: - raise ValueError("step must be >= 1") - if start is None: start = 0 if end is None: @@ -77,21 +60,13 @@ def new_U_select_frame( select_atom = u.select_atoms("all", updating=True) - coordinates = ( - AnalysisFromFunction(lambda ag: ag.positions.copy(), select_atom) - .run() - .results["timeseries"][start:end:step] - ) - forces = ( - AnalysisFromFunction(lambda ag: ag.forces.copy(), select_atom) - .run() - .results["timeseries"][start:end:step] - ) - dimensions = ( - AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) - .run() - .results["timeseries"][start:end:step] - ) + coordinates = self._extract_timeseries(select_atom, kind="positions")[ + start:end:step + ] + forces = self._extract_timeseries(select_atom, kind="forces")[start:end:step] + dimensions = self._extract_timeseries(select_atom, kind="dimensions")[ + start:end:step + ] u2 = mda.Merge(select_atom) u2.load_new( @@ -101,38 +76,32 @@ def new_U_select_frame( dimensions=dimensions, ) - logger.debug("Created reduced universe by frames: %s", u2) + logger.debug("MDAnalysis.Universe - reduced universe (frame-selected): %s", u2) return u2 def new_U_select_atom( self, u: mda.Universe, select_string: str = "all" ) -> mda.Universe: - """Create a reduced universe by selecting a subset of atoms. - - Args: - u: Source universe containing topology, coordinates, forces, and dimensions. - select_string: MDAnalysis selection string. - - Returns: - A new universe containing only the selected atoms across all frames. + """Create a reduced universe by dropping atoms according to user selection. + + Parameters + ---------- + u: + A Universe object with topology, coordinates and (optionally) forces. + select_string: + MDAnalysis `select_atoms` selection string. + + Returns + ------- + mda.Universe: + A reduced universe containing only the selected atoms. Coordinates, + forces (if present) and dimensions are loaded into memory. """ select_atom = u.select_atoms(select_string, updating=True) - coordinates = ( - AnalysisFromFunction(lambda ag: ag.positions.copy(), select_atom) - .run() - .results["timeseries"] - ) - forces = ( - AnalysisFromFunction(lambda ag: ag.forces.copy(), select_atom) - .run() - .results["timeseries"] - ) - dimensions = ( - AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) - .run() - .results["timeseries"] - ) + coordinates = self._extract_timeseries(select_atom, kind="positions") + forces = self._extract_timeseries(select_atom, kind="forces") + dimensions = self._extract_timeseries(select_atom, kind="dimensions") u2 = mda.Merge(select_atom) u2.load_new( @@ -142,87 +111,163 @@ def new_U_select_atom( dimensions=dimensions, ) - logger.debug("Created reduced universe by atoms: %s", u2) + logger.debug("MDAnalysis.Universe - reduced universe (atom-selected): %s", u2) return u2 def get_molecule_container( self, universe: mda.Universe, molecule_id: int ) -> mda.Universe: - """Extract a single molecule (fragment) from a universe. + """Extract a single molecule (fragment) as a standalone reduced universe. Args: - universe: Universe containing the system. - molecule_id: Index of the fragment (molecule) to extract. + universe: + The source universe. + molecule_id: + Fragment index in `universe.atoms.fragments`. Returns: - A new universe containing only the atoms from the specified fragment. - - Raises: - IndexError: If `molecule_id` is out of range. - ValueError: If the fragment has no atoms. + mda.Universe: + A reduced universe containing only the atoms of the selected fragment. """ - fragments = universe.atoms.fragments - frag = fragments[molecule_id] - if len(frag) == 0: - raise ValueError(f"Fragment {molecule_id} is empty.") - + frag = universe.atoms.fragments[molecule_id] selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}" return self.new_U_select_atom(universe, selection_string) def merge_forces( self, tprfile: str, - trrfile: str, + trrfile, forcefile: str, fileformat: Optional[str] = None, kcal: bool = False, + *, + force_format: Optional[str] = None, + fallback_to_positions_if_no_forces: bool = True, ) -> mda.Universe: - """Merge coordinates and forces trajectories into a single universe. + """Create a universe by merging coordinates and forces from different files. + + This method loads: + - coordinates + dimensions from the coordinate trajectory (tprfile + trrfile) + - forces from the force trajectory (tprfile + forcefile) + + If the force trajectory does not expose forces in MDAnalysis (e.g. the file + does not contain forces, or the reader does not provide them), then: + - if `fallback_to_positions_if_no_forces` is True, positions from the force + trajectory are used as the "forces" array (backwards-compatible behaviour + with earlier implementations). + - otherwise, the underlying `NoDataError` is raised. Args: - tprfile: Topology input file. - trrfile: Coordinate trajectory file. - forcefile: Force trajectory file. - fileformat: Optional MDAnalysis format string (e.g., "TRR"). - kcal: If True, convert forces from kcal to kJ by multiplying by 4.184. + tprfile: + Topology input file. + trrfile: + Coordinate trajectory file(s). This can be a single path or a list, + as accepted by MDAnalysis. + forcefile: + Trajectory containing forces. + fileformat: + Optional file format for the coordinate trajectory, as recognised by + MDAnalysis. + kcal: + If True, scale the force array by 4.184 to convert from kcal to kJ. + force_format: + Optional file format for the force trajectory. If not provided, uses + `fileformat`. + fallback_to_positions_if_no_forces: + If True, and the force trajectory has no accessible forces, use + positions from the force trajectory as a fallback (legacy behaviour). Returns: - A universe where coordinates come from `trrfile` and forces come from - `forcefile`. - - Raises: - ValueError: If the coordinate and force trajectories are incompatible. + mda.Universe: + A new Universe containing coordinates, forces and dimensions loaded + into memory. """ - logger.debug("Loading coordinates universe: %s", trrfile) + logger.debug("Loading coordinate Universe with %s", trrfile) u = mda.Universe(tprfile, trrfile, format=fileformat) - logger.debug("Loading forces universe: %s", forcefile) - u_force = mda.Universe(tprfile, forcefile, format=fileformat) + ff = force_format if force_format is not None else fileformat + logger.debug("Loading force Universe with %s", forcefile) + u_force = mda.Universe(tprfile, forcefile, format=ff) select_atom = u.select_atoms("all") select_atom_force = u_force.select_atoms("all") - coordinates = ( - AnalysisFromFunction(lambda ag: ag.positions.copy(), select_atom) - .run() - .results["timeseries"] - ) - forces = ( - AnalysisFromFunction(lambda ag: ag.forces.copy(), select_atom_force) - .run() - .results["timeseries"] - ) - dimensions = ( - AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) - .run() - .results["timeseries"] + coordinates = self._extract_timeseries(select_atom, kind="positions") + dimensions = self._extract_timeseries(select_atom, kind="dimensions") + + forces = self._extract_force_timeseries_with_fallback( + select_atom_force, + fallback_to_positions_if_no_forces=fallback_to_positions_if_no_forces, ) if kcal: - forces = forces * 4.184 + forces *= 4.184 logger.debug("Merging forces with coordinates universe.") new_universe = mda.Merge(select_atom) - new_universe.load_new(coordinates, forces=forces, dimensions=dimensions) + new_universe.load_new( + coordinates, + forces=forces, + dimensions=dimensions, + ) return new_universe + + def _extract_timeseries(self, atomgroup, *, kind: str): + """Extract a time series array for the requested kind from an AtomGroup. + + Args: + atomgroup: + MDAnalysis AtomGroup (may be updating). + kind: + One of {"positions", "forces", "dimensions"}. + + Returns: + np.ndarray: + Time series with shape: + - positions: (n_frames, n_atoms, 3) + - forces: (n_frames, n_atoms, 3) if available, else raises + NoDataError + - dimensions:(n_frames, 6) or (n_frames, 3) depending on reader + """ + if kind == "positions": + func = self._positions_copy + elif kind == "forces": + func = self._forces_copy + elif kind == "dimensions": + func = self._dimensions_copy + else: + raise ValueError(f"Unknown timeseries kind: {kind}") + + return AnalysisFromFunction(func, atomgroup).run().results["timeseries"] + + def _positions_copy(self, ag): + """Return a copy of positions for AnalysisFromFunction.""" + return ag.positions.copy() + + def _forces_copy(self, ag): + """Return a copy of forces for AnalysisFromFunction.""" + return ag.forces.copy() + + def _dimensions_copy(self, ag): + """Return a copy of box dimensions for AnalysisFromFunction.""" + return ag.dimensions.copy() + + def _extract_force_timeseries_with_fallback( + self, + atomgroup_force, + *, + fallback_to_positions_if_no_forces: bool, + ): + """Extract force timeseries, optionally falling back to positions. + + This isolates the behaviour that changed your runtime outcome: older code + used positions from the force trajectory, which never triggered `NoDataError`. + This method keeps that behaviour available for backwards compatibility. + """ + try: + return self._extract_timeseries(atomgroup_force, kind="forces") + except NoDataError: + if not fallback_to_positions_if_no_forces: + raise + return self._extract_timeseries(atomgroup_force, kind="positions") From e070e238c097f3f533a57c3e727271283f38838b Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 13:47:21 +0000 Subject: [PATCH 058/101] fix(vibrational-entropy): restore legacy FT mode splitting to match main branch behaviour --- CodeEntropy/entropy/vibrational_entropy.py | 28 +++------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index 3f52c1e7..22731d02 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -269,30 +269,8 @@ def _sum_components( return float(np.sum(components)) if matrix_type in ("forcetorqueTRANS", "forcetorqueROT"): - first, second = self._split_halves(components) - if second.size == 0: - # Odd number of modes; fallback to total. - logger.warning( - "Combined FT spectrum has odd number of modes (%d); " - "returning total.", - components.size, - ) - return float(np.sum(components)) - - trans_components = first - rot_components = second - - if not highest_level: - trans_components = ( - trans_components[6:] - if trans_components.size > 6 - else np.array([], dtype=float) - ) - - return ( - float(np.sum(trans_components)) - if matrix_type == "forcetorqueTRANS" - else float(np.sum(rot_components)) - ) + if matrix_type == "forcetorqueTRANS": + return float(np.sum(components[:3])) + return float(np.sum(components[3:])) raise ValueError(f"Unknown matrix_type: {matrix_type}") From 9c5565b21f96b999ad367639f8c479e73bd146cc Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 14:58:07 +0000 Subject: [PATCH 059/101] tidy up comments within `level_hierarchy.py` --- CodeEntropy/levels/level_hierarchy.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/CodeEntropy/levels/level_hierarchy.py b/CodeEntropy/levels/level_hierarchy.py index 9e736294..5bae8108 100644 --- a/CodeEntropy/levels/level_hierarchy.py +++ b/CodeEntropy/levels/level_hierarchy.py @@ -95,10 +95,6 @@ def get_beads(self, data_container, level: str) -> List: raise ValueError(f"Unknown level: {level}") - # ------------------------------------------------------------------ - # Bead builders (single responsibility, testable) - # ------------------------------------------------------------------ - def _build_residue_beads(self, data_container) -> List: """Build one bead per residue using the container's residues. From 46500dd42766a0c5da7331ebd086ceeb42bea7be Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 15:02:41 +0000 Subject: [PATCH 060/101] ensure highest level flag within the UA is enabled --- .../entropy/nodes/vibrational_entropy_node.py | 121 ++++-------------- CodeEntropy/entropy/vibrational_entropy.py | 10 -- 2 files changed, 23 insertions(+), 108 deletions(-) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py index 09386614..e11f3129 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational_entropy_node.py @@ -16,7 +16,6 @@ GroupId = int ResidueId = int -GroupIndex = int CovKey = Tuple[GroupId, ResidueId] @@ -29,34 +28,13 @@ class _EntropyPair: class VibrationalEntropyNode: - """Compute vibrational entropy from force/torque (and optional FT) covariances. - - This node reads covariance matrices from ``shared_data`` and computes entropy - contributions for each group and level (e.g., united_atom, residue, polymer). - - If combined force/torque matrices are enabled and available, FT matrices are - used for the highest level at each group/level, otherwise separate force and - torque matrices are used. - - Results are written back into ``shared_data["vibrational_entropy"]``. - """ + """Compute vibrational entropy from force/torque (and optional FT) covariances.""" def __init__(self) -> None: self._mat_ops = MatrixOperations() + self._zero_atol = 1e-8 def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any]: - """Execute vibrational entropy calculation. - - Args: - shared_data: Shared workflow state dictionary. - - Returns: - Dictionary containing vibrational entropy results. - - Raises: - KeyError: If required keys are missing. - ValueError: If an unknown level is encountered. - """ ve = self._build_entropy_engine(shared_data) temp = shared_data["args"].temperature @@ -100,6 +78,7 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] ua_frame_counts=ua_frame_counts, data_logger=data_logger, n_frames_default=shared_data.get("n_frames", 0), + highest=highest, # IMPORTANT: matches main ) self._store_results(results, group_id, level, pair) self._log_molecule_level_results( @@ -110,15 +89,12 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] if level in ("residue", "polymer"): gi = gid2i[group_id] + # FT only applies at the highest level (same as main) if combined and highest and ft_cov is not None: ft_key = "res" if level == "residue" else "poly" ftmat = self._get_indexed_matrix(ft_cov.get(ft_key, []), gi) - pair = self._compute_ft_entropy( - ve=ve, - temp=temp, - ftmat=ftmat, - ) + pair = self._compute_ft_entropy(ve=ve, temp=temp, ftmat=ftmat) self._store_results(results, group_id, level, pair) self._log_molecule_level_results( data_logger, group_id, level, pair, use_ft_labels=True @@ -151,14 +127,6 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] def _build_entropy_engine( self, shared_data: Mapping[str, Any] ) -> VibrationalEntropy: - """Create the entropy calculation engine. - - Args: - shared_data: Shared workflow state. - - Returns: - VibrationalEntropy instance. - """ return VibrationalEntropy( run_manager=shared_data["run_manager"], args=shared_data["args"], @@ -168,14 +136,6 @@ def _build_entropy_engine( ) def _get_group_id_to_index(self, shared_data: Mapping[str, Any]) -> Dict[int, int]: - """Get mapping from group id to contiguous index. - - Args: - shared_data: Shared workflow state. - - Returns: - Mapping of group id -> index. - """ gid2i = shared_data.get("group_id_to_index") if isinstance(gid2i, dict) and gid2i: return gid2i @@ -183,14 +143,6 @@ def _get_group_id_to_index(self, shared_data: Mapping[str, Any]) -> Dict[int, in return {gid: i for i, gid in enumerate(groups.keys())} def _get_ua_frame_counts(self, shared_data: Mapping[str, Any]) -> Dict[CovKey, int]: - """Get per-residue frame counts for united atom computations. - - Args: - shared_data: Shared workflow state. - - Returns: - Mapping (group_id, res_id) -> frame count. - """ counts = shared_data.get("frame_counts", {}) if isinstance(counts, dict): ua_counts = counts.get("ua", {}) @@ -210,23 +162,8 @@ def _compute_united_atom_entropy( ua_frame_counts: Mapping[CovKey, int], data_logger: Optional[Any], n_frames_default: int, + highest: bool, ) -> _EntropyPair: - """Compute united atom vibrational entropy for a group. - - Args: - ve: Vibrational entropy engine. - temp: Temperature used for entropy calculation. - group_id: Group identifier. - residues: Residues iterable for the representative molecule. - force_ua: UA-level force covariance matrices. - torque_ua: UA-level torque covariance matrices. - ua_frame_counts: UA per-residue frame counts. - data_logger: Optional data logger for residue-level output. - n_frames_default: Default frame count if per-residue count is missing. - - Returns: - Total translational and rotational entropy for UA level. - """ s_trans_total = 0.0 s_rot_total = 0.0 @@ -240,7 +177,7 @@ def _compute_united_atom_entropy( temp=temp, fmat=fmat, tmat=tmat, - highest=False, + highest=highest, ) s_trans_total += pair.trans @@ -276,23 +213,19 @@ def _compute_force_torque_entropy( tmat: Any, highest: bool, ) -> _EntropyPair: - """Compute entropy from separate force and torque covariance matrices. - - Args: - ve: Vibrational entropy engine. - temp: Temperature. - fmat: Force covariance matrix. - tmat: Torque covariance matrix. - highest: Whether this is the highest level. - - Returns: - Translational and rotational entropy pair. - """ if fmat is None or tmat is None: return _EntropyPair(trans=0.0, rot=0.0) - f = self._mat_ops.filter_zero_rows_columns(np.asarray(fmat)) - t = self._mat_ops.filter_zero_rows_columns(np.asarray(tmat)) + f = self._mat_ops.filter_zero_rows_columns( + np.asarray(fmat), atol=self._zero_atol + ) + t = self._mat_ops.filter_zero_rows_columns( + np.asarray(tmat), atol=self._zero_atol + ) + + # If filtering removes everything, behave like "no data" + if f.size == 0 or t.size == 0: + return _EntropyPair(trans=0.0, rot=0.0) s_trans = ve.vibrational_entropy_calculation( f, "force", temp, highest_level=highest @@ -309,21 +242,16 @@ def _compute_ft_entropy( temp: float, ftmat: Any, ) -> _EntropyPair: - """Compute entropy from a combined force/torque covariance matrix. - - Args: - ve: Vibrational entropy engine. - temp: Temperature. - ftmat: Combined force/torque covariance matrix. - - Returns: - Translational and rotational entropy pair. - """ if ftmat is None: return _EntropyPair(trans=0.0, rot=0.0) - ft = self._mat_ops.filter_zero_rows_columns(np.asarray(ftmat)) + ft = self._mat_ops.filter_zero_rows_columns( + np.asarray(ftmat), atol=self._zero_atol + ) + if ft.size == 0: + return _EntropyPair(trans=0.0, rot=0.0) + # FT is only used at highest level in main branch s_trans = ve.vibrational_entropy_calculation( ft, "forcetorqueTRANS", temp, highest_level=True ) @@ -339,7 +267,6 @@ def _store_results( level: str, pair: _EntropyPair, ) -> None: - """Store computed entropy pair into results dict.""" results[group_id][level] = {"trans": pair.trans, "rot": pair.rot} @staticmethod @@ -351,7 +278,6 @@ def _log_molecule_level_results( *, use_ft_labels: bool, ) -> None: - """Log molecule-level results, if a data logger is present.""" if data_logger is None: return @@ -369,7 +295,6 @@ def _log_molecule_level_results( @staticmethod def _get_indexed_matrix(mats: Any, index: int) -> Any: - """Safely fetch a matrix from a list-like container.""" try: return mats[index] if index < len(mats) else None except TypeError: diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational_entropy.py index 22731d02..0265d560 100644 --- a/CodeEntropy/entropy/vibrational_entropy.py +++ b/CodeEntropy/entropy/vibrational_entropy.py @@ -247,16 +247,6 @@ def _sum_components( matrix_type: MatrixType, highest_level: bool, ) -> float: - """Sum entropy components according to the matrix type and level rules. - - Args: - components: Per-mode entropy components. - matrix_type: Type selector. - highest_level: Whether this is the highest level. - - Returns: - Summed entropy value. - """ if components.size == 0: return 0.0 From 8ec06e1a3cfb649d7650037a58c8d3e295e46e26 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 19 Feb 2026 16:09:50 +0000 Subject: [PATCH 061/101] Fix force/torque covariance construction to align with main entropy results --- CodeEntropy/levels/nodes/frame_covariance.py | 30 +++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index 4a590467..631f9583 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -94,6 +94,8 @@ def run(self, ctx: FrameCtx) -> Dict[str, Any]: axes_manager=axes_manager, box=box, force_partitioning=fp, + customised_axes=customised_axes, + is_highest=("united_atom" == level_list[-1]), out_force=out_force, out_torque=out_torque, molcount=ua_molcount, @@ -154,11 +156,12 @@ def _process_united_atom( axes_manager: Any, box: Optional[np.ndarray], force_partitioning: float, + customised_axes: bool, + is_highest: bool, out_force: Dict[str, Dict[Any, Matrix]], out_torque: Dict[str, Dict[Any, Matrix]], molcount: Dict[Tuple[int, int], int], ) -> None: - """Compute UA-level per-residue force/torque second moments for one molecule.""" for local_res_i, res in enumerate(mol.residues): bead_key = (mol_id, "united_atom", local_res_i) bead_idx_list = beads.get(bead_key, []) @@ -170,11 +173,13 @@ def _process_united_atom( continue force_vecs, torque_vecs = self._build_ua_vectors( - bead_groups=bead_groups, residue_atoms=res.atoms, + bead_groups=bead_groups, axes_manager=axes_manager, box=box, force_partitioning=force_partitioning, + customised_axes=customised_axes, + is_highest=is_highest, ) F, T = self._ft.compute_frame_covariance(force_vecs, torque_vecs) @@ -322,21 +327,32 @@ def _build_ua_vectors( axes_manager: Any, box: Optional[np.ndarray], force_partitioning: float, + customised_axes: bool, + is_highest: bool, ) -> Tuple[List[np.ndarray], List[np.ndarray]]: - """Build force/torque vectors for UA beads belonging to a single residue.""" force_vecs: List[np.ndarray] = [] torque_vecs: List[np.ndarray] = [] for ua_i, bead in enumerate(bead_groups): - trans_axes, rot_axes, center, moi = axes_manager.get_UA_axes( - residue_atoms, ua_i - ) + if customised_axes and axes_manager is not None: + trans_axes, rot_axes, center, moi = axes_manager.get_UA_axes( + residue_atoms, ua_i + ) + else: + make_whole(residue_atoms) + make_whole(bead) + + trans_axes = residue_atoms.principal_axes() + rot_axes = np.real(bead.principal_axes()) + eigvals, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) + moi = sorted(eigvals, reverse=True) + center = bead.center_of_mass(unwrap=True) force_vecs.append( self._ft.get_weighted_forces( bead=bead, trans_axes=np.asarray(trans_axes), - highest_level=False, + highest_level=is_highest, force_partitioning=force_partitioning, ) ) From 79bb04bd1985d69b9cff538bf3d2662cd64b080e Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 11:50:08 +0000 Subject: [PATCH 062/101] remove redundant `FrameAxes` node and replace with calls to `AxesManager` --- CodeEntropy/levels/force_torque_manager.py | 10 +- CodeEntropy/levels/frame_dag.py | 8 +- CodeEntropy/levels/hierarchy_graph.py | 11 +- CodeEntropy/levels/nodes/frame_axes.py | 159 ------------------- CodeEntropy/levels/nodes/frame_covariance.py | 59 ++----- 5 files changed, 18 insertions(+), 229 deletions(-) delete mode 100644 CodeEntropy/levels/nodes/frame_axes.py diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/force_torque_manager.py index e2bb9686..16a49e7e 100644 --- a/CodeEntropy/levels/force_torque_manager.py +++ b/CodeEntropy/levels/force_torque_manager.py @@ -214,15 +214,9 @@ def _displacements_relative_to_center( box: Optional[np.ndarray], ) -> np.ndarray: """ - Compute displacement vectors from center to positions (optionally PBC-aware). + Compute displacement vectors from center to positions. """ - if ( - axes_manager is not None - and hasattr(axes_manager, "get_vector") - and box is not None - ): - return axes_manager.get_vector(center, positions, box) - return positions - center + return axes_manager.get_vector(center, positions, box) @staticmethod def _outer_second_moment(vectors: Sequence[Vector3]) -> Matrix: diff --git a/CodeEntropy/levels/frame_dag.py b/CodeEntropy/levels/frame_dag.py index cdbf64a6..2d1fb2df 100644 --- a/CodeEntropy/levels/frame_dag.py +++ b/CodeEntropy/levels/frame_dag.py @@ -14,7 +14,6 @@ import networkx as nx -from CodeEntropy.levels.nodes.frame_axes import FrameAxesNode from CodeEntropy.levels.nodes.frame_covariance import FrameCovarianceNode logger = logging.getLogger(__name__) @@ -27,14 +26,12 @@ class FrameContext: Attributes: shared: Shared workflow data (mutated across the full workflow). frame_index: Absolute trajectory frame index being processed. - frame_axes: Frame-local axes output produced by FrameAxesNode. frame_covariance: Frame-local covariance output produced by FrameCovarianceNode. data: Additional frame-local scratch space for nodes, if needed. """ shared: Dict[str, Any] frame_index: int - frame_axes: Any = None frame_covariance: Any = None data: Dict[str, Any] = None @@ -46,7 +43,6 @@ class FrameDAG: `ctx["shared"]` and must write only frame-local outputs into the frame context. Expected node outputs: - - "frame_axes" - "frame_covariance" """ @@ -67,8 +63,7 @@ def build(self) -> "FrameDAG": Returns: Self, to allow fluent chaining. """ - self._add("frame_axes", FrameAxesNode(self._universe_operations)) - self._add("frame_covariance", FrameCovarianceNode(), deps=["frame_axes"]) + self._add("frame_covariance", FrameCovarianceNode()) return self def execute_frame(self, shared_data: Dict[str, Any], frame_index: int) -> Any: @@ -117,6 +112,5 @@ def _make_frame_ctx( return { "shared": shared_data, "frame_index": frame_index, - "frame_axes": None, "frame_covariance": None, } diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/hierarchy_graph.py index 477b4968..fa214541 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/hierarchy_graph.py @@ -21,12 +21,12 @@ import networkx as nx +from CodeEntropy.axes import AxesManager from CodeEntropy.levels.frame_dag import FrameDAG from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode -from CodeEntropy.levels.nodes.frame_axes import FrameAxesNode from CodeEntropy.levels.nodes.init_covariance_accumulators import ( InitCovarianceAccumulatorsNode, ) @@ -70,14 +70,6 @@ def build(self) -> "LevelDAG": self._add_static("detect_levels", DetectLevelsNode(), deps=["detect_molecules"]) self._add_static("build_beads", BuildBeadsNode(), deps=["detect_levels"]) - # Produces a frame axes manager stored in shared_data (node name is explicit - # to avoid ambiguity with per-frame axes). - self._add_static( - "frame_axes_manager", - FrameAxesNode(), - deps=["detect_molecules"], - ) - self._add_static( "init_covariance_accumulators", InitCovarianceAccumulatorsNode(), @@ -101,6 +93,7 @@ def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: Returns: The mutated shared_data dict. """ + shared_data.setdefault("axes_manager", AxesManager()) self._run_static_stage(shared_data) self._run_frame_stage(shared_data) return shared_data diff --git a/CodeEntropy/levels/nodes/frame_axes.py b/CodeEntropy/levels/nodes/frame_axes.py deleted file mode 100644 index 582c8de4..00000000 --- a/CodeEntropy/levels/nodes/frame_axes.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Frame-local axes calculation. - -This module defines a frame DAG node that computes per-molecule translational -axes for a single trajectory frame and exposes an AxesManager for downstream -nodes that need consistent axes and PBC-aware vectors. -""" - -from __future__ import annotations - -import logging -from typing import Any, Dict, Mapping, MutableMapping, Tuple - -import numpy as np - -from CodeEntropy.axes import AxesManager -from CodeEntropy.levels.mda_universe_operations import UniverseOperations - -logger = logging.getLogger(__name__) - -SharedData = MutableMapping[str, Any] -FrameContext = MutableMapping[str, Any] - - -class FrameAxesNode: - """Compute per-frame translational axes for each molecule. - - This node operates in two modes: - - 1) Frame-DAG mode: - - Input is a frame context dict containing: - ctx["shared"] -> shared_data dict - ctx["frame_index"] -> absolute trajectory frame index - - Writes frame-local result to: - ctx["frame_axes"] - - 2) Static mode: - - Input is the shared_data dict directly. - - Uses shared_data["frame_index"] if present, otherwise defaults to 0. - - Returns the computed axes payload (and also provides it in a synthetic - frame context). - - Produces: - - shared_data["axes_manager"]: AxesManager instance for downstream nodes. - - frame_ctx["frame_axes"]: Dict containing: - { - "trans": {mol_id: np.ndarray shape (3, 3)}, - "custom": bool - } - """ - - def __init__(self, universe_operations: UniverseOperations | None = None) -> None: - """Initialize the node. - - Args: - universe_operations: Helper for universe operations. If None, a default - UniverseOperations instance is created. - """ - self._universe_operations = universe_operations or UniverseOperations() - self._axes_manager = AxesManager() - - def run(self, ctx: Dict[str, Any]) -> Dict[str, Any]: - """Run the axes calculation for a single frame. - - Args: - ctx: Either a frame context dict (Frame-DAG mode) or shared_data (static - mode). - - Returns: - A dict with translational axes and whether customized axes are enabled. - - Raises: - KeyError: If required data is missing. - """ - frame_ctx, shared, frame_index = self._resolve_context(ctx) - - universe = self._get_universe(shared) - use_custom_axes = self._use_custom_axes(shared) - - # Expose the AxesManager for downstream nodes (e.g., torque with PBC vectors). - shared["axes_manager"] = self._axes_manager - - # Ensure the universe is positioned at the correct frame before reading coords. - universe.trajectory[frame_index] - - trans_axes = self._compute_trans_axes(universe) - - result = {"trans": trans_axes, "custom": use_custom_axes} - frame_ctx["frame_axes"] = result - return result - - def _resolve_context( - self, ctx: Dict[str, Any] - ) -> Tuple[FrameContext, SharedData, int]: - """Resolve whether `ctx` is a frame context or shared_data. - - Args: - ctx: Frame context dict or shared_data dict. - - Returns: - Tuple of (frame_ctx, shared_data, frame_index). - """ - if "shared" in ctx: - shared = ctx["shared"] - frame_index = int(ctx["frame_index"]) - return ctx, shared, frame_index - - shared = ctx - frame_index = int(shared.get("frame_index", shared.get("time_index", 0))) - frame_ctx: FrameContext = {"shared": shared, "frame_index": frame_index} - return frame_ctx, shared, frame_index - - @staticmethod - def _get_universe(shared: Mapping[str, Any]) -> Any: - """Fetch the universe to operate on. - - Args: - shared: Shared data mapping. - - Returns: - MDAnalysis universe-like object. - - Raises: - KeyError: If neither reduced_universe nor universe is present. - """ - universe = shared.get("reduced_universe") or shared.get("universe") - if universe is None: - raise KeyError("shared_data must contain 'reduced_universe' or 'universe'") - return universe - - @staticmethod - def _use_custom_axes(shared: Mapping[str, Any]) -> bool: - """Determine whether customized axes are enabled. - - Args: - shared: Shared data mapping. - - Returns: - True if customized axes are enabled. - """ - args = shared["args"] - return bool(getattr(args, "customised_axes", False)) - - def _compute_trans_axes(self, universe: Any) -> Dict[int, np.ndarray]: - """Compute translational axes for each molecule in the universe. - - Args: - universe: MDAnalysis universe-like object. - - Returns: - Mapping from molecule id to translational axes (3x3). - """ - trans_axes: Dict[int, np.ndarray] = {} - fragments = universe.atoms.fragments - - for mol_id, mol in enumerate(fragments): - _, axes = self._axes_manager.get_vanilla_axes(mol) - trans_axes[mol_id] = np.asarray(axes, dtype=float) - - return trans_axes diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/frame_covariance.py index 631f9583..7822905d 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/frame_covariance.py @@ -12,7 +12,7 @@ Not responsible for: - Defining groups/levels/beads mapping (provided via shared context). -- Axis construction policy (delegated to axes_manager when present). +- Axis construction policy (delegated to axes_manager). - Accumulating across frames (handled by the higher-level reducer). """ @@ -28,7 +28,6 @@ logger = logging.getLogger(__name__) - FrameCtx = Dict[str, Any] Matrix = np.ndarray @@ -46,7 +45,7 @@ def run(self, ctx: FrameCtx) -> Dict[str, Any]: ctx: Frame context dict expected to include: - "shared": dict containing reduced_universe, groups, levels, beads, args - - may include axes_manager + - MUST include shared["axes_manager"] (created in static stage) Returns: The frame covariance payload also stored at ctx["frame_covariance"]. @@ -55,16 +54,17 @@ def run(self, ctx: FrameCtx) -> Dict[str, Any]: KeyError: If ctx is missing required fields. """ shared = self._get_shared(ctx) + u = shared["reduced_universe"] groups = shared["groups"] levels = shared["levels"] beads = shared["beads"] args = shared["args"] + axes_manager = shared.get("axes_manager") fp = float(args.force_partitioning) combined = bool(getattr(args, "combined_forcetorque", False)) customised_axes = bool(getattr(args, "customised_axes", False)) - axes_manager = shared.get("axes_manager") box = self._try_get_box(u) fragments = u.atoms.fragments @@ -138,7 +138,7 @@ def run(self, ctx: FrameCtx) -> Dict[str, Any]: combined=combined, ) - frame_cov = {"force": out_force, "torque": out_torque} + frame_cov: Dict[str, Any] = {"force": out_force, "torque": out_torque} if combined and out_ft is not None: frame_cov["forcetorque"] = out_ft @@ -330,11 +330,12 @@ def _build_ua_vectors( customised_axes: bool, is_highest: bool, ) -> Tuple[List[np.ndarray], List[np.ndarray]]: + """Build force/torque vectors for UA-level beads of one residue.""" force_vecs: List[np.ndarray] = [] torque_vecs: List[np.ndarray] = [] for ua_i, bead in enumerate(bead_groups): - if customised_axes and axes_manager is not None: + if customised_axes: trans_axes, rot_axes, center, moi = axes_manager.get_UA_axes( residue_atoms, ua_i ) @@ -343,9 +344,7 @@ def _build_ua_vectors( make_whole(bead) trans_axes = residue_atoms.principal_axes() - rot_axes = np.real(bead.principal_axes()) - eigvals, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) - moi = sorted(eigvals, reverse=True) + rot_axes, moi = axes_manager.get_vanilla_axes(bead) center = bead.center_of_mass(unwrap=True) force_vecs.append( @@ -426,7 +425,7 @@ def _get_residue_axes( customised_axes: bool, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Get translation/rotation axes, center and MOI for a residue bead.""" - if customised_axes and axes_manager is not None: + if customised_axes: res = mol.residues[local_res_i] return axes_manager.get_residue_axes(mol, local_res_i, residue=res.atoms) @@ -434,14 +433,7 @@ def _get_residue_axes( make_whole(bead) trans_axes = mol.atoms.principal_axes() - - if axes_manager is not None: - rot_axes, moi = axes_manager.get_vanilla_axes(bead) - else: - rot_axes = np.real(bead.principal_axes()) - eigvals, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) - moi = sorted(eigvals, reverse=True) - + rot_axes, moi = axes_manager.get_vanilla_axes(bead) center = bead.center_of_mass(unwrap=True) return ( np.asarray(trans_axes), @@ -462,15 +454,9 @@ def _get_polymer_axes( make_whole(bead) trans_axes = mol.atoms.principal_axes() - - if axes_manager is not None: - rot_axes, moi = axes_manager.get_vanilla_axes(bead) - else: - rot_axes = np.real(bead.principal_axes()) - eigvals, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) - moi = sorted(eigvals, reverse=True) - + rot_axes, moi = axes_manager.get_vanilla_axes(bead) center = bead.center_of_mass(unwrap=True) + return ( np.asarray(trans_axes), np.asarray(rot_axes), @@ -495,16 +481,7 @@ def _try_get_box(u: Any) -> Optional[np.ndarray]: @staticmethod def _inc_mean(old: Optional[np.ndarray], new: np.ndarray, n: int) -> np.ndarray: - """Compute an incremental mean (streaming average). - - Args: - old: Existing mean matrix or None. - new: New sample matrix. - n: 1-indexed number of samples incorporated into the mean. - - Returns: - Updated mean matrix. - """ + """Compute an incremental mean (streaming average).""" if old is None: return new.copy() return old + (new - old) / float(n) @@ -517,16 +494,6 @@ def _build_ft_block( For each bead i, create a 6-vector [Fi, Ti]. The block matrix is built from outer products of these 6-vectors. - - Args: - force_vecs: List of force vectors, each shape (3,). - torque_vecs: List of torque vectors, each shape (3,). - - Returns: - Block matrix of shape (6N, 6N). - - Raises: - ValueError: If vector sizes are invalid. """ if len(force_vecs) != len(torque_vecs): raise ValueError("force_vecs and torque_vecs must have the same length.") From 222a09fcab3651814c07f48a459376fbce7bc5e4 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 12:26:08 +0000 Subject: [PATCH 063/101] removal of `config/utils` folder --- CodeEntropy/config/utils/__init__.py | 0 CodeEntropy/config/utils/io_utils.py | 0 CodeEntropy/config/utils/math_utils.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 CodeEntropy/config/utils/__init__.py delete mode 100644 CodeEntropy/config/utils/io_utils.py delete mode 100644 CodeEntropy/config/utils/math_utils.py diff --git a/CodeEntropy/config/utils/__init__.py b/CodeEntropy/config/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/CodeEntropy/config/utils/io_utils.py b/CodeEntropy/config/utils/io_utils.py deleted file mode 100644 index e69de29b..00000000 diff --git a/CodeEntropy/config/utils/math_utils.py b/CodeEntropy/config/utils/math_utils.py deleted file mode 100644 index e69de29b..00000000 From 91c865a662ae5078d386310e612db7fcddd7ace1 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 14:07:35 +0000 Subject: [PATCH 064/101] refactor: reorganize package structure for clarity and separation of concerns - move axes module into levels package - split logging into core infrastructure module - separate results reporting into results package - remove redundant entropy_* naming - rename group_molecules to molecules/grouping - introduce cli entrypoint and __main__ module - reorganize config into argparse + runtime - normalize module names across levels and entropy No functional changes. --- CodeEntropy/__main__.py | 6 ++++++ CodeEntropy/{main.py => cli.py} | 20 +++---------------- .../{arg_config_manager.py => argparse.py} | 0 CodeEntropy/config/{run.py => runtime.py} | 14 ++++++------- CodeEntropy/core/__init__.py | 0 .../logging_config.py => core/logging.py} | 0 CodeEntropy/entropy/__init__.py | 0 ...rational_entropy.py => configurational.py} | 0 .../entropy/{entropy_graph.py => graph.py} | 8 +++----- .../{entropy_manager.py => manager.py} | 10 +++++----- CodeEntropy/entropy/nodes/__init__.py | 0 ...aggregate_entropy_node.py => aggregate.py} | 0 ...nal_entropy_node.py => configurational.py} | 2 +- ...ational_entropy_node.py => vibrational.py} | 4 ++-- ...entational_entropy.py => orientational.py} | 0 ...{vibrational_entropy.py => vibrational.py} | 0 .../entropy/{water_entropy.py => water.py} | 0 CodeEntropy/levels/__init__.py | 0 CodeEntropy/{ => levels}/axes.py | 0 .../{dihedral_tools.py => dihedrals.py} | 0 .../{force_torque_manager.py => forces.py} | 0 CodeEntropy/levels/frame_dag.py | 2 +- .../{level_hierarchy.py => hierarchy.py} | 0 .../{hierarchy_graph.py => level_dag.py} | 10 ++++------ .../{matrix_operations.py => linalg.py} | 0 .../{mda_universe_operations.py => mda.py} | 0 CodeEntropy/levels/nodes/__init__.py | 0 ...riance_accumulators.py => accumulators.py} | 0 .../levels/nodes/{build_beads.py => beads.py} | 2 +- ...{compute_dihedrals.py => conformations.py} | 2 +- .../{frame_covariance.py => covariance.py} | 2 +- CodeEntropy/levels/nodes/detect_levels.py | 2 +- CodeEntropy/levels/nodes/detect_molecules.py | 2 +- CodeEntropy/molecules/__init__.py | 0 .../grouping.py} | 0 CodeEntropy/results/__init__.py | 0 .../data_logger.py => results/reporter.py} | 2 +- pyproject.toml | 2 +- .../test_arg_config_manager.py | 2 +- tests/test_CodeEntropy/test_axes.py | 2 +- tests/test_CodeEntropy/test_data_logger.py | 4 ++-- tests/test_CodeEntropy/test_entropy.py | 2 +- tests/test_CodeEntropy/test_levels.py | 2 +- tests/test_CodeEntropy/test_logging_config.py | 2 +- 44 files changed, 46 insertions(+), 58 deletions(-) create mode 100644 CodeEntropy/__main__.py rename CodeEntropy/{main.py => cli.py} (56%) rename CodeEntropy/config/{arg_config_manager.py => argparse.py} (100%) rename CodeEntropy/config/{run.py => runtime.py} (96%) create mode 100644 CodeEntropy/core/__init__.py rename CodeEntropy/{config/logging_config.py => core/logging.py} (100%) create mode 100644 CodeEntropy/entropy/__init__.py rename CodeEntropy/entropy/{configurational_entropy.py => configurational.py} (100%) rename CodeEntropy/entropy/{entropy_graph.py => graph.py} (92%) rename CodeEntropy/entropy/{entropy_manager.py => manager.py} (97%) create mode 100644 CodeEntropy/entropy/nodes/__init__.py rename CodeEntropy/entropy/nodes/{aggregate_entropy_node.py => aggregate.py} (100%) rename CodeEntropy/entropy/nodes/{configurational_entropy_node.py => configurational.py} (99%) rename CodeEntropy/entropy/nodes/{vibrational_entropy_node.py => vibrational.py} (98%) rename CodeEntropy/entropy/{orientational_entropy.py => orientational.py} (100%) rename CodeEntropy/entropy/{vibrational_entropy.py => vibrational.py} (100%) rename CodeEntropy/entropy/{water_entropy.py => water.py} (100%) create mode 100644 CodeEntropy/levels/__init__.py rename CodeEntropy/{ => levels}/axes.py (100%) rename CodeEntropy/levels/{dihedral_tools.py => dihedrals.py} (100%) rename CodeEntropy/levels/{force_torque_manager.py => forces.py} (100%) rename CodeEntropy/levels/{level_hierarchy.py => hierarchy.py} (100%) rename CodeEntropy/levels/{hierarchy_graph.py => level_dag.py} (96%) rename CodeEntropy/levels/{matrix_operations.py => linalg.py} (100%) rename CodeEntropy/levels/{mda_universe_operations.py => mda.py} (100%) create mode 100644 CodeEntropy/levels/nodes/__init__.py rename CodeEntropy/levels/nodes/{init_covariance_accumulators.py => accumulators.py} (100%) rename CodeEntropy/levels/nodes/{build_beads.py => beads.py} (99%) rename CodeEntropy/levels/nodes/{compute_dihedrals.py => conformations.py} (98%) rename CodeEntropy/levels/nodes/{frame_covariance.py => covariance.py} (99%) create mode 100644 CodeEntropy/molecules/__init__.py rename CodeEntropy/{group_molecules/group_molecules.py => molecules/grouping.py} (100%) create mode 100644 CodeEntropy/results/__init__.py rename CodeEntropy/{config/data_logger.py => results/reporter.py} (99%) diff --git a/CodeEntropy/__main__.py b/CodeEntropy/__main__.py new file mode 100644 index 00000000..55b3fbe2 --- /dev/null +++ b/CodeEntropy/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from CodeEntropy.cli import main + +if __name__ == "__main__": + main() diff --git a/CodeEntropy/main.py b/CodeEntropy/cli.py similarity index 56% rename from CodeEntropy/main.py rename to CodeEntropy/cli.py index 3f9c3fbe..b7d5a419 100644 --- a/CodeEntropy/main.py +++ b/CodeEntropy/cli.py @@ -1,23 +1,20 @@ """Command-line entry point for CodeEntropy. -This module provides the program entry point used to run the multiscale cell +This module provides the CLI entry point used to run the multiscale cell correlation entropy workflow. The entry point is intentionally small and only responsible for: 1) Creating a job folder. - 2) Constructing a :class:`~CodeEntropy.config.run.RunManager`. + 2) Constructing a RunManager. 3) Executing the entropy workflow. 4) Handling fatal errors with a non-zero exit code. - -All scientific computation and I/O orchestration lives in RunManager and the -workflow components it coordinates. """ from __future__ import annotations import logging -from CodeEntropy.config.run import RunManager +from CodeEntropy.config.runtime import RunManager logger = logging.getLogger(__name__) @@ -25,13 +22,6 @@ def main() -> None: """Run the entropy workflow. - Main function for calculating the entropy of a system using the multiscale cell - correlation method. - - This function is the CLI entry point. It creates the output/job folder, then - delegates to :class:`~CodeEntropy.config.run.RunManager` to execute the full - workflow. - Raises: SystemExit: Exits with status code 1 on any unhandled exception. """ @@ -45,7 +35,3 @@ def main() -> None: "Fatal error during entropy calculation: %s", exc, exc_info=True ) raise SystemExit(1) from exc - - -if __name__ == "__main__": - main() # pragma: no cover diff --git a/CodeEntropy/config/arg_config_manager.py b/CodeEntropy/config/argparse.py similarity index 100% rename from CodeEntropy/config/arg_config_manager.py rename to CodeEntropy/config/argparse.py diff --git a/CodeEntropy/config/run.py b/CodeEntropy/config/runtime.py similarity index 96% rename from CodeEntropy/config/run.py rename to CodeEntropy/config/runtime.py index 6013aba0..c5e4c96b 100644 --- a/CodeEntropy/config/run.py +++ b/CodeEntropy/config/runtime.py @@ -32,13 +32,13 @@ from rich.table import Table from rich.text import Text -from CodeEntropy.config.arg_config_manager import ConfigManager -from CodeEntropy.config.data_logger import DataLogger -from CodeEntropy.config.logging_config import LoggingConfig -from CodeEntropy.entropy.entropy_manager import EntropyManager -from CodeEntropy.group_molecules.group_molecules import GroupMolecules -from CodeEntropy.levels.dihedral_tools import DihedralAnalysis -from CodeEntropy.levels.mda_universe_operations import UniverseOperations +from CodeEntropy.config.argparse import ConfigManager +from CodeEntropy.core.logging import LoggingConfig +from CodeEntropy.entropy.manager import EntropyManager +from CodeEntropy.levels.dihedrals import DihedralAnalysis +from CodeEntropy.levels.mda import UniverseOperations +from CodeEntropy.molecules.grouping import GroupMolecules +from CodeEntropy.results.reporter import DataLogger logger = logging.getLogger(__name__) console = LoggingConfig.get_console() diff --git a/CodeEntropy/core/__init__.py b/CodeEntropy/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/config/logging_config.py b/CodeEntropy/core/logging.py similarity index 100% rename from CodeEntropy/config/logging_config.py rename to CodeEntropy/core/logging.py diff --git a/CodeEntropy/entropy/__init__.py b/CodeEntropy/entropy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/entropy/configurational_entropy.py b/CodeEntropy/entropy/configurational.py similarity index 100% rename from CodeEntropy/entropy/configurational_entropy.py rename to CodeEntropy/entropy/configurational.py diff --git a/CodeEntropy/entropy/entropy_graph.py b/CodeEntropy/entropy/graph.py similarity index 92% rename from CodeEntropy/entropy/entropy_graph.py rename to CodeEntropy/entropy/graph.py index 9081c9b2..7a343324 100644 --- a/CodeEntropy/entropy/entropy_graph.py +++ b/CodeEntropy/entropy/graph.py @@ -19,11 +19,9 @@ import networkx as nx -from CodeEntropy.entropy.nodes.aggregate_entropy_node import AggregateEntropyNode -from CodeEntropy.entropy.nodes.configurational_entropy_node import ( - ConfigurationalEntropyNode, -) -from CodeEntropy.entropy.nodes.vibrational_entropy_node import VibrationalEntropyNode +from CodeEntropy.entropy.nodes.aggregate import AggregateEntropyNode +from CodeEntropy.entropy.nodes.configurational import ConfigurationalEntropyNode +from CodeEntropy.entropy.nodes.vibrational import VibrationalEntropyNode logger = logging.getLogger(__name__) diff --git a/CodeEntropy/entropy/entropy_manager.py b/CodeEntropy/entropy/manager.py similarity index 97% rename from CodeEntropy/entropy/entropy_manager.py rename to CodeEntropy/entropy/manager.py index 383c428f..09fc75be 100644 --- a/CodeEntropy/entropy/entropy_manager.py +++ b/CodeEntropy/entropy/manager.py @@ -23,11 +23,11 @@ import pandas as pd -from CodeEntropy.config.logging_config import LoggingConfig -from CodeEntropy.entropy.entropy_graph import EntropyGraph -from CodeEntropy.entropy.water_entropy import WaterEntropy -from CodeEntropy.levels.hierarchy_graph import LevelDAG -from CodeEntropy.levels.level_hierarchy import LevelHierarchy +from CodeEntropy.core.logging import LoggingConfig +from CodeEntropy.entropy.graph import EntropyGraph +from CodeEntropy.entropy.water import WaterEntropy +from CodeEntropy.levels.hierarchy import LevelHierarchy +from CodeEntropy.levels.level_dag import LevelDAG logger = logging.getLogger(__name__) console = LoggingConfig.get_console() diff --git a/CodeEntropy/entropy/nodes/__init__.py b/CodeEntropy/entropy/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/entropy/nodes/aggregate_entropy_node.py b/CodeEntropy/entropy/nodes/aggregate.py similarity index 100% rename from CodeEntropy/entropy/nodes/aggregate_entropy_node.py rename to CodeEntropy/entropy/nodes/aggregate.py diff --git a/CodeEntropy/entropy/nodes/configurational_entropy_node.py b/CodeEntropy/entropy/nodes/configurational.py similarity index 99% rename from CodeEntropy/entropy/nodes/configurational_entropy_node.py rename to CodeEntropy/entropy/nodes/configurational.py index 0a716e91..e8aac400 100644 --- a/CodeEntropy/entropy/nodes/configurational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/configurational.py @@ -17,7 +17,7 @@ import numpy as np -from CodeEntropy.entropy.configurational_entropy import ConformationalEntropy +from CodeEntropy.entropy.configurational import ConformationalEntropy logger = logging.getLogger(__name__) diff --git a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py b/CodeEntropy/entropy/nodes/vibrational.py similarity index 98% rename from CodeEntropy/entropy/nodes/vibrational_entropy_node.py rename to CodeEntropy/entropy/nodes/vibrational.py index e11f3129..de617b1f 100644 --- a/CodeEntropy/entropy/nodes/vibrational_entropy_node.py +++ b/CodeEntropy/entropy/nodes/vibrational.py @@ -8,8 +8,8 @@ import numpy as np -from CodeEntropy.entropy.vibrational_entropy import VibrationalEntropy -from CodeEntropy.levels.matrix_operations import MatrixOperations +from CodeEntropy.entropy.vibrational import VibrationalEntropy +from CodeEntropy.levels.linalg import MatrixOperations logger = logging.getLogger(__name__) diff --git a/CodeEntropy/entropy/orientational_entropy.py b/CodeEntropy/entropy/orientational.py similarity index 100% rename from CodeEntropy/entropy/orientational_entropy.py rename to CodeEntropy/entropy/orientational.py diff --git a/CodeEntropy/entropy/vibrational_entropy.py b/CodeEntropy/entropy/vibrational.py similarity index 100% rename from CodeEntropy/entropy/vibrational_entropy.py rename to CodeEntropy/entropy/vibrational.py diff --git a/CodeEntropy/entropy/water_entropy.py b/CodeEntropy/entropy/water.py similarity index 100% rename from CodeEntropy/entropy/water_entropy.py rename to CodeEntropy/entropy/water.py diff --git a/CodeEntropy/levels/__init__.py b/CodeEntropy/levels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/axes.py b/CodeEntropy/levels/axes.py similarity index 100% rename from CodeEntropy/axes.py rename to CodeEntropy/levels/axes.py diff --git a/CodeEntropy/levels/dihedral_tools.py b/CodeEntropy/levels/dihedrals.py similarity index 100% rename from CodeEntropy/levels/dihedral_tools.py rename to CodeEntropy/levels/dihedrals.py diff --git a/CodeEntropy/levels/force_torque_manager.py b/CodeEntropy/levels/forces.py similarity index 100% rename from CodeEntropy/levels/force_torque_manager.py rename to CodeEntropy/levels/forces.py diff --git a/CodeEntropy/levels/frame_dag.py b/CodeEntropy/levels/frame_dag.py index 2d1fb2df..fa94e469 100644 --- a/CodeEntropy/levels/frame_dag.py +++ b/CodeEntropy/levels/frame_dag.py @@ -14,7 +14,7 @@ import networkx as nx -from CodeEntropy.levels.nodes.frame_covariance import FrameCovarianceNode +from CodeEntropy.levels.nodes.covariance import FrameCovarianceNode logger = logging.getLogger(__name__) diff --git a/CodeEntropy/levels/level_hierarchy.py b/CodeEntropy/levels/hierarchy.py similarity index 100% rename from CodeEntropy/levels/level_hierarchy.py rename to CodeEntropy/levels/hierarchy.py diff --git a/CodeEntropy/levels/hierarchy_graph.py b/CodeEntropy/levels/level_dag.py similarity index 96% rename from CodeEntropy/levels/hierarchy_graph.py rename to CodeEntropy/levels/level_dag.py index fa214541..fadb8606 100644 --- a/CodeEntropy/levels/hierarchy_graph.py +++ b/CodeEntropy/levels/level_dag.py @@ -21,15 +21,13 @@ import networkx as nx -from CodeEntropy.axes import AxesManager +from CodeEntropy.levels.axes import AxesManager from CodeEntropy.levels.frame_dag import FrameDAG -from CodeEntropy.levels.nodes.build_beads import BuildBeadsNode -from CodeEntropy.levels.nodes.compute_dihedrals import ComputeConformationalStatesNode +from CodeEntropy.levels.nodes.accumulators import InitCovarianceAccumulatorsNode +from CodeEntropy.levels.nodes.beads import BuildBeadsNode +from CodeEntropy.levels.nodes.conformations import ComputeConformationalStatesNode from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode -from CodeEntropy.levels.nodes.init_covariance_accumulators import ( - InitCovarianceAccumulatorsNode, -) logger = logging.getLogger(__name__) diff --git a/CodeEntropy/levels/matrix_operations.py b/CodeEntropy/levels/linalg.py similarity index 100% rename from CodeEntropy/levels/matrix_operations.py rename to CodeEntropy/levels/linalg.py diff --git a/CodeEntropy/levels/mda_universe_operations.py b/CodeEntropy/levels/mda.py similarity index 100% rename from CodeEntropy/levels/mda_universe_operations.py rename to CodeEntropy/levels/mda.py diff --git a/CodeEntropy/levels/nodes/__init__.py b/CodeEntropy/levels/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/levels/nodes/init_covariance_accumulators.py b/CodeEntropy/levels/nodes/accumulators.py similarity index 100% rename from CodeEntropy/levels/nodes/init_covariance_accumulators.py rename to CodeEntropy/levels/nodes/accumulators.py diff --git a/CodeEntropy/levels/nodes/build_beads.py b/CodeEntropy/levels/nodes/beads.py similarity index 99% rename from CodeEntropy/levels/nodes/build_beads.py rename to CodeEntropy/levels/nodes/beads.py index 7efb538e..c2eed62e 100644 --- a/CodeEntropy/levels/nodes/build_beads.py +++ b/CodeEntropy/levels/nodes/beads.py @@ -17,7 +17,7 @@ import numpy as np -from CodeEntropy.levels.level_hierarchy import LevelHierarchy +from CodeEntropy.levels.hierarchy import LevelHierarchy logger = logging.getLogger(__name__) diff --git a/CodeEntropy/levels/nodes/compute_dihedrals.py b/CodeEntropy/levels/nodes/conformations.py similarity index 98% rename from CodeEntropy/levels/nodes/compute_dihedrals.py rename to CodeEntropy/levels/nodes/conformations.py index 7675da46..671605b2 100644 --- a/CodeEntropy/levels/nodes/compute_dihedrals.py +++ b/CodeEntropy/levels/nodes/conformations.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from typing import Any, Dict -from CodeEntropy.levels.dihedral_tools import DihedralAnalysis +from CodeEntropy.levels.dihedrals import DihedralAnalysis SharedData = Dict[str, Any] ConformationalStates = Dict[str, Any] diff --git a/CodeEntropy/levels/nodes/frame_covariance.py b/CodeEntropy/levels/nodes/covariance.py similarity index 99% rename from CodeEntropy/levels/nodes/frame_covariance.py rename to CodeEntropy/levels/nodes/covariance.py index 7822905d..607bb9a5 100644 --- a/CodeEntropy/levels/nodes/frame_covariance.py +++ b/CodeEntropy/levels/nodes/covariance.py @@ -24,7 +24,7 @@ import numpy as np from MDAnalysis.lib.mdamath import make_whole -from CodeEntropy.levels.force_torque_manager import ForceTorqueManager +from CodeEntropy.levels.forces import ForceTorqueManager logger = logging.getLogger(__name__) diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index 3b518e4e..0d5819a6 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Tuple -from CodeEntropy.levels.level_hierarchy import LevelHierarchy +from CodeEntropy.levels.hierarchy import LevelHierarchy SharedData = Dict[str, Any] Levels = List[List[str]] diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index 1ac8bf08..873c8184 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -10,7 +10,7 @@ import logging from typing import Any, Dict -from CodeEntropy.group_molecules.group_molecules import GroupMolecules +from CodeEntropy.molecules.grouping import GroupMolecules logger = logging.getLogger(__name__) diff --git a/CodeEntropy/molecules/__init__.py b/CodeEntropy/molecules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/group_molecules/group_molecules.py b/CodeEntropy/molecules/grouping.py similarity index 100% rename from CodeEntropy/group_molecules/group_molecules.py rename to CodeEntropy/molecules/grouping.py diff --git a/CodeEntropy/results/__init__.py b/CodeEntropy/results/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/CodeEntropy/config/data_logger.py b/CodeEntropy/results/reporter.py similarity index 99% rename from CodeEntropy/config/data_logger.py rename to CodeEntropy/results/reporter.py index bd9777af..ee47ca40 100644 --- a/CodeEntropy/config/data_logger.py +++ b/CodeEntropy/results/reporter.py @@ -18,7 +18,7 @@ from rich.console import Console from rich.table import Table -from CodeEntropy.config.logging_config import LoggingConfig +from CodeEntropy.core.logging import LoggingConfig logger = logging.getLogger(__name__) console = LoggingConfig.get_console() diff --git a/pyproject.toml b/pyproject.toml index ac13a9dc..5f4be879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ docs = [ ] [project.scripts] -CodeEntropy = "CodeEntropy.main:main" +CodeEntropy = "CodeEntropy.cli:main" [tool.isort] profile = "black" diff --git a/tests/test_CodeEntropy/test_arg_config_manager.py b/tests/test_CodeEntropy/test_arg_config_manager.py index bf6d2202..23144f28 100644 --- a/tests/test_CodeEntropy/test_arg_config_manager.py +++ b/tests/test_CodeEntropy/test_arg_config_manager.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, mock_open, patch import tests.data as data -from CodeEntropy.config.arg_config_manager import ConfigManager +from CodeEntropy.config.argparse import ConfigManager from CodeEntropy.main import main from tests.test_CodeEntropy.test_base import BaseTestCase diff --git a/tests/test_CodeEntropy/test_axes.py b/tests/test_CodeEntropy/test_axes.py index b5f41aa6..d8992743 100644 --- a/tests/test_CodeEntropy/test_axes.py +++ b/tests/test_CodeEntropy/test_axes.py @@ -2,7 +2,7 @@ import numpy as np -from CodeEntropy.axes import AxesManager +from CodeEntropy.levels.axes import AxesManager from tests.test_CodeEntropy.test_base import BaseTestCase diff --git a/tests/test_CodeEntropy/test_data_logger.py b/tests/test_CodeEntropy/test_data_logger.py index 9d657f13..ae503286 100644 --- a/tests/test_CodeEntropy/test_data_logger.py +++ b/tests/test_CodeEntropy/test_data_logger.py @@ -4,9 +4,9 @@ import numpy as np import pandas as pd -from CodeEntropy.config.data_logger import DataLogger -from CodeEntropy.config.logging_config import LoggingConfig +from CodeEntropy.core.logging import LoggingConfig from CodeEntropy.main import main +from CodeEntropy.results.reporter import DataLogger from tests.test_CodeEntropy.test_base import BaseTestCase diff --git a/tests/test_CodeEntropy/test_entropy.py b/tests/test_CodeEntropy/test_entropy.py index 72b54664..80fee4aa 100644 --- a/tests/test_CodeEntropy/test_entropy.py +++ b/tests/test_CodeEntropy/test_entropy.py @@ -12,7 +12,6 @@ import pytest import tests.data as data -from CodeEntropy.config.data_logger import DataLogger from CodeEntropy.entropy import ( ConformationalEntropy, EntropyManager, @@ -22,6 +21,7 @@ from CodeEntropy.levels import LevelManager from CodeEntropy.main import main from CodeEntropy.mda_universe_operations import UniverseOperations +from CodeEntropy.results.reporter import DataLogger from CodeEntropy.run import ConfigManager, RunManager from tests.test_CodeEntropy.test_base import BaseTestCase diff --git a/tests/test_CodeEntropy/test_levels.py b/tests/test_CodeEntropy/test_levels.py index eb853df0..4223d869 100644 --- a/tests/test_CodeEntropy/test_levels.py +++ b/tests/test_CodeEntropy/test_levels.py @@ -2,8 +2,8 @@ import numpy as np -from CodeEntropy.axes import AxesManager from CodeEntropy.levels import LevelManager +from CodeEntropy.levels.axes import AxesManager from CodeEntropy.mda_universe_operations import UniverseOperations from tests.test_CodeEntropy.test_base import BaseTestCase diff --git a/tests/test_CodeEntropy/test_logging_config.py b/tests/test_CodeEntropy/test_logging_config.py index 7a07b2aa..17b60cd1 100644 --- a/tests/test_CodeEntropy/test_logging_config.py +++ b/tests/test_CodeEntropy/test_logging_config.py @@ -3,7 +3,7 @@ import unittest from unittest.mock import MagicMock -from CodeEntropy.config.logging_config import LoggingConfig +from CodeEntropy.core.logging import LoggingConfig from tests.test_CodeEntropy.test_base import BaseTestCase From e2acb830aef37aba2f78b7c4c721a815e0adc4e0 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 15:07:55 +0000 Subject: [PATCH 065/101] refactor: rename core classes and standardise architecture - Rename workflow and entropy components for clearer responsibilities - Standardise manager/node/graph naming across modules - Align class names with current design (workflow + DAG model) - Improve consistency between entropy and levels packages - Update imports and wiring throughout codebase --- CodeEntropy/cli.py | 8 +-- CodeEntropy/config/argparse.py | 14 ++-- CodeEntropy/config/runtime.py | 52 +++++++-------- CodeEntropy/core/logging.py | 6 +- CodeEntropy/entropy/configurational.py | 31 +++------ CodeEntropy/entropy/graph.py | 10 +-- CodeEntropy/entropy/nodes/configurational.py | 45 ++++--------- CodeEntropy/entropy/nodes/vibrational.py | 66 +++++++++---------- CodeEntropy/entropy/orientational.py | 6 +- CodeEntropy/entropy/vibrational.py | 17 +---- CodeEntropy/entropy/water.py | 26 ++++---- .../entropy/{manager.py => workflow.py} | 38 +++++------ CodeEntropy/levels/axes.py | 10 +-- CodeEntropy/levels/dihedrals.py | 20 +++--- CodeEntropy/levels/forces.py | 2 +- CodeEntropy/levels/frame_dag.py | 8 +-- CodeEntropy/levels/hierarchy.py | 4 +- CodeEntropy/levels/level_dag.py | 14 ++-- CodeEntropy/levels/linalg.py | 2 +- CodeEntropy/levels/mda.py | 10 ++- CodeEntropy/levels/nodes/beads.py | 10 +-- CodeEntropy/levels/nodes/conformations.py | 6 +- CodeEntropy/levels/nodes/covariance.py | 6 +- CodeEntropy/levels/nodes/detect_levels.py | 8 +-- CodeEntropy/levels/nodes/detect_molecules.py | 4 +- CodeEntropy/molecules/grouping.py | 2 +- CodeEntropy/results/reporter.py | 4 +- 27 files changed, 186 insertions(+), 243 deletions(-) rename CodeEntropy/entropy/{manager.py => workflow.py} (91%) diff --git a/CodeEntropy/cli.py b/CodeEntropy/cli.py index b7d5a419..6dcf2de1 100644 --- a/CodeEntropy/cli.py +++ b/CodeEntropy/cli.py @@ -5,7 +5,7 @@ The entry point is intentionally small and only responsible for: 1) Creating a job folder. - 2) Constructing a RunManager. + 2) Constructing a CodeEntropyRunner. 3) Executing the entropy workflow. 4) Handling fatal errors with a non-zero exit code. """ @@ -14,7 +14,7 @@ import logging -from CodeEntropy.config.runtime import RunManager +from CodeEntropy.config.runtime import CodeEntropyRunner logger = logging.getLogger(__name__) @@ -25,10 +25,10 @@ def main() -> None: Raises: SystemExit: Exits with status code 1 on any unhandled exception. """ - folder = RunManager.create_job_folder() + folder = CodeEntropyRunner.create_job_folder() try: - run_manager = RunManager(folder=folder) + run_manager = CodeEntropyRunner(folder=folder) run_manager.run_entropy_workflow() except Exception as exc: logger.critical( diff --git a/CodeEntropy/config/argparse.py b/CodeEntropy/config/argparse.py index aeb4f046..ec737301 100644 --- a/CodeEntropy/config/argparse.py +++ b/CodeEntropy/config/argparse.py @@ -4,7 +4,7 @@ 1) A declarative argument specification (`ARG_SPECS`) used to build an ``argparse.ArgumentParser``. -2) A `ConfigManager` that: +2) A `ConfigResolver` that: - loads YAML configuration (if present), - merges YAML values with CLI values (CLI wins), - adjusts logging verbosity, @@ -37,7 +37,7 @@ class ArgSpec: help: Help text shown in CLI usage. default: Default value if not provided via CLI or YAML. type: Python type for parsing (e.g., int, float, str, bool). If bool, - `ConfigManager.str2bool` will be used. + `ConfigResolver.str2bool` will be used. action: Optional argparse action (e.g., "store_true"). nargs: Optional nargs spec (e.g., "+"). """ @@ -145,7 +145,7 @@ class ArgSpec: } -class ConfigManager: +class ConfigResolver: """Load, merge, and validate CodeEntropy configuration. This class provides a consistent interface for: @@ -224,7 +224,7 @@ def str2bool(value: Any) -> bool: return False raise argparse.ArgumentTypeError("Boolean value expected (True/False).") - def setup_argparse(self) -> argparse.ArgumentParser: + def build_parser(self) -> argparse.ArgumentParser: """Build an ArgumentParser from argument specs. Returns: @@ -262,7 +262,7 @@ def setup_argparse(self) -> argparse.ArgumentParser: return parser - def merge_configs( + def resolve( self, args: argparse.Namespace, run_config: Optional[Dict[str, Any]] ) -> argparse.Namespace: """Merge CLI arguments with YAML configuration and adjust logging level. @@ -289,7 +289,7 @@ def merge_configs( args_dict = vars(args) - parser = self.setup_argparse() + parser = self.build_parser() default_args = parser.parse_args([]) default_dict = vars(default_args) @@ -360,7 +360,7 @@ def _apply_logging_level(verbose: bool) -> None: if verbose: logger.debug("Verbose mode enabled. Logger set to DEBUG level.") - def input_parameters_validation(self, u: Any, args: argparse.Namespace) -> None: + def validate_inputs(self, u: Any, args: argparse.Namespace) -> None: """Validate user inputs against sensible runtime constraints. Args: diff --git a/CodeEntropy/config/runtime.py b/CodeEntropy/config/runtime.py index c5e4c96b..f03dc98d 100644 --- a/CodeEntropy/config/runtime.py +++ b/CodeEntropy/config/runtime.py @@ -1,16 +1,16 @@ """Run orchestration for CodeEntropy. -This module provides the RunManager, which is responsible for: +This module provides the CodeEntropyRunner, which is responsible for: - Creating a new job folder for each run - Loading YAML configuration and merging it with CLI arguments - Setting up logging and displaying a Rich splash screen - Building the MDAnalysis Universe (including optional force merging) -- Wiring dependencies and executing the EntropyManager workflow +- Wiring dependencies and executing the EntropyWorkflow workflow - Providing physical-constants helpers used by entropy calculations Notes on design: -- RunManager focuses on orchestration and simple utilities only. -- Computational logic lives in EntropyManager and the level/entropy DAG modules. +- CodeEntropyRunner focuses on orchestration and simple utilities only. +- Computational logic lives in EntropyWorkflow and the level/entropy DAG modules. """ from __future__ import annotations @@ -32,26 +32,26 @@ from rich.table import Table from rich.text import Text -from CodeEntropy.config.argparse import ConfigManager +from CodeEntropy.config.argparse import ConfigResolver from CodeEntropy.core.logging import LoggingConfig -from CodeEntropy.entropy.manager import EntropyManager -from CodeEntropy.levels.dihedrals import DihedralAnalysis +from CodeEntropy.entropy.workflow import EntropyWorkflow +from CodeEntropy.levels.dihedrals import ConformationStateBuilder from CodeEntropy.levels.mda import UniverseOperations -from CodeEntropy.molecules.grouping import GroupMolecules -from CodeEntropy.results.reporter import DataLogger +from CodeEntropy.molecules.grouping import MoleculeGrouper +from CodeEntropy.results.reporter import ResultsReporter logger = logging.getLogger(__name__) console = LoggingConfig.get_console() -class RunManager: +class CodeEntropyRunner: """Coordinate setup and execution of entropy analysis runs. Responsibilities: - Bootstrapping: job folder, logging, splash screen - Configuration: YAML loading + CLI parsing + merge and validation - Universe creation: MDAnalysis Universe (optionally merging forces) - - Dependency wiring and execution: EntropyManager + - Dependency wiring and execution: EntropyWorkflow - Utilities used by downstream modules: constants and unit conversions Attributes: @@ -62,7 +62,7 @@ class RunManager: _DEF_TEMPER = 298 def __init__(self, folder: str) -> None: - """Initialize a RunManager for a given working folder. + """Initialize a CodeEntropyRunner for a given working folder. This sets up configuration helpers, data logging, and logging configuration. It also defines physical constants used in entropy calculations. @@ -71,8 +71,8 @@ def __init__(self, folder: str) -> None: folder: Job folder path where logs and outputs will be written. """ self.folder = folder - self._config_manager = ConfigManager() - self._data_logger = DataLogger() + self._config_manager = ConfigResolver() + self._reporter = ResultsReporter() self._logging_config = LoggingConfig(folder) @property @@ -227,20 +227,20 @@ def run_entropy_workflow(self) -> None: - Merges args with YAML per-run config - Builds the MDAnalysis Universe (with optional force merging) - Validates user parameters - - Constructs dependencies and executes EntropyManager + - Constructs dependencies and executes EntropyWorkflow - Saves recorded console output to a log file Raises: Exception: Re-raises any exception after logging with traceback. """ try: - run_logger = self._logging_config.setup_logging() + run_logger = self._logging_config.configure() self.show_splash() current_directory = os.getcwd() config = self._config_manager.load_config(current_directory) - parser = self._config_manager.setup_argparse() + parser = self._config_manager.build_parser() args, _ = parser.parse_known_args() args.output_file = os.path.join(self.folder, args.output_file) @@ -251,12 +251,12 @@ def run_entropy_workflow(self) -> None: ) continue - args = self._config_manager.merge_configs(args, run_config) + args = self._config_manager.resolve(args, run_config) log_level = ( logging.DEBUG if getattr(args, "verbose", False) else logging.INFO ) - self._logging_config.update_logging_level(log_level) + self._logging_config.set_level(log_level) command = " ".join(os.sys.argv) logging.getLogger("commands").info(command) @@ -268,28 +268,28 @@ def run_entropy_workflow(self) -> None: universe_operations = UniverseOperations() u = self._build_universe(args, universe_operations) - self._config_manager.input_parameters_validation(u, args) + self._config_manager.validate_inputs(u, args) - group_molecules = GroupMolecules() - dihedral_analysis = DihedralAnalysis( + group_molecules = MoleculeGrouper() + dihedral_analysis = ConformationStateBuilder( universe_operations=universe_operations ) - entropy_manager = EntropyManager( + entropy_manager = EntropyWorkflow( run_manager=self, args=args, universe=u, - data_logger=self._data_logger, + reporter=self._reporter, group_molecules=group_molecules, dihedral_analysis=dihedral_analysis, universe_operations=universe_operations, ) entropy_manager.execute() - self._logging_config.save_console_log() + self._logging_config.export_console() except Exception as e: - logger.error("RunManager encountered an error: %s", e, exc_info=True) + logger.error("CodeEntropyRunner encountered an error: %s", e, exc_info=True) raise @staticmethod diff --git a/CodeEntropy/core/logging.py b/CodeEntropy/core/logging.py index 3c5f44a7..4a0bfac4 100644 --- a/CodeEntropy/core/logging.py +++ b/CodeEntropy/core/logging.py @@ -132,7 +132,7 @@ def _setup_handlers(self) -> None: "mdanalysis": mdanalysis_handler, } - def setup_logging(self) -> logging.Logger: + def configure(self) -> logging.Logger: """Attach configured handlers to the appropriate loggers. This method: @@ -173,7 +173,7 @@ def _add_handler_once(logger_obj: logging.Logger, handler: logging.Handler) -> N if handler not in logger_obj.handlers: logger_obj.addHandler(handler) - def update_logging_level(self, log_level: int) -> None: + def set_level(self, log_level: int) -> None: """Update logging levels for root and named loggers. Notes: @@ -207,7 +207,7 @@ def _set_logger_handlers_level(logger_obj: logging.Logger, log_level: int) -> No else: handler.setLevel(logging.INFO) - def save_console_log(self, filename: str = "program_output.txt") -> None: + def export_console(self, filename: str = "program_output.txt") -> None: """Save recorded console output to a file. Args: diff --git a/CodeEntropy/entropy/configurational.py b/CodeEntropy/entropy/configurational.py index 9825ff71..45b08b96 100644 --- a/CodeEntropy/entropy/configurational.py +++ b/CodeEntropy/entropy/configurational.py @@ -19,7 +19,7 @@ @dataclass(frozen=True) -class ConformationAssignmentConfig: +class ConformationConfig: """Configuration for assigning conformational states from a dihedral. Attributes: @@ -53,28 +53,13 @@ class ConformationalEntropy: _GAS_CONST: float = 8.3144598484848 - def __init__( - self, - run_manager: Any, - args: Any, - universe: Any, - data_logger: Any, - group_molecules: Any, - ) -> None: - """Initialize the conformational entropy helper. + def __init__(self) -> None: + """Math-only engine. - Args: - run_manager: Workflow run manager. - args: Parsed CLI/config arguments. - universe: MDAnalysis Universe (or compatible container). - data_logger: Optional logger/collector for results. - group_molecules: Grouping helper used elsewhere in the workflow. + This class assigns conformational states and computes conformational entropy. + It does not depend on the workflow runner, universe, grouping, or reporting. """ - self._run_manager = run_manager - self._args = args - self._universe = universe - self._data_logger = data_logger - self._group_molecules = group_molecules + pass def assign_conformation( self, @@ -113,7 +98,7 @@ def assign_conformation( """ _ = number_frames # kept for compatibility; sizing follows the slice length. - config = ConformationAssignmentConfig( + config = ConformationConfig( bin_width=int(bin_width), start=int(start), end=int(end), @@ -175,7 +160,7 @@ def conformational_entropy_calculation( return s_conf @staticmethod - def _validate_assignment_config(config: ConformationAssignmentConfig) -> None: + def _validate_assignment_config(config: ConformationConfig) -> None: """Validate conformation assignment configuration. Args: diff --git a/CodeEntropy/entropy/graph.py b/CodeEntropy/entropy/graph.py index 7a343324..73374288 100644 --- a/CodeEntropy/entropy/graph.py +++ b/CodeEntropy/entropy/graph.py @@ -30,7 +30,7 @@ @dataclass(frozen=True) -class GraphNodeSpec: +class NodeSpec: """Specification for a node within the entropy graph. Attributes: @@ -66,9 +66,9 @@ def build(self) -> "EntropyGraph": Self for fluent chaining. """ specs = ( - GraphNodeSpec("vibrational_entropy", VibrationalEntropyNode()), - GraphNodeSpec("configurational_entropy", ConfigurationalEntropyNode()), - GraphNodeSpec( + NodeSpec("vibrational_entropy", VibrationalEntropyNode()), + NodeSpec("configurational_entropy", ConfigurationalEntropyNode()), + NodeSpec( "aggregate_entropy", AggregateEntropyNode(), deps=("vibrational_entropy", "configurational_entropy"), @@ -102,7 +102,7 @@ def execute(self, shared_data: SharedData) -> Dict[str, Any]: results.update(out) return results - def _add_node(self, spec: GraphNodeSpec) -> None: + def _add_node(self, spec: NodeSpec) -> None: """Add a node and its dependencies to the graph. Args: diff --git a/CodeEntropy/entropy/nodes/configurational.py b/CodeEntropy/entropy/nodes/configurational.py index e8aac400..957aede3 100644 --- a/CodeEntropy/entropy/nodes/configurational.py +++ b/CodeEntropy/entropy/nodes/configurational.py @@ -52,10 +52,10 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] groups = shared_data["groups"] levels = shared_data["levels"] universe = shared_data["reduced_universe"] - data_logger = shared_data.get("data_logger") + reporter = shared_data.get("reporter") states_ua, states_res = self._get_state_containers(shared_data) - ce = self._build_entropy_engine(shared_data) + ce = self._build_entropy_engine() fragments = universe.atoms.fragments results: Dict[int, Dict[str, float]] = {} @@ -76,7 +76,7 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] residues=rep_mol.residues, states_ua=states_ua, n_frames=n_frames, - data_logger=data_logger, + reporter=reporter, ) results[group_id]["ua"] = ua_total @@ -89,8 +89,8 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] ) results[group_id]["res"] = res_val - if data_logger is not None: - data_logger.add_results_data( + if reporter is not None: + reporter.add_results_data( group_id, "residue", "Conformational", res_val ) @@ -99,24 +99,9 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] return {"configurational_entropy": results} - def _build_entropy_engine( - self, shared_data: Mapping[str, Any] - ) -> ConformationalEntropy: - """Create the entropy calculation engine. - - Args: - shared_data: Shared workflow state. - - Returns: - ConformationalEntropy instance. - """ - return ConformationalEntropy( - run_manager=shared_data["run_manager"], - args=shared_data["args"], - universe=shared_data["reduced_universe"], - data_logger=shared_data.get("data_logger"), - group_molecules=shared_data.get("group_molecules"), - ) + def _build_entropy_engine(self) -> ConformationalEntropy: + """Create the entropy calculation engine.""" + return ConformationalEntropy() def _get_state_containers(self, shared_data: Mapping[str, Any]) -> Tuple[ Dict[StateKey, StateSequence], @@ -158,7 +143,7 @@ def _compute_ua_entropy_for_group( residues: Iterable[Any], states_ua: Mapping[StateKey, StateSequence], n_frames: int, - data_logger: Optional[Any], + reporter: Optional[Any], ) -> float: """Compute united atom entropy for a group. @@ -168,7 +153,7 @@ def _compute_ua_entropy_for_group( residues: Residue iterable. states_ua: Mapping of states. n_frames: Frame count. - data_logger: Optional logger. + reporter: Optional logger. Returns: Total entropy for united atom level. @@ -180,8 +165,8 @@ def _compute_ua_entropy_for_group( val = self._entropy_or_zero(ce, states, n_frames) total += val - if data_logger is not None: - data_logger.add_residue_data( + if reporter is not None: + reporter.add_residue_data( group_id=group_id, resname=getattr(res, "resname", "UNK"), level="united_atom", @@ -190,10 +175,8 @@ def _compute_ua_entropy_for_group( value=val, ) - if data_logger is not None: - data_logger.add_results_data( - group_id, "united_atom", "Conformational", total - ) + if reporter is not None: + reporter.add_results_data(group_id, "united_atom", "Conformational", total) return total diff --git a/CodeEntropy/entropy/nodes/vibrational.py b/CodeEntropy/entropy/nodes/vibrational.py index de617b1f..e4c33fb1 100644 --- a/CodeEntropy/entropy/nodes/vibrational.py +++ b/CodeEntropy/entropy/nodes/vibrational.py @@ -9,7 +9,7 @@ import numpy as np from CodeEntropy.entropy.vibrational import VibrationalEntropy -from CodeEntropy.levels.linalg import MatrixOperations +from CodeEntropy.levels.linalg import MatrixUtils logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ @dataclass(frozen=True) -class _EntropyPair: +class EntropyPair: """Container for paired translational and rotational entropy values.""" trans: float @@ -31,7 +31,7 @@ class VibrationalEntropyNode: """Compute vibrational entropy from force/torque (and optional FT) covariances.""" def __init__(self) -> None: - self._mat_ops = MatrixOperations() + self._mat_ops = MatrixUtils() self._zero_atol = 1e-8 def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any]: @@ -51,7 +51,7 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] ft_cov = shared_data.get("forcetorque_covariances") if combined else None ua_frame_counts = self._get_ua_frame_counts(shared_data) - data_logger = shared_data.get("data_logger") + reporter = shared_data.get("reporter") results: Dict[int, Dict[str, Dict[str, float]]] = {} @@ -76,13 +76,13 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] force_ua=force_cov["ua"], torque_ua=torque_cov["ua"], ua_frame_counts=ua_frame_counts, - data_logger=data_logger, + reporter=reporter, n_frames_default=shared_data.get("n_frames", 0), highest=highest, # IMPORTANT: matches main ) self._store_results(results, group_id, level, pair) self._log_molecule_level_results( - data_logger, group_id, level, pair, use_ft_labels=False + reporter, group_id, level, pair, use_ft_labels=False ) continue @@ -97,7 +97,7 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] pair = self._compute_ft_entropy(ve=ve, temp=temp, ftmat=ftmat) self._store_results(results, group_id, level, pair) self._log_molecule_level_results( - data_logger, group_id, level, pair, use_ft_labels=True + reporter, group_id, level, pair, use_ft_labels=True ) continue @@ -114,7 +114,7 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] ) self._store_results(results, group_id, level, pair) self._log_molecule_level_results( - data_logger, group_id, level, pair, use_ft_labels=False + reporter, group_id, level, pair, use_ft_labels=False ) continue @@ -129,10 +129,6 @@ def _build_entropy_engine( ) -> VibrationalEntropy: return VibrationalEntropy( run_manager=shared_data["run_manager"], - args=shared_data["args"], - universe=shared_data["reduced_universe"], - data_logger=shared_data.get("data_logger"), - group_molecules=shared_data.get("group_molecules"), ) def _get_group_id_to_index(self, shared_data: Mapping[str, Any]) -> Dict[int, int]: @@ -160,10 +156,10 @@ def _compute_united_atom_entropy( force_ua: Mapping[CovKey, Any], torque_ua: Mapping[CovKey, Any], ua_frame_counts: Mapping[CovKey, int], - data_logger: Optional[Any], + reporter: Optional[Any], n_frames_default: int, highest: bool, - ) -> _EntropyPair: + ) -> EntropyPair: s_trans_total = 0.0 s_rot_total = 0.0 @@ -183,9 +179,9 @@ def _compute_united_atom_entropy( s_trans_total += pair.trans s_rot_total += pair.rot - if data_logger is not None: + if reporter is not None: frame_count = ua_frame_counts.get(key, int(n_frames_default or 0)) - data_logger.add_residue_data( + reporter.add_residue_data( group_id=group_id, resname=getattr(res, "resname", "UNK"), level="united_atom", @@ -193,7 +189,7 @@ def _compute_united_atom_entropy( frame_count=frame_count, value=pair.trans, ) - data_logger.add_residue_data( + reporter.add_residue_data( group_id=group_id, resname=getattr(res, "resname", "UNK"), level="united_atom", @@ -202,7 +198,7 @@ def _compute_united_atom_entropy( value=pair.rot, ) - return _EntropyPair(trans=float(s_trans_total), rot=float(s_rot_total)) + return EntropyPair(trans=float(s_trans_total), rot=float(s_rot_total)) def _compute_force_torque_entropy( self, @@ -212,9 +208,9 @@ def _compute_force_torque_entropy( fmat: Any, tmat: Any, highest: bool, - ) -> _EntropyPair: + ) -> EntropyPair: if fmat is None or tmat is None: - return _EntropyPair(trans=0.0, rot=0.0) + return EntropyPair(trans=0.0, rot=0.0) f = self._mat_ops.filter_zero_rows_columns( np.asarray(fmat), atol=self._zero_atol @@ -225,7 +221,7 @@ def _compute_force_torque_entropy( # If filtering removes everything, behave like "no data" if f.size == 0 or t.size == 0: - return _EntropyPair(trans=0.0, rot=0.0) + return EntropyPair(trans=0.0, rot=0.0) s_trans = ve.vibrational_entropy_calculation( f, "force", temp, highest_level=highest @@ -233,7 +229,7 @@ def _compute_force_torque_entropy( s_rot = ve.vibrational_entropy_calculation( t, "torque", temp, highest_level=highest ) - return _EntropyPair(trans=float(s_trans), rot=float(s_rot)) + return EntropyPair(trans=float(s_trans), rot=float(s_rot)) def _compute_ft_entropy( self, @@ -241,15 +237,15 @@ def _compute_ft_entropy( ve: VibrationalEntropy, temp: float, ftmat: Any, - ) -> _EntropyPair: + ) -> EntropyPair: if ftmat is None: - return _EntropyPair(trans=0.0, rot=0.0) + return EntropyPair(trans=0.0, rot=0.0) ft = self._mat_ops.filter_zero_rows_columns( np.asarray(ftmat), atol=self._zero_atol ) if ft.size == 0: - return _EntropyPair(trans=0.0, rot=0.0) + return EntropyPair(trans=0.0, rot=0.0) # FT is only used at highest level in main branch s_trans = ve.vibrational_entropy_calculation( @@ -258,40 +254,38 @@ def _compute_ft_entropy( s_rot = ve.vibrational_entropy_calculation( ft, "forcetorqueROT", temp, highest_level=True ) - return _EntropyPair(trans=float(s_trans), rot=float(s_rot)) + return EntropyPair(trans=float(s_trans), rot=float(s_rot)) @staticmethod def _store_results( results: Dict[int, Dict[str, Dict[str, float]]], group_id: int, level: str, - pair: _EntropyPair, + pair: EntropyPair, ) -> None: results[group_id][level] = {"trans": pair.trans, "rot": pair.rot} @staticmethod def _log_molecule_level_results( - data_logger: Optional[Any], + reporter: Optional[Any], group_id: int, level: str, - pair: _EntropyPair, + pair: EntropyPair, *, use_ft_labels: bool, ) -> None: - if data_logger is None: + if reporter is None: return if use_ft_labels: - data_logger.add_results_data( + reporter.add_results_data( group_id, level, "FTmat-Transvibrational", pair.trans ) - data_logger.add_results_data( - group_id, level, "FTmat-Rovibrational", pair.rot - ) + reporter.add_results_data(group_id, level, "FTmat-Rovibrational", pair.rot) return - data_logger.add_results_data(group_id, level, "Transvibrational", pair.trans) - data_logger.add_results_data(group_id, level, "Rovibrational", pair.rot) + reporter.add_results_data(group_id, level, "Transvibrational", pair.trans) + reporter.add_results_data(group_id, level, "Rovibrational", pair.rot) @staticmethod def _get_indexed_matrix(mats: Any, index: int) -> Any: diff --git a/CodeEntropy/entropy/orientational.py b/CodeEntropy/entropy/orientational.py index 094360ed..e41f0f87 100644 --- a/CodeEntropy/entropy/orientational.py +++ b/CodeEntropy/entropy/orientational.py @@ -51,7 +51,7 @@ def __init__( run_manager: Any, args: Any, universe: Any, - data_logger: Any, + reporter: Any, group_molecules: Any, gas_constant: float = _GAS_CONST_J_PER_MOL_K, ) -> None: @@ -61,14 +61,14 @@ def __init__( run_manager: Run manager (currently unused by this class). args: User arguments (currently unused by this class). universe: MDAnalysis Universe (currently unused by this class). - data_logger: Data logger (currently unused by this class). + reporter: Data logger (currently unused by this class). group_molecules: Grouping helper (currently unused by this class). gas_constant: Gas constant in J/(mol*K). """ self._run_manager = run_manager self._args = args self._universe = universe - self._data_logger = data_logger + self._reporter = reporter self._group_molecules = group_molecules self._gas_constant = float(gas_constant) diff --git a/CodeEntropy/entropy/vibrational.py b/CodeEntropy/entropy/vibrational.py index 0265d560..a7762d70 100644 --- a/CodeEntropy/entropy/vibrational.py +++ b/CodeEntropy/entropy/vibrational.py @@ -47,31 +47,18 @@ class VibrationalEntropy: def __init__( self, run_manager: Any, - args: Any, - universe: Any, - data_logger: Any, - group_molecules: Any, planck_const: float = 6.62607004081818e-34, gas_const: float = 8.3144598484848, ) -> None: """Initialize the vibrational entropy calculator. Args: - run_manager: Provides thermodynamic conversions (e.g., kT in Joules) and - eigenvalue unit conversion. - args: User args (kept for compatibility; not required for math here). - universe: MDAnalysis Universe (kept for compatibility). - data_logger: Data logger (kept for compatibility). - group_molecules: Grouping helper (kept for compatibility). + run_manager: Provides thermodynamic conversions (e.g., kT in Joules) + and eigenvalue unit conversion. planck_const: Planck constant (J*s). gas_const: Gas constant (J/(mol*K)). """ self._run_manager = run_manager - self._args = args - self._universe = universe - self._data_logger = data_logger - self._group_molecules = group_molecules - self._planck_const = float(planck_const) self._gas_const = float(gas_const) diff --git a/CodeEntropy/entropy/water.py b/CodeEntropy/entropy/water.py index 366b9000..5be7d13c 100644 --- a/CodeEntropy/entropy/water.py +++ b/CodeEntropy/entropy/water.py @@ -1,7 +1,7 @@ """Water entropy aggregation. This module wraps the waterEntropy routines and maps their -outputs into the project `DataLogger` format. +outputs into the project `ResultsReporter` format. """ from __future__ import annotations @@ -17,7 +17,7 @@ @dataclass(frozen=True) -class WaterEntropyInputs: +class WaterEntropyInput: """Inputs for water entropy computation. Attributes: @@ -51,7 +51,7 @@ class WaterEntropy: def __init__( self, args: Any, - data_logger: Any, + reporter: Any, solver: Callable[..., Tuple[dict, Any, Any, Any, Any]] = ( GetSolvent.get_interfacial_water_orient_entropy ), @@ -60,14 +60,14 @@ def __init__( Args: args: Argument namespace; must include `temperature`. - data_logger: Logger used to record residue and group results. + reporter: Logger used to record residue and group results. solver: Callable compatible with `get_interfacial_water_orient_entropy (universe, start, end, step, temperature, parallel=True)`. Dependency injection allows unit testing without the external package. """ self._args = args - self._data_logger = data_logger + self._reporter = reporter self._solver = solver def calculate_and_log( @@ -87,7 +87,7 @@ def calculate_and_log( step: Frame stride. group_id: Group ID to assign all water contributions to. """ - inputs = WaterEntropyInputs( + inputs = WaterEntropyInput( universe=universe, start=start, end=end, @@ -97,7 +97,7 @@ def calculate_and_log( ) self._calculate_and_log_from_inputs(inputs) - def _calculate_and_log_from_inputs(self, inputs: WaterEntropyInputs) -> None: + def _calculate_and_log_from_inputs(self, inputs: WaterEntropyInput) -> None: """Run the solver and log all returned entropy components.""" Sorient_dict, covariances, vibrations, _unused, _water_count = self._run_solver( inputs @@ -108,11 +108,11 @@ def _calculate_and_log_from_inputs(self, inputs: WaterEntropyInputs) -> None: self._log_rotational_entropy(vibrations, covariances, inputs.group_id) self._log_group_label(inputs.universe, Sorient_dict, inputs.group_id) - def _run_solver(self, inputs: WaterEntropyInputs): + def _run_solver(self, inputs: WaterEntropyInput): """Call the external solver. Args: - inputs: WaterEntropyInputs. + inputs: WaterEntropyInput. Returns: Tuple of solver outputs. @@ -145,7 +145,7 @@ def _log_orientational_entropy( for resname, values in resname_dict.items(): if isinstance(values, list) and len(values) == 2: entropy, count = values - self._data_logger.add_residue_data( + self._reporter.add_residue_data( group_id, resname, "Water", "Orientational", count, entropy ) @@ -170,7 +170,7 @@ def _log_translational_entropy( ) count = counts.get((solute_id, "WAT"), 1) resname = self._solute_id_to_resname(solute_id) - self._data_logger.add_residue_data( + self._reporter.add_residue_data( group_id, resname, "Water", "Transvibrational", count, value ) @@ -195,7 +195,7 @@ def _log_rotational_entropy( ) count = counts.get((solute_id, "WAT"), 1) resname = self._solute_id_to_resname(solute_id) - self._data_logger.add_residue_data( + self._reporter.add_residue_data( group_id, resname, "Water", "Rovibrational", count, value ) @@ -224,7 +224,7 @@ def _log_group_label( } residue_group = "_".join(sorted(residue_names)) if residue_names else "WAT" - self._data_logger.add_group_label( + self._reporter.add_group_label( group_id, residue_group, actual_water_residues, len(water_selection.atoms) ) diff --git a/CodeEntropy/entropy/manager.py b/CodeEntropy/entropy/workflow.py similarity index 91% rename from CodeEntropy/entropy/manager.py rename to CodeEntropy/entropy/workflow.py index 09fc75be..46f5f85f 100644 --- a/CodeEntropy/entropy/manager.py +++ b/CodeEntropy/entropy/workflow.py @@ -1,6 +1,6 @@ """Entropy manager orchestration. -This module defines `EntropyManager`, which coordinates the end-to-end entropy +This module defines `EntropyWorkflow`, which coordinates the end-to-end entropy workflow: * Determine trajectory bounds and frame count. * Build a reduced universe based on atom selection. @@ -26,7 +26,7 @@ from CodeEntropy.core.logging import LoggingConfig from CodeEntropy.entropy.graph import EntropyGraph from CodeEntropy.entropy.water import WaterEntropy -from CodeEntropy.levels.hierarchy import LevelHierarchy +from CodeEntropy.levels.hierarchy import HierarchyBuilder from CodeEntropy.levels.level_dag import LevelDAG logger = logging.getLogger(__name__) @@ -52,7 +52,7 @@ class TrajectorySlice: n_frames: int -class EntropyManager: +class EntropyWorkflow: """Coordinate entropy calculations across structural levels. This class is responsible for orchestration and IO-level concerns (selection, @@ -65,7 +65,7 @@ def __init__( run_manager: Any, args: Any, universe: Any, - data_logger: Any, + reporter: Any, group_molecules: Any, dihedral_analysis: Any, universe_operations: Any, @@ -76,7 +76,7 @@ def __init__( run_manager: Manager for universe IO and unit conversions. args: Parsed CLI/user arguments. universe: MDAnalysis Universe representing the simulation system. - data_logger: Collector for per-molecule and per-residue outputs. + reporter: Collector for per-molecule and per-residue outputs. group_molecules: Component that groups molecules for averaging. dihedral_analysis: Component used to compute conformational states. (Stored for completeness; computation is typically triggered by nodes.) @@ -85,7 +85,7 @@ def __init__( self._run_manager = run_manager self._args = args self._universe = universe - self._data_logger = data_logger + self._reporter = reporter self._group_molecules = group_molecules self._dihedral_analysis = dihedral_analysis self._universe_operations = universe_operations @@ -95,7 +95,7 @@ def execute(self) -> None: This method orchestrates the complete pipeline, populates shared data, and triggers the DAG/graph executions. Final results are logged and saved - via `DataLogger`. + via `ResultsReporter`. """ traj = self._build_trajectory_slice() console.print( @@ -129,7 +129,7 @@ def execute(self) -> None: self._run_entropy_graph(shared_data) self._finalize_molecule_results() - self._data_logger.log_tables() + self._reporter.log_tables() def _build_shared_data( self, @@ -152,7 +152,7 @@ def _build_shared_data( shared_data: SharedData = { "entropy_manager": self, "run_manager": self._run_manager, - "data_logger": self._data_logger, + "reporter": self._reporter, "args": self._args, "universe": self._universe, "reduced_universe": reduced_universe, @@ -229,7 +229,7 @@ def _build_reduced_universe(self) -> Any: if selection == "all": return self._universe - reduced = self._universe_operations.new_U_select_atom(self._universe, selection) + reduced = self._universe_operations.select_atoms(self._universe, selection) name = f"{len(reduced.trajectory)}_frame_dump_atom_selection" self._run_manager.write_universe(reduced, name) return reduced @@ -241,9 +241,9 @@ def _detect_levels(self, reduced_universe: Any) -> Any: reduced_universe: Reduced MDAnalysis Universe. Returns: - Levels structure as returned by `LevelHierarchy.select_levels`. + Levels structure as returned by `HierarchyBuilder.select_levels`. """ - level_hierarchy = LevelHierarchy() + level_hierarchy = HierarchyBuilder() _number_molecules, levels = level_hierarchy.select_levels(reduced_universe) return levels @@ -307,8 +307,8 @@ def _compute_water_entropy( else "not water" ) - logger.debug("WaterEntropy: molecule_data=%s", self._data_logger.molecule_data) - logger.debug("WaterEntropy: residue_data=%s", self._data_logger.residue_data) + logger.debug("WaterEntropy: molecule_data=%s", self._reporter.molecule_data) + logger.debug("WaterEntropy: residue_data=%s", self._reporter.residue_data) def _finalize_molecule_results(self) -> None: """Aggregate group totals and persist results to JSON. @@ -319,7 +319,7 @@ def _finalize_molecule_results(self) -> None: """ entropy_by_group = defaultdict(float) - for group_id, level, _etype, result in self._data_logger.molecule_data: + for group_id, level, _etype, result in self._reporter.molecule_data: if level == "Group Total": continue try: @@ -328,16 +328,16 @@ def _finalize_molecule_results(self) -> None: logger.warning("Skipping invalid entry: %s, %s", group_id, result) for group_id, total in entropy_by_group.items(): - self._data_logger.molecule_data.append( + self._reporter.molecule_data.append( (group_id, "Group Total", "Group Total Entropy", total) ) molecule_df = pd.DataFrame( - self._data_logger.molecule_data, + self._reporter.molecule_data, columns=["Group ID", "Level", "Type", "Result (J/mol/K)"], ) residue_df = pd.DataFrame( - self._data_logger.residue_data, + self._reporter.residue_data, columns=[ "Group ID", "Residue Name", @@ -347,6 +347,6 @@ def _finalize_molecule_results(self) -> None: "Result (J/mol/K)", ], ) - self._data_logger.save_dataframes_as_json( + self._reporter.save_dataframes_as_json( molecule_df, residue_df, self._args.output_file ) diff --git a/CodeEntropy/levels/axes.py b/CodeEntropy/levels/axes.py index 35ef25d4..de1ab2e8 100644 --- a/CodeEntropy/levels/axes.py +++ b/CodeEntropy/levels/axes.py @@ -1,6 +1,6 @@ """Axes utilities for entropy calculations. -This module contains the :class:`AxesManager`, a geometry-focused helper used by +This module contains the :class:`AxesCalculator`, a geometry-focused helper used by the entropy pipeline to compute translational and rotational axes, centres, and moments of inertia at different hierarchy levels (residue / united-atom). """ @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -class AxesManager: +class AxesCalculator: """Compute translation/rotation axes and inertia utilities used by entropy. Manages the structural and dynamic levels involved in entropy calculations. @@ -33,16 +33,16 @@ class AxesManager: Notes: This class deliberately does **not**: - - compute weighted forces/torques (that belongs in ForceTorqueManager), + - compute weighted forces/torques (that belongs in ForceTorqueCalculator), - build covariances, - compute entropies. """ def __init__(self) -> None: - """Initialize the AxesManager. + """Initialize the AxesCalculator. The original implementation stored a few placeholders for level-related - data (axes, bead counts, etc.). In the current design, AxesManager is a + data (axes, bead counts, etc.). In the current design, AxesCalculator is a stateless helper, but we keep the attributes for compatibility and debugging/extension. diff --git a/CodeEntropy/levels/dihedrals.py b/CodeEntropy/levels/dihedrals.py index 4d3003a6..40e53325 100644 --- a/CodeEntropy/levels/dihedrals.py +++ b/CodeEntropy/levels/dihedrals.py @@ -23,7 +23,7 @@ UAKey = Tuple[int, int] -class DihedralAnalysis: +class ConformationStateBuilder: """Build conformational state labels from dihedral angles.""" def __init__(self, universe_operations=None): @@ -31,8 +31,8 @@ def __init__(self, universe_operations=None): Args: universe_operations: Object providing helper methods: - - get_molecule_container(data_container, molecule_id) - - new_U_select_atom(atomgroup, selection_string) + - extract_fragment(data_container, molecule_id) + - select_atoms(atomgroup, selection_string) """ self._universe_operations = universe_operations @@ -81,7 +81,7 @@ def build_conformational_states( progress.advance(task) continue - mol = self._universe_operations.get_molecule_container( + mol = self._universe_operations.extract_fragment( data_container, molecules[0] ) @@ -162,12 +162,10 @@ def _select_heavy_residue(self, mol, res_id: int): selection1 = mol.residues[res_id].atoms.indices[0] selection2 = mol.residues[res_id].atoms.indices[-1] - res_container = self._universe_operations.new_U_select_atom( + res_container = self._universe_operations.select_atoms( mol, f"index {selection1}:{selection2}" ) - return self._universe_operations.new_U_select_atom( - res_container, "prop mass > 1.1" - ) + return self._universe_operations.select_atoms(res_container, "prop mass > 1.1") def _get_dihedrals(self, data_container, level: str): """Return dihedral AtomGroups for a container at a given level. @@ -297,7 +295,7 @@ def _identify_peaks( phi = [] for molecule in molecules: - mol = self._universe_operations.get_molecule_container( + mol = self._universe_operations.extract_fragment( data_container, molecule ) number_frames = len(mol.trajectory) @@ -429,9 +427,7 @@ def _assign_states( for molecule in molecules: conformations = [] - mol = self._universe_operations.get_molecule_container( - data_container, molecule - ) + mol = self._universe_operations.extract_fragment(data_container, molecule) number_frames = len(mol.trajectory) dihedral_results = Dihedral(dihedrals).run() diff --git a/CodeEntropy/levels/forces.py b/CodeEntropy/levels/forces.py index 16a49e7e..92f2c665 100644 --- a/CodeEntropy/levels/forces.py +++ b/CodeEntropy/levels/forces.py @@ -43,7 +43,7 @@ class TorqueInputs: box: Optional[np.ndarray] = None -class ForceTorqueManager: +class ForceTorqueCalculator: """Computes weighted generalized forces/torques and per-frame second moments.""" def get_weighted_forces( diff --git a/CodeEntropy/levels/frame_dag.py b/CodeEntropy/levels/frame_dag.py index fa94e469..dd27a3b2 100644 --- a/CodeEntropy/levels/frame_dag.py +++ b/CodeEntropy/levels/frame_dag.py @@ -36,7 +36,7 @@ class FrameContext: data: Dict[str, Any] = None -class FrameDAG: +class FrameGraph: """Execute a frame-local directed acyclic graph. The graph is run once per trajectory frame. Nodes may read shared inputs from @@ -47,7 +47,7 @@ class FrameDAG: """ def __init__(self, universe_operations: Optional[Any] = None) -> None: - """Initialise a FrameDAG. + """Initialise a FrameGraph. Args: universe_operations: Optional adapter providing universe operations used @@ -57,7 +57,7 @@ def __init__(self, universe_operations: Optional[Any] = None) -> None: self._graph = nx.DiGraph() self._nodes: Dict[str, Any] = {} - def build(self) -> "FrameDAG": + def build(self) -> "FrameGraph": """Build the default frame DAG topology. Returns: @@ -79,7 +79,7 @@ def execute_frame(self, shared_data: Dict[str, Any], frame_index: int) -> Any: ctx = self._make_frame_ctx(shared_data=shared_data, frame_index=frame_index) for node_name in nx.topological_sort(self._graph): - logger.debug("[FrameDAG] running %s @ frame=%s", node_name, frame_index) + logger.debug("[FrameGraph] running %s @ frame=%s", node_name, frame_index) self._nodes[node_name].run(ctx) return ctx["frame_covariance"] diff --git a/CodeEntropy/levels/hierarchy.py b/CodeEntropy/levels/hierarchy.py index 5bae8108..76c23311 100644 --- a/CodeEntropy/levels/hierarchy.py +++ b/CodeEntropy/levels/hierarchy.py @@ -1,6 +1,6 @@ """Hierarchy level selection and bead construction. -This module defines `LevelHierarchy`, which is responsible for: +This module defines `HierarchyBuilder`, which is responsible for: 1) Determining which hierarchy levels apply to each molecule. 2) Constructing "beads" (AtomGroups) for a given molecule at a given level. @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) -class LevelHierarchy: +class HierarchyBuilder: """Determine applicable hierarchy levels and build beads for each level. A "level" represents a resolution scale used throughout the entropy workflow: diff --git a/CodeEntropy/levels/level_dag.py b/CodeEntropy/levels/level_dag.py index fadb8606..077563b1 100644 --- a/CodeEntropy/levels/level_dag.py +++ b/CodeEntropy/levels/level_dag.py @@ -10,7 +10,7 @@ - Compute conformational state descriptors required later by entropy nodes. 2) Frame stage (runs for each trajectory frame): - - Execute the `FrameDAG` to produce frame-local covariance outputs. + - Execute the `FrameGraph` to produce frame-local covariance outputs. - Reduce frame-local outputs into running (incremental) means. """ @@ -21,8 +21,8 @@ import networkx as nx -from CodeEntropy.levels.axes import AxesManager -from CodeEntropy.levels.frame_dag import FrameDAG +from CodeEntropy.levels.axes import AxesCalculator +from CodeEntropy.levels.frame_dag import FrameGraph from CodeEntropy.levels.nodes.accumulators import InitCovarianceAccumulatorsNode from CodeEntropy.levels.nodes.beads import BuildBeadsNode from CodeEntropy.levels.nodes.conformations import ComputeConformationalStatesNode @@ -49,14 +49,14 @@ def __init__(self, universe_operations: Optional[Any] = None) -> None: Args: universe_operations: Optional adapter providing universe operations. - Passed to the FrameDAG and the conformational-state node. + Passed to the FrameGraph and the conformational-state node. """ self._universe_operations = universe_operations self._static_graph = nx.DiGraph() self._static_nodes: Dict[str, Any] = {} - self._frame_dag = FrameDAG(universe_operations=universe_operations) + self._frame_dag = FrameGraph(universe_operations=universe_operations) def build(self) -> "LevelDAG": """Build the static and frame DAG topology. @@ -91,7 +91,7 @@ def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: Returns: The mutated shared_data dict. """ - shared_data.setdefault("axes_manager", AxesManager()) + shared_data.setdefault("axes_manager", AxesCalculator()) self._run_static_stage(shared_data) self._run_frame_stage(shared_data) return shared_data @@ -144,7 +144,7 @@ def _reduce_one_frame( Args: shared_data: Shared workflow data dict containing accumulators. - frame_out: Frame-local covariance outputs produced by FrameDAG. + frame_out: Frame-local covariance outputs produced by FrameGraph. """ self._reduce_force_and_torque(shared_data, frame_out) self._reduce_forcetorque(shared_data, frame_out) diff --git a/CodeEntropy/levels/linalg.py b/CodeEntropy/levels/linalg.py index 801ad13d..b86defec 100644 --- a/CodeEntropy/levels/linalg.py +++ b/CodeEntropy/levels/linalg.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) -class MatrixOperations: +class MatrixUtils: """Utility operations for small matrix manipulations.""" def create_submatrix(self, data_i: np.ndarray, data_j: np.ndarray) -> np.ndarray: diff --git a/CodeEntropy/levels/mda.py b/CodeEntropy/levels/mda.py index 1a0c4271..d0226ca3 100644 --- a/CodeEntropy/levels/mda.py +++ b/CodeEntropy/levels/mda.py @@ -26,7 +26,7 @@ def __init__(self) -> None: """Initialise the operations helper.""" self._universe = None - def new_U_select_frame( + def select_frames( self, u: mda.Universe, start: Optional[int] = None, @@ -79,9 +79,7 @@ def new_U_select_frame( logger.debug("MDAnalysis.Universe - reduced universe (frame-selected): %s", u2) return u2 - def new_U_select_atom( - self, u: mda.Universe, select_string: str = "all" - ) -> mda.Universe: + def select_atoms(self, u: mda.Universe, select_string: str = "all") -> mda.Universe: """Create a reduced universe by dropping atoms according to user selection. Parameters @@ -114,7 +112,7 @@ def new_U_select_atom( logger.debug("MDAnalysis.Universe - reduced universe (atom-selected): %s", u2) return u2 - def get_molecule_container( + def extract_fragment( self, universe: mda.Universe, molecule_id: int ) -> mda.Universe: """Extract a single molecule (fragment) as a standalone reduced universe. @@ -131,7 +129,7 @@ def get_molecule_container( """ frag = universe.atoms.fragments[molecule_id] selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}" - return self.new_U_select_atom(universe, selection_string) + return self.select_atoms(universe, selection_string) def merge_forces( self, diff --git a/CodeEntropy/levels/nodes/beads.py b/CodeEntropy/levels/nodes/beads.py index c2eed62e..8f47c86f 100644 --- a/CodeEntropy/levels/nodes/beads.py +++ b/CodeEntropy/levels/nodes/beads.py @@ -17,7 +17,7 @@ import numpy as np -from CodeEntropy.levels.hierarchy import LevelHierarchy +from CodeEntropy.levels.hierarchy import HierarchyBuilder logger = logging.getLogger(__name__) @@ -49,18 +49,18 @@ class BuildBeadsNode: Notes: United-atom beads are generated at the molecule level (preserving the - underlying ordering provided by `LevelHierarchy.get_beads`) and then + underlying ordering provided by `HierarchyBuilder.get_beads`) and then grouped into residue buckets based on the heavy atom that defines the bead. """ - def __init__(self, hierarchy: LevelHierarchy | None = None) -> None: + def __init__(self, hierarchy: HierarchyBuilder | None = None) -> None: """Initialize the node. Args: - hierarchy: Optional `LevelHierarchy` dependency. If not provided, + hierarchy: Optional `HierarchyBuilder` dependency. If not provided, a default instance is created. """ - self._hier = hierarchy or LevelHierarchy() + self._hier = hierarchy or HierarchyBuilder() def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: """Build bead definitions for all molecules and levels. diff --git a/CodeEntropy/levels/nodes/conformations.py b/CodeEntropy/levels/nodes/conformations.py index 671605b2..9bd2ce1c 100644 --- a/CodeEntropy/levels/nodes/conformations.py +++ b/CodeEntropy/levels/nodes/conformations.py @@ -11,7 +11,7 @@ from dataclasses import dataclass from typing import Any, Dict -from CodeEntropy.levels.dihedrals import DihedralAnalysis +from CodeEntropy.levels.dihedrals import ConformationStateBuilder SharedData = Dict[str, Any] ConformationalStates = Dict[str, Any] @@ -50,9 +50,9 @@ def __init__(self, universe_operations: Any) -> None: Args: universe_operations: Object providing universe selection utilities used - by `DihedralAnalysis`. + by `ConformationStateBuilder`. """ - self._dihedral_analysis = DihedralAnalysis( + self._dihedral_analysis = ConformationStateBuilder( universe_operations=universe_operations ) diff --git a/CodeEntropy/levels/nodes/covariance.py b/CodeEntropy/levels/nodes/covariance.py index 607bb9a5..1771df46 100644 --- a/CodeEntropy/levels/nodes/covariance.py +++ b/CodeEntropy/levels/nodes/covariance.py @@ -5,7 +5,7 @@ incrementally averaged across molecules within a group for the current frame. Responsibilities: -- Build bead-level force/torque vectors using ForceTorqueManager. +- Build bead-level force/torque vectors using ForceTorqueCalculator. - Construct per-frame force/torque second moments (outer products). - Optionally construct combined force-torque block matrices. - Average per-frame matrices across molecules in the same group. @@ -24,7 +24,7 @@ import numpy as np from MDAnalysis.lib.mdamath import make_whole -from CodeEntropy.levels.forces import ForceTorqueManager +from CodeEntropy.levels.forces import ForceTorqueCalculator logger = logging.getLogger(__name__) @@ -36,7 +36,7 @@ class FrameCovarianceNode: """Build per-frame covariance-like (second-moment) matrices for each group.""" def __init__(self) -> None: - self._ft = ForceTorqueManager() + self._ft = ForceTorqueCalculator() def run(self, ctx: FrameCtx) -> Dict[str, Any]: """Compute and store per-frame force/torque (and optional FT) matrices. diff --git a/CodeEntropy/levels/nodes/detect_levels.py b/CodeEntropy/levels/nodes/detect_levels.py index 0d5819a6..6faecaaa 100644 --- a/CodeEntropy/levels/nodes/detect_levels.py +++ b/CodeEntropy/levels/nodes/detect_levels.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Tuple -from CodeEntropy.levels.hierarchy import LevelHierarchy +from CodeEntropy.levels.hierarchy import HierarchyBuilder SharedData = Dict[str, Any] Levels = List[List[str]] @@ -23,8 +23,8 @@ class DetectLevelsNode: """ def __init__(self) -> None: - """Initialize the node with a LevelHierarchy helper.""" - self._hierarchy = LevelHierarchy() + """Initialize the node with a HierarchyBuilder helper.""" + self._hierarchy = HierarchyBuilder() def run(self, shared_data: SharedData) -> Dict[str, Any]: """Detect levels and store results in shared_data. @@ -54,7 +54,7 @@ def run(self, shared_data: SharedData) -> Dict[str, Any]: } def _detect_levels(self, universe: Any) -> Tuple[int, Levels]: - """Delegate level detection to LevelHierarchy. + """Delegate level detection to HierarchyBuilder. Args: universe: Reduced MDAnalysis universe. diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index 873c8184..11bc2eed 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -10,7 +10,7 @@ import logging from typing import Any, Dict -from CodeEntropy.molecules.grouping import GroupMolecules +from CodeEntropy.molecules.grouping import MoleculeGrouper logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ class DetectMoleculesNode: def __init__(self) -> None: """Initialize the node with a molecule grouping helper.""" - self._grouping = GroupMolecules() + self._grouping = MoleculeGrouper() def run(self, shared_data: SharedData) -> Dict[str, Any]: """Detect molecules and create grouping definitions. diff --git a/CodeEntropy/molecules/grouping.py b/CodeEntropy/molecules/grouping.py index 7a0265cf..f005ed62 100644 --- a/CodeEntropy/molecules/grouping.py +++ b/CodeEntropy/molecules/grouping.py @@ -39,7 +39,7 @@ class GroupingConfig: strategy: str -class GroupMolecules: +class MoleculeGrouper: """Build groups of molecules for averaging. This class provides strategies for grouping molecule fragments from an diff --git a/CodeEntropy/results/reporter.py b/CodeEntropy/results/reporter.py index ee47ca40..75082ae0 100644 --- a/CodeEntropy/results/reporter.py +++ b/CodeEntropy/results/reporter.py @@ -1,6 +1,6 @@ """Utilities for logging entropy results and exporting data. -This module provides the DataLogger class, which is responsible for: +This module provides the ResultsReporter class, which is responsible for: - Collecting molecule-level entropy results - Collecting residue-level entropy results @@ -24,7 +24,7 @@ console = LoggingConfig.get_console() -class DataLogger: +class ResultsReporter: """Collect, format, and output entropy calculation results.""" def __init__(self, console: Optional[Console] = None) -> None: From a566bd2c29e8794997a506d5aa3aad52adb8ab32 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 15:57:45 +0000 Subject: [PATCH 066/101] test(config): add atomic unit tests for argparse ConfigResolver: Add focused pytest unit tests covering load_config, str2bool, build_parser, resolve, logging level application, and input validation. All tests are isolated with mocked IO and fixtures, improving coverage and reliability. --- tests/unit/CodeEntropy/config/conftest.py | 50 ++++++++++++ .../config/test_argparse_build_parser.py | 39 ++++++++++ .../config/test_argparse_load_config.py | 50 ++++++++++++ .../config/test_argparse_resolve.py | 77 +++++++++++++++++++ .../config/test_argparse_str2bool.py | 30 ++++++++ .../config/test_argparse_validate_inputs.py | 59 ++++++++++++++ tests/unit/CodeEntropy/pytest.ini | 3 + 7 files changed, 308 insertions(+) create mode 100644 tests/unit/CodeEntropy/config/conftest.py create mode 100644 tests/unit/CodeEntropy/config/test_argparse_build_parser.py create mode 100644 tests/unit/CodeEntropy/config/test_argparse_load_config.py create mode 100644 tests/unit/CodeEntropy/config/test_argparse_resolve.py create mode 100644 tests/unit/CodeEntropy/config/test_argparse_str2bool.py create mode 100644 tests/unit/CodeEntropy/config/test_argparse_validate_inputs.py create mode 100644 tests/unit/CodeEntropy/pytest.ini diff --git a/tests/unit/CodeEntropy/config/conftest.py b/tests/unit/CodeEntropy/config/conftest.py new file mode 100644 index 00000000..7ad0a4bc --- /dev/null +++ b/tests/unit/CodeEntropy/config/conftest.py @@ -0,0 +1,50 @@ +from types import SimpleNamespace + +import pytest + +from CodeEntropy.config.argparse import ConfigResolver + + +class DummyUniverse: + """Minimal MDAnalysis-like Universe stub for validate_inputs tests.""" + + def __init__(self, length: int): + self.trajectory = [None] * length + + +@pytest.fixture() +def resolver(): + return ConfigResolver() + + +@pytest.fixture() +def dummy_universe(): + # default length used in many tests + return DummyUniverse(length=100) + + +@pytest.fixture() +def make_args(): + """Factory to build an args-like object with defaults used by validation checks.""" + + def _make(**overrides): + base = dict( + start=0, + end=10, + step=1, + bin_width=30, + temperature=298.0, + force_partitioning=0.5, + ) + base.update(overrides) + # validation functions only require attribute access; SimpleNamespace is ideal + return SimpleNamespace(**base) + + return _make + + +@pytest.fixture() +def empty_cli_args(resolver): + """Argparse Namespace with all parser defaults.""" + parser = resolver.build_parser() + return parser.parse_args([]) diff --git a/tests/unit/CodeEntropy/config/test_argparse_build_parser.py b/tests/unit/CodeEntropy/config/test_argparse_build_parser.py new file mode 100644 index 00000000..4505ad9f --- /dev/null +++ b/tests/unit/CodeEntropy/config/test_argparse_build_parser.py @@ -0,0 +1,39 @@ +from CodeEntropy.config.argparse import ConfigResolver + + +def test_build_parser_parses_selection_string(): + resolver = ConfigResolver() + parser = resolver.build_parser() + + args = parser.parse_args(["--selection_string", "protein"]) + + assert args.selection_string == "protein" + + +def test_build_parser_parses_bool_with_str2bool(): + resolver = ConfigResolver() + parser = resolver.build_parser() + + args = parser.parse_args(["--kcal_force_units", "true"]) + + assert args.kcal_force_units is True + + +def test_build_parser_store_true_flag_verbose_defaults_false_and_sets_true(): + resolver = ConfigResolver() + parser = resolver.build_parser() + + args_default = parser.parse_args([]) + assert args_default.verbose is False + + args_verbose = parser.parse_args(["--verbose"]) + assert args_verbose.verbose is True + + +def test_build_parser_nargs_plus_parses_top_traj_file_list(): + resolver = ConfigResolver() + parser = resolver.build_parser() + + args = parser.parse_args(["--top_traj_file", "a.tpr", "b.trr"]) + + assert args.top_traj_file == ["a.tpr", "b.trr"] diff --git a/tests/unit/CodeEntropy/config/test_argparse_load_config.py b/tests/unit/CodeEntropy/config/test_argparse_load_config.py new file mode 100644 index 00000000..bfd5a6fe --- /dev/null +++ b/tests/unit/CodeEntropy/config/test_argparse_load_config.py @@ -0,0 +1,50 @@ +from unittest.mock import mock_open, patch + +from CodeEntropy.config.argparse import ConfigResolver + + +def test_load_config_valid_yaml_returns_dict(): + yaml_content = """ +run1: + selection_string: protein +""" + with ( + patch("glob.glob", return_value=["/fake/config.yaml"]), + patch("builtins.open", mock_open(read_data=yaml_content)), + ): + resolver = ConfigResolver() + config = resolver.load_config("/fake") + + assert "run1" in config + assert config["run1"]["selection_string"] == "protein" + + +def test_load_config_no_yaml_files_returns_default(): + with patch("glob.glob", return_value=[]): + resolver = ConfigResolver() + config = resolver.load_config("/fake") + + assert config == {"run1": {}} + + +def test_load_config_yaml_empty_returns_default_run1(): + yaml_content = "" # yaml.safe_load -> None + with ( + patch("glob.glob", return_value=["/fake/config.yaml"]), + patch("builtins.open", mock_open(read_data=yaml_content)), + ): + resolver = ConfigResolver() + config = resolver.load_config("/fake") + + assert config == {"run1": {}} + + +def test_load_config_open_error_returns_default(): + with ( + patch("glob.glob", return_value=["/fake/config.yaml"]), + patch("builtins.open", side_effect=OSError("boom")), + ): + resolver = ConfigResolver() + config = resolver.load_config("/fake") + + assert config == {"run1": {}} diff --git a/tests/unit/CodeEntropy/config/test_argparse_resolve.py b/tests/unit/CodeEntropy/config/test_argparse_resolve.py new file mode 100644 index 00000000..39893d93 --- /dev/null +++ b/tests/unit/CodeEntropy/config/test_argparse_resolve.py @@ -0,0 +1,77 @@ +import logging + +import pytest + +from CodeEntropy.config.argparse import ConfigResolver, logger + + +def test_resolve_run_config_wrong_type_raises_type_error(resolver, empty_cli_args): + with pytest.raises(TypeError): + resolver.resolve(empty_cli_args, run_config="not-a-dict") + + +def test_resolve_none_run_config_treated_as_empty(resolver, empty_cli_args): + resolved = resolver.resolve(empty_cli_args, None) + # should still have defaults applied + assert resolved.selection_string is not None + + +def test_resolve_yaml_applied_when_cli_not_provided(resolver, empty_cli_args): + run_config = {"selection_string": "yaml_value"} + + resolved = resolver.resolve(empty_cli_args, run_config) + + assert resolved.selection_string == "yaml_value" + + +def test_resolve_cli_value_overrides_yaml(resolver): + parser = resolver.build_parser() + args = parser.parse_args(["--selection_string", "cli_value"]) + run_config = {"selection_string": "yaml_value"} + + resolved = resolver.resolve(args, run_config) + + assert resolved.selection_string == "cli_value" + + +def test_resolve_does_not_apply_yaml_key_not_in_arg_specs(resolver, empty_cli_args): + run_config = {"not_a_real_arg": 123} + + resolved = resolver.resolve(empty_cli_args, run_config) + + assert not hasattr(resolved, "not_a_real_arg") + + +def test_resolve_ensure_defaults_sets_none_values(resolver): + # If a known arg is None, _ensure_defaults should fill it. + parser = resolver.build_parser() + args = parser.parse_args([]) + + # force a known arg to None to simulate partial/mutated namespace + args.selection_string = None + + resolved = resolver.resolve(args, {}) + + assert resolved.selection_string == "all" + + +def test_resolve_verbose_sets_logger_debug_level(resolver): + parser = resolver.build_parser() + args = parser.parse_args(["--verbose"]) + + resolver.resolve(args, {}) + + assert logger.level == logging.DEBUG + + +def test_apply_logging_level_updates_handler_level(): + handler = logging.StreamHandler() + handler.setLevel(logging.WARNING) + logger.addHandler(handler) + + try: + ConfigResolver._apply_logging_level(verbose=True) + assert logger.level == logging.DEBUG + assert handler.level == logging.DEBUG + finally: + logger.removeHandler(handler) diff --git a/tests/unit/CodeEntropy/config/test_argparse_str2bool.py b/tests/unit/CodeEntropy/config/test_argparse_str2bool.py new file mode 100644 index 00000000..3306dd9c --- /dev/null +++ b/tests/unit/CodeEntropy/config/test_argparse_str2bool.py @@ -0,0 +1,30 @@ +import argparse as _argparse + +import pytest + +from CodeEntropy.config.argparse import ConfigResolver + + +@pytest.mark.parametrize("value", ["true", "True", "t", "yes", "1"]) +def test_str2bool_true_variants(value): + assert ConfigResolver.str2bool(value) is True + + +@pytest.mark.parametrize("value", ["false", "False", "f", "no", "0"]) +def test_str2bool_false_variants(value): + assert ConfigResolver.str2bool(value) is False + + +def test_str2bool_bool_passthrough(): + assert ConfigResolver.str2bool(True) is True + assert ConfigResolver.str2bool(False) is False + + +def test_str2bool_non_string_non_bool_raises(): + with pytest.raises(_argparse.ArgumentTypeError): + ConfigResolver.str2bool(123) + + +def test_str2bool_invalid_string_raises(): + with pytest.raises(_argparse.ArgumentTypeError): + ConfigResolver.str2bool("maybe") diff --git a/tests/unit/CodeEntropy/config/test_argparse_validate_inputs.py b/tests/unit/CodeEntropy/config/test_argparse_validate_inputs.py new file mode 100644 index 00000000..1769a9c3 --- /dev/null +++ b/tests/unit/CodeEntropy/config/test_argparse_validate_inputs.py @@ -0,0 +1,59 @@ +import logging + +import pytest + + +def test_validate_inputs_valid_does_not_raise(resolver, dummy_universe, make_args): + args = make_args() + resolver.validate_inputs(dummy_universe, args) + + +def test_check_input_start_raises_when_start_exceeds_trajectory(resolver, make_args): + u = type("U", (), {"trajectory": [None] * 10})() + args = make_args(start=11) + + with pytest.raises(ValueError): + resolver._check_input_start(u, args) + + +def test_check_input_end_raises_when_end_exceeds_trajectory(resolver, make_args): + u = type("U", (), {"trajectory": [None] * 10})() + args = make_args(end=11) + + with pytest.raises(ValueError): + resolver._check_input_end(u, args) + + +def test_check_input_step_negative_logs_warning(resolver, make_args, caplog): + args = make_args(step=-1) + + with caplog.at_level(logging.WARNING): + resolver._check_input_step(args) + + assert "Negative 'step' value" in caplog.text + + +@pytest.mark.parametrize("bin_width", [-1, 361]) +def test_check_input_bin_width_out_of_range_raises(resolver, make_args, bin_width): + args = make_args(bin_width=bin_width) + + with pytest.raises(ValueError): + resolver._check_input_bin_width(args) + + +def test_check_input_temperature_negative_raises(resolver, make_args): + args = make_args(temperature=-0.1) + + with pytest.raises(ValueError): + resolver._check_input_temperature(args) + + +def test_check_input_force_partitioning_non_default_logs_warning( + resolver, make_args, caplog +): + args = make_args(force_partitioning=0.7) + + with caplog.at_level(logging.WARNING): + resolver._check_input_force_partitioning(args) + + assert "differs from the default" in caplog.text diff --git a/tests/unit/CodeEntropy/pytest.ini b/tests/unit/CodeEntropy/pytest.ini new file mode 100644 index 00000000..8adab832 --- /dev/null +++ b/tests/unit/CodeEntropy/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = CodeEntropy/tests/unit +addopts = -q \ No newline at end of file From c03aad87f1d4e6ef3646aafb8981a2c9ec9bbc0e Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 16:06:45 +0000 Subject: [PATCH 067/101] move argparse tests into individual folder --- tests/unit/CodeEntropy/config/{ => argparse}/conftest.py | 0 .../config/{ => argparse}/test_argparse_build_parser.py | 0 .../config/{ => argparse}/test_argparse_load_config.py | 0 .../CodeEntropy/config/{ => argparse}/test_argparse_resolve.py | 0 .../CodeEntropy/config/{ => argparse}/test_argparse_str2bool.py | 0 .../config/{ => argparse}/test_argparse_validate_inputs.py | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename tests/unit/CodeEntropy/config/{ => argparse}/conftest.py (100%) rename tests/unit/CodeEntropy/config/{ => argparse}/test_argparse_build_parser.py (100%) rename tests/unit/CodeEntropy/config/{ => argparse}/test_argparse_load_config.py (100%) rename tests/unit/CodeEntropy/config/{ => argparse}/test_argparse_resolve.py (100%) rename tests/unit/CodeEntropy/config/{ => argparse}/test_argparse_str2bool.py (100%) rename tests/unit/CodeEntropy/config/{ => argparse}/test_argparse_validate_inputs.py (100%) diff --git a/tests/unit/CodeEntropy/config/conftest.py b/tests/unit/CodeEntropy/config/argparse/conftest.py similarity index 100% rename from tests/unit/CodeEntropy/config/conftest.py rename to tests/unit/CodeEntropy/config/argparse/conftest.py diff --git a/tests/unit/CodeEntropy/config/test_argparse_build_parser.py b/tests/unit/CodeEntropy/config/argparse/test_argparse_build_parser.py similarity index 100% rename from tests/unit/CodeEntropy/config/test_argparse_build_parser.py rename to tests/unit/CodeEntropy/config/argparse/test_argparse_build_parser.py diff --git a/tests/unit/CodeEntropy/config/test_argparse_load_config.py b/tests/unit/CodeEntropy/config/argparse/test_argparse_load_config.py similarity index 100% rename from tests/unit/CodeEntropy/config/test_argparse_load_config.py rename to tests/unit/CodeEntropy/config/argparse/test_argparse_load_config.py diff --git a/tests/unit/CodeEntropy/config/test_argparse_resolve.py b/tests/unit/CodeEntropy/config/argparse/test_argparse_resolve.py similarity index 100% rename from tests/unit/CodeEntropy/config/test_argparse_resolve.py rename to tests/unit/CodeEntropy/config/argparse/test_argparse_resolve.py diff --git a/tests/unit/CodeEntropy/config/test_argparse_str2bool.py b/tests/unit/CodeEntropy/config/argparse/test_argparse_str2bool.py similarity index 100% rename from tests/unit/CodeEntropy/config/test_argparse_str2bool.py rename to tests/unit/CodeEntropy/config/argparse/test_argparse_str2bool.py diff --git a/tests/unit/CodeEntropy/config/test_argparse_validate_inputs.py b/tests/unit/CodeEntropy/config/argparse/test_argparse_validate_inputs.py similarity index 100% rename from tests/unit/CodeEntropy/config/test_argparse_validate_inputs.py rename to tests/unit/CodeEntropy/config/argparse/test_argparse_validate_inputs.py From 11ecb866726fa03ac483db6e609fdc42dd001ca1 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 16:38:26 +0000 Subject: [PATCH 068/101] test(config): refactor runtime tests into atomic unit suite with mocks and branch coverage --- .../CodeEntropy/config/runtime/conftest.py | 10 ++ .../runtime/test_build_universe_branches.py | 33 +++++ .../config/runtime/test_citation_data.py | 23 ++++ .../config/runtime/test_create_job_folder.py | 51 ++++++++ .../config/runtime/test_print_args_table.py | 22 ++++ .../config/runtime/test_required_args.py | 28 +++++ .../test_run_entropy_workflow_branches.py | 116 ++++++++++++++++++ .../config/runtime/test_runtime_properties.py | 7 ++ .../config/runtime/test_show_splash.py | 46 +++++++ .../config/runtime/test_unit_conversions.py | 6 + .../config/runtime/test_universe_io.py | 28 +++++ 11 files changed, 370 insertions(+) create mode 100644 tests/unit/CodeEntropy/config/runtime/conftest.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_build_universe_branches.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_citation_data.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_create_job_folder.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_print_args_table.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_required_args.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_runtime_properties.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_show_splash.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_unit_conversions.py create mode 100644 tests/unit/CodeEntropy/config/runtime/test_universe_io.py diff --git a/tests/unit/CodeEntropy/config/runtime/conftest.py b/tests/unit/CodeEntropy/config/runtime/conftest.py new file mode 100644 index 00000000..8649dc57 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from CodeEntropy.config.runtime import CodeEntropyRunner + + +@pytest.fixture() +def runner(tmp_path, monkeypatch): + # keep filesystem effects isolated + monkeypatch.chdir(tmp_path) + return CodeEntropyRunner(folder=str(tmp_path)) diff --git a/tests/unit/CodeEntropy/config/runtime/test_build_universe_branches.py b/tests/unit/CodeEntropy/config/runtime/test_build_universe_branches.py new file mode 100644 index 00000000..2d8571b4 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_build_universe_branches.py @@ -0,0 +1,33 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + + +def test_build_universe_no_force(runner): + args = SimpleNamespace( + top_traj_file=["tpr", "trr"], + force_file=None, + file_format=None, + kcal_force_units=False, + ) + uops = MagicMock() + + with patch("CodeEntropy.config.runtime.mda.Universe", return_value="U"): + out = runner._build_universe(args, uops) + + assert out == "U" + uops.merge_forces.assert_not_called() + + +def test_build_universe_with_force(runner): + args = SimpleNamespace( + top_traj_file=["tpr", "trr"], + force_file="force", + file_format="gro", + kcal_force_units=True, + ) + uops = MagicMock() + uops.merge_forces.return_value = "U2" + + out = runner._build_universe(args, uops) + + assert out == "U2" diff --git a/tests/unit/CodeEntropy/config/runtime/test_citation_data.py b/tests/unit/CodeEntropy/config/runtime/test_citation_data.py new file mode 100644 index 00000000..d5876662 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_citation_data.py @@ -0,0 +1,23 @@ +from unittest.mock import MagicMock, patch + +import requests + + +def test_load_citation_data_success(runner): + mock_response = MagicMock() + mock_response.raise_for_status.return_value = None + mock_response.text = """ +title: TestProject +authors: + - given-names: Alice +""" + + with patch("requests.get", return_value=mock_response): + data = runner.load_citation_data() + + assert data["title"] == "TestProject" + + +def test_load_citation_data_network_error_returns_none(runner): + with patch("requests.get", side_effect=requests.exceptions.ConnectionError()): + assert runner.load_citation_data() is None diff --git a/tests/unit/CodeEntropy/config/runtime/test_create_job_folder.py b/tests/unit/CodeEntropy/config/runtime/test_create_job_folder.py new file mode 100644 index 00000000..1c88ca33 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_create_job_folder.py @@ -0,0 +1,51 @@ +import os +from unittest.mock import patch + +from CodeEntropy.config.runtime import CodeEntropyRunner + + +def test_create_job_folder_empty_creates_job001(): + with ( + patch("os.getcwd", return_value="/cwd"), + patch("os.listdir", return_value=[]), + patch("os.makedirs") as mock_makedirs, + ): + path = CodeEntropyRunner.create_job_folder() + + assert path == os.path.join("/cwd", "job001") + mock_makedirs.assert_called_once() + + +def test_create_job_folder_existing_creates_next(): + with ( + patch("os.getcwd", return_value="/cwd"), + patch("os.listdir", return_value=["job001", "job002"]), + patch("os.makedirs") as mock_makedirs, + ): + path = CodeEntropyRunner.create_job_folder() + + assert path == os.path.join("/cwd", "job003") + mock_makedirs.assert_called_once() + + +def test_create_job_folder_ignores_invalid_names(): + with ( + patch("os.getcwd", return_value="/cwd"), + patch("os.listdir", return_value=["job001", "abc", "job002"]), + patch("os.makedirs") as _, + ): + path = CodeEntropyRunner.create_job_folder() + + assert path == os.path.join("/cwd", "job003") + + +def test_create_job_folder_skips_value_error_suffix(): + # jobABC triggers int("ABC") -> ValueError -> continue + with ( + patch("os.getcwd", return_value="/cwd"), + patch("os.listdir", return_value=["jobABC", "job001"]), + patch("os.makedirs"), + ): + path = CodeEntropyRunner.create_job_folder() + + assert path == os.path.join("/cwd", "job002") diff --git a/tests/unit/CodeEntropy/config/runtime/test_print_args_table.py b/tests/unit/CodeEntropy/config/runtime/test_print_args_table.py new file mode 100644 index 00000000..286b3164 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_print_args_table.py @@ -0,0 +1,22 @@ +from io import StringIO +from types import SimpleNamespace +from unittest.mock import patch + +from rich.console import Console + + +def test_print_args_table_prints_all_args(runner): + args = SimpleNamespace(alpha=1, beta="two") + + buf = StringIO() + test_console = Console(file=buf, force_terminal=False, width=120) + + with patch("CodeEntropy.config.runtime.console", test_console): + runner.print_args_table(args) + + out = buf.getvalue() + assert "Run Configuration" in out + assert "alpha" in out + assert "1" in out + assert "beta" in out + assert "two" in out diff --git a/tests/unit/CodeEntropy/config/runtime/test_required_args.py b/tests/unit/CodeEntropy/config/runtime/test_required_args.py new file mode 100644 index 00000000..c9035954 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_required_args.py @@ -0,0 +1,28 @@ +import pytest + +from CodeEntropy.config.runtime import CodeEntropyRunner + + +class Args: + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +def test_validate_required_args_missing_traj_raises(): + args = Args(top_traj_file=None, selection_string="all") + + with pytest.raises(ValueError): + CodeEntropyRunner._validate_required_args(args) + + +def test_validate_required_args_missing_selection_raises(): + args = Args(top_traj_file=["a"], selection_string=None) + + with pytest.raises(ValueError): + CodeEntropyRunner._validate_required_args(args) + + +def test_validate_required_args_ok(): + args = Args(top_traj_file=["a"], selection_string="all") + CodeEntropyRunner._validate_required_args(args) diff --git a/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py b/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py new file mode 100644 index 00000000..6ec79586 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py @@ -0,0 +1,116 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +def test_run_entropy_workflow_warns_and_skips_non_dict_run_config(runner): + # Arrange: mock all collaborators so the method stays unit-level + runner._logging_config = MagicMock() + runner._config_manager = MagicMock() + runner._reporter = MagicMock() + + run_logger = MagicMock() + runner._logging_config.configure.return_value = run_logger + + runner._config_manager.load_config.return_value = {"bad_run": "not_a_dict"} + + parser = MagicMock() + parser.parse_known_args.return_value = (SimpleNamespace(output_file="out.json"), []) + runner._config_manager.build_parser.return_value = parser + + runner.show_splash = MagicMock() + + # Act + runner.run_entropy_workflow() + + # Assert: one behavior only — warning + skip + run_logger.warning.assert_called_once() + runner._config_manager.resolve.assert_not_called() + + +def test_run_entropy_workflow_raises_when_required_args_missing(runner): + runner._logging_config = MagicMock() + runner._config_manager = MagicMock() + runner._reporter = MagicMock() + + runner._logging_config.configure.return_value = MagicMock() + runner.show_splash = MagicMock() + + # config contains a valid dict run so it will try to process it + runner._config_manager.load_config.return_value = {"run1": {}} + + # parser returns args with missing top_traj_file/selection_string + parser = MagicMock() + args = SimpleNamespace( + output_file="out.json", + verbose=False, + top_traj_file=None, + selection_string=None, + force_file=None, + file_format=None, + kcal_force_units=False, + ) + parser.parse_known_args.return_value = (args, []) + runner._config_manager.build_parser.return_value = parser + + # resolve returns same args (still missing required) + runner._config_manager.resolve.return_value = args + + with pytest.raises(ValueError): + runner.run_entropy_workflow() + + +def test_run_entropy_workflow_happy_path_calls_execute_once(runner): + # Mock collaborators + runner._logging_config = MagicMock() + runner._config_manager = MagicMock() + runner._reporter = MagicMock() + runner.show_splash = MagicMock() + runner.print_args_table = MagicMock() + + run_logger = MagicMock() + runner._logging_config.configure.return_value = run_logger + + # One valid run config dict (so it doesn't hit the "warning+continue" branch) + runner._config_manager.load_config.return_value = {"run1": {}} + + # CLI args (must satisfy required args) + args = SimpleNamespace( + output_file="out.json", + verbose=False, + top_traj_file=["top.tpr", "traj.trr"], + selection_string="all", + force_file=None, + file_format=None, + kcal_force_units=False, + ) + parser = MagicMock() + parser.parse_known_args.return_value = (args, []) + runner._config_manager.build_parser.return_value = parser + + # resolve returns the args + runner._config_manager.resolve.return_value = args + + # Avoid MDAnalysis/real work: stub universe creation + validation + runner._build_universe = MagicMock(return_value="U") + runner._config_manager.validate_inputs = MagicMock() + + # Patch constructors used in the happy path + with ( + patch("CodeEntropy.config.runtime.UniverseOperations") as _, + patch("CodeEntropy.config.runtime.MoleculeGrouper") as _, + patch("CodeEntropy.config.runtime.ConformationStateBuilder") as _, + patch("CodeEntropy.config.runtime.EntropyWorkflow") as EWCls, + ): + entropy_instance = MagicMock() + EWCls.return_value = entropy_instance + + runner.run_entropy_workflow() + + runner.print_args_table.assert_called_once_with(args) + runner._build_universe.assert_called_once() + runner._config_manager.validate_inputs.assert_called_once_with("U", args) + EWCls.assert_called_once() + entropy_instance.execute.assert_called_once() + runner._logging_config.export_console.assert_called_once() diff --git a/tests/unit/CodeEntropy/config/runtime/test_runtime_properties.py b/tests/unit/CodeEntropy/config/runtime/test_runtime_properties.py new file mode 100644 index 00000000..cd2cae36 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_runtime_properties.py @@ -0,0 +1,7 @@ +def test_n_avogadro_property_returns_internal_value(runner): + # uses the runner fixture (tmp folder) + assert runner.N_AVOGADRO == runner._N_AVOGADRO + + +def test_def_temper_property_returns_internal_value(runner): + assert runner.DEF_TEMPER == runner._DEF_TEMPER diff --git a/tests/unit/CodeEntropy/config/runtime/test_show_splash.py b/tests/unit/CodeEntropy/config/runtime/test_show_splash.py new file mode 100644 index 00000000..171927f1 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_show_splash.py @@ -0,0 +1,46 @@ +from io import StringIO +from unittest.mock import patch + +from rich.console import Console + + +def test_show_splash_with_citation(runner): + citation = { + "title": "TestProject", + "version": "1.0", + "date-released": "2025-01-01", + "url": "https://example.com", + "abstract": "This is a test abstract.", + "authors": [{"given-names": "Alice", "family-names": "Smith"}], + } + + buf = StringIO() + console = Console(file=buf, force_terminal=False, width=120) + + with ( + patch.object(runner, "load_citation_data", return_value=citation), + patch("CodeEntropy.config.runtime.console", console), + ): + runner.show_splash() + + out = buf.getvalue() + + assert "Welcome to CodeEntropy" in out + assert "Version 1.0" in out + assert "2025-01-01" in out + assert "https://example.com" in out + assert "This is a test abstract." in out + assert "Alice Smith" in out + + +def test_show_splash_without_citation(runner): + buf = StringIO() + console = Console(file=buf, force_terminal=False) + + with ( + patch.object(runner, "load_citation_data", return_value=None), + patch("CodeEntropy.config.runtime.console", console), + ): + runner.show_splash() + + assert "Welcome to CodeEntropy" in buf.getvalue() diff --git a/tests/unit/CodeEntropy/config/runtime/test_unit_conversions.py b/tests/unit/CodeEntropy/config/runtime/test_unit_conversions.py new file mode 100644 index 00000000..31b7543d --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_unit_conversions.py @@ -0,0 +1,6 @@ +def test_change_lambda_units(runner): + assert runner.change_lambda_units(2.0) == 2.0 * 1e29 / runner.N_AVOGADRO + + +def test_get_kt2j(runner): + assert runner.get_KT2J(298.0) == 4.11e-21 * 298.0 / runner.DEF_TEMPER diff --git a/tests/unit/CodeEntropy/config/runtime/test_universe_io.py b/tests/unit/CodeEntropy/config/runtime/test_universe_io.py new file mode 100644 index 00000000..023d4271 --- /dev/null +++ b/tests/unit/CodeEntropy/config/runtime/test_universe_io.py @@ -0,0 +1,28 @@ +from unittest.mock import MagicMock, patch + + +def test_write_universe(runner): + u = MagicMock() + + with patch("pickle.dump") as mock_dump: + name = runner.write_universe(u, name="test") + + assert name == "test" + mock_dump.assert_called_once() + + +def test_read_universe(runner): + mock_file = MagicMock() + + # Make open() context manager return mock_file + mock_file.__enter__.return_value = mock_file + + with ( + patch("builtins.open", return_value=mock_file) as mock_open, + patch("pickle.load", return_value="U") as mock_load, + ): + out = runner.read_universe("file.pkl") + + mock_open.assert_called_once_with("file.pkl", "rb") + mock_load.assert_called_once_with(mock_file) + assert out == "U" From e96b2dff2781317341da4dd3155aeac2bb0d0cce Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Fri, 20 Feb 2026 16:56:02 +0000 Subject: [PATCH 069/101] test(core): add atomic pytest unit tests for logging configuration --- .../unit/CodeEntropy/core/logging/conftest.py | 23 +++++++++++ .../core/logging/test_configure_loggers.py | 26 ++++++++++++ .../core/logging/test_error_filter.py | 23 +++++++++++ .../core/logging/test_export_console.py | 17 ++++++++ .../logging/test_get_console_singleton.py | 19 +++++++++ .../core/logging/test_handlers_setup.py | 41 +++++++++++++++++++ .../core/logging/test_set_level.py | 27 ++++++++++++ 7 files changed, 176 insertions(+) create mode 100644 tests/unit/CodeEntropy/core/logging/conftest.py create mode 100644 tests/unit/CodeEntropy/core/logging/test_configure_loggers.py create mode 100644 tests/unit/CodeEntropy/core/logging/test_error_filter.py create mode 100644 tests/unit/CodeEntropy/core/logging/test_export_console.py create mode 100644 tests/unit/CodeEntropy/core/logging/test_get_console_singleton.py create mode 100644 tests/unit/CodeEntropy/core/logging/test_handlers_setup.py create mode 100644 tests/unit/CodeEntropy/core/logging/test_set_level.py diff --git a/tests/unit/CodeEntropy/core/logging/conftest.py b/tests/unit/CodeEntropy/core/logging/conftest.py new file mode 100644 index 00000000..20867d87 --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/conftest.py @@ -0,0 +1,23 @@ +import logging + +import pytest + +from CodeEntropy.core.logging import LoggingConfig + + +@pytest.fixture(autouse=True) +def _isolate_global_logging(): + """ + LoggingConfig modifies global loggers. Keep tests atomic by clearing handlers + after each test so tests don't leak state into each other. + """ + yield + for name in ("", "commands", "MDAnalysis"): + lg = logging.getLogger(name) + lg.handlers.clear() + lg.propagate = True + + +@pytest.fixture() +def config(tmp_path): + return LoggingConfig(folder=str(tmp_path), level=logging.INFO) diff --git a/tests/unit/CodeEntropy/core/logging/test_configure_loggers.py b/tests/unit/CodeEntropy/core/logging/test_configure_loggers.py new file mode 100644 index 00000000..94a1fa39 --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/test_configure_loggers.py @@ -0,0 +1,26 @@ +import logging + + +def test_configure_attaches_handlers(config): + config.configure() + + root = logging.getLogger() + assert config.handlers["rich"] in root.handlers + assert config.handlers["main"] in root.handlers + assert config.handlers["error"] in root.handlers + + +def test_configure_commands_logger_non_propagating_with_handler(config): + config.configure() + + commands_logger = logging.getLogger("commands") + assert commands_logger.propagate is False + assert config.handlers["command"] in commands_logger.handlers + + +def test_configure_mdanalysis_logger_non_propagating_with_handler(config): + config.configure() + + mda_logger = logging.getLogger("MDAnalysis") + assert mda_logger.propagate is False + assert config.handlers["mdanalysis"] in mda_logger.handlers diff --git a/tests/unit/CodeEntropy/core/logging/test_error_filter.py b/tests/unit/CodeEntropy/core/logging/test_error_filter.py new file mode 100644 index 00000000..be6e6670 --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/test_error_filter.py @@ -0,0 +1,23 @@ +import logging + +from CodeEntropy.core.logging import ErrorFilter + + +def test_error_filter_allows_error_and_critical(): + f = ErrorFilter() + + record_error = logging.LogRecord("x", logging.ERROR, "f.py", 1, "msg", (), None) + record_crit = logging.LogRecord("x", logging.CRITICAL, "f.py", 1, "msg", (), None) + + assert f.filter(record_error) is True + assert f.filter(record_crit) is True + + +def test_error_filter_blocks_below_error(): + f = ErrorFilter() + + record_warn = logging.LogRecord("x", logging.WARNING, "f.py", 1, "msg", (), None) + record_info = logging.LogRecord("x", logging.INFO, "f.py", 1, "msg", (), None) + + assert f.filter(record_warn) is False + assert f.filter(record_info) is False diff --git a/tests/unit/CodeEntropy/core/logging/test_export_console.py b/tests/unit/CodeEntropy/core/logging/test_export_console.py new file mode 100644 index 00000000..65f5276f --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/test_export_console.py @@ -0,0 +1,17 @@ +import os +from unittest.mock import MagicMock + + +def test_export_console_writes_recorded_output(config): + # Make export_text deterministic + config.console.export_text = MagicMock(return_value="HELLO") + + config.export_console("out.txt") + + out_path = os.path.join(config.log_dir, "out.txt") + assert os.path.exists(out_path) + + with open(out_path, "r", encoding="utf-8") as f: + assert f.read() == "HELLO" + + config.console.export_text.assert_called_once() diff --git a/tests/unit/CodeEntropy/core/logging/test_get_console_singleton.py b/tests/unit/CodeEntropy/core/logging/test_get_console_singleton.py new file mode 100644 index 00000000..53900eb2 --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/test_get_console_singleton.py @@ -0,0 +1,19 @@ +from CodeEntropy.core.logging import LoggingConfig + + +def test_get_console_returns_singleton(): + # Reset singleton to make the test independent + LoggingConfig._console = None + + c1 = LoggingConfig.get_console() + c2 = LoggingConfig.get_console() + + assert c1 is c2 + + +def test_get_console_records_output_enabled(): + LoggingConfig._console = None + c = LoggingConfig.get_console() + + # Rich Console uses 'record' attribute when recording is enabled + assert getattr(c, "record", False) is True diff --git a/tests/unit/CodeEntropy/core/logging/test_handlers_setup.py b/tests/unit/CodeEntropy/core/logging/test_handlers_setup.py new file mode 100644 index 00000000..496f17be --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/test_handlers_setup.py @@ -0,0 +1,41 @@ +import logging +import os + +from rich.logging import RichHandler + +from CodeEntropy.core.logging import LoggingConfig + + +def test_init_creates_log_dir(tmp_path): + cfg = LoggingConfig(folder=str(tmp_path)) + assert os.path.isdir(cfg.log_dir) + + +def test_setup_handlers_creates_expected_handlers(config): + assert set(config.handlers.keys()) == { + "rich", + "main", + "error", + "command", + "mdanalysis", + } + + assert isinstance(config.handlers["rich"], RichHandler) + assert isinstance(config.handlers["main"], logging.FileHandler) + assert isinstance(config.handlers["error"], logging.FileHandler) + assert isinstance(config.handlers["command"], logging.FileHandler) + assert isinstance(config.handlers["mdanalysis"], logging.FileHandler) + + +def test_handler_paths_match_expected_filenames(config): + expected = { + "main": "program.log", + "error": "program.err", + "command": "program.com", + "mdanalysis": "mdanalysis.log", + } + + for handler_key, filename in expected.items(): + handler = config.handlers[handler_key] + assert os.path.basename(handler.baseFilename) == filename + assert os.path.dirname(handler.baseFilename) == config.log_dir diff --git a/tests/unit/CodeEntropy/core/logging/test_set_level.py b/tests/unit/CodeEntropy/core/logging/test_set_level.py new file mode 100644 index 00000000..b28a0bf5 --- /dev/null +++ b/tests/unit/CodeEntropy/core/logging/test_set_level.py @@ -0,0 +1,27 @@ +import logging + + +def test_set_level_updates_root_and_named_loggers(config): + config.configure() + + config.set_level(logging.DEBUG) + + root = logging.getLogger() + assert root.level == logging.DEBUG + + assert logging.getLogger("commands").level == logging.DEBUG + assert logging.getLogger("MDAnalysis").level == logging.DEBUG + + +def test_set_level_sets_filehandlers_to_log_level_and_other_handlers_to_info(config): + config.configure() + + config.set_level(logging.DEBUG) + + root = logging.getLogger() + + for h in root.handlers: + if isinstance(h, logging.FileHandler): + assert h.level == logging.DEBUG + else: + assert h.level == logging.INFO From 636500e3f79d26c0cc32696a7881c1e4a3c5eafa Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 10:36:34 +0000 Subject: [PATCH 070/101] test(core): add atomic pytest unit tests for the entropy modules --- tests/unit/CodeEntropy/entropy/conftest.py | 76 +++++++ .../entropy/test_aggregate_node.py | 22 +++ .../entropy/test_configurational_edges.py | 85 ++++++++ .../test_configurational_entropy_math.py | 120 ++++++++++++ .../entropy/test_configurational_node.py | 27 +++ ...t_configurational_node_missing_branches.py | 47 +++++ tests/unit/CodeEntropy/entropy/test_graph.py | 48 +++++ .../entropy/test_orientational_entropy.py | 54 +++++ .../entropy/test_vibrational_entropy_math.py | 161 +++++++++++++++ .../entropy/test_vibrational_node_branches.py | 174 ++++++++++++++++ .../test_vibrational_node_missing_branches.py | 109 +++++++++++ .../test_vibrational_node_more_branches.py | 96 +++++++++ .../CodeEntropy/entropy/test_water_entropy.py | 178 +++++++++++++++++ .../entropy/test_workflow_atomic_branches.py | 105 ++++++++++ .../entropy/test_workflow_helpers.py | 81 ++++++++ .../entropy/test_workflow_more_branches.py | 185 ++++++++++++++++++ .../test_workflow_remaining_branches.py | 67 +++++++ 17 files changed, 1635 insertions(+) create mode 100644 tests/unit/CodeEntropy/entropy/conftest.py create mode 100644 tests/unit/CodeEntropy/entropy/test_aggregate_node.py create mode 100644 tests/unit/CodeEntropy/entropy/test_configurational_edges.py create mode 100644 tests/unit/CodeEntropy/entropy/test_configurational_entropy_math.py create mode 100644 tests/unit/CodeEntropy/entropy/test_configurational_node.py create mode 100644 tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_graph.py create mode 100644 tests/unit/CodeEntropy/entropy/test_orientational_entropy.py create mode 100644 tests/unit/CodeEntropy/entropy/test_vibrational_entropy_math.py create mode 100644 tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_water_entropy.py create mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_helpers.py create mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py diff --git a/tests/unit/CodeEntropy/entropy/conftest.py b/tests/unit/CodeEntropy/entropy/conftest.py new file mode 100644 index 00000000..4a05ccbb --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/conftest.py @@ -0,0 +1,76 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + + +class FakeResidue: + def __init__(self, resname="RES"): + self.resname = resname + + +class FakeMol: + def __init__(self, residues=None): + self.residues = residues or [FakeResidue("ALA"), FakeResidue("GLY")] + + +class FakeAtoms: + def __init__(self, fragments): + self.fragments = fragments + + +class FakeUniverse: + def __init__(self, n_frames=10, fragments=None): + self.trajectory = list(range(n_frames)) + self.atoms = FakeAtoms(fragments or [FakeMol()]) + + +@pytest.fixture() +def args(): + # Only fields used by entropy modules. + return SimpleNamespace( + temperature=298.0, + bin_width=30, + grouping="molecules", + water_entropy=False, + combined_forcetorque=True, + selection_string="all", + start=0, + end=-1, + step=1, + ) + + +@pytest.fixture() +def reporter(): + return MagicMock() + + +@pytest.fixture() +def run_manager(): + rm = MagicMock() + rm.change_lambda_units.side_effect = lambda x: x + rm.get_KT2J.return_value = 2.479e-21 + return rm + + +@pytest.fixture() +def reduced_universe(): + return FakeUniverse(n_frames=12, fragments=[FakeMol()]) + + +@pytest.fixture() +def shared_data(args, reporter, run_manager, reduced_universe): + return { + "args": args, + "reporter": reporter, + "run_manager": run_manager, + "reduced_universe": reduced_universe, + "universe": reduced_universe, + "groups": {0: [0]}, + "levels": {0: ["united_atom", "residue"]}, + "start": 0, + "end": 12, + "step": 1, + "n_frames": 12, + } diff --git a/tests/unit/CodeEntropy/entropy/test_aggregate_node.py b/tests/unit/CodeEntropy/entropy/test_aggregate_node.py new file mode 100644 index 00000000..87230f40 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_aggregate_node.py @@ -0,0 +1,22 @@ +from CodeEntropy.entropy.nodes.aggregate import AggregateEntropyNode + + +def test_aggregate_node_collects_values_and_writes_shared_data(): + node = AggregateEntropyNode() + shared = {"vibrational_entropy": {"v": 1}, "configurational_entropy": {"c": 2}} + + out = node.run(shared) + + assert out["entropy_results"]["vibrational_entropy"] == {"v": 1} + assert out["entropy_results"]["configurational_entropy"] == {"c": 2} + assert shared["entropy_results"] == out["entropy_results"] + + +def test_aggregate_node_missing_upstreams_yields_none_values(): + node = AggregateEntropyNode() + shared = {} + + out = node.run(shared) + + assert out["entropy_results"]["vibrational_entropy"] is None + assert out["entropy_results"]["configurational_entropy"] is None diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_edges.py b/tests/unit/CodeEntropy/entropy/test_configurational_edges.py new file mode 100644 index 00000000..7fbc26f1 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_configurational_edges.py @@ -0,0 +1,85 @@ +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from CodeEntropy.entropy.configurational import ConformationalEntropy + + +def test_validate_assignment_config_step_must_be_positive(): + ce = ConformationalEntropy() + with pytest.raises(ValueError): + ce.assign_conformation( + data_container=SimpleNamespace(trajectory=list(range(5))), + dihedral=MagicMock(value=lambda: 10.0), + number_frames=5, + bin_width=30, + start=0, + end=5, + step=0, + ) + + +def test_validate_assignment_config_bin_width_out_of_range(): + ce = ConformationalEntropy() + with pytest.raises(ValueError): + ce.assign_conformation( + data_container=SimpleNamespace(trajectory=list(range(5))), + dihedral=MagicMock(value=lambda: 10.0), + number_frames=5, + bin_width=0, + start=0, + end=5, + step=1, + ) + + +def test_validate_assignment_config_warns_when_bin_width_not_dividing_360(caplog): + ce = ConformationalEntropy() + caplog.set_level(logging.WARNING) + + data_container = SimpleNamespace(trajectory=list(range(5))) + dihedral = MagicMock() + dihedral.value.return_value = 10.0 + + ce.assign_conformation( + data_container=data_container, + dihedral=dihedral, + number_frames=5, + bin_width=7, + start=0, + end=5, + step=1, + ) + + assert any("does not evenly divide 360" in r.message for r in caplog.records) + + +def test_collect_dihedral_angles_normalizes_negative_values(): + ce = ConformationalEntropy() + + traj_slice = list(range(3)) + dihedral = MagicMock() + dihedral.value.side_effect = [-10.0, 0.0, 10.0] + + phi = ce._collect_dihedral_angles(traj_slice, dihedral) + + assert phi[0] == pytest.approx(350.0) + + +def test_to_1d_array_returns_none_for_non_iterable_state_input(): + ce = ConformationalEntropy() + # int is not iterable -> list(states) raises TypeError -> returns None + assert ce._to_1d_array(123) is None + + +def test_find_histogram_peaks_skips_zero_population_bins(): + ce = ConformationalEntropy() + + phi = np.zeros(50, dtype=float) + + peaks = ce._find_histogram_peaks(phi, bin_width=30) + + assert peaks.size >= 1 diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_entropy_math.py b/tests/unit/CodeEntropy/entropy/test_configurational_entropy_math.py new file mode 100644 index 00000000..7624fbf6 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_configurational_entropy_math.py @@ -0,0 +1,120 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from CodeEntropy.entropy.configurational import ConformationalEntropy + + +def test_find_histogram_peaks_empty_histogram_returns_empty(): + ce = ConformationalEntropy() + phi = np.zeros(100, dtype=float) + + peaks = ce._find_histogram_peaks(phi, bin_width=30) + + assert isinstance(peaks, np.ndarray) + assert peaks.dtype == float + + +def test_find_histogram_peaks_returns_empty_for_empty_phi(): + ce = ConformationalEntropy() + phi = np.array([], dtype=float) + + peaks = ce._find_histogram_peaks(phi, bin_width=30) + + assert isinstance(peaks, np.ndarray) + assert peaks.size == 0 + + +def test_assign_nearest_peaks_with_single_peak_assigns_all_zero(): + ce = ConformationalEntropy() + phi = np.array([0.0, 10.0, 20.0], dtype=float) + peak_values = np.array([15.0], dtype=float) + + states = ce._assign_nearest_peaks(phi, peak_values) + + assert np.all(states == 0) + + +def test_assign_conformation_no_peaks_returns_all_zero(): + ce = ConformationalEntropy() + + data_container = SimpleNamespace(trajectory=[]) + dihedral = MagicMock() + + states = ce.assign_conformation( + data_container=data_container, + dihedral=dihedral, + number_frames=0, + bin_width=30, + start=0, + end=0, + step=1, + ) + + assert states.size == 0 + + +def test_assign_conformation_fallback_when_peak_finder_returns_empty(monkeypatch): + ce = ConformationalEntropy() + data_container = SimpleNamespace(trajectory=list(range(5))) + dihedral = MagicMock() + dihedral.value.return_value = 10.0 + + monkeypatch.setattr( + ce, "_find_histogram_peaks", lambda phi, bw: np.array([], dtype=float) + ) + + states = ce.assign_conformation( + data_container=data_container, + dihedral=dihedral, + number_frames=5, + bin_width=30, + start=0, + end=5, + step=1, + ) + assert np.all(states == 0) + + +def test_assign_conformation_detects_multiple_states(): + ce = ConformationalEntropy() + + values = [0.0] * 50 + [180.0] * 50 + data_container = SimpleNamespace(trajectory=list(range(len(values)))) + dihedral = MagicMock() + dihedral.value.side_effect = values + + states = ce.assign_conformation( + data_container=data_container, + dihedral=dihedral, + number_frames=len(values), + bin_width=30, + start=0, + end=len(values), + step=1, + ) + + assert len(np.unique(states)) >= 2 + + +def test_conformational_entropy_empty_returns_zero(): + ce = ConformationalEntropy() + assert ce.conformational_entropy_calculation([], number_frames=10) == 0.0 + + +def test_conformational_entropy_single_state_returns_zero(): + ce = ConformationalEntropy() + assert ce.conformational_entropy_calculation([0, 0, 0], number_frames=3) == 0.0 + + +def test_conformational_entropy_known_distribution_matches_expected(): + ce = ConformationalEntropy() + states = np.array([0, 0, 1, 1, 1, 2]) + + probs = np.array([2 / 6, 3 / 6, 1 / 6], dtype=float) + expected = -ce._GAS_CONST * float(np.sum(probs * np.log(probs))) + + got = ce.conformational_entropy_calculation(states, number_frames=6) + assert got == pytest.approx(expected) diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_node.py b/tests/unit/CodeEntropy/entropy/test_configurational_node.py new file mode 100644 index 00000000..f8a19b2e --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_configurational_node.py @@ -0,0 +1,27 @@ +import pytest + +from CodeEntropy.entropy.nodes.configurational import ConfigurationalEntropyNode + + +def test_config_node_raises_if_frame_count_missing(): + node = ConfigurationalEntropyNode() + with pytest.raises(KeyError): + node._get_n_frames({}) + + +def test_config_node_run_writes_results(shared_data): + node = ConfigurationalEntropyNode() + + shared_data["conformational_states"] = { + "ua": {(0, 0): [0, 0, 1, 1]}, + "res": {0: [0, 1, 1, 1]}, + } + + shared_data["levels"] = {0: ["united_atom", "residue"]} + shared_data["groups"] = {0: [0]} + + out = node.run(shared_data) + + assert "configurational_entropy" in out + assert "configurational_entropy" in shared_data + assert 0 in shared_data["configurational_entropy"] diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py b/tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py new file mode 100644 index 00000000..b8aa4a79 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py @@ -0,0 +1,47 @@ +from unittest.mock import MagicMock + +import numpy as np + +from CodeEntropy.entropy.nodes.configurational import ConfigurationalEntropyNode + + +def test_run_skips_empty_mol_ids_group(): + node = ConfigurationalEntropyNode() + + shared_data = { + "n_frames": 5, + "groups": {0: []}, + "levels": {0: ["united_atom"]}, + "reduced_universe": MagicMock(), + "conformational_states": {"ua": {}, "res": {}}, + "reporter": None, + } + + out = node.run(shared_data) + + assert "configurational_entropy" in out + assert out["configurational_entropy"][0]["ua"] == 0.0 + + +def test_get_group_states_sequence_in_range_returns_value(): + states_res = [None, [1, 2, 3]] + out = ConfigurationalEntropyNode._get_group_states(states_res, group_id=1) + assert out == [1, 2, 3] + + +def test_get_group_states_sequence_out_of_range_returns_none(): + states_res = [None] + out = ConfigurationalEntropyNode._get_group_states(states_res, group_id=2) + assert out is None + + +def test_has_state_data_numpy_array_uses_np_any_branch(): + assert ConfigurationalEntropyNode._has_state_data(np.array([0, 0, 1])) is True + assert ConfigurationalEntropyNode._has_state_data(np.array([0, 0, 0])) is False + + +def test_has_state_data_noniterable_hits_typeerror_fallback(): + # any(0) raises TypeError -> returns bool(0) == False + assert ConfigurationalEntropyNode._has_state_data(0) is False + # any(1) raises TypeError -> returns bool(1) == True + assert ConfigurationalEntropyNode._has_state_data(1) is True diff --git a/tests/unit/CodeEntropy/entropy/test_graph.py b/tests/unit/CodeEntropy/entropy/test_graph.py new file mode 100644 index 00000000..5396a43a --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_graph.py @@ -0,0 +1,48 @@ +from unittest.mock import MagicMock + +import pytest + +from CodeEntropy.entropy.graph import EntropyGraph, NodeSpec + + +def test_build_creates_expected_nodes_and_edges(): + g = EntropyGraph().build() + + assert set(g._nodes.keys()) == { + "vibrational_entropy", + "configurational_entropy", + "aggregate_entropy", + } + + assert g._graph.has_edge("vibrational_entropy", "aggregate_entropy") + assert g._graph.has_edge("configurational_entropy", "aggregate_entropy") + + +def test_execute_runs_nodes_in_topological_order_and_merges_dict_outputs(shared_data): + g = EntropyGraph() + + node_a = MagicMock() + node_b = MagicMock() + node_c = MagicMock() + + node_a.run.return_value = {"a": 1} + node_b.run.return_value = {"b": 2} + node_c.run.return_value = "not-a-dict" + + g._add_node(NodeSpec("a", node_a)) + g._add_node(NodeSpec("b", node_b)) + g._add_node(NodeSpec("c", node_c, deps=("a", "b"))) + + out = g.execute(shared_data) + + assert node_a.run.called + assert node_b.run.called + assert node_c.run.called + assert out == {"a": 1, "b": 2} + + +def test_add_node_duplicate_name_raises(): + g = EntropyGraph() + g._add_node(NodeSpec("x", object())) + with pytest.raises(ValueError): + g._add_node(NodeSpec("x", object())) diff --git a/tests/unit/CodeEntropy/entropy/test_orientational_entropy.py b/tests/unit/CodeEntropy/entropy/test_orientational_entropy.py new file mode 100644 index 00000000..1411d08d --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_orientational_entropy.py @@ -0,0 +1,54 @@ +import pytest + +from CodeEntropy.entropy.orientational import OrientationalEntropy + + +def test_orientational_skips_water_species(): + oe = OrientationalEntropy(None, None, None, None, None) + res = oe.calculate({"WAT": 10, "LIG": 2}) + assert res.total > 0 + + +def test_orientational_negative_count_raises(): + oe = OrientationalEntropy(None, None, None, None, None) + with pytest.raises(ValueError): + oe.calculate({"LIG": -1}) + + +def test_orientational_zero_count_contributes_zero(): + oe = OrientationalEntropy(None, None, None, None, None) + res = oe.calculate({"LIG": 0}) + assert res.total == 0.0 + + +def test_orientational_skips_water_resname_all_uppercase(): + oe = OrientationalEntropy(None, None, None, None, None) + res = oe.calculate({"WAT": 10}) + assert res.total == 0.0 + + +def test_orientational_entropy_skips_water_species(): + oe = OrientationalEntropy(None, None, None, None, None) + res = oe.calculate({"WAT": 10, "Na+": 2}) + assert res.total > 0.0 + + +def test_orientational_calculate_only_water_returns_zero(): + oe = OrientationalEntropy(None, None, None, None, None) + res = oe.calculate({"WAT": 5}) + assert res.total == 0.0 + + +def test_calculate_skips_water_species_branch(): + oe = OrientationalEntropy(None, None, None, None, None) + out = oe.calculate({"WAT": 10, "Na+": 2}) + + assert out.total > 0.0 + + +def test_entropy_contribution_returns_zero_when_omega_nonpositive(monkeypatch): + oe = OrientationalEntropy(None, None, None, None, None) + + monkeypatch.setattr(OrientationalEntropy, "_omega", staticmethod(lambda n: 0.0)) + + assert oe._entropy_contribution(5) == 0.0 diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_entropy_math.py b/tests/unit/CodeEntropy/entropy/test_vibrational_entropy_math.py new file mode 100644 index 00000000..43fb8e04 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_vibrational_entropy_math.py @@ -0,0 +1,161 @@ +import numpy as np +import pytest + +from CodeEntropy.entropy.vibrational import VibrationalEntropy + + +@pytest.fixture() +def run_manager(): + class RM: + def change_lambda_units(self, x): + return np.asarray(x) + + def get_KT2J(self, temp): + return 1e-34 + + return RM() + + +def test_matrix_eigenvalues_returns_complex_dtype_possible(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + m = np.array([[0.0, -1.0], [1.0, 0.0]]) + eigs = ve._matrix_eigenvalues(m) + assert eigs.shape == (2,) + + +def test_frequencies_from_lambdas_filters_nonpositive_and_near_zero(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + + lambdas = np.array([-1.0, 0.0, 1e-12, 1.0, 4.0]) + freqs = ve._frequencies_from_lambdas(lambdas, temp=298.0) + + assert freqs.size == 2 + assert np.all(freqs > 0) + + +def test_frequencies_from_lambdas_filters_complex(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + lambdas = np.array([1.0 + 2.0j, 9.0 + 0.0j, 16.0]) + + freqs = ve._frequencies_from_lambdas(lambdas, temp=298.0) + + assert freqs.size == 2 + assert np.all(freqs > 0) + + +def test_entropy_components_returns_empty_when_all_invalid(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + + ve._matrix_eigenvalues = lambda m: np.array([-1.0, 0.0, 0.0]) + comps = ve._entropy_components(np.eye(3), temp=298.0) + + assert comps.size == 0 + + +def test_entropy_components_from_frequencies_returns_correct_shape(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + + freqs = np.array([1.0, 2.0, 3.0], dtype=float) + comps = ve._entropy_components_from_frequencies(freqs, temp=298.0) + + assert comps.shape == (3,) + assert isinstance(comps, np.ndarray) + + assert np.all(comps >= 0) or np.isinf(comps).any() + + +def test_split_halves_even_length(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + + arr = np.arange(10, dtype=float) + a, b = ve._split_halves(arr) + + assert a.shape == (5,) + assert b.shape == (5,) + assert np.all(a == np.array([0, 1, 2, 3, 4], dtype=float)) + assert np.all(b == np.array([5, 6, 7, 8, 9], dtype=float)) + + +def test_split_halves_odd_length_returns_empty_second(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + + arr = np.arange(5, dtype=float) + a, b = ve._split_halves(arr) + + assert a.shape == (5,) + assert b.size == 0 + + +def test_sum_components_empty_returns_zero(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + assert ( + ve._sum_components(np.array([], dtype=float), "force", highest_level=True) + == 0.0 + ) + + +def test_sum_components_force_highest_sums_all(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + comps = np.arange(12, dtype=float) + assert ve._sum_components(comps, "force", highest_level=True) == pytest.approx( + float(np.sum(comps)) + ) + + +def test_sum_components_force_not_highest_drops_first_six(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + comps = np.arange(12, dtype=float) + assert ve._sum_components(comps, "force", highest_level=False) == pytest.approx( + float(np.sum(comps[6:])) + ) + + +def test_sum_components_torque_sums_all(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + comps = np.arange(12, dtype=float) + assert ve._sum_components(comps, "torque", highest_level=False) == pytest.approx( + float(np.sum(comps)) + ) + + +def test_sum_components_forcetorque_trans_uses_first_three(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + comps = np.arange(10, dtype=float) + assert ve._sum_components( + comps, "forcetorqueTRANS", highest_level=False + ) == pytest.approx(float(np.sum(comps[:3]))) + + +def test_sum_components_forcetorque_rot_uses_after_three(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + comps = np.arange(10, dtype=float) + assert ve._sum_components( + comps, "forcetorqueROT", highest_level=False + ) == pytest.approx(float(np.sum(comps[3:]))) + + +def test_sum_components_unknown_matrix_type_raises(run_manager): + ve = VibrationalEntropy(run_manager=run_manager) + comps = np.arange(6, dtype=float) + + with pytest.raises(ValueError): + ve._sum_components(comps, "nope", highest_level=True) + + +def test_vibrational_entropy_calculation_end_to_end_returns_float( + run_manager, monkeypatch +): + ve = VibrationalEntropy(run_manager=run_manager) + + monkeypatch.setattr(ve, "_matrix_eigenvalues", lambda m: np.array([1.0, 2.0, 3.0])) + monkeypatch.setattr(ve, "_convert_lambda_units", lambda x: np.asarray(x)) + monkeypatch.setattr( + ve, "_frequencies_from_lambdas", lambda lambdas, temp: np.array([1.0, 2.0, 3.0]) + ) + + out = ve.vibrational_entropy_calculation( + np.eye(3), matrix_type="torque", temp=298.0, highest_level=False + ) + + assert isinstance(out, float) + assert out >= 0.0 diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py b/tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py new file mode 100644 index 00000000..1e32d344 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py @@ -0,0 +1,174 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from CodeEntropy.entropy.nodes.vibrational import EntropyPair, VibrationalEntropyNode + + +@pytest.fixture() +def shared_data_base(): + frag = MagicMock() + frag.residues = [MagicMock(resname="RES")] + reduced_universe = MagicMock() + reduced_universe.atoms.fragments = [frag] + + return { + "run_manager": MagicMock(), + "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), + "groups": {0: [0]}, + "levels": {0: ["united_atom"]}, + "reduced_universe": reduced_universe, + "force_covariances": {"ua": {}, "res": [], "poly": []}, + "torque_covariances": {"ua": {}, "res": [], "poly": []}, + "n_frames": 5, + "reporter": MagicMock(), + } + + +def test_united_atom_branch_logs_and_stores(shared_data_base, monkeypatch): + node = VibrationalEntropyNode() + + monkeypatch.setattr( + node, + "_compute_united_atom_entropy", + MagicMock(return_value=EntropyPair(trans=1.0, rot=2.0)), + ) + monkeypatch.setattr(node, "_log_molecule_level_results", MagicMock()) + + out = node.run(shared_data_base) + + assert out["vibrational_entropy"][0]["united_atom"]["trans"] == 1.0 + assert out["vibrational_entropy"][0]["united_atom"]["rot"] == 2.0 + + +def test_residue_branch_noncombined(shared_data_base, monkeypatch): + node = VibrationalEntropyNode() + + shared_data_base["levels"] = {0: ["residue"]} + shared_data_base["group_id_to_index"] = {0: 0} + shared_data_base["force_covariances"]["res"] = [np.eye(3)] + shared_data_base["torque_covariances"]["res"] = [np.eye(3)] + + monkeypatch.setattr( + node, + "_compute_force_torque_entropy", + MagicMock(return_value=EntropyPair(trans=3.0, rot=4.0)), + ) + monkeypatch.setattr(node, "_log_molecule_level_results", MagicMock()) + + out = node.run(shared_data_base) + + assert out["vibrational_entropy"][0]["residue"]["trans"] == 3.0 + assert out["vibrational_entropy"][0]["residue"]["rot"] == 4.0 + + +def test_polymer_branch_combined_ft_at_highest(shared_data_base, monkeypatch): + node = VibrationalEntropyNode() + + shared_data_base["args"].combined_forcetorque = True + shared_data_base["levels"] = {0: ["polymer"]} + shared_data_base["group_id_to_index"] = {0: 0} + shared_data_base["forcetorque_covariances"] = {"poly": [np.eye(6)]} + + monkeypatch.setattr( + node, + "_compute_ft_entropy", + MagicMock(return_value=EntropyPair(trans=5.0, rot=6.0)), + ) + monkeypatch.setattr(node, "_log_molecule_level_results", MagicMock()) + + out = node.run(shared_data_base) + + assert out["vibrational_entropy"][0]["polymer"]["trans"] == 5.0 + assert out["vibrational_entropy"][0]["polymer"]["rot"] == 6.0 + + +def test_get_indexed_matrix_typeerror_returns_none(): + node = VibrationalEntropyNode() + assert node._get_indexed_matrix(mats=123, index=0) is None + + +def test_get_group_id_to_index_uses_cached_mapping(shared_data): + node = VibrationalEntropyNode() + shared_data["group_id_to_index"] = {7: 0} + assert node._get_group_id_to_index(shared_data) == {7: 0} + + +def test_get_group_id_to_index_falls_back_to_enumeration(shared_data): + node = VibrationalEntropyNode() + shared_data.pop("group_id_to_index", None) + shared_data["groups"] = {5: [0], 9: [1]} + assert node._get_group_id_to_index(shared_data) == {5: 0, 9: 1} + + +def test_run_raises_on_unknown_level(shared_data, monkeypatch): + node = VibrationalEntropyNode() + + shared_data["levels"] = {0: ["banana"]} + shared_data["groups"] = {0: [0]} + + shared_data["force_covariances"] = {"ua": {}, "res": [], "poly": []} + shared_data["torque_covariances"] = {"ua": {}, "res": [], "poly": []} + + with pytest.raises(ValueError): + node.run(shared_data) + + +def test_run_united_atom_branch_stores_results(shared_data, monkeypatch): + node = VibrationalEntropyNode() + + shared_data["levels"] = {0: ["united_atom"]} + shared_data["groups"] = {0: [0]} + shared_data["force_covariances"] = {"ua": {}, "res": [], "poly": []} + shared_data["torque_covariances"] = {"ua": {}, "res": [], "poly": []} + + fake_pair = MagicMock(trans=1.0, rot=2.0) + monkeypatch.setattr( + node, "_compute_united_atom_entropy", MagicMock(return_value=fake_pair) + ) + monkeypatch.setattr(node, "_log_molecule_level_results", MagicMock()) + + out = node.run(shared_data) + + assert "vibrational_entropy" in out + assert shared_data["vibrational_entropy"][0]["united_atom"]["trans"] == 1.0 + assert shared_data["vibrational_entropy"][0]["united_atom"]["rot"] == 2.0 + + +def test_unknown_level_raises(shared_data): + node = VibrationalEntropyNode() + + shared_data["levels"] = {0: ["invalid"]} + shared_data["groups"] = {0: [0]} + shared_data["force_covariances"] = {"ua": {}, "res": [], "poly": []} + shared_data["torque_covariances"] = {"ua": {}, "res": [], "poly": []} + + with pytest.raises(ValueError): + node.run(shared_data) + + +def test_polymer_branch_executes(shared_data, monkeypatch): + node = VibrationalEntropyNode() + + shared_data["levels"] = {0: ["polymer"]} + shared_data["groups"] = {0: [0]} + + shared_data["force_covariances"] = {"ua": {}, "res": [], "poly": [MagicMock()]} + shared_data["torque_covariances"] = {"ua": {}, "res": [], "poly": [MagicMock()]} + + shared_data["reduced_universe"].atoms.fragments = [MagicMock(residues=[])] + + monkeypatch.setattr( + node, + "_compute_force_torque_entropy", + MagicMock(return_value=EntropyPair(trans=1.0, rot=1.0)), + ) + monkeypatch.setattr(node, "_log_molecule_level_results", MagicMock()) + + out = node.run(shared_data) + + assert "vibrational_entropy" in out + assert out["vibrational_entropy"][0]["polymer"]["trans"] == 1.0 + assert out["vibrational_entropy"][0]["polymer"]["rot"] == 1.0 diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py b/tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py new file mode 100644 index 00000000..4d5f98bb --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py @@ -0,0 +1,109 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np + +from CodeEntropy.entropy.nodes.vibrational import EntropyPair, VibrationalEntropyNode + + +def test_run_skips_empty_mol_ids_group(): + node = VibrationalEntropyNode() + + shared = { + "run_manager": MagicMock(), + "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), + "groups": {0: []}, + "levels": {0: ["united_atom"]}, + "reduced_universe": MagicMock(atoms=MagicMock(fragments=[])), + "force_covariances": {"ua": {}, "res": [], "poly": []}, + "torque_covariances": {"ua": {}, "res": [], "poly": []}, + "n_frames": 5, + "reporter": None, + } + + out = node.run(shared) + assert "vibrational_entropy" in out + assert out["vibrational_entropy"][0] == {} + + +def test_get_ua_frame_counts_falls_back_to_empty_when_shape_wrong(): + node = VibrationalEntropyNode() + assert node._get_ua_frame_counts({"frame_counts": "not-a-dict"}) == {} + + +def test_compute_united_atom_entropy_logs_residue_data_when_reporter_present(): + node = VibrationalEntropyNode() + ve = MagicMock() + + node._compute_force_torque_entropy = MagicMock(return_value=EntropyPair(1.0, 2.0)) + + reporter = MagicMock() + residues = [SimpleNamespace(resname="A"), SimpleNamespace(resname="B")] + + out = node._compute_united_atom_entropy( + ve=ve, + temp=298.0, + group_id=7, + residues=residues, + force_ua={}, + torque_ua={}, + ua_frame_counts={(7, 0): 3, (7, 1): 4}, + reporter=reporter, + n_frames_default=10, + highest=True, + ) + + assert out == EntropyPair(trans=2.0, rot=4.0) + assert reporter.add_residue_data.call_count == 4 + + +def test_compute_force_torque_entropy_success_calls_vibrational_engine(): + node = VibrationalEntropyNode() + ve = MagicMock() + ve.vibrational_entropy_calculation.side_effect = [10.0, 20.0] + + out = node._compute_force_torque_entropy( + ve=ve, + temp=298.0, + fmat=np.eye(3), + tmat=np.eye(3), + highest=False, + ) + + assert out == EntropyPair(trans=10.0, rot=20.0) + assert ve.vibrational_entropy_calculation.call_count == 2 + + +def test_compute_ft_entropy_success_calls_vibrational_engine_for_trans_and_rot(): + node = VibrationalEntropyNode() + ve = MagicMock() + ve.vibrational_entropy_calculation.side_effect = [1.5, 2.5] + + out = node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=np.eye(6)) + + assert out == EntropyPair(trans=1.5, rot=2.5) + assert ve.vibrational_entropy_calculation.call_count == 2 + + +def test_log_molecule_level_results_returns_when_reporter_none(): + VibrationalEntropyNode._log_molecule_level_results( + reporter=None, + group_id=1, + level="residue", + pair=EntropyPair(1.0, 2.0), + use_ft_labels=False, + ) + + +def test_log_molecule_level_results_writes_trans_and_rot_labels(): + reporter = MagicMock() + VibrationalEntropyNode._log_molecule_level_results( + reporter=reporter, + group_id=1, + level="residue", + pair=EntropyPair(3.0, 4.0), + use_ft_labels=False, + ) + + reporter.add_results_data.assert_any_call(1, "residue", "Transvibrational", 3.0) + reporter.add_results_data.assert_any_call(1, "residue", "Rovibrational", 4.0) diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py b/tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py new file mode 100644 index 00000000..0e43751d --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py @@ -0,0 +1,96 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from CodeEntropy.entropy.nodes.vibrational import EntropyPair, VibrationalEntropyNode + + +@pytest.fixture() +def shared(): + frag = MagicMock() + frag.residues = [MagicMock(resname="RES")] + ru = MagicMock() + ru.atoms.fragments = [frag] + + return { + "run_manager": MagicMock(), + "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), + "groups": {5: [0]}, + "levels": {0: ["united_atom"]}, + "reduced_universe": ru, + "force_covariances": {"ua": {}, "res": [], "poly": []}, + "torque_covariances": {"ua": {}, "res": [], "poly": []}, + "n_frames": 5, + "reporter": MagicMock(), + } + + +def test_get_group_id_to_index_builds_from_groups(shared): + node = VibrationalEntropyNode() + gid2i = node._get_group_id_to_index(shared) + assert gid2i == {5: 0} + + +def test_get_ua_frame_counts_returns_empty_when_missing(shared): + node = VibrationalEntropyNode() + assert node._get_ua_frame_counts(shared) == {} + + +def test_compute_force_torque_entropy_returns_zero_when_missing_matrix(shared): + node = VibrationalEntropyNode() + ve = MagicMock() + pair = node._compute_force_torque_entropy( + ve=ve, temp=298.0, fmat=None, tmat=np.eye(3), highest=True + ) + assert pair == EntropyPair(trans=0.0, rot=0.0) + + +def test_compute_force_torque_entropy_returns_zero_when_filter_removes_all(monkeypatch): + node = VibrationalEntropyNode() + ve = MagicMock() + + monkeypatch.setattr( + node._mat_ops, "filter_zero_rows_columns", lambda a, atol: np.array([]) + ) + + pair = node._compute_force_torque_entropy( + ve=ve, temp=298.0, fmat=np.eye(3), tmat=np.eye(3), highest=True + ) + assert pair == EntropyPair(trans=0.0, rot=0.0) + + +def test_compute_ft_entropy_returns_zero_when_none(): + node = VibrationalEntropyNode() + ve = MagicMock() + assert node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=None) == EntropyPair( + trans=0.0, rot=0.0 + ) + + +def test_log_molecule_level_results_ft_labels_branch(): + node = VibrationalEntropyNode() + reporter = MagicMock() + + node._log_molecule_level_results( + reporter, 1, "residue", EntropyPair(1.0, 2.0), use_ft_labels=True + ) + + reporter.add_results_data.assert_any_call( + 1, "residue", "FTmat-Transvibrational", 1.0 + ) + reporter.add_results_data.assert_any_call(1, "residue", "FTmat-Rovibrational", 2.0) + + +def test_get_indexed_matrix_out_of_range_returns_none(): + node = VibrationalEntropyNode() + assert node._get_indexed_matrix([np.eye(3)], 5) is None + + +def test_run_unknown_level_raises(shared): + node = VibrationalEntropyNode() + shared["levels"] = {0: ["nope"]} + + with pytest.raises(ValueError): + node.run(shared) diff --git a/tests/unit/CodeEntropy/entropy/test_water_entropy.py b/tests/unit/CodeEntropy/entropy/test_water_entropy.py new file mode 100644 index 00000000..d1500b63 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_water_entropy.py @@ -0,0 +1,178 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from CodeEntropy.entropy.water import WaterEntropy + + +def _make_fake_universe_with_water(): + universe = MagicMock() + + water_selection = MagicMock() + + water_residues = MagicMock() + water_residues.resnames = ["WAT"] + water_residues.__len__.return_value = 1 + + water_selection.residues = water_residues + water_selection.atoms = [1, 2, 3] + + universe.select_atoms.return_value = water_selection + return universe + + +def test_water_entropy_calls_solver_and_logs_components(): + args = SimpleNamespace(temperature=298.0) + reporter = MagicMock() + + Sorient_dict = {1: {"WAT": [3.0, 5]}} + + covariances = SimpleNamespace(counts={(7, "WAT"): 2}) + + vibrations = SimpleNamespace( + translational_S={(7, "WAT"): [1.0, 2.0]}, + rotational_S={(7, "WAT"): 4.0}, + ) + + solver = MagicMock(return_value=(Sorient_dict, covariances, vibrations, None, 123)) + + we = WaterEntropy(args=args, reporter=reporter, solver=solver) + + we._solute_id_to_resname = MagicMock(return_value="SOL") + + universe = _make_fake_universe_with_water() + + we.calculate_and_log(universe=universe, start=0, end=10, step=1, group_id=9) + + solver.assert_called_once() + + reporter.add_residue_data.assert_any_call( + 9, "WAT", "Water", "Orientational", 5, 3.0 + ) + + reporter.add_residue_data.assert_any_call( + 9, "SOL", "Water", "Transvibrational", 2, 3.0 + ) + + reporter.add_residue_data.assert_any_call( + 9, "SOL", "Water", "Rovibrational", 2, 4.0 + ) + + reporter.add_group_label.assert_called_once() + + +def test_water_entropy_handles_empty_solver_results_gracefully(): + args = SimpleNamespace(temperature=298.0) + reporter = MagicMock() + + solver = MagicMock( + return_value=( + {}, + SimpleNamespace(counts={}), + SimpleNamespace(translational_S={}, rotational_S={}), + None, + 0, + ) + ) + + we = WaterEntropy(args=args, reporter=reporter, solver=solver) + we._solute_id_to_resname = MagicMock(return_value="SOL") + + universe = _make_fake_universe_with_water() + + we.calculate_and_log(universe=universe, start=0, end=10, step=1, group_id=1) + + reporter.add_residue_data.assert_not_called() + reporter.add_group_label.assert_called_once() + + +def test_water_group_label_handles_multiple_water_resnames(): + args = SimpleNamespace(temperature=298.0) + reporter = MagicMock() + + solver = MagicMock( + return_value=( + {}, + SimpleNamespace(counts={}), + SimpleNamespace(translational_S={}, rotational_S={}), + None, + 0, + ) + ) + we = WaterEntropy(args=args, reporter=reporter, solver=solver) + we._solute_id_to_resname = MagicMock(return_value="SOL") + + universe = MagicMock() + water_selection = MagicMock() + + water_residues = MagicMock() + water_residues.resnames = ["WAT", "TIP3"] + water_residues.__len__.return_value = 2 + + water_selection.residues = water_residues + water_selection.atoms = [1, 2, 3, 4] + + universe.select_atoms.return_value = water_selection + + we.calculate_and_log(universe=universe, start=0, end=10, step=1, group_id=1) + + reporter.add_group_label.assert_called_once() + + +def test_log_group_label_defaults_to_WAT_when_no_residue_names_match(): + args = SimpleNamespace(temperature=298.0) + reporter = MagicMock() + + solver = MagicMock( + return_value=( + {}, + SimpleNamespace(counts={}), + SimpleNamespace(translational_S={}, rotational_S={}), + None, + 0, + ) + ) + we = WaterEntropy(args=args, reporter=reporter, solver=solver) + + universe = MagicMock() + + water_selection = MagicMock() + water_residues = MagicMock() + water_residues.resnames = ["WAT"] + water_residues.__len__.return_value = 1 + + water_selection.residues = water_residues + water_selection.atoms = [1, 2, 3] + universe.select_atoms.return_value = water_selection + + Sorient_dict = {1: {"TIP3": [1.0, 1]}} + + we._log_group_label(universe, Sorient_dict, group_id=7) + + reporter.add_group_label.assert_called_once() + _, residue_group, *_ = reporter.add_group_label.call_args.args + assert residue_group == "WAT" + + +def test_log_group_label_defaults_to_WAT_when_no_names_match(): + args = SimpleNamespace(temperature=298.0) + reporter = MagicMock() + we = WaterEntropy(args=args, reporter=reporter, solver=MagicMock()) + + universe = MagicMock() + water_selection = MagicMock() + + residues = MagicMock() + residues.resnames = ["WAT"] + residues.__len__.return_value = 2 + + water_selection.residues = residues + water_selection.atoms = [1, 2, 3, 4] + universe.select_atoms.return_value = water_selection + + Sorient_dict = {1: {"TIP3": [1.0, 2]}} + + we._log_group_label(universe, Sorient_dict, group_id=7) + + reporter.add_group_label.assert_called_once() + + assert reporter.add_group_label.call_args.args[1] == "WAT" diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py b/tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py new file mode 100644 index 00000000..83b19557 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py @@ -0,0 +1,105 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from CodeEntropy.entropy.workflow import EntropyWorkflow + + +def test_execute_calls_level_dag_and_entropy_graph_and_logs_tables(): + args = SimpleNamespace( + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=False, + selection_string="all", + ) + + universe = MagicMock() + universe.trajectory = list(range(5)) + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=universe, + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + wf._build_reduced_universe = MagicMock(return_value=MagicMock()) + wf._detect_levels = MagicMock(return_value={0: ["united_atom"]}) + wf._split_water_groups = MagicMock(return_value=({0: [0]}, {})) + wf._finalize_molecule_results = MagicMock() + + wf._group_molecules.grouping_molecules.return_value = {0: [0]} + + with ( + patch("CodeEntropy.entropy.workflow.LevelDAG") as LevelDAGCls, + patch("CodeEntropy.entropy.workflow.EntropyGraph") as GraphCls, + ): + LevelDAGCls.return_value.build.return_value.execute.return_value = None + GraphCls.return_value.build.return_value.execute.return_value = {"x": 1} + + wf.execute() + + wf._reporter.log_tables.assert_called_once() + + +def test_execute_water_entropy_branch_calls_water_entropy_solver(): + args = SimpleNamespace( + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=True, + selection_string="all", + output_file="out.json", + ) + + universe = MagicMock() + universe.trajectory = list(range(5)) + + reporter = MagicMock() + reporter.molecule_data = [] + reporter.residue_data = [] + reporter.save_dataframes_as_json = MagicMock() + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=universe, + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + wf._build_reduced_universe = MagicMock(return_value=MagicMock()) + wf._detect_levels = MagicMock(return_value={0: ["united_atom"]}) + + wf._split_water_groups = MagicMock(return_value=({0: [0]}, {9: [1, 2]})) + wf._finalize_molecule_results = MagicMock() + + wf._group_molecules.grouping_molecules.return_value = {0: [0], 9: [1, 2]} + + with ( + patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls, + patch("CodeEntropy.entropy.workflow.LevelDAG") as LevelDAGCls, + patch("CodeEntropy.entropy.workflow.EntropyGraph") as GraphCls, + ): + water_instance = WaterCls.return_value + water_instance._calculate_water_entropy = MagicMock() + + LevelDAGCls.return_value.build.return_value.execute.return_value = None + GraphCls.return_value.build.return_value.execute.return_value = {} + + wf.execute() + + water_instance._calculate_water_entropy.assert_called_once() + _, kwargs = water_instance._calculate_water_entropy.call_args + assert kwargs["universe"] is universe + assert kwargs["start"] == 0 + assert kwargs["end"] == 5 + assert kwargs["step"] == 1 + assert kwargs["group_id"] == 9 diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_helpers.py b/tests/unit/CodeEntropy/entropy/test_workflow_helpers.py new file mode 100644 index 00000000..6041de71 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_workflow_helpers.py @@ -0,0 +1,81 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from CodeEntropy.entropy.workflow import EntropyWorkflow + + +def test_get_trajectory_bounds_end_minus_one_uses_trajectory_length(): + args = SimpleNamespace( + start=0, + end=-1, + step=2, + grouping="molecules", + water_entropy=False, + selection_string="all", + ) + universe = SimpleNamespace(trajectory=list(range(10))) + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=universe, + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + start, end, step = wf._get_trajectory_bounds() + assert (start, end, step) == (0, 10, 2) + + +def test_get_number_frames_matches_python_slice_math(): + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=MagicMock(), + universe=MagicMock(), + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + assert wf._get_number_frames(0, 10, 1) == 10 + assert wf._get_number_frames(0, 10, 2) == 5 + + +def test_finalize_results_called_even_if_empty(): + args = SimpleNamespace(output_file="out.json") + reporter = MagicMock() + reporter.molecule_data = [] + reporter.residue_data = [] + reporter.save_dataframes_as_json = MagicMock() + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + wf._finalize_molecule_results() + + reporter.save_dataframes_as_json.assert_called_once() + + +def test_split_water_groups_returns_empty_when_none(): + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=MagicMock(water_entropy=False), + universe=MagicMock(), + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + groups, water = wf._split_water_groups({0: [1, 2]}) + + assert water == {} diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py b/tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py new file mode 100644 index 00000000..8531aa02 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py @@ -0,0 +1,185 @@ +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from CodeEntropy.entropy.workflow import EntropyWorkflow + + +def test_build_reduced_universe_non_all_selects_and_writes_universe(): + args = SimpleNamespace( + selection_string="protein", + grouping="molecules", + start=0, + end=-1, + step=1, + water_entropy=False, + output_file="out.json", + ) + universe = MagicMock() + universe.trajectory = list(range(3)) + + reduced = MagicMock() + reduced.trajectory = list(range(2)) + + uops = MagicMock() + uops.select_atoms.return_value = reduced + + run_manager = MagicMock() + reporter = MagicMock() + + wf = EntropyWorkflow( + run_manager=run_manager, + args=args, + universe=universe, + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=uops, + ) + + out = wf._build_reduced_universe() + + assert out is reduced + uops.select_atoms.assert_called_once_with(universe, "protein") + run_manager.write_universe.assert_called_once() + + +def test_compute_water_entropy_updates_selection_string_and_calls_internal_method(): + args = SimpleNamespace( + selection_string="all", water_entropy=True, temperature=298.0 + ) + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=MagicMock(molecule_data=[], residue_data=[]), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + traj = SimpleNamespace(start=0, end=5, step=1) + water_groups = {9: [1, 2]} + + with patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls: + inst = WaterCls.return_value + inst._calculate_water_entropy = MagicMock() + + wf._compute_water_entropy(traj, water_groups) + + inst._calculate_water_entropy.assert_called_once() + assert wf._args.selection_string == "not water" + + +def test_finalize_molecule_results_skips_invalid_entries_with_warning(caplog): + args = SimpleNamespace(output_file="out.json") + reporter = MagicMock() + + reporter.molecule_data = [(1, "united_atom", "Trans", "not-a-number")] + reporter.residue_data = [] + reporter.save_dataframes_as_json = MagicMock() + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + caplog.set_level(logging.WARNING) + wf._finalize_molecule_results() + + assert any("Skipping invalid entry" in r.message for r in caplog.records) + reporter.save_dataframes_as_json.assert_called_once() + + +def test_build_reduced_universe_all_returns_original_universe(): + args = SimpleNamespace( + selection_string="all", + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=False, + output_file="out.json", + ) + universe = MagicMock() + uops = MagicMock() + run_manager = MagicMock() + wf = EntropyWorkflow( + run_manager, args, universe, MagicMock(), MagicMock(), MagicMock(), uops + ) + + out = wf._build_reduced_universe() + + assert out is universe + uops.select_atoms.assert_not_called() + run_manager.write_universe.assert_not_called() + + +def test_split_water_groups_partitions_correctly(): + args = SimpleNamespace( + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=False, + selection_string="all", + output_file="out.json", + ) + universe = MagicMock() + + water_res = MagicMock() + water_res.resid = 10 + water_atoms = MagicMock() + water_atoms.residues = [water_res] + universe.select_atoms.return_value = water_atoms + + frag0 = MagicMock() + r0 = MagicMock() + r0.resid = 10 + frag0.residues = [r0] + + frag1 = MagicMock() + r1 = MagicMock() + r1.resid = 99 + frag1.residues = [r1] + + universe.atoms.fragments = [frag0, frag1] + + wf = EntropyWorkflow( + MagicMock(), args, universe, MagicMock(), MagicMock(), MagicMock(), MagicMock() + ) + + groups = {0: [0], 1: [1]} + nonwater, water = wf._split_water_groups(groups) + + assert 0 in water + assert 1 in nonwater + + +def test_compute_water_entropy_instantiates_waterentropy_and_updates_selection_string(): + args = SimpleNamespace( + selection_string="all", water_entropy=True, temperature=298.0 + ) + universe = MagicMock() + reporter = MagicMock() + wf = EntropyWorkflow( + MagicMock(), args, universe, reporter, MagicMock(), MagicMock(), MagicMock() + ) + + traj = SimpleNamespace(start=0, end=5, step=1, n_frames=5) + water_groups = {9: [0]} + + with patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls: + inst = WaterCls.return_value + inst._calculate_water_entropy = MagicMock() + + wf._compute_water_entropy(traj, water_groups) + + WaterCls.assert_called_once_with(args) + inst._calculate_water_entropy.assert_called_once() + assert wf._args.selection_string == "not water" diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py b/tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py new file mode 100644 index 00000000..e0a718b2 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py @@ -0,0 +1,67 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from CodeEntropy.entropy.workflow import EntropyWorkflow + + +def _make_wf(args): + return EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=MagicMock(molecule_data=[], residue_data=[]), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + +def test_detect_levels_calls_hierarchy_builder(): + args = SimpleNamespace( + selection_string="all", water_entropy=False, output_file="out.json" + ) + wf = _make_wf(args) + + with patch("CodeEntropy.entropy.workflow.HierarchyBuilder") as HB: + HB.return_value.select_levels.return_value = (123, {"levels": "ok"}) + + out = wf._detect_levels(reduced_universe=MagicMock()) + + assert out == {"levels": "ok"} + HB.return_value.select_levels.assert_called_once() + + +def test_compute_water_entropy_returns_early_when_disabled_or_empty_groups(): + args = SimpleNamespace( + selection_string="all", + water_entropy=False, + temperature=298.0, + output_file="out.json", + ) + wf = _make_wf(args) + + traj = SimpleNamespace(start=0, end=5, step=1, n_frames=5) + + # empty water groups OR water_entropy disabled -> early return + wf._compute_water_entropy(traj, water_groups={}) + # no exception and no side effects expected + + +def test_finalize_molecule_results_skips_group_total_rows(): + args = SimpleNamespace( + output_file="out.json", selection_string="all", water_entropy=False + ) + wf = _make_wf(args) + + wf._reporter.molecule_data = [ + (1, "Group Total", "Group Total Entropy", 999.0), # should be skipped + (1, "united_atom", "Transvibrational", 1.5), # should count + ] + wf._reporter.residue_data = [] + + wf._finalize_molecule_results() + + # should append a new "Group Total" row based only on the non-total entries + assert any( + row[1] == "Group Total" and row[3] == 1.5 for row in wf._reporter.molecule_data + ) From 2ae41d7cc54dd00db394cfaf2573fead7534660a Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 15:48:13 +0000 Subject: [PATCH 071/101] test(core): add atomic pytest unit tests for the levels modules --- tests/unit/CodeEntropy/levels/conftest.py | 58 ++ .../levels/nodes/test_build_beads_node.py | 190 +++++ .../levels/nodes/test_conformations_node.py | 30 + .../levels/nodes/test_detect_levels_node.py | 19 + .../nodes/test_detect_molecules_node.py | 45 ++ .../nodes/test_frame_covariance_node.py | 530 +++++++++++++ .../test_init_covariance_accumulators_node.py | 23 + tests/unit/CodeEntropy/levels/test_axes.py | 701 ++++++++++++++++++ .../unit/CodeEntropy/levels/test_dihedrals.py | 550 ++++++++++++++ .../test_forces_force_torque_calculator.py | 238 ++++++ .../CodeEntropy/levels/test_frame_graph.py | 50 ++ .../levels/test_hierarchy_builder.py | 108 +++ .../levels/test_level_dag_orchestration.py | 257 +++++++ .../levels/test_level_dag_reduce.py | 184 +++++ .../levels/test_level_dag_reduction.py | 91 +++ .../levels/test_linalg_matrix_utils.py | 57 ++ .../levels/test_mda_universe_operations.py | 217 ++++++ 17 files changed, 3348 insertions(+) create mode 100644 tests/unit/CodeEntropy/levels/conftest.py create mode 100644 tests/unit/CodeEntropy/levels/nodes/test_build_beads_node.py create mode 100644 tests/unit/CodeEntropy/levels/nodes/test_conformations_node.py create mode 100644 tests/unit/CodeEntropy/levels/nodes/test_detect_levels_node.py create mode 100644 tests/unit/CodeEntropy/levels/nodes/test_detect_molecules_node.py create mode 100644 tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py create mode 100644 tests/unit/CodeEntropy/levels/nodes/test_init_covariance_accumulators_node.py create mode 100644 tests/unit/CodeEntropy/levels/test_axes.py create mode 100644 tests/unit/CodeEntropy/levels/test_dihedrals.py create mode 100644 tests/unit/CodeEntropy/levels/test_forces_force_torque_calculator.py create mode 100644 tests/unit/CodeEntropy/levels/test_frame_graph.py create mode 100644 tests/unit/CodeEntropy/levels/test_hierarchy_builder.py create mode 100644 tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py create mode 100644 tests/unit/CodeEntropy/levels/test_level_dag_reduce.py create mode 100644 tests/unit/CodeEntropy/levels/test_level_dag_reduction.py create mode 100644 tests/unit/CodeEntropy/levels/test_linalg_matrix_utils.py create mode 100644 tests/unit/CodeEntropy/levels/test_mda_universe_operations.py diff --git a/tests/unit/CodeEntropy/levels/conftest.py b/tests/unit/CodeEntropy/levels/conftest.py new file mode 100644 index 00000000..11a2c6d5 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/conftest.py @@ -0,0 +1,58 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + + +@pytest.fixture +def args(): + # minimal args object used by nodes + return SimpleNamespace(grouping="each") + + +@pytest.fixture +def reduced_universe(): + """ + Minimal Universe-like object: + - .atoms.fragments exists and is list-like + """ + u = MagicMock() + u.atoms = MagicMock() + u.atoms.fragments = [] + return u + + +@pytest.fixture +def universe_with_fragments(): + """ + Universe with 3 fragments. + Each fragment can be customized by tests. + """ + u = MagicMock() + u.atoms = MagicMock() + u.atoms.fragments = [MagicMock(), MagicMock(), MagicMock()] + return u + + +@pytest.fixture +def simple_ts_list(): + # list supports slicing directly: lst[start:end:step] + return [SimpleNamespace(frame=i) for i in range(10)] + + +@pytest.fixture +def axes_manager_identity(): + """ + AxesCalculator-like adapter used by ForceTorqueCalculator for displacements. + Returns positions-center (no PBC). + """ + mgr = MagicMock() + + def _get_vector(center, positions, box): + center = np.asarray(center, dtype=float).reshape(3) + positions = np.asarray(positions, dtype=float) + return positions - center + + mgr.get_vector.side_effect = _get_vector + return mgr diff --git a/tests/unit/CodeEntropy/levels/nodes/test_build_beads_node.py b/tests/unit/CodeEntropy/levels/nodes/test_build_beads_node.py new file mode 100644 index 00000000..a48e83e1 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/nodes/test_build_beads_node.py @@ -0,0 +1,190 @@ +from unittest.mock import MagicMock + +import numpy as np + +from CodeEntropy.levels.nodes.beads import BuildBeadsNode + + +def _bead(indices, heavy_resindex=None, empty=False): + b = MagicMock() + b.__len__.return_value = 0 if empty else len(indices) + b.indices = np.asarray(indices, dtype=int) + + heavy = MagicMock() + if heavy_resindex is None: + heavy.__len__.return_value = 0 + heavy.__iter__.return_value = iter([]) + else: + a0 = MagicMock() + a0.resindex = int(heavy_resindex) + heavy.__len__.return_value = 1 + heavy.__getitem__.side_effect = lambda i: a0 + heavy.__iter__.return_value = iter([a0]) + + b.select_atoms.return_value = heavy + return b + + +def test_build_beads_node_groups_united_atom_beads_into_local_residue_buckets(): + r0 = MagicMock() + r0.resindex = 10 + r1 = MagicMock() + r1.resindex = 11 + + mol = MagicMock() + mol.residues = [r0, r1] + + ua0 = _bead([1, 2], heavy_resindex=10) + ua1 = _bead([3], heavy_resindex=11) + ua_empty = _bead([], heavy_resindex=10, empty=True) + + hier = MagicMock() + hier.get_beads.side_effect = lambda m, lvl: ( + [ua0, ua1, ua_empty] if lvl == "united_atom" else [] + ) + + node = BuildBeadsNode(hierarchy=hier) + + u = MagicMock() + u.atoms = MagicMock() + u.atoms.fragments = [mol] + + shared = {"reduced_universe": u, "levels": [["united_atom"]]} + + out = node.run(shared) + beads = out["beads"] + + assert (0, "united_atom", 0) in beads + assert (0, "united_atom", 1) in beads + assert len(beads[(0, "united_atom", 0)]) == 1 + assert len(beads[(0, "united_atom", 1)]) == 1 + + np.testing.assert_array_equal(beads[(0, "united_atom", 0)][0], np.array([1, 2])) + np.testing.assert_array_equal(beads[(0, "united_atom", 1)][0], np.array([3])) + + +def test_add_residue_beads_logs_error_if_none_kept(caplog): + hier = MagicMock() + # returns one empty bead -> skipped -> kept stays 0 + empty_bead = MagicMock() + empty_bead.__len__.return_value = 0 + hier.get_beads.return_value = [empty_bead] + + node = BuildBeadsNode(hierarchy=hier) + + beads = {} + mol = MagicMock() + mol.residues = [MagicMock()] + + node._add_residue_beads(beads=beads, mol_id=0, mol=mol) + + assert (0, "residue") in beads + assert beads[(0, "residue")] == [] + assert any("No residue beads kept" in rec.message for rec in caplog.records) + + +def test_infer_local_residue_id_returns_zero_if_no_heavy_atoms(): + mol = MagicMock() + mol.residues = [MagicMock(resindex=10), MagicMock(resindex=11)] + + bead = MagicMock() + heavy = MagicMock() + heavy.__len__.return_value = 0 + bead.select_atoms.return_value = heavy + + out = BuildBeadsNode._infer_local_residue_id(mol=mol, bead=bead) + assert out == 0 + + +def test_infer_local_residue_id_returns_zero_if_resindex_not_found(): + mol = MagicMock() + mol.residues = [MagicMock(resindex=10), MagicMock(resindex=11)] + + bead = MagicMock() + heavy = MagicMock() + heavy.__len__.return_value = 1 + heavy0 = MagicMock(resindex=999) + heavy.__getitem__.return_value = heavy0 + bead.select_atoms.return_value = heavy + + out = BuildBeadsNode._infer_local_residue_id(mol=mol, bead=bead) + assert out == 0 + + +def test_build_beads_node_skips_when_no_levels(): + """ + Covers: early return when levels missing (92/95 style guard branches) + """ + node = BuildBeadsNode(hierarchy=MagicMock()) + out = node.run({"reduced_universe": MagicMock(), "levels": []}) + assert out["beads"] == {} + + +def test_build_beads_node_residue_level_adds_residue_beads(): + """ + Covers: residue path + _add_residue_beads bookkeeping (log around 145 and 166-177) + """ + r0 = MagicMock(resindex=10) + r1 = MagicMock(resindex=11) + + mol = MagicMock() + mol.residues = [r0, r1] + + res0 = _bead([100, 101], heavy_resindex=10) + res1 = _bead([200], heavy_resindex=11) + + ua0 = _bead([1, 2], heavy_resindex=10) + ua1 = _bead([3], heavy_resindex=11) + + hier = MagicMock() + + def _get_beads(m, lvl): + if lvl == "residue": + return [res0, res1] + if lvl == "united_atom": + return [ua0, ua1] + return [] + + hier.get_beads.side_effect = _get_beads + + node = BuildBeadsNode(hierarchy=hier) + + u = MagicMock() + u.atoms = MagicMock() + u.atoms.fragments = [mol] + + shared = {"reduced_universe": u, "levels": [["residue", "united_atom"]]} + out = node.run(shared) + + beads = out["beads"] + + assert (0, "residue") in beads + assert len(beads[(0, "residue")]) == 2 + assert np.array_equal(beads[(0, "residue")][0], np.array([100, 101])) + assert np.array_equal(beads[(0, "residue")][1], np.array([200])) + + +def test_build_beads_node_polymer_level_adds_polymer_beads_and_skips_empty(): + mol0 = MagicMock() + mol0.residues = [MagicMock(resindex=10)] + + u = MagicMock() + u.atoms.fragments = [mol0] + + polymer_beads = [_bead([]), _bead([7, 8, 9])] + + hier = MagicMock() + hier.get_beads.side_effect = lambda m, lvl: ( + polymer_beads if lvl == "polymer" else [] + ) + + node = BuildBeadsNode(hierarchy=hier) + + shared = {"reduced_universe": u, "levels": [["polymer"]]} + out = node.run(shared) + + beads = out["beads"] + assert (0, "polymer") in beads + + assert len(beads[(0, "polymer")]) == 1 + np.testing.assert_array_equal(beads[(0, "polymer")][0], np.array([7, 8, 9])) diff --git a/tests/unit/CodeEntropy/levels/nodes/test_conformations_node.py b/tests/unit/CodeEntropy/levels/nodes/test_conformations_node.py new file mode 100644 index 00000000..a03fb555 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/nodes/test_conformations_node.py @@ -0,0 +1,30 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from CodeEntropy.levels.nodes.conformations import ComputeConformationalStatesNode + + +def test_compute_conformational_states_node_runs_and_writes_shared_data(): + uops = MagicMock() + node = ComputeConformationalStatesNode(universe_operations=uops) + + node._dihedral_analysis.build_conformational_states = MagicMock( + return_value=({"ua_key": ["0", "1"]}, [["00", "01"]]) + ) + + shared = { + "reduced_universe": MagicMock(), + "levels": {0: ["united_atom"]}, + "groups": {0: [0]}, + "start": 0, + "end": 10, + "step": 1, + "args": SimpleNamespace(bin_width=10), + } + + out = node.run(shared) + + assert "conformational_states" in out + assert shared["conformational_states"]["ua"] == {"ua_key": ["0", "1"]} + assert shared["conformational_states"]["res"] == [["00", "01"]] + node._dihedral_analysis.build_conformational_states.assert_called_once() diff --git a/tests/unit/CodeEntropy/levels/nodes/test_detect_levels_node.py b/tests/unit/CodeEntropy/levels/nodes/test_detect_levels_node.py new file mode 100644 index 00000000..e9500810 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/nodes/test_detect_levels_node.py @@ -0,0 +1,19 @@ +from unittest.mock import patch + +from CodeEntropy.levels.nodes.detect_levels import DetectLevelsNode + + +def test_detect_levels_node_stores_results(reduced_universe): + node = DetectLevelsNode() + shared = {"reduced_universe": reduced_universe} + + with patch.object( + node._hierarchy, + "select_levels", + return_value=(2, [["united_atom"], ["united_atom", "residue"]]), + ): + out = node.run(shared) + + assert shared["number_molecules"] == 2 + assert shared["levels"] == [["united_atom"], ["united_atom", "residue"]] + assert out["levels"] == shared["levels"] diff --git a/tests/unit/CodeEntropy/levels/nodes/test_detect_molecules_node.py b/tests/unit/CodeEntropy/levels/nodes/test_detect_molecules_node.py new file mode 100644 index 00000000..249db1e4 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/nodes/test_detect_molecules_node.py @@ -0,0 +1,45 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from CodeEntropy.levels.nodes.detect_molecules import DetectMoleculesNode + + +def test_run_sets_reduced_universe_when_missing(args, universe_with_fragments): + node = DetectMoleculesNode() + + shared = { + "universe": universe_with_fragments, + "args": args, + } + + with patch.object(node._grouping, "grouping_molecules", return_value={0: [1]}): + out = node.run(shared) + + assert shared["reduced_universe"] is universe_with_fragments + assert shared["groups"] == {0: [1]} + assert shared["number_molecules"] == len(universe_with_fragments.atoms.fragments) + assert out["number_molecules"] == shared["number_molecules"] + + +def test_run_uses_args_grouping_strategy(universe_with_fragments): + node = DetectMoleculesNode() + shared = { + "universe": universe_with_fragments, + "args": SimpleNamespace(grouping="molecules"), + } + + with patch.object( + node._grouping, "grouping_molecules", return_value={"g": [1]} + ) as gm: + node.run(shared) + + gm.assert_called_once() + assert gm.call_args[0][1] == "molecules" + + +def test_ensure_reduced_universe_raises_if_missing_universe(): + node = DetectMoleculesNode() + with pytest.raises(KeyError): + node._ensure_reduced_universe({}) diff --git a/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py b/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py new file mode 100644 index 00000000..79e54c30 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py @@ -0,0 +1,530 @@ +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from CodeEntropy.levels.nodes import covariance as covmod +from CodeEntropy.levels.nodes.covariance import FrameCovarianceNode + + +class _BeadGroup: + def __init__(self, n=1): + self._n = n + + def __len__(self): + return self._n + + def center_of_mass(self, unwrap=False): + return np.array([0.0, 0.0, 0.0], dtype=float) + + +class _EmptyGroup: + def __len__(self): + return 0 + + +def _mk_atomgroup(n=1): + g = MagicMock() + g.__len__.return_value = n + return g + + +def test_get_shared_missing_raises_keyerror(): + node = FrameCovarianceNode() + with pytest.raises(KeyError): + node._get_shared({}) + + +def test_try_get_box_returns_none_on_failure(): + node = FrameCovarianceNode() + u = MagicMock() + type(u).dimensions = property(lambda self: (_ for _ in ()).throw(RuntimeError("x"))) + assert node._try_get_box(u) is None + + +def test_inc_mean_first_sample_copies(): + node = FrameCovarianceNode() + new = np.eye(2) + out = node._inc_mean(None, new, n=1) + np.testing.assert_allclose(out, new) + new[0, 0] = 999.0 + assert out[0, 0] != 999.0 + + +def test_inc_mean_updates_streaming_average(): + node = FrameCovarianceNode() + old = np.array([[2.0, 2.0], [2.0, 2.0]]) + new = np.array([[4.0, 0.0], [0.0, 4.0]]) + out = node._inc_mean(old, new, n=2) + np.testing.assert_allclose(out, np.array([[3.0, 1.0], [1.0, 3.0]])) + + +def test_build_ft_block_rejects_mismatched_lengths(): + node = FrameCovarianceNode() + with pytest.raises(ValueError): + node._build_ft_block([np.zeros(3)], [np.zeros(3), np.zeros(3)]) + + +def test_build_ft_block_rejects_empty(): + node = FrameCovarianceNode() + with pytest.raises(ValueError): + node._build_ft_block([], []) + + +def test_build_ft_block_rejects_non_length3_vectors(): + node = FrameCovarianceNode() + with pytest.raises(ValueError): + node._build_ft_block([np.zeros(2)], [np.zeros(3)]) + + +def test_build_ft_block_returns_symmetric_block_matrix(): + node = FrameCovarianceNode() + + force_vecs = [np.array([1.0, 0.0, 0.0]), np.array([0.0, 2.0, 0.0])] + torque_vecs = [np.array([0.0, 0.0, 3.0]), np.array([4.0, 0.0, 0.0])] + + M = node._build_ft_block(force_vecs, torque_vecs) + assert M.shape == (12, 12) + + np.testing.assert_allclose(M, M.T) + + +def test_process_residue_skips_when_no_beads_key_present(): + node = FrameCovarianceNode() + + shared = { + "reduced_universe": MagicMock(), + "groups": {0: [0]}, + "levels": [["residue"]], + "beads": {}, + "args": MagicMock( + force_partitioning=1.0, combined_forcetorque=False, customised_axes=False + ), + "axes_manager": MagicMock(), + } + ctx = {"shared": shared} + + out = node.run(ctx) + assert out["force"]["res"] == {} + assert out["torque"]["res"] == {} + assert "forcetorque" not in out + + +def test_process_residue_combined_only_when_highest_level(): + node = FrameCovarianceNode() + + u = MagicMock() + u.atoms = MagicMock() + frag = MagicMock() + frag.residues = [MagicMock()] + u.atoms.fragments = [frag] + u.atoms.__getitem__.side_effect = lambda idx: _mk_atomgroup(1) + u.dimensions = np.array([10.0, 10.0, 10.0, 90.0, 90.0, 90.0]) + + args = MagicMock() + args.force_partitioning = 1.0 + args.combined_forcetorque = True + args.customised_axes = True + + axes_manager = MagicMock() + axes_manager.get_residue_axes.return_value = ( + np.eye(3), + np.eye(3), + np.zeros(3), + np.array([1.0, 1.0, 1.0]), + ) + + shared = { + "reduced_universe": u, + "groups": {7: [0]}, + "levels": [["residue"]], + "beads": {(0, "residue"): [np.array([1, 2, 3])]}, + "args": args, + "axes_manager": axes_manager, + } + + with ( + patch.object( + node._ft, "get_weighted_forces", return_value=np.array([1.0, 0.0, 0.0]) + ), + patch.object( + node._ft, "get_weighted_torques", return_value=np.array([0.0, 1.0, 0.0]) + ), + patch.object( + node._ft, + "compute_frame_covariance", + return_value=(np.eye(3), 2.0 * np.eye(3)), + ), + ): + ctx = {"shared": shared} + out = node.run(ctx) + + assert "forcetorque" in out + assert 7 in out["force"]["res"] + assert 7 in out["torque"]["res"] + assert 7 in out["forcetorque"]["res"] + + +def test_process_residue_combined_not_added_if_not_highest_level(): + node = FrameCovarianceNode() + + u = MagicMock() + u.atoms = MagicMock() + frag = MagicMock() + frag.residues = [MagicMock()] + u.atoms.fragments = [frag] + u.atoms.__getitem__.side_effect = lambda idx: _mk_atomgroup(1) + u.dimensions = np.array([10.0, 10.0, 10.0, 90.0, 90.0, 90.0]) + + args = MagicMock( + force_partitioning=1.0, combined_forcetorque=True, customised_axes=True + ) + + axes_manager = MagicMock() + axes_manager.get_residue_axes.return_value = ( + np.eye(3), + np.eye(3), + np.zeros(3), + np.ones(3), + ) + + shared = { + "reduced_universe": u, + "groups": {7: [0]}, + "levels": [["united_atom", "residue", "polymer"]], + "beads": {(0, "residue"): [np.array([1, 2, 3])]}, + "args": args, + "axes_manager": axes_manager, + } + + with ( + patch.object( + node._ft, "get_weighted_forces", return_value=np.array([1.0, 0.0, 0.0]) + ), + patch.object( + node._ft, "get_weighted_torques", return_value=np.array([0.0, 1.0, 0.0]) + ), + patch.object( + node._ft, + "compute_frame_covariance", + return_value=(np.eye(3), 2.0 * np.eye(3)), + ), + ): + out = node.run({"shared": shared}) + + assert "forcetorque" in out + assert out["forcetorque"]["res"] == {} + + +def test_process_united_atom_returns_when_no_beads_for_level(): + node = FrameCovarianceNode() + + res = MagicMock() + res.atoms = MagicMock() + mol = MagicMock() + mol.residues = [res] + + axes_manager = MagicMock() + + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + molcount = {} + + node._process_united_atom( + u=MagicMock(), + mol=mol, + mol_id=0, + group_id=0, + beads={}, + axes_manager=axes_manager, + box=np.array([10.0, 10.0, 10.0], dtype=float), + force_partitioning=1.0, + customised_axes=False, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + molcount=molcount, + ) + + assert out_force["ua"] == {} + assert out_torque["ua"] == {} + assert molcount == {} + axes_manager.get_UA_axes.assert_not_called() + axes_manager.get_vanilla_axes.assert_not_called() + + +def test_get_residue_axes_vanilla_branch_returns_arrays(monkeypatch): + node = FrameCovarianceNode() + + monkeypatch.setattr( + "CodeEntropy.levels.nodes.covariance.make_whole", lambda _ag: None + ) + + mol = MagicMock() + mol.atoms.principal_axes.return_value = np.eye(3) * 2 + + bead = MagicMock() + bead.center_of_mass.return_value = np.array([1.0, 2.0, 3.0]) + + axes_manager = MagicMock() + axes_manager.get_vanilla_axes.return_value = (np.eye(3), np.array([9.0, 8.0, 7.0])) + + trans, rot, center, moi = node._get_residue_axes( + mol=mol, + bead=bead, + local_res_i=0, + axes_manager=axes_manager, + customised_axes=False, + ) + + assert trans.shape == (3, 3) + assert rot.shape == (3, 3) + assert center.shape == (3,) + assert moi.shape == (3,) + assert np.allclose(trans, np.eye(3) * 2) + assert np.allclose(rot, np.eye(3)) + assert np.allclose(center, np.array([1.0, 2.0, 3.0])) + assert np.allclose(moi, np.array([9.0, 8.0, 7.0])) + + +def test_get_polymer_axes_returns_arrays(monkeypatch): + node = FrameCovarianceNode() + + monkeypatch.setattr( + "CodeEntropy.levels.nodes.covariance.make_whole", lambda _ag: None + ) + + mol = MagicMock() + mol.atoms.principal_axes.return_value = np.eye(3) * 3 + + bead = MagicMock() + bead.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) + + axes_manager = MagicMock() + axes_manager.get_vanilla_axes.return_value = (np.eye(3), np.array([1.0, 1.0, 1.0])) + + trans, rot, center, moi = node._get_polymer_axes( + mol=mol, + bead=bead, + axes_manager=axes_manager, + ) + + assert trans.shape == (3, 3) + assert rot.shape == (3, 3) + assert center.shape == (3,) + assert moi.shape == (3,) + assert np.allclose(trans, np.eye(3) * 3) + assert np.allclose(rot, np.eye(3)) + assert np.allclose(center, np.array([0.0, 0.0, 0.0])) + assert np.allclose(moi, np.array([1.0, 1.0, 1.0])) + + +def test_process_united_atom_updates_outputs_and_molcount(): + node = FrameCovarianceNode() + + node._build_ua_vectors = MagicMock( + return_value=( + [np.array([1.0, 0.0, 0.0])], + [np.array([0.0, 1.0, 0.0])], + ) + ) + + F = np.eye(3) + T = np.eye(3) * 2 + node._ft.compute_frame_covariance = MagicMock(return_value=(F, T)) + + u = MagicMock() + u.atoms = MagicMock() + u.atoms.__getitem__.side_effect = lambda idx: _BeadGroup(1) + + res = MagicMock() + res.atoms = MagicMock() + mol = MagicMock() + mol.residues = [res] + + beads = {(0, "united_atom", 0): [123]} + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + molcount = {} + + node._process_united_atom( + u=u, + mol=mol, + mol_id=0, + group_id=7, + beads=beads, + axes_manager=MagicMock(), + box=np.array([10.0, 10.0, 10.0]), + force_partitioning=1.0, + customised_axes=False, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + molcount=molcount, + ) + + key = (7, 0) + assert np.allclose(out_force["ua"][key], F) + assert np.allclose(out_torque["ua"][key], T) + assert molcount[key] == 1 + + +def test_process_residue_returns_early_when_no_beads(): + node = FrameCovarianceNode() + + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + + node._process_residue( + u=MagicMock(), + mol=MagicMock(), + mol_id=0, + group_id=0, + beads={}, + axes_manager=MagicMock(), + box=np.array([10.0, 10.0, 10.0]), + customised_axes=False, + force_partitioning=1.0, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + out_ft=None, + molcount={}, + combined=False, + ) + + assert out_force["res"] == {} + assert out_torque["res"] == {} + + +def test_build_ua_vectors_customised_axes_true_calls_get_UA_axes(): + node = FrameCovarianceNode() + + bead = _BeadGroup(1) + residue_atoms = MagicMock() + + axes_manager = MagicMock() + axes_manager.get_UA_axes.return_value = ( + np.eye(3), + np.eye(3), + np.array([0.0, 0.0, 0.0]), + np.array([1.0, 1.0, 1.0]), + ) + + node._ft.get_weighted_forces = MagicMock(return_value=np.array([1.0, 2.0, 3.0])) + node._ft.get_weighted_torques = MagicMock(return_value=np.array([4.0, 5.0, 6.0])) + + force_vecs, torque_vecs = node._build_ua_vectors( + bead_groups=[bead], + residue_atoms=residue_atoms, + axes_manager=axes_manager, + box=np.array([10.0, 10.0, 10.0]), + force_partitioning=1.0, + customised_axes=True, + is_highest=True, + ) + + axes_manager.get_UA_axes.assert_called_once() + assert len(force_vecs) == 1 and len(torque_vecs) == 1 + + +def test_build_ua_vectors_vanilla_path_uses_principal_axes_and_vanilla_axes( + monkeypatch, +): + node = FrameCovarianceNode() + + residue_atoms = MagicMock() + residue_atoms.principal_axes.return_value = np.eye(3) + + bead = _BeadGroup(1) + + axes_manager = MagicMock() + axes_manager.get_vanilla_axes.return_value = ( + np.eye(3) * 2, + np.array([9.0, 8.0, 7.0]), + ) + + monkeypatch.setattr(covmod, "make_whole", lambda *_: None) + + node._ft.get_weighted_forces = MagicMock(return_value=np.array([1.0, 0.0, 0.0])) + node._ft.get_weighted_torques = MagicMock(return_value=np.array([0.0, 1.0, 0.0])) + + force_vecs, torque_vecs = node._build_ua_vectors( + bead_groups=[bead], + residue_atoms=residue_atoms, + axes_manager=axes_manager, + box=np.array([10.0, 10.0, 10.0]), + force_partitioning=1.0, + customised_axes=False, + is_highest=True, + ) + + axes_manager.get_vanilla_axes.assert_called_once() + assert len(force_vecs) == 1 and len(torque_vecs) == 1 + + +def test_process_united_atom_skips_when_any_bead_group_is_empty(): + node = FrameCovarianceNode() + + res = MagicMock() + res.atoms = MagicMock() + mol = MagicMock() + mol.residues = [res] + + u = MagicMock() + u.atoms = MagicMock() + u.atoms.__getitem__.side_effect = lambda idx: _EmptyGroup() + + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + + node._process_united_atom( + u=u, + mol=mol, + mol_id=0, + group_id=0, + beads={(0, "united_atom", 0): [123]}, + axes_manager=MagicMock(), + box=np.array([10.0, 10.0, 10.0]), + force_partitioning=1.0, + customised_axes=False, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + molcount={}, + ) + + assert out_force["ua"] == {} + assert out_torque["ua"] == {} + + +def test_process_residue_returns_early_when_any_bead_group_is_empty(): + node = FrameCovarianceNode() + + u = MagicMock() + u.atoms = MagicMock() + u.atoms.__getitem__.side_effect = lambda idx: _EmptyGroup() + + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + + node._process_residue( + u=u, + mol=MagicMock(), + mol_id=0, + group_id=0, + beads={(0, "residue"): [np.array([1, 2, 3])]}, + axes_manager=MagicMock(), + box=np.array([10.0, 10.0, 10.0]), + customised_axes=False, + force_partitioning=1.0, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + out_ft=None, + molcount={}, + combined=False, + ) + + assert out_force["res"] == {} + assert out_torque["res"] == {} diff --git a/tests/unit/CodeEntropy/levels/nodes/test_init_covariance_accumulators_node.py b/tests/unit/CodeEntropy/levels/nodes/test_init_covariance_accumulators_node.py new file mode 100644 index 00000000..fd5d31e8 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/nodes/test_init_covariance_accumulators_node.py @@ -0,0 +1,23 @@ +import numpy as np + +from CodeEntropy.levels.nodes.accumulators import InitCovarianceAccumulatorsNode + + +def test_init_covariance_accumulators_allocates_and_sets_aliases(): + node = InitCovarianceAccumulatorsNode() + + shared = {"groups": {9: [1, 2], 2: [3]}} + + out = node.run(shared) + + assert out["group_id_to_index"] == {9: 0, 2: 1} + assert out["index_to_group_id"] == [9, 2] + + assert shared["force_covariances"]["res"] == [None, None] + assert shared["torque_covariances"]["poly"] == [None, None] + + assert np.all(shared["frame_counts"]["res"] == np.array([0, 0])) + assert np.all(shared["forcetorque_counts"]["poly"] == np.array([0, 0])) + + assert shared["force_torque_stats"] is shared["forcetorque_covariances"] + assert shared["force_torque_counts"] is shared["forcetorque_counts"] diff --git a/tests/unit/CodeEntropy/levels/test_axes.py b/tests/unit/CodeEntropy/levels/test_axes.py new file mode 100644 index 00000000..d6d0093f --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_axes.py @@ -0,0 +1,701 @@ +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from CodeEntropy.levels.axes import AxesCalculator + + +class _FakeAtom: + def __init__(self, index: int, mass: float, position): + self.index = int(index) + self.mass = float(mass) + self.position = np.asarray(position, dtype=float) + + def __add__(self, other): + # atom + atomgroup => atomgroup + if isinstance(other, _FakeAtomGroup): + return _FakeAtomGroup([self] + list(other._atoms)) + if isinstance(other, _FakeAtom): + return _FakeAtomGroup([self, other]) + raise TypeError(f"Unsupported add: _FakeAtom + {type(other)}") + + +class _FakeAtomGroup: + def __init__(self, atoms, positions=None, select_map=None): + self._atoms = list(atoms) + self._select_map = dict(select_map or {}) + + if positions is None: + if self._atoms: + self.positions = np.vstack([a.position for a in self._atoms]).astype( + float + ) + else: + self.positions = np.zeros((0, 3), dtype=float) + else: + self.positions = np.asarray(positions, dtype=float) + + def __len__(self): + return len(self._atoms) + + def __iter__(self): + return iter(self._atoms) + + def __getitem__(self, idx): + return self._atoms[idx] + + @property + def masses(self): + return np.asarray([a.mass for a in self._atoms], dtype=float) + + def select_atoms(self, query: str): + return self._select_map.get(query, _FakeAtomGroup([])) + + def __add__(self, other): + if isinstance(other, _FakeAtomGroup): + return _FakeAtomGroup(self._atoms + other._atoms) + if isinstance(other, _FakeAtom): + return _FakeAtomGroup(self._atoms + [other]) + raise TypeError(f"Unsupported add: _FakeAtomGroup + {type(other)}") + + +def _atom(index=0, mass=12.0, pos=(0.0, 0.0, 0.0), resindex=0): + a = MagicMock() + a.index = index + a.mass = mass + a.position = np.array(pos, dtype=float) + a.resindex = resindex + return a + + +def test_get_residue_axes_empty_residue_raises(): + ax = AxesCalculator() + u = MagicMock() + u.select_atoms.return_value = [] + + with pytest.raises(ValueError): + ax.get_residue_axes(u, index=5) + + +def test_get_residue_axes_no_bonds_uses_custom_principal_axes(monkeypatch): + ax = AxesCalculator() + + # residue selection: non-empty, has heavy atoms and positions + residue = MagicMock() + residue.__len__.return_value = 1 + residue.atoms.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) + residue.select_atoms.return_value = MagicMock(positions=np.zeros((2, 3))) + residue.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) + + u = MagicMock() + u.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90]) + + # atom_set empty => "no bonds to other residues" branch + def _select_atoms(q): + if q.startswith("(resindex"): + return [] + if q.startswith("resindex "): + return residue + return [] + + u.select_atoms.side_effect = _select_atoms + + monkeypatch.setattr(ax, "get_UA_masses", lambda mol: [10.0, 12.0]) + monkeypatch.setattr(ax, "get_moment_of_inertia_tensor", lambda **kwargs: np.eye(3)) + monkeypatch.setattr( + ax, + "get_custom_principal_axes", + lambda moi: (np.eye(3), np.array([3.0, 2.0, 1.0])), + ) + + trans, rot, center, moi = ax.get_residue_axes(u, index=7) + + assert np.allclose(trans, np.eye(3)) + assert np.allclose(rot, np.eye(3)) + assert np.allclose(center, np.array([0.0, 0.0, 0.0])) + assert np.allclose(moi, np.array([3.0, 2.0, 1.0])) + + +def test_get_residue_axes_with_bonds_uses_vanilla_axes(monkeypatch): + ax = AxesCalculator() + + residue = MagicMock() + residue.__len__.return_value = 1 + residue.atoms.center_of_mass.return_value = np.array([1.0, 2.0, 3.0]) + residue.center_of_mass.return_value = np.array([1.0, 2.0, 3.0]) + + u = MagicMock() + u.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90]) + u.atoms.principal_axes.return_value = np.eye(3) + + # atom_set non-empty => bonded branch + def _select_atoms(q): + if q.startswith("(resindex"): + return [1] # non-empty + if q.startswith("resindex "): + return residue + return [] + + u.select_atoms.side_effect = _select_atoms + + monkeypatch.setattr("CodeEntropy.levels.axes.make_whole", lambda _ag: None) + monkeypatch.setattr( + ax, "get_vanilla_axes", lambda mol: (np.eye(3) * 2, np.array([9.0, 8.0, 7.0])) + ) + + trans, rot, center, moi = ax.get_residue_axes(u, index=10) + + assert np.allclose(trans, np.eye(3)) + assert np.allclose(rot, np.eye(3) * 2) + assert np.allclose(moi, np.array([9.0, 8.0, 7.0])) + + +def test_get_UA_axes_uses_principal_axes_when_single_heavy(monkeypatch): + ax = AxesCalculator() + + u = MagicMock() + u.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90]) + u.atoms.principal_axes.return_value = np.eye(3) + + # heavy_atoms length <= 1 => principal_axes path + heavy_atom = MagicMock(index=5) + heavy_atoms = [heavy_atom] + + def _sel(q): + if q == "prop mass > 1.1": + return heavy_atoms + if q.startswith("index "): + # return atom group with positions + ag = MagicMock() + ag.positions = np.array([[4.0, 0.0, 0.0]]) + ag.__getitem__.return_value = MagicMock( + mass=12.0, position=np.array([4.0, 0.0, 0.0]), index=5 + ) + return ag + return [] + + u.select_atoms.side_effect = _sel + + monkeypatch.setattr("CodeEntropy.levels.axes.make_whole", lambda _ag: None) + monkeypatch.setattr( + ax, + "get_bonded_axes", + lambda system, atom, dimensions: (np.eye(3), np.array([1.0, 2.0, 3.0])), + ) + + trans, rot, center, moi = ax.get_UA_axes(u, index=0) + + assert np.allclose(trans, np.eye(3)) + assert np.allclose(rot, np.eye(3)) + assert np.allclose(center, np.array([4.0, 0.0, 0.0])) + assert np.allclose(moi, np.array([1.0, 2.0, 3.0])) + + +def test_get_UA_axes_raises_when_bonded_axes_fail(monkeypatch): + ax = AxesCalculator() + u = MagicMock() + u.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90]) + + heavy_atom = MagicMock(index=5) + heavy_atoms = [heavy_atom] + + def _sel(q): + if q == "prop mass > 1.1": + return heavy_atoms + if q.startswith("index "): + ag = MagicMock() + ag.positions = np.array([[1.0, 1.0, 1.0]]) + ag.__getitem__.return_value = MagicMock( + mass=12.0, position=np.array([1.0, 1.0, 1.0]), index=5 + ) + return ag + return [] + + u.select_atoms.side_effect = _sel + monkeypatch.setattr("CodeEntropy.levels.axes.make_whole", lambda _ag: None) + monkeypatch.setattr(ax, "get_bonded_axes", lambda **kwargs: (None, None)) + + with pytest.raises(ValueError): + ax.get_UA_axes(u, index=0) + + +def test_get_custom_axes_degenerate_axis1_raises(): + ax = AxesCalculator() + a = np.zeros(3) + b_list = [np.zeros(3)] + with pytest.raises(ValueError): + ax.get_custom_axes( + a=a, b_list=b_list, c=np.zeros(3), dimensions=np.array([10.0, 10.0, 10.0]) + ) + + +def test_get_custom_axes_normalizes_and_uses_bc_when_multiple_b(monkeypatch): + ax = AxesCalculator() + a = np.array([0.0, 0.0, 0.0]) + b_list = [np.array([1.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0])] + c = np.array([0.0, 1.0, 0.0]) + + axes = ax.get_custom_axes( + a=a, b_list=b_list, c=c, dimensions=np.array([10.0, 10.0, 10.0]) + ) + assert axes.shape == (3, 3) + # axes rows must be unit length + assert np.allclose(np.linalg.norm(axes, axis=1), 1.0) + + +def test_get_custom_moment_of_inertia_two_atom_sets_smallest_to_zero(): + ax = AxesCalculator() + + a0 = _FakeAtom(0, 12.0, [0.0, 0.0, 0.0]) + a1 = _FakeAtom(1, 1.0, [1.0, 0.0, 0.0]) + ua = _FakeAtomGroup([a0, a1]) + + axes = np.eye(3) + moi = ax.get_custom_moment_of_inertia( + UA=ua, + custom_rotation_axes=axes, + center_of_mass=np.array([0.0, 0.0, 0.0]), + dimensions=np.array([10.0, 10.0, 10.0]), + ) + assert moi.shape == (3,) + assert np.isclose(np.min(moi), 0.0) + + +def test_get_flipped_axes_flips_negative_dot(): + ax = AxesCalculator() + + a0 = _FakeAtom(0, 12.0, [0.0, 0.0, 0.0]) + ua = _FakeAtomGroup([a0]) + + # axis0 points opposite to rr_axis -> should flip + custom_axes = np.array( + [ + [-1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + ], + dtype=float, + ) + flipped = ax.get_flipped_axes( + UA=ua, + custom_axes=custom_axes, + center_of_mass=np.array([1.0, 0.0, 0.0]), + dimensions=np.array([10.0, 10.0, 10.0]), + ) + assert np.allclose(flipped[0], np.array([1.0, 0.0, 0.0])) + + +def test_get_custom_principal_axes_flips_z_when_left_handed(): + ax = AxesCalculator() + moi = np.eye(3) + axes, vals = ax.get_custom_principal_axes(moi) + assert axes.shape == (3, 3) + assert vals.shape == (3,) + + +def test_get_UA_masses_sums_bonded_hydrogens(): + ax = AxesCalculator() + + heavy = _FakeAtom(index=0, mass=12.0, position=[0, 0, 0]) + h1 = _FakeAtom(index=1, mass=1.0, position=[1, 0, 0]) + h2 = _FakeAtom(index=2, mass=1.0, position=[0, 1, 0]) + + bonded_atoms = _FakeAtomGroup( + [h1, h2], select_map={"mass 1 to 1.1": _FakeAtomGroup([h1, h2])} + ) + + mol = _FakeAtomGroup( + [heavy, h1, h2], + select_map={ + "bonded index 0": bonded_atoms, + }, + ) + + masses = ax.get_UA_masses(mol) + assert masses == [14.0] + + +def test_get_vanilla_axes_sorts_eigenvalues_desc_by_abs(monkeypatch): + ax = AxesCalculator() + mol = MagicMock() + moi_tensor = np.diag([1.0, -10.0, 3.0]) + mol.moment_of_inertia.return_value = moi_tensor + mol.principal_axes.return_value = np.eye(3) + mol.atoms = MagicMock() + + # avoid real MDAnalysis unwrap + monkeypatch.setattr("CodeEntropy.levels.axes.make_whole", lambda _ag: None) + + axes, moments = ax.get_vanilla_axes(mol) + + assert axes.shape == (3, 3) + # sorted by abs descending => -10, 3, 1 + assert np.allclose(moments, np.array([-10.0, 3.0, 1.0])) + + +def test_find_bonded_atoms_selects_heavy_and_hydrogen_groups(): + ax = AxesCalculator() + + bonded = MagicMock() + heavy = MagicMock() + hyd = MagicMock() + bonded.select_atoms.side_effect = [heavy, hyd] + + system = MagicMock() + system.select_atoms.return_value = bonded + + out_heavy, out_h = ax.find_bonded_atoms(atom_idx=7, system=system) + + system.select_atoms.assert_called_once_with("bonded index 7") + bonded.select_atoms.assert_any_call("mass 2 to 999") + bonded.select_atoms.assert_any_call("mass 1 to 1.1") + assert out_heavy is heavy + assert out_h is hyd + + +def test_get_bonded_axes_non_heavy_returns_none(): + ax = AxesCalculator() + system = MagicMock() + atom = _atom(index=1, mass=1.0) + + out_axes, out_moi = ax.get_bonded_axes( + system, atom, dimensions=np.array([10.0, 10.0, 10.0]) + ) + assert out_axes is None + assert out_moi is None + + +def test_get_bonded_axes_case1_uses_vanilla_axes_and_returns_flipped(monkeypatch): + ax = AxesCalculator() + system = MagicMock() + atom = _atom(index=1, mass=12.0, pos=(1, 0, 0)) + + heavy = _FakeAtomGroup([]) # len == 0 -> case1 + hyd = _FakeAtomGroup([_atom(index=2, mass=1.0)]) + monkeypatch.setattr(ax, "find_bonded_atoms", lambda _idx, _sys: (heavy, hyd)) + + monkeypatch.setattr( + ax, "get_vanilla_axes", lambda _ag: (np.eye(3) * 7, np.array([1.0, 2.0, 3.0])) + ) + monkeypatch.setattr(ax, "get_flipped_axes", lambda ua, axes, com, dims: axes * -1) + + out_axes, out_moi = ax.get_bonded_axes(system, atom, np.array([10.0, 10.0, 10.0])) + + assert np.allclose(out_axes, -np.eye(3) * 7) + assert np.allclose(out_moi, np.array([1.0, 2.0, 3.0])) + + +def test_get_bonded_axes_case2_one_heavy_no_h_calls_get_custom_axes_and_custom_moi( + monkeypatch, +): + ax = AxesCalculator() + system = MagicMock() + atom = _atom(index=1, mass=12.0, pos=(0, 0, 0)) + + heavy = _FakeAtomGroup( + [_atom(index=3, mass=12.0, pos=(1, 0, 0))], positions=np.array([[1, 0, 0]]) + ) + hyd = _FakeAtomGroup([]) + + monkeypatch.setattr(ax, "find_bonded_atoms", lambda _idx, _sys: (heavy, hyd)) + monkeypatch.setattr(ax, "get_custom_axes", lambda **kwargs: np.eye(3)) + monkeypatch.setattr( + ax, "get_custom_moment_of_inertia", lambda **kwargs: np.array([9.0, 8.0, 7.0]) + ) + monkeypatch.setattr(ax, "get_flipped_axes", lambda ua, axes, com, dims: axes) + + out_axes, out_moi = ax.get_bonded_axes(system, atom, np.array([10.0, 10.0, 10.0])) + + assert out_axes.shape == (3, 3) + assert np.allclose(out_moi, np.array([9.0, 8.0, 7.0])) + + +def test_get_bonded_axes_case3_one_heavy_with_h_calls_get_custom_axes(monkeypatch): + ax = AxesCalculator() + system = MagicMock() + atom = _atom(index=1, mass=12.0, pos=(0, 0, 0)) + + heavy = _FakeAtomGroup( + [_atom(index=3, mass=12.0, pos=(1, 0, 0))], positions=np.array([[1, 0, 0]]) + ) + hyd = _FakeAtomGroup([_atom(index=4, mass=1.0, pos=(0, 1, 0))]) + + monkeypatch.setattr(ax, "find_bonded_atoms", lambda _idx, _sys: (heavy, hyd)) + called = {"n": 0} + + def _custom_axes(**kwargs): + called["n"] += 1 + return np.eye(3) * 2 + + monkeypatch.setattr(ax, "get_custom_axes", _custom_axes) + monkeypatch.setattr( + ax, "get_custom_moment_of_inertia", lambda **kwargs: np.array([1.0, 1.0, 1.0]) + ) + monkeypatch.setattr(ax, "get_flipped_axes", lambda ua, axes, com, dims: axes) + + out_axes, out_moi = ax.get_bonded_axes(system, atom, np.array([10.0, 10.0, 10.0])) + + assert called["n"] == 1 + assert np.allclose(out_axes, np.eye(3) * 2) + assert np.allclose(out_moi, np.array([1.0, 1.0, 1.0])) + + +def test_get_bonded_axes_case5_two_heavy_calls_get_custom_axes(monkeypatch): + ax = AxesCalculator() + system = MagicMock() + atom = _atom(index=1, mass=12.0, pos=(0, 0, 0)) + + heavy_atoms = [ + _atom(index=3, mass=12.0, pos=(1, 0, 0)), + _atom(index=5, mass=12.0, pos=(0, 1, 0)), + ] + heavy = _FakeAtomGroup(heavy_atoms, positions=np.array([[1, 0, 0], [0, 1, 0]])) + heavy.positions = np.array([[1, 0, 0], [0, 1, 0]]) + hyd = _FakeAtomGroup([]) + + monkeypatch.setattr(ax, "find_bonded_atoms", lambda _idx, _sys: (heavy, hyd)) + monkeypatch.setattr(ax, "get_custom_axes", lambda **kwargs: np.eye(3) * 3) + monkeypatch.setattr( + ax, "get_custom_moment_of_inertia", lambda **kwargs: np.array([2.0, 2.0, 2.0]) + ) + monkeypatch.setattr(ax, "get_flipped_axes", lambda ua, axes, com, dims: axes) + + out_axes, out_moi = ax.get_bonded_axes(system, atom, np.array([10.0, 10.0, 10.0])) + + assert np.allclose(out_axes, np.eye(3) * 3) + assert np.allclose(out_moi, np.array([2.0, 2.0, 2.0])) + + +def test_get_residue_axes_no_bonds_custom_path(monkeypatch): + ax = AxesCalculator() + + residue = MagicMock() + residue.__len__.return_value = 1 + residue.atoms.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) + residue.select_atoms.return_value = MagicMock(positions=np.zeros((2, 3))) + residue.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) + + u = MagicMock() + u.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90]) + + def _select_atoms(q): + if q.startswith("(resindex"): + return [] # no bonds + if q.startswith("resindex "): + return residue + return [] + + u.select_atoms.side_effect = _select_atoms + + monkeypatch.setattr(ax, "get_UA_masses", lambda mol: [10.0, 12.0]) + monkeypatch.setattr(ax, "get_moment_of_inertia_tensor", lambda **kwargs: np.eye(3)) + monkeypatch.setattr( + ax, + "get_custom_principal_axes", + lambda moi: (np.eye(3), np.array([3.0, 2.0, 1.0])), + ) + + trans, rot, center, moi = ax.get_residue_axes(u, index=7) + + assert trans.shape == (3, 3) + assert rot.shape == (3, 3) + assert np.allclose(center, np.array([0.0, 0.0, 0.0])) + assert np.allclose(moi, np.array([3.0, 2.0, 1.0])) + + +def test_get_residue_axes_with_bonds_vanilla_path(monkeypatch): + ax = AxesCalculator() + + residue = MagicMock() + residue.__len__.return_value = 1 + residue.atoms.principal_axes.return_value = np.eye(3) * 2 + residue.atoms.center_of_mass.return_value = np.array([1.0, 2.0, 3.0]) + residue.center_of_mass.return_value = np.array([1.0, 2.0, 3.0]) + + u = MagicMock() + u.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90]) + u.atoms.principal_axes.return_value = np.eye(3) * 2 + + def _select_atoms(q): + if q.startswith("(resindex"): + return [1] + if q.startswith("resindex "): + return residue + return [] + + u.select_atoms.side_effect = _select_atoms + + monkeypatch.setattr("CodeEntropy.levels.axes.make_whole", lambda _ag: None) + monkeypatch.setattr( + ax, "get_vanilla_axes", lambda mol: (np.eye(3) * 2, np.array([9.0, 8.0, 7.0])) + ) + + trans, rot, center, moi = ax.get_residue_axes(u, index=10) + + assert np.allclose(trans, np.eye(3) * 2) + assert np.allclose(rot, np.eye(3) * 2) + assert np.allclose(center, np.array([1.0, 2.0, 3.0])) + assert np.allclose(moi, np.array([9.0, 8.0, 7.0])) + + +def test_get_vector_wraps_periodic_boundaries(): + ac = AxesCalculator() + dims = np.array([10.0, 10.0, 10.0]) + + a = np.array([9.0, 0.0, 0.0]) + b = np.array([1.0, 0.0, 0.0]) + out = ac.get_vector(a, b, dims) + np.testing.assert_allclose(out, np.array([2.0, 0.0, 0.0])) + + +def test_get_custom_axes_raises_when_axis1_degenerate(): + ac = AxesCalculator() + a = np.zeros(3) + b_list = [np.zeros(3), np.zeros(3)] + c = np.ones(3) + dims = np.array([10.0, 10.0, 10.0]) + with pytest.raises(ValueError): + ac.get_custom_axes(a=a, b_list=b_list, c=c, dimensions=dims) + + +def test_get_custom_axes_raises_when_normalization_degenerate(): + ac = AxesCalculator() + dims = np.array([10.0, 10.0, 10.0]) + + a = np.zeros(3) + b_list = [np.array([1.0, 0.0, 0.0])] + c = np.array([2.0, 0.0, 0.0]) + + with pytest.raises(ValueError): + ac.get_custom_axes(a=a, b_list=b_list, c=c, dimensions=dims) + + +def test_get_custom_principal_axes_flips_z_for_handedness(): + ac = AxesCalculator() + + moi = np.diag([3.0, 2.0, 1.0]) + axes, vals = ac.get_custom_principal_axes(moi) + + assert axes.shape == (3, 3) + assert vals.shape == (3,) + + cross_xy = np.cross(axes[0], axes[1]) + assert float(np.dot(cross_xy, axes[2])) > 0.0 + + +def test_get_moment_of_inertia_tensor_shape_and_symmetry(): + ac = AxesCalculator() + dims = np.array([10.0, 10.0, 10.0]) + com = np.zeros(3) + positions = np.array([[1.0, 0.0, 0.0], [0.0, 2.0, 0.0]]) + masses = [1.0, 3.0] + + moi = ac.get_moment_of_inertia_tensor(com, positions, masses, dims) + + assert moi.shape == (3, 3) + np.testing.assert_allclose(moi, moi.T) + + +def test_get_custom_moment_of_inertia_len2_zeros_smallest_component(): + ac = AxesCalculator() + dims = np.array([10.0, 10.0, 10.0]) + + UA = MagicMock() + UA.positions = np.array([[1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + UA.masses = [12.0, 1.0] + UA.__len__.return_value = 2 + + axes = np.eye(3) + com = np.zeros(3) + + moi = ac.get_custom_moment_of_inertia(UA, axes, com, dims) + + assert moi.shape == (3,) + assert np.isclose(np.min(moi), 0.0) + + +def test_get_UA_axes_multiple_heavy_atoms_uses_custom_principal_axes(monkeypatch): + ax = AxesCalculator() + + heavy_atoms = _FakeAtomGroup( + [ + _FakeAtom(0, 12.0, [0, 0, 0]), + _FakeAtom(1, 12.0, [1, 0, 0]), + ], + positions=np.array([[0, 0, 0], [1, 0, 0]], dtype=float), + ) + + system_atom = _FakeAtom(index=0, mass=12.0, position=[0, 0, 0]) + heavy_atom_selection = _FakeAtomGroup( + [system_atom], positions=np.array([[0, 0, 0]], dtype=float) + ) + + class _Atoms: + def center_of_mass(self, *args, **kwargs): + return np.array([0.0, 0.0, 0.0], dtype=float) + + def __getitem__(self, idx): + return system_atom + + data_container = MagicMock() + data_container.atoms = _Atoms() + data_container.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90], dtype=float) + + def _select_atoms(q): + if q == "prop mass > 1.1": + return heavy_atoms + if q.startswith("index "): + return heavy_atom_selection + return _FakeAtomGroup([]) + + data_container.select_atoms.side_effect = _select_atoms + + monkeypatch.setattr( + ax, + "get_bonded_axes", + lambda system, atom, dimensions: (np.eye(3), np.array([1.0, 1.0, 1.0])), + ) + monkeypatch.setattr(ax, "get_UA_masses", lambda _ag: [12.0, 12.0]) + + got_tensor = MagicMock(return_value=np.eye(3)) + monkeypatch.setattr(ax, "get_moment_of_inertia_tensor", got_tensor) + + got_custom_axes = MagicMock(return_value=(np.eye(3), np.array([3.0, 2.0, 1.0]))) + monkeypatch.setattr(ax, "get_custom_principal_axes", got_custom_axes) + + trans_axes, rot_axes, center, moi = ax.get_UA_axes(data_container, index=0) + + assert trans_axes.shape == (3, 3) + assert rot_axes.shape == (3, 3) + assert np.allclose(center, np.array([0.0, 0.0, 0.0])) + assert moi.shape == (3,) + got_tensor.assert_called_once() + got_custom_axes.assert_called_once() + + +def test_get_bonded_axes_returns_none_none_if_custom_axes_none(monkeypatch): + ax = AxesCalculator() + + atom = _FakeAtom(index=7, mass=12.0, position=[0, 0, 0]) + system = MagicMock() + dimensions = np.array([10.0, 10.0, 10.0], dtype=float) + + heavy_bonded = _FakeAtomGroup( + [_FakeAtom(8, 12.0, [1, 0, 0])], + positions=np.array([[1.0, 0.0, 0.0]], dtype=float), + ) + light_bonded = _FakeAtomGroup([], positions=np.zeros((0, 3), dtype=float)) + + monkeypatch.setattr( + ax, "find_bonded_atoms", lambda _idx, _sys: (heavy_bonded, light_bonded) + ) + + monkeypatch.setattr(ax, "get_custom_axes", lambda **kwargs: None) + + custom_axes, moi = ax.get_bonded_axes( + system=system, atom=atom, dimensions=dimensions + ) + + assert custom_axes is None + assert moi is None diff --git a/tests/unit/CodeEntropy/levels/test_dihedrals.py b/tests/unit/CodeEntropy/levels/test_dihedrals.py new file mode 100644 index 00000000..cdfd52a8 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_dihedrals.py @@ -0,0 +1,550 @@ +import contextlib +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np + +from CodeEntropy.levels.dihedrals import ConformationStateBuilder + + +class _AddableAG: + def __init__(self, name: str): + self.name = name + + def __add__(self, other: "_AddableAG") -> "_AddableAG": + return _AddableAG(f"({self.name}+{other.name})") + + +class _FakeProgress: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def add_task(self, *args, **kwargs): + return 1 + + def advance(self, *args, **kwargs): + return None + + +@contextlib.contextmanager +def _fake_progress_bar(*_args, **_kwargs): + yield _FakeProgress() + + +def test_select_heavy_residue_builds_two_selections(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.residues = [MagicMock()] + mol.residues[0].atoms.indices = np.array([10, 11, 12], dtype=int) + + uops.select_atoms.side_effect = ["res_container", "heavy_only"] + + out = dt._select_heavy_residue(mol, res_id=0) + + assert out == "heavy_only" + assert uops.select_atoms.call_count == 2 + uops.select_atoms.assert_any_call(mol, "index 10:12") + uops.select_atoms.assert_any_call("res_container", "prop mass > 1.1") + + +def test_get_dihedrals_united_atom_collects_atoms_from_dihedral_objects(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + d0 = MagicMock() + d0.atoms = "A0" + d1 = MagicMock() + d1.atoms = "A1" + + container = MagicMock() + container.dihedrals = [d0, d1] + + assert dt._get_dihedrals(container, level="united_atom") == ["A0", "A1"] + + +def test_get_dihedrals_residue_returns_empty_when_less_than_4_residues(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + mol = MagicMock() + mol.residues = [MagicMock(), MagicMock(), MagicMock()] + mol.select_atoms = MagicMock() + + assert dt._get_dihedrals(mol, level="residue") == [] + mol.select_atoms.assert_not_called() + + +def test_get_dihedrals_residue_builds_one_dihedral_when_4_residues(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + mol = MagicMock() + mol.residues = [MagicMock(), MagicMock(), MagicMock(), MagicMock()] + mol.select_atoms = MagicMock( + side_effect=[ + _AddableAG("a1"), + _AddableAG("a2"), + _AddableAG("a3"), + _AddableAG("a4"), + ] + ) + + out = dt._get_dihedrals(mol, level="residue") + + assert len(out) == 1 + assert isinstance(out[0], _AddableAG) + assert mol.select_atoms.call_count == 4 + + +def test_collect_dihedrals_for_group_handles_both_levels(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + mol = MagicMock() + mol.residues = [MagicMock(), MagicMock()] + + with ( + patch.object( + dt, "_select_heavy_residue", side_effect=["heavy0", "heavy1"] + ) as sel_spy, + patch.object( + dt, "_get_dihedrals", side_effect=[["ua0"], ["ua1"], ["res_d0"]] + ) as get_spy, + ): + ua, res = dt._collect_dihedrals_for_group( + mol=mol, level_list=["united_atom", "residue"] + ) + + assert ua == [["ua0"], ["ua1"]] + assert res == ["res_d0"] + assert sel_spy.call_count == 2 + assert get_spy.call_count == 3 + + +def test_collect_peaks_for_group_sets_empty_outputs_when_no_dihedrals(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + dihedrals_ua = [[], []] + dihedrals_res = [] + + with patch.object(dt, "_identify_peaks") as identify_spy: + peaks_ua, peaks_res = dt._collect_peaks_for_group( + data_container=MagicMock(), + molecules=[0], + dihedrals_ua=dihedrals_ua, + dihedrals_res=dihedrals_res, + bin_width=30.0, + start=0, + end=10, + step=1, + level_list=["united_atom", "residue"], + ) + + assert peaks_ua == [[], []] + assert peaks_res == [] + identify_spy.assert_not_called() + + +def test_identify_peaks_wraps_negative_angles_and_calls_find_histogram_peaks(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.trajectory = [0, 1] + uops.extract_fragment.return_value = mol + + angles = np.array([[-10.0], [10.0]], dtype=float) + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with ( + patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral), + patch.object(dt, "_find_histogram_peaks", return_value=[15.0]) as peaks_spy, + ): + out = dt._identify_peaks( + data_container=MagicMock(), + molecules=[0], + dihedrals=[MagicMock()], + bin_width=180.0, + start=0, + end=2, + step=1, + ) + + assert out == [[15.0]] + peaks_spy.assert_called_once() + + +def test_find_histogram_peaks_hits_interior_and_wraparound_last_bin(): + popul = [0, 2, 0, 3] + bin_value = [10.0, 20.0, 30.0, 40.0] + assert ConformationStateBuilder._find_histogram_peaks(popul, bin_value) == [ + 20.0, + 40.0, + ] + + +def test_assign_states_initialises_then_extends_for_multiple_molecules(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.trajectory = [0, 1] + uops.extract_fragment.return_value = mol + + angles = np.array([[5.0], [15.0]], dtype=float) + peaks = [[5.0, 15.0]] + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + states = dt._assign_states( + data_container=MagicMock(), + molecules=[0, 1], + dihedrals=["D0"], + peaks=peaks, + start=0, + end=2, + step=1, + ) + + assert states == ["0", "1", "0", "1"] + + +def test_assign_states_for_group_sets_empty_lists_and_delegates_for_nonempty(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + states_ua = {} + states_res = [None, None] + + with patch.object(dt, "_assign_states", return_value=["x"]) as assign_spy: + dt._assign_states_for_group( + data_container=MagicMock(), + group_id=1, + molecules=[99], + dihedrals_ua=[[], ["UA"]], + peaks_ua=[[], [["p"]]], + dihedrals_res=[], + peaks_res=[], + start=0, + end=2, + step=1, + level_list=["united_atom", "residue"], + states_ua=states_ua, + states_res=states_res, + ) + + assert states_ua[(1, 0)] == [] + assert states_ua[(1, 1)] == ["x"] + assert states_res[1] == [] + assert assign_spy.call_count == 1 + + +def test_build_conformational_states_runs_group_and_skips_empty_group(monkeypatch): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + monkeypatch.setattr(dt, "_progress_bar", _fake_progress_bar) + + groups = {0: [], 1: [7]} + levels = {7: ["residue"]} + + uops.extract_fragment.return_value = MagicMock(trajectory=[0]) + + monkeypatch.setattr(dt, "_collect_dihedrals_for_group", lambda **kw: ([], [])) + monkeypatch.setattr(dt, "_collect_peaks_for_group", lambda **kw: ([], [])) + monkeypatch.setattr(dt, "_assign_states_for_group", lambda **kw: None) + + states_ua, states_res = dt.build_conformational_states( + data_container=MagicMock(), + levels=levels, + groups=groups, + start=0, + end=1, + step=1, + bin_width=30.0, + ) + + assert states_ua == {} + assert len(states_res) == 2 + + +def test_count_total_items_counts_all_levels_across_grouped_molecules(): + levels = {10: ["residue"], 11: ["united_atom", "residue"]} + groups = {0: [10], 1: [11]} + assert ( + ConformationStateBuilder._count_total_items(levels=levels, groups=groups) == 3 + ) + + +def test_progress_bar_constructs_rich_progress_instance(): + prog = ConformationStateBuilder._progress_bar(total_items=1) + assert hasattr(prog, "add_task") + + +def test_identify_peaks_handles_multiple_dihedrals_and_calls_histogram_each_time(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.trajectory = [0, 1] + uops.extract_fragment.return_value = mol + + angles = np.array( + [ + [-10.0, 10.0], + [20.0, -20.0], + ], + dtype=float, + ) + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with ( + patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral), + patch( + "CodeEntropy.levels.dihedrals.np.histogram", wraps=np.histogram + ) as hist_spy, + ): + out = dt._identify_peaks( + data_container=MagicMock(), + molecules=[0], + dihedrals=["D0", "D1"], + bin_width=180.0, + start=0, + end=2, + step=1, + ) + + assert len(out) == 2 + assert hist_spy.call_count == 2 + + +def test_assign_states_filters_out_empty_state_strings_when_no_dihedrals(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.trajectory = [0, 1, 2] + uops.extract_fragment.return_value = mol + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=[])) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + out = dt._assign_states( + data_container=MagicMock(), + molecules=[0], + dihedrals=[], + peaks=[], + start=0, + end=3, + step=1, + ) + + assert out == [] + + +def test_identify_peaks_multiple_molecules_real_histogram(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol0 = MagicMock() + mol0.trajectory = [0, 1] + mol1 = MagicMock() + mol1.trajectory = [0, 1] + + uops.extract_fragment.side_effect = [mol0, mol1] + + angles = np.array([[10.0], [20.0]], dtype=float) + + class _FakeDihedral: + def __init__(self, _): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + peaks = dt._identify_peaks( + data_container=MagicMock(), + molecules=[0, 1], + dihedrals=["D0"], + bin_width=90.0, + start=0, + end=2, + step=1, + ) + + assert len(peaks) == 1 + + +def test_identify_peaks_real_histogram_without_spy(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.trajectory = [0, 1] + uops.extract_fragment.return_value = mol + + angles = np.array([[10.0], [20.0]], dtype=float) + + class _FakeDihedral: + def __init__(self, _): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + peaks = dt._identify_peaks( + data_container=MagicMock(), + molecules=[0], + dihedrals=["D0"], + bin_width=90.0, + start=0, + end=2, + step=1, + ) + + assert isinstance(peaks, list) + + +def test_assign_states_for_group_residue_nonempty_calls_assign_states(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + states_ua = {} + states_res = [None, None] + + with patch.object(dt, "_assign_states", return_value=["A"]) as spy: + dt._assign_states_for_group( + data_container=MagicMock(), + group_id=1, + molecules=[0], + dihedrals_ua=[[]], + peaks_ua=[[]], + dihedrals_res=["D"], + peaks_res=[["p"]], + start=0, + end=1, + step=1, + level_list=["residue"], + states_ua=states_ua, + states_res=states_res, + ) + + assert states_res[1] == ["A"] + spy.assert_called_once() + + +def test_assign_states_first_empty_then_extend(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol0 = MagicMock() + mol0.trajectory = [] + mol1 = MagicMock() + mol1.trajectory = [0] + + uops.extract_fragment.side_effect = [mol0, mol1] + + angles = np.array([[10.0]], dtype=float) + + class _FakeDihedral: + def __init__(self, _): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + states = dt._assign_states( + data_container=MagicMock(), + molecules=[0, 1], + dihedrals=["D0"], + peaks=[[10.0]], + start=0, + end=1, + step=1, + ) + + assert states == ["0"] + + +def test_collect_peaks_for_group_calls_identify_peaks_for_ua_and_residue(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + dihedrals_ua = [["UA_D0"]] + dihedrals_res = ["RES_D0"] + + with patch.object( + dt, + "_identify_peaks", + side_effect=[[["ua_peak"]], [["res_peak"]]], + ) as identify_spy: + peaks_ua, peaks_res = dt._collect_peaks_for_group( + data_container=MagicMock(), + molecules=[0], + dihedrals_ua=dihedrals_ua, + dihedrals_res=dihedrals_res, + bin_width=30.0, + start=0, + end=10, + step=1, + level_list=["united_atom", "residue"], + ) + + assert peaks_ua == [[["ua_peak"]]] + assert peaks_res == [["res_peak"]] + assert identify_spy.call_count == 2 + + +def test_assign_states_wraps_negative_angles(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.trajectory = [0, 1] + uops.extract_fragment.return_value = mol + + angles = np.array([[-10.0], [10.0]], dtype=float) + peaks = [[10.0, 350.0]] + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + states = dt._assign_states( + data_container=MagicMock(), + molecules=[0], + dihedrals=["D0"], + peaks=peaks, + start=0, + end=2, + step=1, + ) + + assert states == ["1", "0"] diff --git a/tests/unit/CodeEntropy/levels/test_forces_force_torque_calculator.py b/tests/unit/CodeEntropy/levels/test_forces_force_torque_calculator.py new file mode 100644 index 00000000..d9111f80 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_forces_force_torque_calculator.py @@ -0,0 +1,238 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from CodeEntropy.levels.forces import ForceTorqueCalculator, TorqueInputs + + +def test_get_weighted_forces_applies_partitioning_when_highest_level(): + calc = ForceTorqueCalculator() + + bead = MagicMock() + bead.atoms = [ + SimpleNamespace(force=np.array([1.0, 0.0, 0.0])), + SimpleNamespace(force=np.array([0.0, 2.0, 0.0])), + ] + bead.total_mass.return_value = 4.0 + + trans_axes = np.eye(3) + + out = calc.get_weighted_forces( + bead=bead, + trans_axes=trans_axes, + highest_level=True, + force_partitioning=2.0, + ) + + np.testing.assert_allclose(out, np.array([1.0, 2.0, 0.0])) + + +def test_get_weighted_forces_no_partitioning_when_not_highest_level(): + calc = ForceTorqueCalculator() + + bead = MagicMock() + bead.atoms = [SimpleNamespace(force=np.array([2.0, 0.0, 0.0]))] + bead.total_mass.return_value = 4.0 + + out = calc.get_weighted_forces( + bead=bead, + trans_axes=np.eye(3), + highest_level=False, + force_partitioning=999.0, + ) + + np.testing.assert_allclose(out, np.array([1.0, 0.0, 0.0])) + + +def test_get_weighted_forces_raises_on_non_positive_mass(): + calc = ForceTorqueCalculator() + + bead = MagicMock() + bead.atoms = [SimpleNamespace(force=np.array([1.0, 0.0, 0.0]))] + bead.total_mass.return_value = 0.0 + + with pytest.raises(ValueError): + calc.get_weighted_forces( + bead=bead, + trans_axes=np.eye(3), + highest_level=False, + force_partitioning=1.0, + ) + + +def test_get_weighted_torques_uses_axes_manager_displacements(axes_manager_identity): + calc = ForceTorqueCalculator() + + bead = MagicMock() + bead.positions = np.array([[1.0, 0.0, 0.0]]) + bead.forces = np.array([[0.0, 1.0, 0.0]]) + + out = calc.get_weighted_torques( + bead=bead, + rot_axes=np.eye(3), + center=np.array([0.0, 0.0, 0.0]), + force_partitioning=1.0, + moment_of_inertia=np.array([4.0, 9.0, 16.0]), + axes_manager=axes_manager_identity, + box=None, + ) + + np.testing.assert_allclose(out, np.array([0.0, 0.0, 0.25])) + + +def test_get_weighted_torques_skips_zero_or_invalid_moi_components( + axes_manager_identity, +): + calc = ForceTorqueCalculator() + + bead = MagicMock() + bead.positions = np.array([[1.0, 0.0, 0.0]]) + bead.forces = np.array([[0.0, 1.0, 0.0]]) + + out = calc.get_weighted_torques( + bead=bead, + rot_axes=np.eye(3), + center=np.array([0.0, 0.0, 0.0]), + force_partitioning=1.0, + moment_of_inertia=np.array([0.0, -1.0, 16.0]), + axes_manager=axes_manager_identity, + box=None, + ) + + np.testing.assert_allclose(out, np.array([0.0, 0.0, 0.25])) + + +def test_compute_frame_covariance_outer_products(): + calc = ForceTorqueCalculator() + + f_vecs = [np.array([1.0, 0.0, 0.0]), np.array([0.0, 2.0, 0.0])] + t_vecs = [np.array([0.0, 0.0, 3.0])] + + F, T = calc.compute_frame_covariance(f_vecs, t_vecs) + + assert F.shape == (6, 6) + assert T.shape == (3, 3) + + flat_f = np.array([1.0, 0.0, 0.0, 0.0, 2.0, 0.0]) + np.testing.assert_allclose(F, np.outer(flat_f, flat_f)) + + flat_t = np.array([0.0, 0.0, 3.0]) + np.testing.assert_allclose(T, np.outer(flat_t, flat_t)) + + +def test_outer_second_moment_empty_returns_0x0(): + calc = ForceTorqueCalculator() + F, T = calc.compute_frame_covariance([], []) + assert F.shape == (0, 0) + assert T.shape == (0, 0) + + +def test_outer_second_moment_raises_if_vector_not_length_3(): + calc = ForceTorqueCalculator() + with pytest.raises(ValueError): + calc.compute_frame_covariance([np.array([1.0, 2.0])], []) + + +def test_compute_weighted_force_rejects_wrong_axes_shape(): + ft = ForceTorqueCalculator() + bead = MagicMock() + bead.atoms = [] + bead.total_mass.return_value = 10.0 + + with pytest.raises(ValueError): + ft._compute_weighted_force( + bead, + trans_axes=np.zeros((2, 2)), + apply_partitioning=False, + force_partitioning=1.0, + ) + + +def test_compute_weighted_torque_rejects_wrong_rot_axes_shape(): + ft = ForceTorqueCalculator() + bead = MagicMock() + bead.positions = np.zeros((1, 3)) + bead.forces = np.zeros((1, 3)) + + inputs = TorqueInputs( + rot_axes=np.zeros((2, 2)), + center=np.zeros(3), + moment_of_inertia=np.ones(3), + axes_manager=MagicMock(), + box=None, + force_partitioning=1.0, + ) + + with pytest.raises(ValueError): + ft._compute_weighted_torque(bead, inputs) + + +def test_compute_weighted_torque_rejects_wrong_moi_shape(): + ft = ForceTorqueCalculator() + bead = MagicMock() + bead.positions = np.zeros((1, 3)) + bead.forces = np.zeros((1, 3)) + + inputs = TorqueInputs( + rot_axes=np.eye(3), + center=np.zeros(3), + moment_of_inertia=np.ones(2), + axes_manager=MagicMock(), + box=None, + force_partitioning=1.0, + ) + + with pytest.raises(ValueError): + ft._compute_weighted_torque(bead, inputs) + + +def test_compute_weighted_torque_skips_zero_torque_and_nonpositive_moi(monkeypatch): + ft = ForceTorqueCalculator() + + bead = MagicMock() + bead.positions = np.array([[1.0, 0.0, 0.0]]) + bead.forces = np.array([[0.0, 0.0, 0.0]]) + + monkeypatch.setattr( + ft, + "_displacements_relative_to_center", + lambda **kwargs: np.array([[1.0, 0.0, 0.0]]), + ) + + inputs = TorqueInputs( + rot_axes=np.eye(3), + center=np.zeros(3), + moment_of_inertia=np.array([0.0, -1.0, 2.0]), + axes_manager=MagicMock(), + box=None, + force_partitioning=1.0, + ) + + out = ft._compute_weighted_torque(bead, inputs) + assert np.allclose(out, np.zeros(3)) + + +def test_compute_weighted_torque_skips_nonpositive_moi_components(): + calc = ForceTorqueCalculator() + + bead = SimpleNamespace( + positions=np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=float), + forces=np.array([[0, 0, 1], [1, 0, 0], [0, 1, 0]], dtype=float), + ) + + inputs = SimpleNamespace( + center=np.array([0.0, 0.0, 0.0]), + rot_axes=np.eye(3), + moment_of_inertia=np.array([1.0, 0.0, -1.0], dtype=float), # triggers skips + force_partitioning=1.0, + axes_manager=None, + box=np.array([10.0, 10.0, 10.0], dtype=float), + ) + + calc._displacements_relative_to_center = lambda **kwargs: bead.positions + + weighted = calc._compute_weighted_torque(bead=bead, inputs=inputs) + + assert np.allclose(weighted, np.array([1.0, 0.0, 0.0])) diff --git a/tests/unit/CodeEntropy/levels/test_frame_graph.py b/tests/unit/CodeEntropy/levels/test_frame_graph.py new file mode 100644 index 00000000..e4490c01 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_frame_graph.py @@ -0,0 +1,50 @@ +from unittest.mock import MagicMock + +from CodeEntropy.levels.frame_dag import FrameGraph + + +def test_make_frame_ctx_has_required_keys(): + ctx = FrameGraph._make_frame_ctx(shared_data={"x": 1}, frame_index=7) + assert ctx["shared"] == {"x": 1} + assert ctx["frame_index"] == 7 + assert ctx["frame_covariance"] is None + + +def test_add_registers_node_and_deps_edges(): + fg = FrameGraph() + n1 = MagicMock() + n2 = MagicMock() + + fg._add("a", n1) + fg._add("b", n2, deps=["a"]) + + assert "a" in fg._nodes and "b" in fg._nodes + assert ("a", "b") in fg._graph.edges + + +def test_execute_frame_runs_nodes_in_topological_order_and_returns_frame_covariance(): + fg = FrameGraph() + + a = MagicMock() + b = MagicMock() + + fg._add("a", a) + fg._add("b", b, deps=["a"]) + + def _b_run(ctx): + ctx["frame_covariance"] = {"ok": True} + + b.run.side_effect = _b_run + + out = fg.execute_frame(shared_data={"S": 1}, frame_index=3) + + assert out == {"ok": True} + assert a.run.call_count == 1 + assert b.run.call_count == 1 + + +def test_build_adds_frame_covariance_node(): + fg = FrameGraph() + fg.build() + assert "frame_covariance" in fg._nodes + assert "frame_covariance" in fg._graph.nodes diff --git a/tests/unit/CodeEntropy/levels/test_hierarchy_builder.py b/tests/unit/CodeEntropy/levels/test_hierarchy_builder.py new file mode 100644 index 00000000..99628f78 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_hierarchy_builder.py @@ -0,0 +1,108 @@ +from unittest.mock import MagicMock + +import pytest + +from CodeEntropy.levels.hierarchy import HierarchyBuilder + + +def _heavy_atoms_group(n_atoms: int, n_residues: int): + heavy = MagicMock() + heavy.__len__.return_value = n_atoms + heavy.residues = [MagicMock() for _ in range(n_residues)] + return heavy + + +def test_select_levels_assigns_expected_levels(): + hb = HierarchyBuilder() + + u = MagicMock() + u.atoms = MagicMock() + frag0 = MagicMock() + frag1 = MagicMock() + frag2 = MagicMock() + + frag0.select_atoms.return_value = _heavy_atoms_group(n_atoms=1, n_residues=1) + frag1.select_atoms.return_value = _heavy_atoms_group(n_atoms=2, n_residues=1) + frag2.select_atoms.return_value = _heavy_atoms_group(n_atoms=3, n_residues=2) + + u.atoms.fragments = [frag0, frag1, frag2] + + n_mols, levels = hb.select_levels(u) + + assert n_mols == 3 + assert levels[0] == ["united_atom"] + assert levels[1] == ["united_atom", "residue"] + assert levels[2] == ["united_atom", "residue", "polymer"] + + +def test_get_beads_unknown_level_raises(): + hb = HierarchyBuilder() + with pytest.raises(ValueError): + hb.get_beads(MagicMock(), "nonsense") + + +def test_get_beads_polymer_returns_single_all_selection(): + hb = HierarchyBuilder() + mol = MagicMock() + mol.select_atoms.return_value = "ALL" + out = hb.get_beads(mol, "polymer") + assert out == ["ALL"] + mol.select_atoms.assert_called_once_with("all") + + +def test_get_beads_residue_returns_residue_atomgroups(): + hb = HierarchyBuilder() + + mol = MagicMock() + r0 = MagicMock() + r1 = MagicMock() + r0.atoms = "R0_ATOMS" + r1.atoms = "R1_ATOMS" + mol.residues = [r0, r1] + + out = hb.get_beads(mol, "residue") + assert out == ["R0_ATOMS", "R1_ATOMS"] + + +def test_get_beads_united_atom_no_heavy_atoms_falls_back_to_all(): + hb = HierarchyBuilder() + + mol = MagicMock() + heavy = MagicMock() + heavy.__len__.return_value = 0 + mol.select_atoms.side_effect = lambda sel: ( + heavy if sel == "prop mass > 1.1" else "ALL" + ) + out = hb.get_beads(mol, "united_atom") + + assert out == ["ALL"] + + +def test_get_beads_united_atom_builds_selection_per_heavy_atom(): + hb = HierarchyBuilder() + + mol = MagicMock() + h0 = MagicMock() + h1 = MagicMock() + h0.index = 7 + h1.index = 9 + + heavy = [h0, h1] + heavy_group = MagicMock() + heavy_group.__len__.return_value = 2 + heavy_group.__iter__.return_value = iter(heavy) + + def _select(sel): + if sel == "prop mass > 1.1": + return heavy_group + bead = MagicMock() + bead.__len__.return_value = 1 + return bead + + mol.select_atoms.side_effect = _select + + out = hb.get_beads(mol, "united_atom") + assert len(out) == 2 + calls = [c.args[0] for c in mol.select_atoms.call_args_list] + assert any("index 7" in s for s in calls) + assert any("index 9" in s for s in calls) diff --git a/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py b/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py new file mode 100644 index 00000000..88276049 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py @@ -0,0 +1,257 @@ +from unittest.mock import MagicMock, patch + +import numpy as np + +from CodeEntropy.levels.level_dag import LevelDAG + + +def _shared(): + return { + "levels": [["united_atom"]], + "frame_counts": {}, + "force_covariances": {}, + "torque_covariances": {}, + "force_counts": {}, + "torque_counts": {}, + "reduced_force_covariances": {}, + "reduced_torque_covariances": {}, + "reduced_force_counts": {}, + "reduced_torque_counts": {}, + "group_id_to_index": {0: 0}, + } + + +def test_execute_sets_default_axes_manager_once(): + dag = LevelDAG() + + shared = { + "reduced_universe": MagicMock(), + "start": 0, + "end": 0, + "step": 1, + } + + dag._run_static_stage = MagicMock() + dag._run_frame_stage = MagicMock() + + dag.execute(shared) + + assert "axes_manager" in shared + dag._run_static_stage.assert_called_once() + dag._run_frame_stage.assert_called_once() + + +def test_run_static_stage_calls_nodes_in_topological_sort_order(): + dag = LevelDAG() + dag._static_graph.add_node("a") + dag._static_graph.add_node("b") + + dag._static_nodes["a"] = MagicMock() + dag._static_nodes["b"] = MagicMock() + + with patch("networkx.topological_sort", return_value=["a", "b"]): + dag._run_static_stage({"X": 1}) + + dag._static_nodes["a"].run.assert_called_once() + dag._static_nodes["b"].run.assert_called_once() + + +def test_run_frame_stage_iterates_selected_frames_and_reduces_each(): + dag = LevelDAG() + + ts0 = MagicMock(frame=10) + ts1 = MagicMock(frame=11) + u = MagicMock() + u.trajectory = [ts0, ts1] + + shared = {"reduced_universe": u, "start": 0, "end": 2, "step": 1} + + dag._frame_dag = MagicMock() + dag._frame_dag.execute_frame.side_effect = [ + { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + ] * 2 + dag._reduce_one_frame = MagicMock() + + dag._run_frame_stage(shared) + + assert dag._frame_dag.execute_frame.call_count == 2 + assert dag._reduce_one_frame.call_count == 2 + dag._frame_dag.execute_frame.assert_any_call(shared, 10) + dag._frame_dag.execute_frame.assert_any_call(shared, 11) + + +def test_incremental_mean_handles_non_copyable_values(): + out = LevelDAG._incremental_mean(old=None, new=3.0, n=1) + assert out == 3.0 + + +def test_reduce_forcetorque_no_key_is_noop(): + dag = LevelDAG() + shared = { + "forcetorque_covariances": {"res": [None], "poly": [None]}, + "forcetorque_counts": { + "res": np.zeros(1, dtype=int), + "poly": np.zeros(1, dtype=int), + }, + "group_id_to_index": {9: 0}, + } + dag._reduce_forcetorque(shared, frame_out={}) + assert shared["forcetorque_counts"]["res"][0] == 0 + assert shared["forcetorque_covariances"]["res"][0] is None + + +def test_build_registers_static_nodes_and_builds_frame_dag(): + with ( + patch("CodeEntropy.levels.level_dag.DetectMoleculesNode") as _, + patch("CodeEntropy.levels.level_dag.DetectLevelsNode") as _, + patch("CodeEntropy.levels.level_dag.BuildBeadsNode") as _, + patch("CodeEntropy.levels.level_dag.InitCovarianceAccumulatorsNode") as _, + patch("CodeEntropy.levels.level_dag.ComputeConformationalStatesNode") as _, + ): + dag = LevelDAG(universe_operations=MagicMock()) + dag._frame_dag.build = MagicMock() + + dag.build() + + assert "detect_molecules" in dag._static_nodes + assert "detect_levels" in dag._static_nodes + assert "build_beads" in dag._static_nodes + assert "init_covariance_accumulators" in dag._static_nodes + assert "compute_conformational_states" in dag._static_nodes + dag._frame_dag.build.assert_called_once() + + +def test_add_static_adds_dependency_edges(): + dag = LevelDAG() + dag._add_static("A", MagicMock()) + dag._add_static("B", MagicMock(), deps=["A"]) + + assert ("A", "B") in dag._static_graph.edges + + +def test_reduce_force_and_torque_hits_zero_count_branches(): + dag = LevelDAG() + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": { + "ua": {(7, 0): np.eye(1)}, + "res": {7: np.eye(2)}, + "poly": {7: np.eye(3)}, + }, + "torque": { + "ua": {(7, 0): np.eye(1)}, + "res": {7: np.eye(2)}, + "poly": {7: np.eye(3)}, + }, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["ua"][(7, 0)] == 1 + assert (7, 0) in shared["force_covariances"]["ua"] + assert (7, 0) in shared["torque_covariances"]["ua"] + + assert shared["frame_counts"]["res"][0] == 1 + assert shared["frame_counts"]["poly"][0] == 1 + + +def test_reduce_force_and_torque_handles_empty_frame_gracefully(): + dag = LevelDAG() + + shared = { + "group_id_to_index": {0: 0}, + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + } + + frame_out = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared_data=shared, frame_out=frame_out) + + assert shared["force_covariances"]["ua"] == {} + assert shared["torque_covariances"]["ua"] == {} + assert shared["frame_counts"]["res"][0] == 0 + assert shared["frame_counts"]["poly"][0] == 0 + + +def test_reduce_force_and_torque_increments_res_and_poly_counts_from_zero(): + dag = LevelDAG() + + shared = { + "group_id_to_index": {7: 0}, + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + } + + F = np.eye(3) + T = np.eye(3) * 2 + + frame_out = { + "force": {"ua": {}, "res": {7: F}, "poly": {7: F}}, + "torque": {"ua": {}, "res": {7: T}, "poly": {7: T}}, + } + + dag._reduce_force_and_torque(shared_data=shared, frame_out=frame_out) + + assert shared["frame_counts"]["res"][0] == 1 + assert shared["frame_counts"]["poly"][0] == 1 + assert np.allclose(shared["torque_covariances"]["res"][0], T) + assert np.allclose(shared["torque_covariances"]["poly"][0], T) + + +def test_reduce_one_frame_skips_missing_force_and_torque_keys(): + dag = LevelDAG() + shared = _shared() + + bead_key = (0, "united_atom", 0) + frame_out = { + "beads": {bead_key: [1, 2, 3]}, + "counts": {bead_key: 1}, + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + + dag._reduce_one_frame(shared_data=shared, frame_out=frame_out) + + assert shared["force_covariances"] == {} + assert shared["torque_covariances"] == {} + + +def test_reduce_force_and_torque_skips_when_counts_are_zero(): + dag = LevelDAG() + shared = _shared() + + k = (0, "united_atom", 0) + shared["force_covariances"][k] = np.eye(3) + shared["torque_covariances"][k] = np.eye(3) + shared["force_counts"][k] = 0 + shared["torque_counts"][k] = 0 + shared["frame_counts"][k] = 0 + + frame_out = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + "beads": {}, + } + + dag._reduce_force_and_torque(shared_data=shared, frame_out=frame_out) + + assert shared["reduced_force_covariances"] == {} + assert shared["reduced_torque_covariances"] == {} + assert shared["reduced_force_counts"] == {} + assert shared["reduced_torque_counts"] == {} diff --git a/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py b/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py new file mode 100644 index 00000000..71fef21f --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py @@ -0,0 +1,184 @@ +import numpy as np + +from CodeEntropy.levels.level_dag import LevelDAG + + +def test_incremental_mean_first_sample_copies(): + x = np.array([1.0, 2.0]) + out = LevelDAG._incremental_mean(None, x, n=1) + assert np.allclose(out, x) + x[0] = 999.0 + assert out[0] != 999.0 + + +def test_reduce_force_and_torque_exercises_count_branches(): + dag = LevelDAG() + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": { + "ua": {(9, 0): np.array([1.0])}, + "res": {7: np.array([2.0])}, + "poly": {7: np.array([3.0])}, + }, + "torque": { + "ua": {(9, 0): np.array([4.0])}, + "res": {7: np.array([5.0])}, + "poly": {7: np.array([6.0])}, + }, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert (9, 0) in shared["torque_covariances"]["ua"] + assert shared["frame_counts"]["res"][0] == 1 + assert shared["frame_counts"]["poly"][0] == 1 + + +def test_reduce_forcetorque_returns_when_missing_key(): + dag = LevelDAG() + shared = { + "forcetorque_covariances": {"res": [None], "poly": [None]}, + "forcetorque_counts": {"res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + dag._reduce_forcetorque(shared, frame_out={}) + assert shared["forcetorque_counts"]["res"][0] == 0 + + +def test_reduce_forcetorque_updates_res_and_poly(): + dag = LevelDAG() + + shared = { + "forcetorque_covariances": {"res": [None], "poly": [None]}, + "forcetorque_counts": {"res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "forcetorque": { + "res": {7: np.array([1.0, 1.0])}, + "poly": {7: np.array([2.0, 2.0])}, + } + } + + dag._reduce_forcetorque(shared, frame_out) + + assert shared["forcetorque_counts"]["res"][0] == 1 + assert shared["forcetorque_counts"]["poly"][0] == 1 + assert shared["forcetorque_covariances"]["res"][0] is not None + assert shared["forcetorque_covariances"]["poly"][0] is not None + + +def test_reduce_force_and_torque_res_torque_increments_when_res_count_is_zero(): + dag = LevelDAG() + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {7: np.eye(3)}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["res"][0] == 1 + assert shared["torque_covariances"]["res"][0] is not None + + +def test_reduce_force_and_torque_poly_torque_increments_when_poly_count_is_zero(): + dag = LevelDAG() + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {7: np.eye(3)}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["poly"][0] == 1 + assert shared["torque_covariances"]["poly"][0] is not None + + +def test_reduce_force_and_torque_increments_ua_frame_counts_for_force(): + dag = LevelDAG() + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + k = (9, 0) + frame_out = { + "force": {"ua": {k: np.eye(3)}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["ua"][k] == 1 + assert k in shared["force_covariances"]["ua"] + + +def test_reduce_force_and_torque_increments_ua_counts_from_zero(): + dag = LevelDAG() + + key = (9, 0) + F = np.eye(3) + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": {"ua": {key: F}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["ua"][key] == 1 + + np.testing.assert_array_equal(shared["force_covariances"]["ua"][key], F) + + +def test_reduce_force_and_torque_hits_ua_force_count_increment_line(): + dag = LevelDAG() + key = (9, 0) + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": {"ua": {key: np.eye(3)}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["ua"][key] == 1 diff --git a/tests/unit/CodeEntropy/levels/test_level_dag_reduction.py b/tests/unit/CodeEntropy/levels/test_level_dag_reduction.py new file mode 100644 index 00000000..5c8c4716 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_level_dag_reduction.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock + +import numpy as np + +from CodeEntropy.levels.level_dag import LevelDAG + + +def test_incremental_mean_none_returns_copy_for_numpy(): + arr = np.array([1.0, 2.0]) + out = LevelDAG._incremental_mean(None, arr, n=1) + assert np.all(out == arr) + arr[0] = 999.0 + assert out[0] != 999.0 + + +def test_incremental_mean_updates_mean_correctly(): + old = np.array([2.0, 2.0]) + new = np.array([4.0, 0.0]) + out = LevelDAG._incremental_mean(old, new, n=2) + np.testing.assert_allclose(out, np.array([3.0, 1.0])) + + +def test_reduce_force_and_torque_updates_counts_and_means(): + dag = LevelDAG() + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": { + "ua": {}, + "res": np.zeros(1, dtype=int), + "poly": np.zeros(1, dtype=int), + }, + "group_id_to_index": {9: 0}, + } + + F1 = np.eye(3) + T1 = 2.0 * np.eye(3) + + frame_out = { + "force": {"ua": {(0, 0): F1}, "res": {9: F1}, "poly": {}}, + "torque": {"ua": {(0, 0): T1}, "res": {9: T1}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["ua"][(0, 0)] == 1 + np.testing.assert_allclose(shared["force_covariances"]["ua"][(0, 0)], F1) + np.testing.assert_allclose(shared["torque_covariances"]["ua"][(0, 0)], T1) + + assert shared["frame_counts"]["res"][0] == 1 + np.testing.assert_allclose(shared["force_covariances"]["res"][0], F1) + np.testing.assert_allclose(shared["torque_covariances"]["res"][0], T1) + + +def test_reduce_forcetorque_no_key_is_noop(): + dag = LevelDAG() + shared = { + "forcetorque_covariances": {"res": [None], "poly": [None]}, + "forcetorque_counts": { + "res": np.zeros(1, dtype=int), + "poly": np.zeros(1, dtype=int), + }, + "group_id_to_index": {9: 0}, + } + + dag._reduce_forcetorque(shared, frame_out={}) + assert shared["forcetorque_counts"]["res"][0] == 0 + assert shared["forcetorque_covariances"]["res"][0] is None + + +def test_run_frame_stage_calls_execute_frame_for_each_ts(simple_ts_list): + dag = LevelDAG() + + u = MagicMock() + u.trajectory = simple_ts_list + + shared = {"reduced_universe": u, "start": 0, "end": 3, "step": 1} + + dag._frame_dag = MagicMock() + dag._frame_dag.execute_frame.side_effect = lambda shared_data, frame_index: { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + + dag._reduce_one_frame = MagicMock() + + dag._run_frame_stage(shared) + + assert dag._frame_dag.execute_frame.call_count == 3 + assert dag._reduce_one_frame.call_count == 3 diff --git a/tests/unit/CodeEntropy/levels/test_linalg_matrix_utils.py b/tests/unit/CodeEntropy/levels/test_linalg_matrix_utils.py new file mode 100644 index 00000000..d49b6b11 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_linalg_matrix_utils.py @@ -0,0 +1,57 @@ +import numpy as np +import pytest + +from CodeEntropy.levels.linalg import MatrixUtils + + +def test_create_submatrix_outer_product_correct(): + mu = MatrixUtils() + a = np.array([1.0, 2.0, 3.0]) + b = np.array([4.0, 5.0, 6.0]) + + out = mu.create_submatrix(a, b) + + assert out.shape == (3, 3) + np.testing.assert_allclose(out, np.outer(a, b)) + + +def test_create_submatrix_rejects_non_3_vectors(): + mu = MatrixUtils() + with pytest.raises(ValueError): + mu.create_submatrix(np.array([1.0, 2.0]), np.array([1.0, 2.0, 3.0])) + + +def test_filter_zero_rows_columns_removes_all_zero_rows_and_cols(): + mu = MatrixUtils() + mat = np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 2.0, 0.0], + [0.0, 0.0, 0.0], + ] + ) + + out = mu.filter_zero_rows_columns(mat) + + assert out.shape == (1, 1) + assert out[0, 0] == 2.0 + + +def test_filter_zero_rows_columns_uses_atol(): + mu = MatrixUtils() + mat = np.array( + [ + [1e-9, 0.0], + [0.0, 1.0], + ] + ) + + out = mu.filter_zero_rows_columns(mat, atol=1e-8) + assert out.shape == (1, 1) + assert out[0, 0] == 1.0 + + +def test_filter_zero_rows_columns_rejects_non_2d(): + mu = MatrixUtils() + with pytest.raises(ValueError): + mu.filter_zero_rows_columns(np.array([1.0, 2.0, 3.0])) diff --git a/tests/unit/CodeEntropy/levels/test_mda_universe_operations.py b/tests/unit/CodeEntropy/levels/test_mda_universe_operations.py new file mode 100644 index 00000000..98e3a7d7 --- /dev/null +++ b/tests/unit/CodeEntropy/levels/test_mda_universe_operations.py @@ -0,0 +1,217 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest +from MDAnalysis.exceptions import NoDataError + +from CodeEntropy.levels.mda import UniverseOperations + + +class _FakeAF: + """Fake AnalysisFromFunction that avoids MDAnalysis trajectory requirements.""" + + def __init__(self, func, atomgroup): + self._func = func + self._ag = atomgroup + self.results = {} + + def run(self): + self.results["timeseries"] = self._func(self._ag) + return self + + +def test_extract_timeseries_unknown_kind_raises(): + ops = UniverseOperations() + with pytest.raises(ValueError): + ops._extract_timeseries(MagicMock(), kind="nope") + + +def test_extract_force_timeseries_fallback_to_positions_when_no_forces(): + ops = UniverseOperations() + ag_force = MagicMock() + + def _extract(atomgroup, *, kind): + if kind == "forces": + raise NoDataError("no forces") + return np.ones((2, 3, 3)) + + ops._extract_timeseries = MagicMock(side_effect=_extract) + + out = ops._extract_force_timeseries_with_fallback( + ag_force, fallback_to_positions_if_no_forces=True + ) + assert out.shape == (2, 3, 3) + + +def test_extract_force_timeseries_raises_when_no_fallback(): + ops = UniverseOperations() + ops._extract_timeseries = MagicMock(side_effect=NoDataError("no forces")) + + with pytest.raises(NoDataError): + ops._extract_force_timeseries_with_fallback( + MagicMock(), fallback_to_positions_if_no_forces=False + ) + + +def test_select_frames_defaults_start_end_and_slices(monkeypatch): + ops = UniverseOperations() + + u = MagicMock() + u.trajectory = list(range(10)) + u.select_atoms.return_value = MagicMock() + + # timeseries arrays + ops._extract_timeseries = MagicMock( + side_effect=[ + np.zeros((10, 2, 3)), # positions + np.ones((10, 2, 3)), # forces + np.zeros((10, 6)), # dimensions + ] + ) + + merged = MagicMock() + merged.load_new = MagicMock() + monkeypatch.setattr("CodeEntropy.levels.mda.mda.Merge", lambda ag: merged) + + out = ops.select_frames(u, start=None, end=None, step=2) + + assert out is merged + merged.load_new.assert_called_once() + + +def test_merge_forces_scales_kcal(monkeypatch): + ops = UniverseOperations() + + u = MagicMock() + u.select_atoms.return_value = MagicMock() + u_force = MagicMock() + u_force.select_atoms.return_value = MagicMock() + + monkeypatch.setattr( + "CodeEntropy.levels.mda.mda.Universe", MagicMock(side_effect=[u, u_force]) + ) + + ops._extract_timeseries = MagicMock( + side_effect=[ + np.zeros((2, 2, 3)), # coordinates + np.zeros((2, 6)), # dimensions + ] + ) + + forces = np.ones((2, 2, 3), dtype=float) + ops._extract_force_timeseries_with_fallback = MagicMock(return_value=forces) + + merged = MagicMock() + merged.load_new = MagicMock() + monkeypatch.setattr("CodeEntropy.levels.mda.mda.Merge", lambda ag: merged) + + out = ops.merge_forces( + tprfile="tpr", + trrfile="trr", + forcefile="force.trr", + fileformat=None, + kcal=True, + ) + + assert out is merged + assert np.allclose(forces, np.ones((2, 2, 3)) * 4.184) + + +def test_select_atoms_builds_merged_universe_and_loads_timeseries(monkeypatch): + ops = UniverseOperations() + + u = MagicMock() + sel = MagicMock() + u.select_atoms.return_value = sel + + monkeypatch.setattr( + ops, + "_extract_timeseries", + lambda _sel, kind: np.zeros((2, 3)) if kind == "positions" else np.zeros((2,)), + ) + + merged = MagicMock() + with ( + patch("CodeEntropy.levels.mda.mda.Merge", return_value=merged) as MergeCls, + patch("CodeEntropy.levels.mda.MemoryReader"), + ): + out = ops.select_atoms(u, "name CA") + + u.select_atoms.assert_called_once_with("name CA", updating=True) + MergeCls.assert_called_once_with(sel) + merged.load_new.assert_called_once() + assert out is merged + + +def test_extract_fragment_selects_by_resindices(monkeypatch): + u = MagicMock() + frag0 = MagicMock() + frag0.indices = np.array([10, 11, 12], dtype=int) + + u.atoms.fragments = [frag0] + + uops = UniverseOperations() + + select_spy = MagicMock(return_value="FRAG") + monkeypatch.setattr(uops, "select_atoms", select_spy) + + out = uops.extract_fragment(u, molecule_id=0) + + assert out == "FRAG" + select_spy.assert_called_once_with(u, "index 10:12") + + +def test_extract_timeseries_kind_positions_returns_xyz_array(): + uops = UniverseOperations() + + ag = MagicMock() + ag.positions = np.array([[1.0, 2.0, 3.0]], dtype=float) + + class _FakeAnalysisFromFunction: + def __init__(self, func, atomgroup): + self.func = func + self.atomgroup = atomgroup + + def run(self): + return SimpleNamespace(results={"timeseries": self.func(self.atomgroup)}) + + with patch( + "CodeEntropy.levels.mda.AnalysisFromFunction", _FakeAnalysisFromFunction + ): + out = uops._extract_timeseries(atomgroup=ag, kind="positions") + + assert out.shape == (1, 3) + assert np.allclose(out, np.array([[1.0, 2.0, 3.0]])) + + +def test_extract_timeseries_invalid_kind_raises_value_error(): + uops = UniverseOperations() + ag = MagicMock() + + with pytest.raises(ValueError): + uops._extract_timeseries(atomgroup=ag, kind="not-a-kind") + + +def test_extract_timeseries_forces_branch_uses_forces_copy(): + uops = UniverseOperations() + + ag = MagicMock() + ag.forces = np.array([[1.0, 2.0, 3.0]], dtype=float) + + with patch("CodeEntropy.levels.mda.AnalysisFromFunction", _FakeAF): + out = uops._extract_timeseries(ag, kind="forces") + + assert np.allclose(out, ag.forces) + + +def test_extract_timeseries_dimensions_branch_uses_dimensions_copy(): + uops = UniverseOperations() + + ag = MagicMock() + ag.dimensions = np.array([10.0, 10.0, 10.0, 90, 90, 90], dtype=float) + + with patch("CodeEntropy.levels.mda.AnalysisFromFunction", _FakeAF): + out = uops._extract_timeseries(ag, kind="dimensions") + + assert np.allclose(out, ag.dimensions) From 7becd02c79a49fa68f4675a7eb79479fdc201619 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 15:58:13 +0000 Subject: [PATCH 072/101] test(core): add atomic pytest unit tests for the molecules module --- .../CodeEntropy/molecules/test_grouping.py | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/unit/CodeEntropy/molecules/test_grouping.py diff --git a/tests/unit/CodeEntropy/molecules/test_grouping.py b/tests/unit/CodeEntropy/molecules/test_grouping.py new file mode 100644 index 00000000..49777e31 --- /dev/null +++ b/tests/unit/CodeEntropy/molecules/test_grouping.py @@ -0,0 +1,128 @@ +import logging +from unittest.mock import MagicMock + +import pytest + +from CodeEntropy.molecules.grouping import MoleculeGrouper + + +def _universe_with_fragments(fragments): + u = MagicMock() + u.atoms.fragments = fragments + return u + + +def _fragment(names): + f = MagicMock() + f.names = names + return f + + +def test_get_strategy_returns_each_callable(): + g = MoleculeGrouper() + fn = g._get_strategy("each") + assert callable(fn) + assert fn == g._group_each + + +def test_get_strategy_returns_molecules_callable(): + g = MoleculeGrouper() + fn = g._get_strategy("molecules") + assert callable(fn) + assert fn == g._group_by_signature + + +def test_get_strategy_raises_value_error_for_unknown(): + g = MoleculeGrouper() + with pytest.raises(ValueError, match="Unknown grouping strategy"): + g._get_strategy("nope") + + +def test_fragments_returns_universe_fragments(): + g = MoleculeGrouper() + frags = [MagicMock(), MagicMock()] + u = _universe_with_fragments(frags) + assert g._fragments(u) is frags + + +def test_num_molecules_counts_fragments_length(): + g = MoleculeGrouper() + u = _universe_with_fragments([MagicMock(), MagicMock(), MagicMock()]) + assert g._num_molecules(u) == 3 + + +def test_group_each_returns_one_group_per_molecule(): + g = MoleculeGrouper() + u = _universe_with_fragments([MagicMock(), MagicMock(), MagicMock()]) + assert g._group_each(u) == {0: [0], 1: [1], 2: [2]} + + +def test_signature_uses_atom_count_and_ordered_names(): + g = MoleculeGrouper() + frag = _fragment(["H", "O", "H"]) + assert g._signature(frag) == (3, ("H", "O", "H")) + + +def test_representative_id_first_seen_sets_rep_and_returns_candidate(): + g = MoleculeGrouper() + cache = {} + sig = (3, ("H", "O", "H")) + rep = g._representative_id(cache, sig, candidate_id=5) + assert rep == 5 + assert cache[sig] == 5 + + +def test_representative_id_returns_existing_rep_when_seen_before(): + g = MoleculeGrouper() + sig = (3, ("H", "O", "H")) + cache = {sig: 2} + rep = g._representative_id(cache, sig, candidate_id=9) + assert rep == 2 + assert cache[sig] == 2 + + +def test_group_by_signature_groups_identical_signatures_and_uses_first_id_as_group_id(): + g = MoleculeGrouper() + f0 = _fragment(["H", "O", "H"]) + f1 = _fragment(["H", "O", "H"]) # same signature as f0 + f2 = _fragment(["C", "C", "H", "H"]) # different signature + u = _universe_with_fragments([f0, f1, f2]) + + out = g._group_by_signature(u) + + assert out == {0: [0, 1], 2: [2]} + + +def test_group_by_signature_is_deterministic_for_first_seen_representative(): + g = MoleculeGrouper() + f0 = _fragment(["B"]) + f1 = _fragment(["A"]) + f2 = _fragment(["B"]) + u = _universe_with_fragments([f0, f1, f2]) + + out = g._group_by_signature(u) + + assert out[0] == [0, 2] + assert out[1] == [1] + + +def test_grouping_molecules_dispatches_each_and_logs_summary(caplog): + g = MoleculeGrouper() + u = _universe_with_fragments([MagicMock(), MagicMock()]) + + caplog.set_level(logging.INFO) + out = g.grouping_molecules(u, "each") + + assert out == {0: [0], 1: [1]} + assert any("Number of molecule groups" in rec.message for rec in caplog.records) + + +def test_grouping_molecules_dispatches_molecules_strategy(): + g = MoleculeGrouper() + f0 = _fragment(["H", "O", "H"]) + f1 = _fragment(["H", "O", "H"]) + u = _universe_with_fragments([f0, f1]) + + out = g.grouping_molecules(u, "molecules") + + assert out == {0: [0, 1]} From 02a18bb4ccca8df5bf4c46a41042487f4894cae1 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:05:44 +0000 Subject: [PATCH 073/101] test(core): add atomic pytest unit tests for the results module --- .../unit/CodeEntropy/results/test_reporter.py | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 tests/unit/CodeEntropy/results/test_reporter.py diff --git a/tests/unit/CodeEntropy/results/test_reporter.py b/tests/unit/CodeEntropy/results/test_reporter.py new file mode 100644 index 00000000..e63b6682 --- /dev/null +++ b/tests/unit/CodeEntropy/results/test_reporter.py @@ -0,0 +1,224 @@ +import json +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np + +import CodeEntropy.results.reporter as reporter +from CodeEntropy.results.reporter import ResultsReporter + + +class _FakeTable: + """Tiny Table stand-in: records columns and rows added.""" + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + self.columns = [] + self.rows = [] + + def add_column(self, *args, **kwargs): + self.columns.append((args, kwargs)) + + def add_row(self, *cells): + self.rows.append(cells) + + +def test_clean_residue_name_removes_dash_like_characters(): + assert ResultsReporter.clean_residue_name("A-LA") == "ALA" + assert ResultsReporter.clean_residue_name("A–LA") == "ALA" + assert ResultsReporter.clean_residue_name("A—LA") == "ALA" + assert ResultsReporter.clean_residue_name(123) == "123" + + +def test_add_results_data_appends_molecule_tuple(): + rr = ResultsReporter() + rr.add_results_data(group_id=7, level="residue", entropy_type="vib", value=1.23) + + assert rr.molecule_data == [(7, "residue", "vib", 1.23)] + + +def test_add_residue_data_cleans_resname_and_converts_ndarray_count_to_list(): + rr = ResultsReporter() + + rr.add_residue_data( + group_id=1, + resname="A-LA", + level="residue", + entropy_type="conf", + frame_count=np.array([1, 2, 3], dtype=int), + value=9.0, + ) + + assert rr.residue_data == [[1, "ALA", "residue", "conf", [1, 2, 3], 9.0]] + + +def test_add_residue_data_keeps_non_ndarray_count_as_is(): + rr = ResultsReporter() + + rr.add_residue_data( + group_id=2, + resname="GLY", + level="residue", + entropy_type="conf", + frame_count=5, + value=3.14, + ) + + assert rr.residue_data == [[2, "GLY", "residue", "conf", 5, 3.14]] + + +def test_add_group_label_stores_metadata_with_optionals(): + rr = ResultsReporter() + rr.add_group_label(group_id="G0", label="protein", residue_count=10, atom_count=100) + + assert rr.group_labels["G0"] == { + "label": "protein", + "residue_count": 10, + "atom_count": 100, + } + + +def test_save_dataframes_as_json_writes_expected_payload(tmp_path): + rr = ResultsReporter() + + molecule_df = MagicMock() + residue_df = MagicMock() + + molecule_df.to_dict.return_value = [{"a": 1}] + residue_df.to_dict.return_value = [{"b": 2}] + + out = tmp_path / "out.json" + rr.save_dataframes_as_json( + molecule_df=molecule_df, residue_df=residue_df, output_file=str(out) + ) + + data = json.loads(out.read_text()) + assert data == {"molecule_data": [{"a": 1}], "residue_data": [{"b": 2}]} + + molecule_df.to_dict.assert_called_once_with(orient="records") + residue_df.to_dict.assert_called_once_with(orient="records") + + +def test_log_tables_calls_each_internal_table_renderer(monkeypatch): + rr = ResultsReporter() + + mol_spy = MagicMock() + res_spy = MagicMock() + grp_spy = MagicMock() + + monkeypatch.setattr(rr, "_log_molecule_table", mol_spy) + monkeypatch.setattr(rr, "_log_residue_table", res_spy) + monkeypatch.setattr(rr, "_log_group_label_table", grp_spy) + + rr.log_tables() + + mol_spy.assert_called_once() + res_spy.assert_called_once() + grp_spy.assert_called_once() + + +def test_log_molecule_table_returns_early_when_no_data(monkeypatch): + rr = ResultsReporter() + + fake_console = SimpleNamespace(print=MagicMock()) + monkeypatch.setattr(reporter, "console", fake_console) + monkeypatch.setattr(reporter, "Table", _FakeTable) + + rr._log_molecule_table() + + fake_console.print.assert_not_called() + + +def test_log_residue_table_returns_early_when_no_data(monkeypatch): + rr = ResultsReporter() + + fake_console = SimpleNamespace(print=MagicMock()) + monkeypatch.setattr(reporter, "console", fake_console) + monkeypatch.setattr(reporter, "Table", _FakeTable) + + rr._log_residue_table() + + fake_console.print.assert_not_called() + + +def test_log_group_label_table_returns_early_when_no_labels(monkeypatch): + rr = ResultsReporter() + + fake_console = SimpleNamespace(print=MagicMock()) + monkeypatch.setattr(reporter, "console", fake_console) + monkeypatch.setattr(reporter, "Table", _FakeTable) + + rr._log_group_label_table() + + fake_console.print.assert_not_called() + + +def test_log_molecule_table_builds_rows_and_prints_table(monkeypatch): + rr = ResultsReporter() + rr.molecule_data = [ + (1, "residue", "conf", 1.0), + (2, "polymer", "vib", 2.0), + ] + + fake_console = SimpleNamespace(print=MagicMock()) + monkeypatch.setattr(reporter, "console", fake_console) + monkeypatch.setattr(reporter, "Table", _FakeTable) + + rr._log_molecule_table() + + fake_console.print.assert_called_once() + table = fake_console.print.call_args.args[0] + + assert isinstance(table, _FakeTable) + # 4 columns defined, 2 rows added + assert len(table.columns) == 4 + assert len(table.rows) == 2 + # cells were stringified + assert table.rows[0] == ("1", "residue", "conf", "1.0") + + +def test_log_residue_table_builds_rows_and_prints_table(monkeypatch): + rr = ResultsReporter() + rr.residue_data = [ + [1, "ALA", "residue", "conf", [1, 2], 9.0], + ] + + fake_console = SimpleNamespace(print=MagicMock()) + monkeypatch.setattr(reporter, "console", fake_console) + monkeypatch.setattr(reporter, "Table", _FakeTable) + + rr._log_residue_table() + + fake_console.print.assert_called_once() + table = fake_console.print.call_args.args[0] + + assert isinstance(table, _FakeTable) + # 6 columns defined, 1 row added + assert len(table.columns) == 6 + assert len(table.rows) == 1 + assert table.rows[0] == ("1", "ALA", "residue", "conf", "[1, 2]", "9.0") + + +def test_log_group_label_table_adds_rows_for_each_label_and_prints(monkeypatch): + rr = ResultsReporter() + rr.group_labels = { + 7: {"label": "protein", "residue_count": 10, "atom_count": 100}, + 8: {"label": "water", "residue_count": None, "atom_count": None}, + } + + fake_console = SimpleNamespace(print=MagicMock()) + monkeypatch.setattr(reporter, "console", fake_console) + monkeypatch.setattr(reporter, "Table", _FakeTable) + + rr._log_group_label_table() + + fake_console.print.assert_called_once() + table = fake_console.print.call_args.args[0] + + assert isinstance(table, _FakeTable) + assert len(table.columns) == 4 + assert len(table.rows) == 2 + + assert table.rows[0] == ("7", "protein", "10", "100") + assert table.rows[1] == ("8", "water", "None", "None") From de1715ac70fc8d2798762712df2e697431fbce6d Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:25:54 +0000 Subject: [PATCH 074/101] test(core): add atomic pytest unit tests for `cli.py` --- tests/unit/CodeEntropy/cli/test_cli.py | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/unit/CodeEntropy/cli/test_cli.py diff --git a/tests/unit/CodeEntropy/cli/test_cli.py b/tests/unit/CodeEntropy/cli/test_cli.py new file mode 100644 index 00000000..d69a20a1 --- /dev/null +++ b/tests/unit/CodeEntropy/cli/test_cli.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock + +import pytest + +import CodeEntropy.cli as entry + + +def test_main_creates_job_folder_and_runs_workflow(monkeypatch): + fake_runner_cls = MagicMock() + fake_runner_cls.create_job_folder.return_value = "/tmp/job" + + fake_runner = MagicMock() + fake_runner_cls.return_value = fake_runner + + monkeypatch.setattr(entry, "CodeEntropyRunner", fake_runner_cls) + + entry.main() + + fake_runner_cls.create_job_folder.assert_called_once_with() + fake_runner_cls.assert_called_once_with(folder="/tmp/job") + fake_runner.run_entropy_workflow.assert_called_once_with() + + +def test_main_logs_and_exits_nonzero_on_exception(monkeypatch): + fake_runner_cls = MagicMock() + fake_runner_cls.create_job_folder.return_value = "/tmp/job" + + fake_runner = MagicMock() + fake_runner.run_entropy_workflow.side_effect = RuntimeError("boom") + fake_runner_cls.return_value = fake_runner + + monkeypatch.setattr(entry, "CodeEntropyRunner", fake_runner_cls) + + critical_spy = MagicMock() + monkeypatch.setattr(entry.logger, "critical", critical_spy) + + with pytest.raises(SystemExit) as exc: + entry.main() + + assert exc.value.code == 1 + critical_spy.assert_called_once() + _, kwargs = critical_spy.call_args + assert kwargs.get("exc_info") is True From bbcedca870293a6e9884eba2fb44d737eed62d83 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:30:29 +0000 Subject: [PATCH 075/101] test(core): add atomic pytest unit tests for `__main__.py` --- tests/unit/CodeEntropy/cli/test___main__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 tests/unit/CodeEntropy/cli/test___main__.py diff --git a/tests/unit/CodeEntropy/cli/test___main__.py b/tests/unit/CodeEntropy/cli/test___main__.py new file mode 100644 index 00000000..ce8c3c29 --- /dev/null +++ b/tests/unit/CodeEntropy/cli/test___main__.py @@ -0,0 +1,13 @@ +import runpy +from unittest.mock import MagicMock + +import CodeEntropy.cli as cli + + +def test___main___invokes_cli_main(monkeypatch): + main_spy = MagicMock() + monkeypatch.setattr(cli, "main", main_spy) + + runpy.run_module("CodeEntropy", run_name="__main__") + + main_spy.assert_called_once_with() From a2361ad6a1534c08e463c3ea2034686d1855397d Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:33:49 +0000 Subject: [PATCH 076/101] ensure `job***` folders are not created within test execution --- tests/unit/CodeEntropy/cli/test_cli.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/unit/CodeEntropy/cli/test_cli.py b/tests/unit/CodeEntropy/cli/test_cli.py index d69a20a1..2df420cc 100644 --- a/tests/unit/CodeEntropy/cli/test_cli.py +++ b/tests/unit/CodeEntropy/cli/test_cli.py @@ -31,13 +31,8 @@ def test_main_logs_and_exits_nonzero_on_exception(monkeypatch): monkeypatch.setattr(entry, "CodeEntropyRunner", fake_runner_cls) - critical_spy = MagicMock() - monkeypatch.setattr(entry.logger, "critical", critical_spy) - with pytest.raises(SystemExit) as exc: entry.main() assert exc.value.code == 1 - critical_spy.assert_called_once() - _, kwargs = critical_spy.call_args - assert kwargs.get("exc_info") is True + fake_runner.run_entropy_workflow.assert_called_once_with() From 948119387e8ef7b367602ecc1a781407d663d8b8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:40:54 +0000 Subject: [PATCH 077/101] add polymer branch test cases for `test_frame_covariance_node.py` --- .../nodes/test_frame_covariance_node.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py b/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py index 79e54c30..1b51006b 100644 --- a/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py +++ b/tests/unit/CodeEntropy/levels/nodes/test_frame_covariance_node.py @@ -528,3 +528,107 @@ def test_process_residue_returns_early_when_any_bead_group_is_empty(): assert out_force["res"] == {} assert out_torque["res"] == {} + + +def test_process_polymer_skips_when_any_bead_group_is_empty(): + node = FrameCovarianceNode() + + u = MagicMock() + u.atoms = MagicMock() + u.atoms.__getitem__.side_effect = lambda idx: _EmptyGroup() + + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + out_ft = {"ua": {}, "res": {}, "poly": {}} + + node._process_polymer( + u=u, + mol=MagicMock(), + mol_id=0, + group_id=7, + beads={(0, "polymer"): [np.array([1, 2, 3])]}, + axes_manager=MagicMock(), + box=np.array([10.0, 10.0, 10.0]), + force_partitioning=1.0, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + out_ft=out_ft, + molcount={}, + combined=True, + ) + + assert out_force["poly"] == {} + assert out_torque["poly"] == {} + assert out_ft["poly"] == {} + + +def test_process_polymer_happy_path_updates_force_torque_and_optional_ft(): + node = FrameCovarianceNode() + + u = MagicMock() + u.atoms = MagicMock() + + bead_obj = _BeadGroup(1) + u.atoms.__getitem__.side_effect = lambda idx: bead_obj + + mol = MagicMock() + mol.atoms = MagicMock() + + axes_manager = MagicMock() + + f_vec = np.array([1.0, 0.0, 0.0], dtype=float) + t_vec = np.array([0.0, 1.0, 0.0], dtype=float) + + F = np.eye(3) + T = 2.0 * np.eye(3) + FT = np.eye(6) + + out_force = {"ua": {}, "res": {}, "poly": {}} + out_torque = {"ua": {}, "res": {}, "poly": {}} + out_ft = {"ua": {}, "res": {}, "poly": {}} + molcount = {} + + with ( + patch.object( + node, + "_get_polymer_axes", + return_value=(np.eye(3), np.eye(3), np.zeros(3), np.ones(3)), + ) as axes_spy, + patch.object(node._ft, "get_weighted_forces", return_value=f_vec) as f_spy, + patch.object(node._ft, "get_weighted_torques", return_value=t_vec) as t_spy, + patch.object( + node._ft, "compute_frame_covariance", return_value=(F, T) + ) as cov_spy, + patch.object(node, "_build_ft_block", return_value=FT) as ft_spy, + ): + node._process_polymer( + u=u, + mol=mol, + mol_id=0, + group_id=7, + beads={(0, "polymer"): [np.array([1, 2, 3])]}, + axes_manager=axes_manager, + box=np.array([10.0, 10.0, 10.0]), + force_partitioning=0.5, + is_highest=True, + out_force=out_force, + out_torque=out_torque, + out_ft=out_ft, + molcount=molcount, + combined=True, + ) + + assert u.atoms.__getitem__.call_count == 1 + axes_spy.assert_called_once_with(mol=mol, bead=bead_obj, axes_manager=axes_manager) + + f_spy.assert_called_once() + t_spy.assert_called_once() + cov_spy.assert_called_once() + + np.testing.assert_allclose(out_force["poly"][7], F) + np.testing.assert_allclose(out_torque["poly"][7], T) + assert molcount[7] == 1 + + ft_spy.assert_called_once() + np.testing.assert_allclose(out_ft["poly"][7], FT) From 4142f1f315c644082d9839f5249e7aa1ae1084fe Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:52:25 +0000 Subject: [PATCH 078/101] tidy tests within `CodeEntropy/tests/unit/entropy` --- .../{ => nodes}/test_aggregate_node.py | 0 .../test_configurational_node.py} | 25 + .../test_vibrational_node.py} | 192 ++++++++ .../entropy/test_configurational_node.py | 27 -- .../test_vibrational_node_missing_branches.py | 109 ----- .../test_vibrational_node_more_branches.py | 96 ---- .../unit/CodeEntropy/entropy/test_workflow.py | 426 ++++++++++++++++++ .../entropy/test_workflow_atomic_branches.py | 105 ----- .../entropy/test_workflow_helpers.py | 81 ---- .../entropy/test_workflow_more_branches.py | 185 -------- .../test_workflow_remaining_branches.py | 67 --- 11 files changed, 643 insertions(+), 670 deletions(-) rename tests/unit/CodeEntropy/entropy/{ => nodes}/test_aggregate_node.py (100%) rename tests/unit/CodeEntropy/entropy/{test_configurational_node_missing_branches.py => nodes/test_configurational_node.py} (69%) rename tests/unit/CodeEntropy/entropy/{test_vibrational_node_branches.py => nodes/test_vibrational_node.py} (50%) delete mode 100644 tests/unit/CodeEntropy/entropy/test_configurational_node.py delete mode 100644 tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py delete mode 100644 tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py create mode 100644 tests/unit/CodeEntropy/entropy/test_workflow.py delete mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py delete mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_helpers.py delete mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py delete mode 100644 tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py diff --git a/tests/unit/CodeEntropy/entropy/test_aggregate_node.py b/tests/unit/CodeEntropy/entropy/nodes/test_aggregate_node.py similarity index 100% rename from tests/unit/CodeEntropy/entropy/test_aggregate_node.py rename to tests/unit/CodeEntropy/entropy/nodes/test_aggregate_node.py diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py b/tests/unit/CodeEntropy/entropy/nodes/test_configurational_node.py similarity index 69% rename from tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py rename to tests/unit/CodeEntropy/entropy/nodes/test_configurational_node.py index b8aa4a79..b31cff3c 100644 --- a/tests/unit/CodeEntropy/entropy/test_configurational_node_missing_branches.py +++ b/tests/unit/CodeEntropy/entropy/nodes/test_configurational_node.py @@ -1,10 +1,35 @@ from unittest.mock import MagicMock import numpy as np +import pytest from CodeEntropy.entropy.nodes.configurational import ConfigurationalEntropyNode +def test_config_node_raises_if_frame_count_missing(): + node = ConfigurationalEntropyNode() + with pytest.raises(KeyError): + node._get_n_frames({}) + + +def test_config_node_run_writes_results(shared_data): + node = ConfigurationalEntropyNode() + + shared_data["conformational_states"] = { + "ua": {(0, 0): [0, 0, 1, 1]}, + "res": {0: [0, 1, 1, 1]}, + } + + shared_data["levels"] = {0: ["united_atom", "residue"]} + shared_data["groups"] = {0: [0]} + + out = node.run(shared_data) + + assert "configurational_entropy" in out + assert "configurational_entropy" in shared_data + assert 0 in shared_data["configurational_entropy"] + + def test_run_skips_empty_mol_ids_group(): node = ConfigurationalEntropyNode() diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py b/tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py similarity index 50% rename from tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py rename to tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py index 1e32d344..8165e41b 100644 --- a/tests/unit/CodeEntropy/entropy/test_vibrational_node_branches.py +++ b/tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py @@ -27,6 +27,26 @@ def shared_data_base(): } +@pytest.fixture() +def shared_groups(): + frag = MagicMock() + frag.residues = [MagicMock(resname="RES")] + ru = MagicMock() + ru.atoms.fragments = [frag] + + return { + "run_manager": MagicMock(), + "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), + "groups": {5: [0]}, + "levels": {0: ["united_atom"]}, + "reduced_universe": ru, + "force_covariances": {"ua": {}, "res": [], "poly": []}, + "torque_covariances": {"ua": {}, "res": [], "poly": []}, + "n_frames": 5, + "reporter": MagicMock(), + } + + def test_united_atom_branch_logs_and_stores(shared_data_base, monkeypatch): node = VibrationalEntropyNode() @@ -172,3 +192,175 @@ def test_polymer_branch_executes(shared_data, monkeypatch): assert "vibrational_entropy" in out assert out["vibrational_entropy"][0]["polymer"]["trans"] == 1.0 assert out["vibrational_entropy"][0]["polymer"]["rot"] == 1.0 + + +def test_run_skips_empty_mol_ids_group(): + node = VibrationalEntropyNode() + + shared_groups = { + "run_manager": MagicMock(), + "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), + "groups": {0: []}, + "levels": {0: ["united_atom"]}, + "reduced_universe": MagicMock(atoms=MagicMock(fragments=[])), + "force_covariances": {"ua": {}, "res": [], "poly": []}, + "torque_covariances": {"ua": {}, "res": [], "poly": []}, + "n_frames": 5, + "reporter": None, + } + + out = node.run(shared_groups) + assert "vibrational_entropy" in out + assert out["vibrational_entropy"][0] == {} + + +def test_get_ua_frame_counts_falls_back_to_empty_when_shape_wrong(): + node = VibrationalEntropyNode() + assert node._get_ua_frame_counts({"frame_counts": "not-a-dict"}) == {} + + +def test_compute_united_atom_entropy_logs_residue_data_when_reporter_present(): + node = VibrationalEntropyNode() + ve = MagicMock() + + node._compute_force_torque_entropy = MagicMock(return_value=EntropyPair(1.0, 2.0)) + + reporter = MagicMock() + residues = [SimpleNamespace(resname="A"), SimpleNamespace(resname="B")] + + out = node._compute_united_atom_entropy( + ve=ve, + temp=298.0, + group_id=7, + residues=residues, + force_ua={}, + torque_ua={}, + ua_frame_counts={(7, 0): 3, (7, 1): 4}, + reporter=reporter, + n_frames_default=10, + highest=True, + ) + + assert out == EntropyPair(trans=2.0, rot=4.0) + assert reporter.add_residue_data.call_count == 4 + + +def test_compute_force_torque_entropy_success_calls_vibrational_engine(): + node = VibrationalEntropyNode() + ve = MagicMock() + ve.vibrational_entropy_calculation.side_effect = [10.0, 20.0] + + out = node._compute_force_torque_entropy( + ve=ve, + temp=298.0, + fmat=np.eye(3), + tmat=np.eye(3), + highest=False, + ) + + assert out == EntropyPair(trans=10.0, rot=20.0) + assert ve.vibrational_entropy_calculation.call_count == 2 + + +def test_compute_ft_entropy_success_calls_vibrational_engine_for_trans_and_rot(): + node = VibrationalEntropyNode() + ve = MagicMock() + ve.vibrational_entropy_calculation.side_effect = [1.5, 2.5] + + out = node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=np.eye(6)) + + assert out == EntropyPair(trans=1.5, rot=2.5) + assert ve.vibrational_entropy_calculation.call_count == 2 + + +def test_log_molecule_level_results_returns_when_reporter_none(): + VibrationalEntropyNode._log_molecule_level_results( + reporter=None, + group_id=1, + level="residue", + pair=EntropyPair(1.0, 2.0), + use_ft_labels=False, + ) + + +def test_log_molecule_level_results_writes_trans_and_rot_labels(): + reporter = MagicMock() + VibrationalEntropyNode._log_molecule_level_results( + reporter=reporter, + group_id=1, + level="residue", + pair=EntropyPair(3.0, 4.0), + use_ft_labels=False, + ) + + reporter.add_results_data.assert_any_call(1, "residue", "Transvibrational", 3.0) + reporter.add_results_data.assert_any_call(1, "residue", "Rovibrational", 4.0) + + +def test_get_group_id_to_index_builds_from_groups(shared_groups): + node = VibrationalEntropyNode() + gid2i = node._get_group_id_to_index(shared_groups) + assert gid2i == {5: 0} + + +def test_get_ua_frame_counts_returns_empty_when_missing(shared_groups): + node = VibrationalEntropyNode() + assert node._get_ua_frame_counts(shared_groups) == {} + + +def test_compute_force_torque_entropy_returns_zero_when_missing_matrix(shared_groups): + node = VibrationalEntropyNode() + ve = MagicMock() + pair = node._compute_force_torque_entropy( + ve=ve, temp=298.0, fmat=None, tmat=np.eye(3), highest=True + ) + assert pair == EntropyPair(trans=0.0, rot=0.0) + + +def test_compute_force_torque_entropy_returns_zero_when_filter_removes_all(monkeypatch): + node = VibrationalEntropyNode() + ve = MagicMock() + + monkeypatch.setattr( + node._mat_ops, "filter_zero_rows_columns", lambda a, atol: np.array([]) + ) + + pair = node._compute_force_torque_entropy( + ve=ve, temp=298.0, fmat=np.eye(3), tmat=np.eye(3), highest=True + ) + assert pair == EntropyPair(trans=0.0, rot=0.0) + + +def test_compute_ft_entropy_returns_zero_when_none(): + node = VibrationalEntropyNode() + ve = MagicMock() + assert node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=None) == EntropyPair( + trans=0.0, rot=0.0 + ) + + +def test_log_molecule_level_results_ft_labels_branch(): + node = VibrationalEntropyNode() + reporter = MagicMock() + + node._log_molecule_level_results( + reporter, 1, "residue", EntropyPair(1.0, 2.0), use_ft_labels=True + ) + + reporter.add_results_data.assert_any_call( + 1, "residue", "FTmat-Transvibrational", 1.0 + ) + reporter.add_results_data.assert_any_call(1, "residue", "FTmat-Rovibrational", 2.0) + + +def test_get_indexed_matrix_out_of_range_returns_none(): + node = VibrationalEntropyNode() + assert node._get_indexed_matrix([np.eye(3)], 5) is None + + +def test_run_unknown_level_raises(shared_groups): + node = VibrationalEntropyNode() + shared_groups["levels"] = {0: ["nope"]} + + with pytest.raises(ValueError): + node.run(shared_groups) diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_node.py b/tests/unit/CodeEntropy/entropy/test_configurational_node.py deleted file mode 100644 index f8a19b2e..00000000 --- a/tests/unit/CodeEntropy/entropy/test_configurational_node.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from CodeEntropy.entropy.nodes.configurational import ConfigurationalEntropyNode - - -def test_config_node_raises_if_frame_count_missing(): - node = ConfigurationalEntropyNode() - with pytest.raises(KeyError): - node._get_n_frames({}) - - -def test_config_node_run_writes_results(shared_data): - node = ConfigurationalEntropyNode() - - shared_data["conformational_states"] = { - "ua": {(0, 0): [0, 0, 1, 1]}, - "res": {0: [0, 1, 1, 1]}, - } - - shared_data["levels"] = {0: ["united_atom", "residue"]} - shared_data["groups"] = {0: [0]} - - out = node.run(shared_data) - - assert "configurational_entropy" in out - assert "configurational_entropy" in shared_data - assert 0 in shared_data["configurational_entropy"] diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py b/tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py deleted file mode 100644 index 4d5f98bb..00000000 --- a/tests/unit/CodeEntropy/entropy/test_vibrational_node_missing_branches.py +++ /dev/null @@ -1,109 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock - -import numpy as np - -from CodeEntropy.entropy.nodes.vibrational import EntropyPair, VibrationalEntropyNode - - -def test_run_skips_empty_mol_ids_group(): - node = VibrationalEntropyNode() - - shared = { - "run_manager": MagicMock(), - "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), - "groups": {0: []}, - "levels": {0: ["united_atom"]}, - "reduced_universe": MagicMock(atoms=MagicMock(fragments=[])), - "force_covariances": {"ua": {}, "res": [], "poly": []}, - "torque_covariances": {"ua": {}, "res": [], "poly": []}, - "n_frames": 5, - "reporter": None, - } - - out = node.run(shared) - assert "vibrational_entropy" in out - assert out["vibrational_entropy"][0] == {} - - -def test_get_ua_frame_counts_falls_back_to_empty_when_shape_wrong(): - node = VibrationalEntropyNode() - assert node._get_ua_frame_counts({"frame_counts": "not-a-dict"}) == {} - - -def test_compute_united_atom_entropy_logs_residue_data_when_reporter_present(): - node = VibrationalEntropyNode() - ve = MagicMock() - - node._compute_force_torque_entropy = MagicMock(return_value=EntropyPair(1.0, 2.0)) - - reporter = MagicMock() - residues = [SimpleNamespace(resname="A"), SimpleNamespace(resname="B")] - - out = node._compute_united_atom_entropy( - ve=ve, - temp=298.0, - group_id=7, - residues=residues, - force_ua={}, - torque_ua={}, - ua_frame_counts={(7, 0): 3, (7, 1): 4}, - reporter=reporter, - n_frames_default=10, - highest=True, - ) - - assert out == EntropyPair(trans=2.0, rot=4.0) - assert reporter.add_residue_data.call_count == 4 - - -def test_compute_force_torque_entropy_success_calls_vibrational_engine(): - node = VibrationalEntropyNode() - ve = MagicMock() - ve.vibrational_entropy_calculation.side_effect = [10.0, 20.0] - - out = node._compute_force_torque_entropy( - ve=ve, - temp=298.0, - fmat=np.eye(3), - tmat=np.eye(3), - highest=False, - ) - - assert out == EntropyPair(trans=10.0, rot=20.0) - assert ve.vibrational_entropy_calculation.call_count == 2 - - -def test_compute_ft_entropy_success_calls_vibrational_engine_for_trans_and_rot(): - node = VibrationalEntropyNode() - ve = MagicMock() - ve.vibrational_entropy_calculation.side_effect = [1.5, 2.5] - - out = node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=np.eye(6)) - - assert out == EntropyPair(trans=1.5, rot=2.5) - assert ve.vibrational_entropy_calculation.call_count == 2 - - -def test_log_molecule_level_results_returns_when_reporter_none(): - VibrationalEntropyNode._log_molecule_level_results( - reporter=None, - group_id=1, - level="residue", - pair=EntropyPair(1.0, 2.0), - use_ft_labels=False, - ) - - -def test_log_molecule_level_results_writes_trans_and_rot_labels(): - reporter = MagicMock() - VibrationalEntropyNode._log_molecule_level_results( - reporter=reporter, - group_id=1, - level="residue", - pair=EntropyPair(3.0, 4.0), - use_ft_labels=False, - ) - - reporter.add_results_data.assert_any_call(1, "residue", "Transvibrational", 3.0) - reporter.add_results_data.assert_any_call(1, "residue", "Rovibrational", 4.0) diff --git a/tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py b/tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py deleted file mode 100644 index 0e43751d..00000000 --- a/tests/unit/CodeEntropy/entropy/test_vibrational_node_more_branches.py +++ /dev/null @@ -1,96 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock - -import numpy as np -import pytest - -from CodeEntropy.entropy.nodes.vibrational import EntropyPair, VibrationalEntropyNode - - -@pytest.fixture() -def shared(): - frag = MagicMock() - frag.residues = [MagicMock(resname="RES")] - ru = MagicMock() - ru.atoms.fragments = [frag] - - return { - "run_manager": MagicMock(), - "args": SimpleNamespace(temperature=298.0, combined_forcetorque=False), - "groups": {5: [0]}, - "levels": {0: ["united_atom"]}, - "reduced_universe": ru, - "force_covariances": {"ua": {}, "res": [], "poly": []}, - "torque_covariances": {"ua": {}, "res": [], "poly": []}, - "n_frames": 5, - "reporter": MagicMock(), - } - - -def test_get_group_id_to_index_builds_from_groups(shared): - node = VibrationalEntropyNode() - gid2i = node._get_group_id_to_index(shared) - assert gid2i == {5: 0} - - -def test_get_ua_frame_counts_returns_empty_when_missing(shared): - node = VibrationalEntropyNode() - assert node._get_ua_frame_counts(shared) == {} - - -def test_compute_force_torque_entropy_returns_zero_when_missing_matrix(shared): - node = VibrationalEntropyNode() - ve = MagicMock() - pair = node._compute_force_torque_entropy( - ve=ve, temp=298.0, fmat=None, tmat=np.eye(3), highest=True - ) - assert pair == EntropyPair(trans=0.0, rot=0.0) - - -def test_compute_force_torque_entropy_returns_zero_when_filter_removes_all(monkeypatch): - node = VibrationalEntropyNode() - ve = MagicMock() - - monkeypatch.setattr( - node._mat_ops, "filter_zero_rows_columns", lambda a, atol: np.array([]) - ) - - pair = node._compute_force_torque_entropy( - ve=ve, temp=298.0, fmat=np.eye(3), tmat=np.eye(3), highest=True - ) - assert pair == EntropyPair(trans=0.0, rot=0.0) - - -def test_compute_ft_entropy_returns_zero_when_none(): - node = VibrationalEntropyNode() - ve = MagicMock() - assert node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=None) == EntropyPair( - trans=0.0, rot=0.0 - ) - - -def test_log_molecule_level_results_ft_labels_branch(): - node = VibrationalEntropyNode() - reporter = MagicMock() - - node._log_molecule_level_results( - reporter, 1, "residue", EntropyPair(1.0, 2.0), use_ft_labels=True - ) - - reporter.add_results_data.assert_any_call( - 1, "residue", "FTmat-Transvibrational", 1.0 - ) - reporter.add_results_data.assert_any_call(1, "residue", "FTmat-Rovibrational", 2.0) - - -def test_get_indexed_matrix_out_of_range_returns_none(): - node = VibrationalEntropyNode() - assert node._get_indexed_matrix([np.eye(3)], 5) is None - - -def test_run_unknown_level_raises(shared): - node = VibrationalEntropyNode() - shared["levels"] = {0: ["nope"]} - - with pytest.raises(ValueError): - node.run(shared) diff --git a/tests/unit/CodeEntropy/entropy/test_workflow.py b/tests/unit/CodeEntropy/entropy/test_workflow.py new file mode 100644 index 00000000..29b93cc6 --- /dev/null +++ b/tests/unit/CodeEntropy/entropy/test_workflow.py @@ -0,0 +1,426 @@ +import logging +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from CodeEntropy.entropy.workflow import EntropyWorkflow + + +def _make_wf(args): + return EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=MagicMock(molecule_data=[], residue_data=[]), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + +def test_execute_calls_level_dag_and_entropy_graph_and_logs_tables(): + args = SimpleNamespace( + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=False, + selection_string="all", + ) + + universe = MagicMock() + universe.trajectory = list(range(5)) + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=universe, + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + wf._build_reduced_universe = MagicMock(return_value=MagicMock()) + wf._detect_levels = MagicMock(return_value={0: ["united_atom"]}) + wf._split_water_groups = MagicMock(return_value=({0: [0]}, {})) + wf._finalize_molecule_results = MagicMock() + + wf._group_molecules.grouping_molecules.return_value = {0: [0]} + + with ( + patch("CodeEntropy.entropy.workflow.LevelDAG") as LevelDAGCls, + patch("CodeEntropy.entropy.workflow.EntropyGraph") as GraphCls, + ): + LevelDAGCls.return_value.build.return_value.execute.return_value = None + GraphCls.return_value.build.return_value.execute.return_value = {"x": 1} + + wf.execute() + + wf._reporter.log_tables.assert_called_once() + + +def test_execute_water_entropy_branch_calls_water_entropy_solver(): + args = SimpleNamespace( + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=True, + selection_string="all", + output_file="out.json", + ) + + universe = MagicMock() + universe.trajectory = list(range(5)) + + reporter = MagicMock() + reporter.molecule_data = [] + reporter.residue_data = [] + reporter.save_dataframes_as_json = MagicMock() + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=universe, + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + wf._build_reduced_universe = MagicMock(return_value=MagicMock()) + wf._detect_levels = MagicMock(return_value={0: ["united_atom"]}) + + wf._split_water_groups = MagicMock(return_value=({0: [0]}, {9: [1, 2]})) + wf._finalize_molecule_results = MagicMock() + + wf._group_molecules.grouping_molecules.return_value = {0: [0], 9: [1, 2]} + + with ( + patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls, + patch("CodeEntropy.entropy.workflow.LevelDAG") as LevelDAGCls, + patch("CodeEntropy.entropy.workflow.EntropyGraph") as GraphCls, + ): + water_instance = WaterCls.return_value + water_instance._calculate_water_entropy = MagicMock() + + LevelDAGCls.return_value.build.return_value.execute.return_value = None + GraphCls.return_value.build.return_value.execute.return_value = {} + + wf.execute() + + water_instance._calculate_water_entropy.assert_called_once() + _, kwargs = water_instance._calculate_water_entropy.call_args + assert kwargs["universe"] is universe + assert kwargs["start"] == 0 + assert kwargs["end"] == 5 + assert kwargs["step"] == 1 + assert kwargs["group_id"] == 9 + + +def test_get_trajectory_bounds_end_minus_one_uses_trajectory_length(): + args = SimpleNamespace( + start=0, + end=-1, + step=2, + grouping="molecules", + water_entropy=False, + selection_string="all", + ) + universe = SimpleNamespace(trajectory=list(range(10))) + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=universe, + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + start, end, step = wf._get_trajectory_bounds() + assert (start, end, step) == (0, 10, 2) + + +def test_get_number_frames_matches_python_slice_math(): + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=MagicMock(), + universe=MagicMock(), + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + assert wf._get_number_frames(0, 10, 1) == 10 + assert wf._get_number_frames(0, 10, 2) == 5 + + +def test_finalize_results_called_even_if_empty(): + args = SimpleNamespace(output_file="out.json") + reporter = MagicMock() + reporter.molecule_data = [] + reporter.residue_data = [] + reporter.save_dataframes_as_json = MagicMock() + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + wf._finalize_molecule_results() + + reporter.save_dataframes_as_json.assert_called_once() + + +def test_split_water_groups_returns_empty_when_none(): + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=MagicMock(water_entropy=False), + universe=MagicMock(), + reporter=MagicMock(), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + groups, water = wf._split_water_groups({0: [1, 2]}) + + assert water == {} + + +def test_build_reduced_universe_non_all_selects_and_writes_universe(): + args = SimpleNamespace( + selection_string="protein", + grouping="molecules", + start=0, + end=-1, + step=1, + water_entropy=False, + output_file="out.json", + ) + universe = MagicMock() + universe.trajectory = list(range(3)) + + reduced = MagicMock() + reduced.trajectory = list(range(2)) + + uops = MagicMock() + uops.select_atoms.return_value = reduced + + run_manager = MagicMock() + reporter = MagicMock() + + wf = EntropyWorkflow( + run_manager=run_manager, + args=args, + universe=universe, + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=uops, + ) + + out = wf._build_reduced_universe() + + assert out is reduced + uops.select_atoms.assert_called_once_with(universe, "protein") + run_manager.write_universe.assert_called_once() + + +def test_compute_water_entropy_updates_selection_string_and_calls_internal_method(): + args = SimpleNamespace( + selection_string="all", water_entropy=True, temperature=298.0 + ) + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=MagicMock(molecule_data=[], residue_data=[]), + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + traj = SimpleNamespace(start=0, end=5, step=1) + water_groups = {9: [1, 2]} + + with patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls: + inst = WaterCls.return_value + inst._calculate_water_entropy = MagicMock() + + wf._compute_water_entropy(traj, water_groups) + + inst._calculate_water_entropy.assert_called_once() + assert wf._args.selection_string == "not water" + + +def test_finalize_molecule_results_skips_invalid_entries_with_warning(caplog): + args = SimpleNamespace(output_file="out.json") + reporter = MagicMock() + + reporter.molecule_data = [(1, "united_atom", "Trans", "not-a-number")] + reporter.residue_data = [] + reporter.save_dataframes_as_json = MagicMock() + + wf = EntropyWorkflow( + run_manager=MagicMock(), + args=args, + universe=MagicMock(), + reporter=reporter, + group_molecules=MagicMock(), + dihedral_analysis=MagicMock(), + universe_operations=MagicMock(), + ) + + caplog.set_level(logging.WARNING) + wf._finalize_molecule_results() + + assert any("Skipping invalid entry" in r.message for r in caplog.records) + reporter.save_dataframes_as_json.assert_called_once() + + +def test_build_reduced_universe_all_returns_original_universe(): + args = SimpleNamespace( + selection_string="all", + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=False, + output_file="out.json", + ) + universe = MagicMock() + uops = MagicMock() + run_manager = MagicMock() + wf = EntropyWorkflow( + run_manager, args, universe, MagicMock(), MagicMock(), MagicMock(), uops + ) + + out = wf._build_reduced_universe() + + assert out is universe + uops.select_atoms.assert_not_called() + run_manager.write_universe.assert_not_called() + + +def test_split_water_groups_partitions_correctly(): + args = SimpleNamespace( + start=0, + end=-1, + step=1, + grouping="molecules", + water_entropy=False, + selection_string="all", + output_file="out.json", + ) + universe = MagicMock() + + water_res = MagicMock() + water_res.resid = 10 + water_atoms = MagicMock() + water_atoms.residues = [water_res] + universe.select_atoms.return_value = water_atoms + + frag0 = MagicMock() + r0 = MagicMock() + r0.resid = 10 + frag0.residues = [r0] + + frag1 = MagicMock() + r1 = MagicMock() + r1.resid = 99 + frag1.residues = [r1] + + universe.atoms.fragments = [frag0, frag1] + + wf = EntropyWorkflow( + MagicMock(), args, universe, MagicMock(), MagicMock(), MagicMock(), MagicMock() + ) + + groups = {0: [0], 1: [1]} + nonwater, water = wf._split_water_groups(groups) + + assert 0 in water + assert 1 in nonwater + + +def test_compute_water_entropy_instantiates_waterentropy_and_updates_selection_string(): + args = SimpleNamespace( + selection_string="all", water_entropy=True, temperature=298.0 + ) + universe = MagicMock() + reporter = MagicMock() + wf = EntropyWorkflow( + MagicMock(), args, universe, reporter, MagicMock(), MagicMock(), MagicMock() + ) + + traj = SimpleNamespace(start=0, end=5, step=1, n_frames=5) + water_groups = {9: [0]} + + with patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls: + inst = WaterCls.return_value + inst._calculate_water_entropy = MagicMock() + + wf._compute_water_entropy(traj, water_groups) + + WaterCls.assert_called_once_with(args) + inst._calculate_water_entropy.assert_called_once() + assert wf._args.selection_string == "not water" + + +def test_detect_levels_calls_hierarchy_builder(): + args = SimpleNamespace( + selection_string="all", water_entropy=False, output_file="out.json" + ) + wf = _make_wf(args) + + with patch("CodeEntropy.entropy.workflow.HierarchyBuilder") as HB: + HB.return_value.select_levels.return_value = (123, {"levels": "ok"}) + + out = wf._detect_levels(reduced_universe=MagicMock()) + + assert out == {"levels": "ok"} + HB.return_value.select_levels.assert_called_once() + + +def test_compute_water_entropy_returns_early_when_disabled_or_empty_groups(): + args = SimpleNamespace( + selection_string="all", + water_entropy=False, + temperature=298.0, + output_file="out.json", + ) + wf = _make_wf(args) + + traj = SimpleNamespace(start=0, end=5, step=1, n_frames=5) + + # empty water groups OR water_entropy disabled -> early return + wf._compute_water_entropy(traj, water_groups={}) + # no exception and no side effects expected + + +def test_finalize_molecule_results_skips_group_total_rows(): + args = SimpleNamespace( + output_file="out.json", selection_string="all", water_entropy=False + ) + wf = _make_wf(args) + + wf._reporter.molecule_data = [ + (1, "Group Total", "Group Total Entropy", 999.0), # should be skipped + (1, "united_atom", "Transvibrational", 1.5), # should count + ] + wf._reporter.residue_data = [] + + wf._finalize_molecule_results() + + # should append a new "Group Total" row based only on the non-total entries + assert any( + row[1] == "Group Total" and row[3] == 1.5 for row in wf._reporter.molecule_data + ) diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py b/tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py deleted file mode 100644 index 83b19557..00000000 --- a/tests/unit/CodeEntropy/entropy/test_workflow_atomic_branches.py +++ /dev/null @@ -1,105 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from CodeEntropy.entropy.workflow import EntropyWorkflow - - -def test_execute_calls_level_dag_and_entropy_graph_and_logs_tables(): - args = SimpleNamespace( - start=0, - end=-1, - step=1, - grouping="molecules", - water_entropy=False, - selection_string="all", - ) - - universe = MagicMock() - universe.trajectory = list(range(5)) - - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=universe, - reporter=MagicMock(), - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - wf._build_reduced_universe = MagicMock(return_value=MagicMock()) - wf._detect_levels = MagicMock(return_value={0: ["united_atom"]}) - wf._split_water_groups = MagicMock(return_value=({0: [0]}, {})) - wf._finalize_molecule_results = MagicMock() - - wf._group_molecules.grouping_molecules.return_value = {0: [0]} - - with ( - patch("CodeEntropy.entropy.workflow.LevelDAG") as LevelDAGCls, - patch("CodeEntropy.entropy.workflow.EntropyGraph") as GraphCls, - ): - LevelDAGCls.return_value.build.return_value.execute.return_value = None - GraphCls.return_value.build.return_value.execute.return_value = {"x": 1} - - wf.execute() - - wf._reporter.log_tables.assert_called_once() - - -def test_execute_water_entropy_branch_calls_water_entropy_solver(): - args = SimpleNamespace( - start=0, - end=-1, - step=1, - grouping="molecules", - water_entropy=True, - selection_string="all", - output_file="out.json", - ) - - universe = MagicMock() - universe.trajectory = list(range(5)) - - reporter = MagicMock() - reporter.molecule_data = [] - reporter.residue_data = [] - reporter.save_dataframes_as_json = MagicMock() - - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=universe, - reporter=reporter, - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - wf._build_reduced_universe = MagicMock(return_value=MagicMock()) - wf._detect_levels = MagicMock(return_value={0: ["united_atom"]}) - - wf._split_water_groups = MagicMock(return_value=({0: [0]}, {9: [1, 2]})) - wf._finalize_molecule_results = MagicMock() - - wf._group_molecules.grouping_molecules.return_value = {0: [0], 9: [1, 2]} - - with ( - patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls, - patch("CodeEntropy.entropy.workflow.LevelDAG") as LevelDAGCls, - patch("CodeEntropy.entropy.workflow.EntropyGraph") as GraphCls, - ): - water_instance = WaterCls.return_value - water_instance._calculate_water_entropy = MagicMock() - - LevelDAGCls.return_value.build.return_value.execute.return_value = None - GraphCls.return_value.build.return_value.execute.return_value = {} - - wf.execute() - - water_instance._calculate_water_entropy.assert_called_once() - _, kwargs = water_instance._calculate_water_entropy.call_args - assert kwargs["universe"] is universe - assert kwargs["start"] == 0 - assert kwargs["end"] == 5 - assert kwargs["step"] == 1 - assert kwargs["group_id"] == 9 diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_helpers.py b/tests/unit/CodeEntropy/entropy/test_workflow_helpers.py deleted file mode 100644 index 6041de71..00000000 --- a/tests/unit/CodeEntropy/entropy/test_workflow_helpers.py +++ /dev/null @@ -1,81 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock - -from CodeEntropy.entropy.workflow import EntropyWorkflow - - -def test_get_trajectory_bounds_end_minus_one_uses_trajectory_length(): - args = SimpleNamespace( - start=0, - end=-1, - step=2, - grouping="molecules", - water_entropy=False, - selection_string="all", - ) - universe = SimpleNamespace(trajectory=list(range(10))) - - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=universe, - reporter=MagicMock(), - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - start, end, step = wf._get_trajectory_bounds() - assert (start, end, step) == (0, 10, 2) - - -def test_get_number_frames_matches_python_slice_math(): - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=MagicMock(), - universe=MagicMock(), - reporter=MagicMock(), - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - assert wf._get_number_frames(0, 10, 1) == 10 - assert wf._get_number_frames(0, 10, 2) == 5 - - -def test_finalize_results_called_even_if_empty(): - args = SimpleNamespace(output_file="out.json") - reporter = MagicMock() - reporter.molecule_data = [] - reporter.residue_data = [] - reporter.save_dataframes_as_json = MagicMock() - - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=MagicMock(), - reporter=reporter, - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - wf._finalize_molecule_results() - - reporter.save_dataframes_as_json.assert_called_once() - - -def test_split_water_groups_returns_empty_when_none(): - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=MagicMock(water_entropy=False), - universe=MagicMock(), - reporter=MagicMock(), - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - groups, water = wf._split_water_groups({0: [1, 2]}) - - assert water == {} diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py b/tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py deleted file mode 100644 index 8531aa02..00000000 --- a/tests/unit/CodeEntropy/entropy/test_workflow_more_branches.py +++ /dev/null @@ -1,185 +0,0 @@ -import logging -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from CodeEntropy.entropy.workflow import EntropyWorkflow - - -def test_build_reduced_universe_non_all_selects_and_writes_universe(): - args = SimpleNamespace( - selection_string="protein", - grouping="molecules", - start=0, - end=-1, - step=1, - water_entropy=False, - output_file="out.json", - ) - universe = MagicMock() - universe.trajectory = list(range(3)) - - reduced = MagicMock() - reduced.trajectory = list(range(2)) - - uops = MagicMock() - uops.select_atoms.return_value = reduced - - run_manager = MagicMock() - reporter = MagicMock() - - wf = EntropyWorkflow( - run_manager=run_manager, - args=args, - universe=universe, - reporter=reporter, - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=uops, - ) - - out = wf._build_reduced_universe() - - assert out is reduced - uops.select_atoms.assert_called_once_with(universe, "protein") - run_manager.write_universe.assert_called_once() - - -def test_compute_water_entropy_updates_selection_string_and_calls_internal_method(): - args = SimpleNamespace( - selection_string="all", water_entropy=True, temperature=298.0 - ) - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=MagicMock(), - reporter=MagicMock(molecule_data=[], residue_data=[]), - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - traj = SimpleNamespace(start=0, end=5, step=1) - water_groups = {9: [1, 2]} - - with patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls: - inst = WaterCls.return_value - inst._calculate_water_entropy = MagicMock() - - wf._compute_water_entropy(traj, water_groups) - - inst._calculate_water_entropy.assert_called_once() - assert wf._args.selection_string == "not water" - - -def test_finalize_molecule_results_skips_invalid_entries_with_warning(caplog): - args = SimpleNamespace(output_file="out.json") - reporter = MagicMock() - - reporter.molecule_data = [(1, "united_atom", "Trans", "not-a-number")] - reporter.residue_data = [] - reporter.save_dataframes_as_json = MagicMock() - - wf = EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=MagicMock(), - reporter=reporter, - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - caplog.set_level(logging.WARNING) - wf._finalize_molecule_results() - - assert any("Skipping invalid entry" in r.message for r in caplog.records) - reporter.save_dataframes_as_json.assert_called_once() - - -def test_build_reduced_universe_all_returns_original_universe(): - args = SimpleNamespace( - selection_string="all", - start=0, - end=-1, - step=1, - grouping="molecules", - water_entropy=False, - output_file="out.json", - ) - universe = MagicMock() - uops = MagicMock() - run_manager = MagicMock() - wf = EntropyWorkflow( - run_manager, args, universe, MagicMock(), MagicMock(), MagicMock(), uops - ) - - out = wf._build_reduced_universe() - - assert out is universe - uops.select_atoms.assert_not_called() - run_manager.write_universe.assert_not_called() - - -def test_split_water_groups_partitions_correctly(): - args = SimpleNamespace( - start=0, - end=-1, - step=1, - grouping="molecules", - water_entropy=False, - selection_string="all", - output_file="out.json", - ) - universe = MagicMock() - - water_res = MagicMock() - water_res.resid = 10 - water_atoms = MagicMock() - water_atoms.residues = [water_res] - universe.select_atoms.return_value = water_atoms - - frag0 = MagicMock() - r0 = MagicMock() - r0.resid = 10 - frag0.residues = [r0] - - frag1 = MagicMock() - r1 = MagicMock() - r1.resid = 99 - frag1.residues = [r1] - - universe.atoms.fragments = [frag0, frag1] - - wf = EntropyWorkflow( - MagicMock(), args, universe, MagicMock(), MagicMock(), MagicMock(), MagicMock() - ) - - groups = {0: [0], 1: [1]} - nonwater, water = wf._split_water_groups(groups) - - assert 0 in water - assert 1 in nonwater - - -def test_compute_water_entropy_instantiates_waterentropy_and_updates_selection_string(): - args = SimpleNamespace( - selection_string="all", water_entropy=True, temperature=298.0 - ) - universe = MagicMock() - reporter = MagicMock() - wf = EntropyWorkflow( - MagicMock(), args, universe, reporter, MagicMock(), MagicMock(), MagicMock() - ) - - traj = SimpleNamespace(start=0, end=5, step=1, n_frames=5) - water_groups = {9: [0]} - - with patch("CodeEntropy.entropy.workflow.WaterEntropy") as WaterCls: - inst = WaterCls.return_value - inst._calculate_water_entropy = MagicMock() - - wf._compute_water_entropy(traj, water_groups) - - WaterCls.assert_called_once_with(args) - inst._calculate_water_entropy.assert_called_once() - assert wf._args.selection_string == "not water" diff --git a/tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py b/tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py deleted file mode 100644 index e0a718b2..00000000 --- a/tests/unit/CodeEntropy/entropy/test_workflow_remaining_branches.py +++ /dev/null @@ -1,67 +0,0 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock, patch - -from CodeEntropy.entropy.workflow import EntropyWorkflow - - -def _make_wf(args): - return EntropyWorkflow( - run_manager=MagicMock(), - args=args, - universe=MagicMock(), - reporter=MagicMock(molecule_data=[], residue_data=[]), - group_molecules=MagicMock(), - dihedral_analysis=MagicMock(), - universe_operations=MagicMock(), - ) - - -def test_detect_levels_calls_hierarchy_builder(): - args = SimpleNamespace( - selection_string="all", water_entropy=False, output_file="out.json" - ) - wf = _make_wf(args) - - with patch("CodeEntropy.entropy.workflow.HierarchyBuilder") as HB: - HB.return_value.select_levels.return_value = (123, {"levels": "ok"}) - - out = wf._detect_levels(reduced_universe=MagicMock()) - - assert out == {"levels": "ok"} - HB.return_value.select_levels.assert_called_once() - - -def test_compute_water_entropy_returns_early_when_disabled_or_empty_groups(): - args = SimpleNamespace( - selection_string="all", - water_entropy=False, - temperature=298.0, - output_file="out.json", - ) - wf = _make_wf(args) - - traj = SimpleNamespace(start=0, end=5, step=1, n_frames=5) - - # empty water groups OR water_entropy disabled -> early return - wf._compute_water_entropy(traj, water_groups={}) - # no exception and no side effects expected - - -def test_finalize_molecule_results_skips_group_total_rows(): - args = SimpleNamespace( - output_file="out.json", selection_string="all", water_entropy=False - ) - wf = _make_wf(args) - - wf._reporter.molecule_data = [ - (1, "Group Total", "Group Total Entropy", 999.0), # should be skipped - (1, "united_atom", "Transvibrational", 1.5), # should count - ] - wf._reporter.residue_data = [] - - wf._finalize_molecule_results() - - # should append a new "Group Total" row based only on the non-total entries - assert any( - row[1] == "Group Total" and row[3] == 1.5 for row in wf._reporter.molecule_data - ) From 6edaaddefb81a470f728d1aa997767f7bdb8b1e7 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 16:56:43 +0000 Subject: [PATCH 079/101] add unit test `test_to_1d_array_returns_none_when_states_is_none` --- tests/unit/CodeEntropy/entropy/test_configurational_edges.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/CodeEntropy/entropy/test_configurational_edges.py b/tests/unit/CodeEntropy/entropy/test_configurational_edges.py index 7fbc26f1..1b8f9792 100644 --- a/tests/unit/CodeEntropy/entropy/test_configurational_edges.py +++ b/tests/unit/CodeEntropy/entropy/test_configurational_edges.py @@ -83,3 +83,8 @@ def test_find_histogram_peaks_skips_zero_population_bins(): peaks = ce._find_histogram_peaks(phi, bin_width=30) assert peaks.size >= 1 + + +def test_to_1d_array_returns_none_when_states_is_none(): + ce = ConformationalEntropy() + assert ce._to_1d_array(None) is None From c71ee1d9a149232671c257a16faa7e1ccc19f7b8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 17:02:19 +0000 Subject: [PATCH 080/101] add unit tests to ensure `_solute_id_to_resname` is covered --- tests/unit/CodeEntropy/entropy/test_water_entropy.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/unit/CodeEntropy/entropy/test_water_entropy.py b/tests/unit/CodeEntropy/entropy/test_water_entropy.py index d1500b63..67de69d0 100644 --- a/tests/unit/CodeEntropy/entropy/test_water_entropy.py +++ b/tests/unit/CodeEntropy/entropy/test_water_entropy.py @@ -176,3 +176,13 @@ def test_log_group_label_defaults_to_WAT_when_no_names_match(): reporter.add_group_label.assert_called_once() assert reporter.add_group_label.call_args.args[1] == "WAT" + + +def test_solute_id_to_resname_strips_suffix_after_last_underscore(): + assert WaterEntropy._solute_id_to_resname("ALA_0") == "ALA" + assert WaterEntropy._solute_id_to_resname("ALA_BLA_12") == "ALA_BLA" + + +def test_solute_id_to_resname_returns_string_when_no_underscore(): + assert WaterEntropy._solute_id_to_resname("WAT") == "WAT" + assert WaterEntropy._solute_id_to_resname(123) == "123" From 3ed442cb59730aa166f4f6cd8e5767a377452de8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 17:11:32 +0000 Subject: [PATCH 081/101] add unit test for `_compute_ft_entropy` function within `entropy/vibrational.py` --- CodeEntropy/entropy/nodes/vibrational.py | 5 +---- .../entropy/nodes/test_vibrational_node.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CodeEntropy/entropy/nodes/vibrational.py b/CodeEntropy/entropy/nodes/vibrational.py index e4c33fb1..22b38853 100644 --- a/CodeEntropy/entropy/nodes/vibrational.py +++ b/CodeEntropy/entropy/nodes/vibrational.py @@ -78,7 +78,7 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] ua_frame_counts=ua_frame_counts, reporter=reporter, n_frames_default=shared_data.get("n_frames", 0), - highest=highest, # IMPORTANT: matches main + highest=highest, ) self._store_results(results, group_id, level, pair) self._log_molecule_level_results( @@ -89,7 +89,6 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] if level in ("residue", "polymer"): gi = gid2i[group_id] - # FT only applies at the highest level (same as main) if combined and highest and ft_cov is not None: ft_key = "res" if level == "residue" else "poly" ftmat = self._get_indexed_matrix(ft_cov.get(ft_key, []), gi) @@ -219,7 +218,6 @@ def _compute_force_torque_entropy( np.asarray(tmat), atol=self._zero_atol ) - # If filtering removes everything, behave like "no data" if f.size == 0 or t.size == 0: return EntropyPair(trans=0.0, rot=0.0) @@ -247,7 +245,6 @@ def _compute_ft_entropy( if ft.size == 0: return EntropyPair(trans=0.0, rot=0.0) - # FT is only used at highest level in main branch s_trans = ve.vibrational_entropy_calculation( ft, "forcetorqueTRANS", temp, highest_level=True ) diff --git a/tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py b/tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py index 8165e41b..45f2d05e 100644 --- a/tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py +++ b/tests/unit/CodeEntropy/entropy/nodes/test_vibrational_node.py @@ -364,3 +364,19 @@ def test_run_unknown_level_raises(shared_groups): with pytest.raises(ValueError): node.run(shared_groups) + + +def test_compute_ft_entropy_returns_zeros_when_filtered_ft_matrix_is_empty(monkeypatch): + node = VibrationalEntropyNode() + ve = MagicMock() + + monkeypatch.setattr( + node._mat_ops, + "filter_zero_rows_columns", + lambda _arr, atol: np.empty((0, 0), dtype=float), + ) + + out = node._compute_ft_entropy(ve=ve, temp=298.0, ftmat=np.eye(6)) + + assert out == EntropyPair(trans=0.0, rot=0.0) + ve.vibrational_entropy_calculation.assert_not_called() From 0c38b55dfe687070df613d1fe26c1afe35908c39 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 17:19:13 +0000 Subject: [PATCH 082/101] ensure unit tests cover all cases within `_reduce_force_and_torque` --- .../levels/test_level_dag_reduce.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py b/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py index 71fef21f..cb14bc1a 100644 --- a/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py +++ b/tests/unit/CodeEntropy/levels/test_level_dag_reduce.py @@ -182,3 +182,27 @@ def test_reduce_force_and_torque_hits_ua_force_count_increment_line(): dag._reduce_force_and_torque(shared, frame_out) assert shared["frame_counts"]["ua"][key] == 1 + + +def test_reduce_force_and_torque_ua_torque_increments_count_when_force_missing_key(): + dag = LevelDAG() + + key = (9, 0) + T = np.eye(3) + + shared = { + "force_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "torque_covariances": {"ua": {}, "res": [None], "poly": [None]}, + "frame_counts": {"ua": {}, "res": [0], "poly": [0]}, + "group_id_to_index": {7: 0}, + } + + frame_out = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {key: T}, "res": {}, "poly": {}}, + } + + dag._reduce_force_and_torque(shared, frame_out) + + assert shared["frame_counts"]["ua"][key] == 1 + np.testing.assert_array_equal(shared["torque_covariances"]["ua"][key], T) From 514b5a84785fa744127d1496340dc271609190e7 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 23 Feb 2026 17:19:58 +0000 Subject: [PATCH 083/101] remove legacy `test_CodeEntropy` tests --- .../test_arg_config_manager.py | 601 ----- tests/test_CodeEntropy/test_axes.py | 476 ---- tests/test_CodeEntropy/test_base.py | 45 - tests/test_CodeEntropy/test_data_logger.py | 136 -- tests/test_CodeEntropy/test_dihedral_tools.py | 571 ----- tests/test_CodeEntropy/test_entropy.py | 2135 ----------------- .../test_CodeEntropy/test_group_molecules.py | 78 - tests/test_CodeEntropy/test_levels.py | 1726 ------------- tests/test_CodeEntropy/test_logging_config.py | 103 - tests/test_CodeEntropy/test_main.py | 134 -- .../test_mda_universe_operations.py | 304 --- tests/test_CodeEntropy/test_run.py | 632 ----- 12 files changed, 6941 deletions(-) delete mode 100644 tests/test_CodeEntropy/test_arg_config_manager.py delete mode 100644 tests/test_CodeEntropy/test_axes.py delete mode 100644 tests/test_CodeEntropy/test_base.py delete mode 100644 tests/test_CodeEntropy/test_data_logger.py delete mode 100644 tests/test_CodeEntropy/test_dihedral_tools.py delete mode 100644 tests/test_CodeEntropy/test_entropy.py delete mode 100644 tests/test_CodeEntropy/test_group_molecules.py delete mode 100644 tests/test_CodeEntropy/test_levels.py delete mode 100644 tests/test_CodeEntropy/test_logging_config.py delete mode 100644 tests/test_CodeEntropy/test_main.py delete mode 100644 tests/test_CodeEntropy/test_mda_universe_operations.py delete mode 100644 tests/test_CodeEntropy/test_run.py diff --git a/tests/test_CodeEntropy/test_arg_config_manager.py b/tests/test_CodeEntropy/test_arg_config_manager.py deleted file mode 100644 index 23144f28..00000000 --- a/tests/test_CodeEntropy/test_arg_config_manager.py +++ /dev/null @@ -1,601 +0,0 @@ -import argparse -import logging -import os -import unittest -from unittest.mock import MagicMock, mock_open, patch - -import tests.data as data -from CodeEntropy.config.argparse import ConfigManager -from CodeEntropy.main import main -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestArgConfigManager(BaseTestCase): - """ - Unit tests for the ConfigManager. - """ - - def setUp(self): - super().setUp() - - self.test_data_dir = os.path.dirname(data.__file__) - self.config_file = os.path.join(self.test_dir, "config.yaml") - - # Create a mock config file - with patch("builtins.open", new_callable=mock_open) as mock_file: - self.setup_file(mock_file) - with open(self.config_file, "w") as f: - f.write(mock_file.return_value.read()) - - def list_data_files(self): - """ - List all files in the test data directory. - """ - return os.listdir(self.test_data_dir) - - def setup_file(self, mock_file): - """ - Mock the contents of a configuration file. - """ - mock_file.return_value = mock_open( - read_data="--- \n \nrun1:\n " - "top_traj_file: ['/path/to/tpr', '/path/to/trr']\n " - "selection_string: 'all'\n " - "start: 0\n " - "end: -1\n " - "step: 1\n " - "bin_width: 30\n " - "tempra: 298.0\n " - "verbose: False\n " - "thread: 1\n " - "output_file: 'output_file.json'\n " - "force_partitioning: 0.5\n " - "water_entropy: False" - ).return_value - - @patch("builtins.open") - @patch("glob.glob", return_value=["config.yaml"]) - def test_load_config(self, mock_glob, mock_file): - """ - Test loading a valid configuration file. - """ - # Setup the mock file content - self.setup_file(mock_file) - - arg_config = ConfigManager() - config = arg_config.load_config("/some/path") - - self.assertIn("run1", config) - self.assertEqual( - config["run1"]["top_traj_file"], ["/path/to/tpr", "/path/to/trr"] - ) - self.assertEqual(config["run1"]["selection_string"], "all") - self.assertEqual(config["run1"]["start"], 0) - self.assertEqual(config["run1"]["end"], -1) - self.assertEqual(config["run1"]["step"], 1) - self.assertEqual(config["run1"]["bin_width"], 30) - self.assertEqual(config["run1"]["tempra"], 298.0) - self.assertFalse(config["run1"]["verbose"]) - self.assertEqual(config["run1"]["thread"], 1) - self.assertEqual(config["run1"]["output_file"], "output_file.json") - self.assertEqual(config["run1"]["force_partitioning"], 0.5) - self.assertFalse(config["run1"]["water_entropy"]) - - @patch("glob.glob", return_value=[]) - def test_load_config_no_yaml_files(self, mock_glob): - arg_config = ConfigManager() - config = arg_config.load_config("/some/path") - self.assertEqual(config, {"run1": {}}) - - @patch("builtins.open", side_effect=FileNotFoundError) - @patch("glob.glob", return_value=["config.yaml"]) - def test_load_config_file_not_found(self, mock_glob, mock_open): - """ - Test loading a configuration file that exists but cannot be opened. - Should return default config instead of raising an error. - """ - arg_config = ConfigManager() - config = arg_config.load_config("/some/path") - self.assertEqual(config, {"run1": {}}) - - @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.""" - with self.assertRaises(SystemExit) as context: - main() - self.assertEqual(context.exception.code, 1) - - 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): - arg_config.merge_configs(args, invalid) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - top_traj_file=["/path/to/tpr", "/path/to/trr"], - selection_string="all", - start=0, - end=-1, - step=1, - bin_width=30, - tempra=298.0, - verbose=False, - thread=1, - output_file="output_file.json", - force_partitioning=0.5, - water_entropy=False, - ), - ) - def test_setup_argparse(self, mock_args): - """ - Test parsing command-line arguments. - """ - 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") - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - top_traj_file=["/path/to/tpr", "/path/to/trr"], - start=10, - water_entropy=False, - ), - ) - def test_setup_argparse_false_boolean(self, mock_args): - """ - Test that non-boolean arguments are parsed correctly. - """ - 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.start, 10) - self.assertFalse(args.water_entropy) - - def test_str2bool_true_variants(self): - """Test that various string representations of True are correctly parsed.""" - arg_config = ConfigManager() - - self.assertTrue(arg_config.str2bool("true")) - self.assertTrue(arg_config.str2bool("True")) - self.assertTrue(arg_config.str2bool("t")) - self.assertTrue(arg_config.str2bool("yes")) - self.assertTrue(arg_config.str2bool("1")) - - def test_str2bool_false_variants(self): - """Test that various string representations of False are correctly parsed.""" - arg_config = ConfigManager() - - self.assertFalse(arg_config.str2bool("false")) - self.assertFalse(arg_config.str2bool("False")) - self.assertFalse(arg_config.str2bool("f")) - self.assertFalse(arg_config.str2bool("no")) - self.assertFalse(arg_config.str2bool("0")) - - def test_str2bool_boolean_passthrough(self): - """Test that boolean values passed directly are returned unchanged.""" - arg_config = ConfigManager() - - self.assertTrue(arg_config.str2bool(True)) - self.assertFalse(arg_config.str2bool(False)) - - def test_str2bool_invalid_input(self): - """Test that invalid string inputs raise an ArgumentTypeError.""" - arg_config = ConfigManager() - - with self.assertRaises(Exception) as context: - arg_config.str2bool("maybe") - self.assertIn("Boolean value expected", str(context.exception)) - - def test_str2bool_empty_string(self): - """Test that an empty string raises an ArgumentTypeError.""" - arg_config = ConfigManager() - - with self.assertRaises(Exception) as context: - arg_config.str2bool("") - self.assertIn("Boolean value expected", str(context.exception)) - - def test_str2bool_unexpected_number(self): - """Test that unexpected numeric strings raise an ArgumentTypeError.""" - arg_config = ConfigManager() - - with self.assertRaises(Exception) as context: - arg_config.str2bool("2") - self.assertIn("Boolean value expected", str(context.exception)) - - def test_cli_overrides_defaults(self): - """ - Test if CLI parameters override default values. - """ - arg_config = ConfigManager() - parser = arg_config.setup_argparse() - args = parser.parse_args( - ["--top_traj_file", "/cli/path", "--selection_string", "cli_value"] - ) - self.assertEqual(args.top_traj_file, ["/cli/path"]) - self.assertEqual(args.selection_string, "cli_value") - - def test_cli_overrides_yaml(self): - """ - Test if CLI parameters override YAML parameters correctly. - """ - 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 = arg_config.merge_configs(args, run_config) - self.assertEqual(merged_args.top_traj_file, ["/cli/path"]) - self.assertEqual(merged_args.selection_string, "cli_value") - - def test_cli_overrides_yaml_with_multiple_values(self): - """ - Ensures that CLI arguments override YAML when multiple values are provided in - YAML. - """ - arg_config = ConfigManager() - yaml_config = {"top_traj_file": ["/yaml/path1", "/yaml/path2"]} - args = argparse.Namespace(top_traj_file=["/cli/path"]) - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.top_traj_file, ["/cli/path"]) - - def test_yaml_overrides_defaults(self): - """ - Test if YAML parameters override default values. - """ - run_config = {"top_traj_file": ["/yaml/path"], "selection_string": "yaml_value"} - args = argparse.Namespace() - 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") - - def test_yaml_does_not_override_cli_if_set(self): - """ - Ensure YAML does not override CLI arguments that are set. - """ - arg_config = ConfigManager() - - yaml_config = {"bin_width": 50} - args = argparse.Namespace(bin_width=100) - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.bin_width, 100) - - def test_yaml_overrides_defaults_when_no_cli(self): - """ - Test if YAML parameters override default values when no CLI input is given. - """ - arg_config = ConfigManager() - - yaml_config = { - "top_traj_file": ["/yaml/path"], - "bin_width": 50, - } - - args = argparse.Namespace() - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.top_traj_file, ["/yaml/path"]) - self.assertEqual(merged_args.bin_width, 50) - - def test_yaml_none_does_not_override_defaults(self): - """ - Ensures that YAML values set to `None` do not override existing CLI values. - """ - arg_config = ConfigManager() - yaml_config = {"bin_width": None} - args = argparse.Namespace(bin_width=100) - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.bin_width, 100) - - def test_hierarchy_cli_yaml_defaults(self): - """ - Test if CLI arguments override YAML, and YAML overrides defaults. - """ - arg_config = ConfigManager() - - yaml_config = { - "top_traj_file": ["/yaml/path", "/yaml/path"], - "bin_width": "50", - } - - args = argparse.Namespace( - top_traj_file=["/cli/path", "/cli/path"], bin_width=100 - ) - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.top_traj_file, ["/cli/path", "/cli/path"]) - self.assertEqual(merged_args.bin_width, 100) - - 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, - start=None, - end=None, - step=None, - bin_width=None, - tempra=None, - verbose=None, - thread=None, - output_file=None, - force_partitioning=None, - water_entropy=None, - ) - run_config = { - "top_traj_file": ["/path/to/tpr", "/path/to/trr"], - "selection_string": "all", - "start": 0, - "end": -1, - "step": 1, - "bin_width": 30, - "tempra": 298.0, - "verbose": False, - "thread": 1, - "output_file": "output_file.json", - "force_partitioning": 0.5, - "water_entropy": False, - } - 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") - - def test_merge_with_none_yaml(self): - """ - Ensure merging still works if no YAML config is provided. - """ - arg_config = ConfigManager() - - args = argparse.Namespace(top_traj_file=["/cli/path"]) - yaml_config = None - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.top_traj_file, ["/cli/path"]) - - @patch("CodeEntropy.config.arg_config_manager.logger") - def test_merge_configs_sets_debug_logging(self, mock_logger): - """ - Ensure logging is set to DEBUG when verbose=True. - """ - arg_config = ConfigManager() - args = argparse.Namespace(verbose=True) - for key in arg_config.arg_map: - if not hasattr(args, key): - setattr(args, key, None) - - # Mock logger handlers - mock_handler = MagicMock() - mock_logger.handlers = [mock_handler] - - arg_config.merge_configs(args, {}) - - mock_logger.setLevel.assert_called_with(logging.DEBUG) - mock_handler.setLevel.assert_called_with(logging.DEBUG) - mock_logger.debug.assert_called_with( - "Verbose mode enabled. Logger set to DEBUG level." - ) - - @patch("CodeEntropy.config.arg_config_manager.logger") - def test_merge_configs_sets_info_logging(self, mock_logger): - """ - Ensure logging is set to INFO when verbose=False. - """ - arg_config = ConfigManager() - args = argparse.Namespace(verbose=False) - for key in arg_config.arg_map: - if not hasattr(args, key): - setattr(args, key, None) - - # Mock logger handlers - mock_handler = MagicMock() - mock_logger.handlers = [mock_handler] - - arg_config.merge_configs(args, {}) - - mock_logger.setLevel.assert_called_with(logging.INFO) - mock_handler.setLevel.assert_called_with(logging.INFO) - - @patch("argparse.ArgumentParser.parse_args") - def test_default_values(self, mock_parse_args): - """ - Test if argument parser assigns default values correctly. - """ - arg_config = ConfigManager() - mock_parse_args.return_value = MagicMock( - top_traj_file=["example.top", "example.traj"] - ) - parser = arg_config.setup_argparse() - args = parser.parse_args() - self.assertEqual(args.top_traj_file, ["example.top", "example.traj"]) - - def test_fallback_to_defaults(self): - """ - Ensure arguments fall back to defaults if neither YAML nor CLI provides them. - """ - arg_config = ConfigManager() - - yaml_config = {} - args = argparse.Namespace() - - merged_args = arg_config.merge_configs(args, yaml_config) - - self.assertEqual(merged_args.step, 1) - self.assertEqual(merged_args.end, -1) - - @patch( - "argparse.ArgumentParser.parse_args", return_value=MagicMock(top_traj_file=None) - ) - def test_missing_required_arguments(self, mock_args): - """ - Test behavior when required arguments are missing. - """ - arg_config = ConfigManager() - parser = arg_config.setup_argparse() - args = parser.parse_args() - with self.assertRaises(ValueError): - if not args.top_traj_file: - raise ValueError( - "The 'top_traj_file' argument is required but not provided." - ) - - def test_invalid_argument_type(self): - """ - Test handling of invalid argument types. - """ - arg_config = ConfigManager() - parser = arg_config.setup_argparse() - with self.assertRaises(SystemExit): - parser.parse_args(["--start", "invalid"]) - - @patch( - "argparse.ArgumentParser.parse_args", return_value=MagicMock(start=-1, end=-10) - ) - def test_edge_case_argument_values(self, mock_args): - """ - Test parsing of edge case values. - """ - arg_config = ConfigManager() - parser = arg_config.setup_argparse() - args = parser.parse_args() - self.assertEqual(args.start, -1) - self.assertEqual(args.end, -10) - - @patch("builtins.open", new_callable=mock_open, read_data="--- \n") - @patch("glob.glob", return_value=["config.yaml"]) - def test_empty_yaml_config(self, mock_glob, mock_file): - """ - Test behavior when an empty YAML file is provided. - Should return default config {'run1': {}}. - """ - arg_config = ConfigManager() - config = arg_config.load_config("/some/path") - - self.assertIsInstance(config, dict) - self.assertEqual(config, {"run1": {}}) - - def test_input_parameters_validation_all_valid(self): - """Test that input_parameters_validation passes with all valid inputs.""" - manager = ConfigManager() - u = MagicMock() - u.trajectory = [0] * 100 - - args = MagicMock( - start=10, - end=90, - step=1, - bin_width=30, - temperature=298.0, - force_partitioning=0.5, - ) - - with patch.dict( - "CodeEntropy.config.arg_config_manager.arg_map", - {"force_partitioning": {"default": 0.5}}, - ): - manager.input_parameters_validation(u, args) - - def test_check_input_start_valid(self): - """Test that a valid 'start' value does not raise an error.""" - args = MagicMock(start=50) - u = MagicMock() - u.trajectory = [0] * 100 - ConfigManager()._check_input_start(u, args) - - def test_check_input_start_invalid(self): - """Test that an invalid 'start' value raises a ValueError.""" - args = MagicMock(start=150) - u = MagicMock() - u.trajectory = [0] * 100 - with self.assertRaises(ValueError): - ConfigManager()._check_input_start(u, args) - - def test_check_input_end_valid(self): - """Test that a valid 'end' value does not raise an error.""" - args = MagicMock(end=100) - u = MagicMock() - u.trajectory = [0] * 100 - ConfigManager()._check_input_end(u, args) - - def test_check_input_end_invalid(self): - """Test that an 'end' value exceeding trajectory length raises a ValueError.""" - args = MagicMock(end=101) - u = MagicMock() - u.trajectory = [0] * 100 - with self.assertRaises(ValueError): - ConfigManager()._check_input_end(u, args) - - @patch("CodeEntropy.config.arg_config_manager.logger") - def test_check_input_step_negative(self, mock_logger): - """Test that a negative 'step' value triggers a warning.""" - args = MagicMock(step=-1) - ConfigManager()._check_input_step(args) - mock_logger.warning.assert_called_once() - - def test_check_input_bin_width_valid(self): - """Test that a valid 'bin_width' value does not raise an error.""" - args = MagicMock(bin_width=180) - ConfigManager()._check_input_bin_width(args) - - def test_check_input_bin_width_invalid_low(self): - """Test that a negative 'bin_width' value raises a ValueError.""" - args = MagicMock(bin_width=-10) - with self.assertRaises(ValueError): - ConfigManager()._check_input_bin_width(args) - - def test_check_input_bin_width_invalid_high(self): - """Test that a 'bin_width' value above 360 raises a ValueError.""" - args = MagicMock(bin_width=400) - with self.assertRaises(ValueError): - ConfigManager()._check_input_bin_width(args) - - def test_check_input_temperature_valid(self): - """Test that a valid 'temperature' value does not raise an error.""" - args = MagicMock(temperature=298.0) - ConfigManager()._check_input_temperature(args) - - def test_check_input_temperature_invalid(self): - """Test that a negative 'temperature' value raises a ValueError.""" - args = MagicMock(temperature=-5) - with self.assertRaises(ValueError): - ConfigManager()._check_input_temperature(args) - - @patch("CodeEntropy.config.arg_config_manager.logger") - def test_check_input_force_partitioning_warning(self, mock_logger): - """Test that a non-default 'force_partitioning' value triggers a warning.""" - args = MagicMock(force_partitioning=0.7) - with patch.dict( - "CodeEntropy.config.arg_config_manager.arg_map", - {"force_partitioning": {"default": 0.5}}, - ): - ConfigManager()._check_input_force_partitioning(args) - mock_logger.warning.assert_called_once() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_CodeEntropy/test_axes.py b/tests/test_CodeEntropy/test_axes.py deleted file mode 100644 index d8992743..00000000 --- a/tests/test_CodeEntropy/test_axes.py +++ /dev/null @@ -1,476 +0,0 @@ -from unittest.mock import MagicMock, patch - -import numpy as np - -from CodeEntropy.levels.axes import AxesManager -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestAxesManager(BaseTestCase): - def setUp(self): - super().setUp() - - def test_get_residue_axes_no_bonds_custom_axes_branch(self): - """ - Tests that: atom_set empty (len == 0) -> custom axes branch - """ - axes_manager = AxesManager() - data_container = MagicMock() - - atom_set = MagicMock() - atom_set.__len__.return_value = 0 - - residue = MagicMock() - - data_container.select_atoms.side_effect = [atom_set, residue] - - center = np.array([1.0, 2.0, 3.0]) - residue.atoms.center_of_mass.return_value = center - - UAs = MagicMock() - UAs.positions = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]) - residue.select_atoms.return_value = UAs - - UA_masses = [12.0, 14.0] - axes_manager.get_UA_masses = MagicMock(return_value=UA_masses) - - moi_tensor = np.eye(3) * 5.0 - axes_manager.get_moment_of_inertia_tensor = MagicMock(return_value=moi_tensor) - - rot_axes = np.eye(3) - moi = np.array([10.0, 9.0, 8.0]) - axes_manager.get_custom_principal_axes = MagicMock(return_value=(rot_axes, moi)) - - trans_axes_out, rot_axes_out, center_out, moi_out = ( - axes_manager.get_residue_axes( - data_container=data_container, - index=5, - ) - ) - - calls = data_container.select_atoms.call_args_list - assert len(calls) == 2 - assert calls[0].args[0] == "(resindex 4 or resindex 6) and bonded resid 5" - assert calls[1].args[0] == "resindex 5" - - residue.select_atoms.assert_called_once_with("mass 2 to 999") - - axes_manager.get_UA_masses.assert_called_once_with(residue) - - axes_manager.get_moment_of_inertia_tensor.assert_called_once() - tensor_args, tensor_kwargs = axes_manager.get_moment_of_inertia_tensor.call_args - np.testing.assert_array_equal(tensor_args[0], center) - np.testing.assert_array_equal(tensor_args[1], UAs.positions) - assert tensor_args[2] == UA_masses - assert tensor_kwargs == {} - - axes_manager.get_custom_principal_axes.assert_called_once_with(moi_tensor) - - np.testing.assert_array_equal(trans_axes_out, rot_axes) - np.testing.assert_array_equal(rot_axes_out, rot_axes) - np.testing.assert_array_equal(center_out, center) - np.testing.assert_array_equal(moi_out, moi) - - def test_get_residue_axes_bonded_default_axes_branch(self): - """ - Tests that: atom_set non-empty (len != 0) -> default/bonded branch - """ - axes_manager = AxesManager() - data_container = MagicMock() - data_container.atoms = MagicMock() - - atom_set = MagicMock() - atom_set.__len__.return_value = 2 - - residue = MagicMock() - data_container.select_atoms.side_effect = [atom_set, residue] - - trans_axes_expected = np.eye(3) * 2 - data_container.atoms.principal_axes.return_value = trans_axes_expected - - rot_axes_expected = np.eye(3) * 3 - residue.principal_axes.return_value = rot_axes_expected - - moi_tensor = np.eye(3) - residue.moment_of_inertia.return_value = moi_tensor - - center_expected = np.array([9.0, 8.0, 7.0]) - residue.atoms.center_of_mass.return_value = center_expected - residue.center_of_mass.return_value = center_expected - - with ( - patch("CodeEntropy.axes.make_whole", autospec=True), - patch.object( - AxesManager, - "get_vanilla_axes", - return_value=(rot_axes_expected, np.array([3.0, 2.0, 1.0])), - ), - ): - trans_axes_out, rot_axes_out, center_out, moi_out = ( - axes_manager.get_residue_axes( - data_container=data_container, - index=5, - ) - ) - - np.testing.assert_allclose(trans_axes_out, trans_axes_expected) - np.testing.assert_allclose(rot_axes_out, rot_axes_expected) - np.testing.assert_allclose(center_out, center_expected) - np.testing.assert_allclose(moi_out, np.array([3.0, 2.0, 1.0])) - - @patch("CodeEntropy.axes.make_whole", autospec=True) - def test_get_UA_axes_returns_expected_outputs(self, mock_make_whole): - """ - Tests that: `get_UA_axes` returns expected UA axes. - """ - axes = AxesManager() - - dc = MagicMock() - dc.atoms = MagicMock() - dc.dimensions = np.array([1.0, 2.0, 3.0, 90.0, 90.0, 90.0]) - dc.atoms.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) - - a0 = MagicMock() - a0.index = 7 - a1 = MagicMock() - a1.index = 9 - - heavy_atoms = MagicMock() - heavy_atoms.__len__.return_value = 2 - heavy_atoms.__iter__.return_value = iter([a0, a1]) - heavy_atoms.positions = np.array([[9.9, 8.8, 7.7], [1.1, 2.2, 3.3]]) - - dc.select_atoms.side_effect = [heavy_atoms, heavy_atoms] - - axes.get_UA_masses = MagicMock(return_value=[1.0, 1.0]) - axes.get_moment_of_inertia_tensor = MagicMock(return_value=np.eye(3)) - - trans_axes_expected = np.eye(3) - axes.get_custom_principal_axes = MagicMock( - return_value=(trans_axes_expected, np.array([1.0, 1.0, 1.0])) - ) - - rot_axes_expected = np.eye(3) * 2 - moi_expected = np.array([3.0, 2.0, 1.0]) - axes.get_bonded_axes = MagicMock(return_value=(rot_axes_expected, moi_expected)) - - trans_axes, rot_axes, center, moi = axes.get_UA_axes(dc, index=1) - - np.testing.assert_array_equal(trans_axes, trans_axes_expected) - np.testing.assert_array_equal(rot_axes, rot_axes_expected) - np.testing.assert_array_equal(center, heavy_atoms.positions[0]) - np.testing.assert_array_equal(moi, moi_expected) - - calls = [c.args[0] for c in dc.select_atoms.call_args_list] - assert calls[0] == "prop mass > 1.1" - assert calls[1] == "index 9" - - def test_get_bonded_axes_returns_none_for_light_atom(self): - """ - Tests that: bonded axes return none for light atoms - """ - axes = AxesManager() - - atom = MagicMock() - atom.mass = 1.0 - system = MagicMock() - - out = axes.get_bonded_axes( - system=system, atom=atom, dimensions=np.array([1.0, 2.0, 3.0]) - ) - assert out is None - - def test_get_bonded_axes_case2_one_heavy_zero_light(self): - """ - Tests that: bonded return one heavy and zero light atoms - """ - axes = AxesManager() - - system = MagicMock() - atom = MagicMock() - atom.mass = 12.0 - atom.index = 0 - atom.position = np.array([0.0, 0.0, 0.0]) - - heavy0 = MagicMock() - heavy0.position = np.array([1.0, 0.0, 0.0]) - - heavy_bonded = [heavy0] - light_bonded = [] - - axes.find_bonded_atoms = MagicMock(return_value=(heavy_bonded, light_bonded)) - - custom_axes = np.eye(3) - axes.get_custom_axes = MagicMock(return_value=custom_axes) - - moi = np.array([1.0, 2.0, 3.0]) - axes.get_custom_moment_of_inertia = MagicMock(return_value=moi) - - flipped_axes = np.eye(3) * 2 - axes.get_flipped_axes = MagicMock(return_value=flipped_axes) - - out_axes, out_moi = axes.get_bonded_axes( - system, atom, np.array([10.0, 10.0, 10.0]) - ) - - np.testing.assert_array_equal(out_axes, flipped_axes) - np.testing.assert_array_equal(out_moi, moi) - - axes.get_custom_axes.assert_called_once() - args, _ = axes.get_custom_axes.call_args - np.testing.assert_array_equal(args[0], atom.position) - assert len(args[1]) == 1 - np.testing.assert_array_equal(args[1][0], heavy0.position) - np.testing.assert_array_equal(args[2], np.zeros(3)) - np.testing.assert_array_equal(args[3], np.array([10.0, 10.0, 10.0])) - - def test_get_bonded_axes_case3_one_heavy_with_light(self): - """ - Tests that: bonded axes return one heavy with one light atom - """ - axes = AxesManager() - - system = MagicMock() - atom = MagicMock() - atom.mass = 12.0 - atom.index = 0 - atom.position = np.array([0.0, 0.0, 0.0]) - - heavy0 = MagicMock() - heavy0.position = np.array([1.0, 0.0, 0.0]) - - light0 = MagicMock() - light0.position = np.array([0.0, 1.0, 0.0]) - - heavy_bonded = [heavy0] - light_bonded = [light0] - - axes.find_bonded_atoms = MagicMock(return_value=(heavy_bonded, light_bonded)) - - custom_axes = np.eye(3) - axes.get_custom_axes = MagicMock(return_value=custom_axes) - axes.get_custom_moment_of_inertia = MagicMock( - return_value=np.array([1.0, 1.0, 1.0]) - ) - axes.get_flipped_axes = MagicMock(return_value=custom_axes) - - axes.get_bonded_axes(system, atom, np.array([10.0, 10.0, 10.0])) - - axes.get_custom_axes.assert_called_once() - args, _ = axes.get_custom_axes.call_args - - np.testing.assert_array_equal(args[2], light0.position) - - def test_get_bonded_axes_case5_two_or_more_heavy(self): - """ - Tests that: bonded axes return two or more heavy atoms - """ - axes = AxesManager() - - system = MagicMock() - atom = MagicMock() - atom.mass = 12.0 - atom.index = 0 - atom.position = np.array([0.0, 0.0, 0.0]) - - heavy0 = MagicMock() - heavy0.position = np.array([1.0, 0.0, 0.0]) - heavy1 = MagicMock() - heavy1.position = np.array([0.0, 1.0, 0.0]) - - heavy_bonded = MagicMock() - heavy_bonded.__len__.return_value = 2 - heavy_bonded.positions = np.array([heavy0.position, heavy1.position]) - - heavy_bonded.__getitem__.side_effect = lambda i: [heavy0, heavy1][i] - - light_bonded = [] - - axes.find_bonded_atoms = MagicMock(return_value=(heavy_bonded, light_bonded)) - - custom_axes = np.eye(3) - axes.get_custom_axes = MagicMock(return_value=custom_axes) - axes.get_custom_moment_of_inertia = MagicMock( - return_value=np.array([9.0, 9.0, 9.0]) - ) - axes.get_flipped_axes = MagicMock(return_value=custom_axes) - - axes.get_bonded_axes(system, atom, np.array([10.0, 10.0, 10.0])) - - axes.get_custom_axes.assert_called_once() - args, _ = axes.get_custom_axes.call_args - - np.testing.assert_array_equal(args[1], heavy_bonded.positions) - np.testing.assert_array_equal(args[2], heavy1.position) - - def test_find_bonded_atoms_splits_heavy_and_h(self): - """ - Tests that: Bonded atoms split into heavy and hydrogen. - """ - axes = AxesManager() - - system = MagicMock() - bonded = MagicMock() - heavy = MagicMock() - hydrogens = MagicMock() - - system.select_atoms.return_value = bonded - bonded.select_atoms.side_effect = [heavy, hydrogens] - - out_heavy, out_h = axes.find_bonded_atoms(5, system) - - system.select_atoms.assert_called_once_with("bonded index 5") - assert bonded.select_atoms.call_args_list[0].args[0] == "mass 2 to 999" - assert bonded.select_atoms.call_args_list[1].args[0] == "mass 1 to 1.1" - assert out_heavy is heavy - assert out_h is hydrogens - - def test_get_vector_wraps_pbc(self): - """ - Tests that: The vector wraps across periodic boundary. - """ - axes = AxesManager() - - a = np.array([9.0, 0.0, 0.0]) - b = np.array([1.0, 0.0, 0.0]) - dims = np.array([10.0, 10.0, 10.0]) - - out = axes.get_vector(a, b, dims) - np.testing.assert_array_equal(out, np.array([2.0, 0.0, 0.0])) - - def test_get_custom_axes_returns_unit_axes(self): - """ - Tests that: `get_axes` returns normalized 3x3 axes. - """ - axes = AxesManager() - - a = np.zeros(3) - b_list = [np.array([1.0, 0.0, 0.0])] - c = np.array([0.0, 1.0, 0.0]) - dims = np.array([100.0, 100.0, 100.0]) - - out = axes.get_custom_axes(a, b_list, c, dims) - - assert out.shape == (3, 3) - norms = np.linalg.norm(out, axis=1) - np.testing.assert_allclose(norms, np.ones(3)) - - def test_get_custom_axes_uses_bc_vector_when_multiple_heavy_atoms(self): - """ - Tests that: `get_custom_axes` uses c → b_list[0] vector when b_list has - ≥ 2 atoms. - """ - axes = AxesManager() - - a = np.zeros(3) - b0 = np.array([1.0, 0.0, 0.0]) - b1 = np.array([0.0, 1.0, 0.0]) - b_list = [b0, b1] - c = np.array([0.0, 0.0, 1.0]) - dimensions = np.array([10.0, 10.0, 10.0]) - - # Track calls to get_vector - axes.get_vector = MagicMock(return_value=np.array([1.0, 0.0, 0.0])) - - axes.get_custom_axes(a, b_list, c, dimensions) - - # get_vector should be called - calls = axes.get_vector.call_args_list - - # Last call must be (c, b_list[0], dimensions) - last_args = calls[-1].args - np.testing.assert_array_equal(last_args[0], c) - np.testing.assert_array_equal(last_args[1], b0) - np.testing.assert_array_equal(last_args[2], dimensions) - - def test_get_custom_moment_of_inertia_len2_zeroed(self): - """ - Tests that: `get_custom_moment_of_inertia` zeroes one MOI component for - two-atom UA. - """ - axes = AxesManager() - - UA = MagicMock() - UA.positions = np.array([[1, 0, 0], [0, 1, 0]]) - UA.masses = np.array([12.0, 1.0]) - UA.__len__.return_value = 2 - - dimensions = np.array([100.0, 100.0, 100.0]) - - moi = axes.get_custom_moment_of_inertia(UA, np.eye(3), np.zeros(3), dimensions) - - assert moi.shape == (3,) - assert np.any(np.isclose(moi, 0.0)) - - def test_get_flipped_axes_flips_negative_dot(self): - """ - Tests that: `get_flipped_axes` flips axis when dot product is negative. - """ - axes = AxesManager() - - UA = MagicMock() - atom0 = MagicMock() - atom0.position = np.zeros(3) - UA.__getitem__.return_value = atom0 - - axes.get_vector = MagicMock(return_value=np.array([-1.0, 0.0, 0.0])) - - custom_axes = np.eye(3) - out = axes.get_flipped_axes( - UA, custom_axes, np.zeros(3), np.array([10, 10, 10]) - ) - - np.testing.assert_array_equal(out[0], np.array([-1.0, 0.0, 0.0])) - - def test_get_moment_of_inertia_tensor_simple(self): - """ - Tests that: `get_moment_of_inertia` Computes inertia tensor correctly. - """ - axes = AxesManager() - - center = np.zeros(3) - pos = np.array([[1, 0, 0], [0, 1, 0]]) - masses = np.array([1.0, 1.0]) - dimensions = np.array([100.0, 100.0, 100.0]) - - tensor = axes.get_moment_of_inertia_tensor(center, pos, masses, dimensions) - - expected = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 2]]) - np.testing.assert_array_equal(tensor, expected) - - def test_get_custom_principal_axes_flips_z(self): - """ - Tests that: `get_custom_principle_axes` ensures right-handed axes. - """ - axes = AxesManager() - - with patch("CodeEntropy.axes.np.linalg.eig") as eig: - eig.return_value = ( - np.array([3, 2, 1]), - np.array([[1, 0, 0], [0, 1, 0], [0, 0, -1]]), - ) - - axes_out, moi = axes.get_custom_principal_axes(np.eye(3)) - - np.testing.assert_array_equal(axes_out[2], np.array([0, 0, 1])) - - def test_get_UA_masses_sums_hydrogens(self): - """ - Tests that: `get_UA_masses` sums heavy atom with bonded hydrogens. - """ - axes = AxesManager() - - heavy = MagicMock(mass=12.0, index=0) - light = MagicMock(mass=1.0, index=1) - - mol = MagicMock() - mol.__iter__.return_value = iter([heavy, light]) - - bonded = MagicMock() - H = MagicMock(mass=1.0) - mol.select_atoms.return_value = bonded - bonded.select_atoms.return_value = [H] - - out = axes.get_UA_masses(mol) - - assert out == [13.0] diff --git a/tests/test_CodeEntropy/test_base.py b/tests/test_CodeEntropy/test_base.py deleted file mode 100644 index 2c4da9a8..00000000 --- a/tests/test_CodeEntropy/test_base.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import shutil -import tempfile -import unittest - - -class BaseTestCase(unittest.TestCase): - """ - Base test case class for cross-platform unit tests. - - Provides: - 1. A unique temporary directory for each test to avoid filesystem conflicts. - 2. Automatic restoration of the working directory after each test. - 3. Prepares a logs folder path for tests that need logging configuration. - """ - - def setUp(self): - """ - Prepare the test environment before each test method runs. - - Actions performed: - 1. Creates a unique temporary directory for the test. - 2. Creates a 'logs' subdirectory within the temp directory. - 3. Changes the current working directory to the temporary directory. - """ - # Create a unique temporary test directory - self.test_dir = tempfile.mkdtemp(prefix="CodeEntropy_") - self.logs_path = os.path.join(self.test_dir, "logs") - os.makedirs(self.logs_path, exist_ok=True) - - self._orig_dir = os.getcwd() - os.chdir(self.test_dir) - - def tearDown(self): - """ - Clean up the test environment after each test method runs. - - Actions performed: - 1. Restores the original working directory. - 2. Deletes the temporary test directory along with all its contents. - """ - os.chdir(self._orig_dir) - - if os.path.exists(self.test_dir): - shutil.rmtree(self.test_dir, ignore_errors=True) diff --git a/tests/test_CodeEntropy/test_data_logger.py b/tests/test_CodeEntropy/test_data_logger.py deleted file mode 100644 index ae503286..00000000 --- a/tests/test_CodeEntropy/test_data_logger.py +++ /dev/null @@ -1,136 +0,0 @@ -import json -import unittest - -import numpy as np -import pandas as pd - -from CodeEntropy.core.logging import LoggingConfig -from CodeEntropy.main import main -from CodeEntropy.results.reporter import DataLogger -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestDataLogger(BaseTestCase): - """ - Unit tests for the DataLogger class. - """ - - def setUp(self): - super().setUp() - self.code_entropy = main - self.logger = DataLogger() - self.output_file = "test_output.json" - - def test_init(self): - """ - Test that the DataLogger initializes with empty molecule and residue data lists. - """ - self.assertEqual(self.logger.molecule_data, []) - self.assertEqual(self.logger.residue_data, []) - - def test_add_results_data(self): - """ - Test that add_results_data correctly appends a molecule-level entry. - """ - self.logger.add_results_data( - 0, "united_atom", "Transvibrational", 653.4041220313459 - ) - self.assertEqual( - self.logger.molecule_data, - [(0, "united_atom", "Transvibrational", 653.4041220313459)], - ) - - def test_add_residue_data(self): - """ - Test that add_residue_data correctly appends a residue-level entry. - """ - self.logger.add_residue_data( - 0, "DA", "united_atom", "Transvibrational", 10, 122.61216935211893 - ) - self.assertEqual( - self.logger.residue_data, - [[0, "DA", "united_atom", "Transvibrational", 10, 122.61216935211893]], - ) - - def test_add_residue_data_with_numpy_array(self): - """ - Test that add_residue_data correctly converts a NumPy array to a list. - """ - frame_array = np.array([10]) - self.logger.add_residue_data( - 1, "DT", "united_atom", "Transvibrational", frame_array, 98.123456789 - ) - self.assertEqual( - self.logger.residue_data, - [[1, "DT", "united_atom", "Transvibrational", [10], 98.123456789]], - ) - - def test_save_dataframes_as_json(self): - """ - Test that save_dataframes_as_json correctly writes molecule and residue data - to a JSON file with the expected structure and values. - """ - molecule_df = pd.DataFrame( - [ - { - "Molecule ID": 0, - "Level": "united_atom", - "Type": "Transvibrational (J/mol/K)", - "Result": 653.404, - }, - { - "Molecule ID": 1, - "Level": "united_atom", - "Type": "Rovibrational (J/mol/K)", - "Result": 236.081, - }, - ] - ) - residue_df = pd.DataFrame( - [ - { - "Molecule ID": 0, - "Residue": 0, - "Type": "Transvibrational (J/mol/K)", - "Result": 122.612, - }, - { - "Molecule ID": 1, - "Residue": 0, - "Type": "Conformational (J/mol/K)", - "Result": 6.845, - }, - ] - ) - - self.logger.save_dataframes_as_json(molecule_df, residue_df, self.output_file) - - with open(self.output_file, "r") as f: - data = json.load(f) - - self.assertIn("molecule_data", data) - self.assertIn("residue_data", data) - self.assertEqual(data["molecule_data"][0]["Type"], "Transvibrational (J/mol/K)") - self.assertEqual(data["residue_data"][0]["Residue"], 0) - - def test_log_tables_rich_output(self): - console = LoggingConfig.get_console() - - self.logger.add_results_data( - 0, "united_atom", "Transvibrational", 653.4041220313459 - ) - self.logger.add_residue_data( - 0, "DA", "united_atom", "Transvibrational", 10, 122.61216935211893 - ) - self.logger.add_group_label(0, "DA", 10, 100) - - self.logger.log_tables() - - output = console.export_text() - assert "Molecule Entropy Results" in output - assert "Residue Entropy Results" in output - assert "Group ID to Residue Label Mapping" in output - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_CodeEntropy/test_dihedral_tools.py b/tests/test_CodeEntropy/test_dihedral_tools.py deleted file mode 100644 index 99071f93..00000000 --- a/tests/test_CodeEntropy/test_dihedral_tools.py +++ /dev/null @@ -1,571 +0,0 @@ -from unittest.mock import MagicMock, patch - -from CodeEntropy.dihedral_tools import DihedralAnalysis -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestDihedralAnalysis(BaseTestCase): - """ - Unit tests for DihedralAnalysis. - """ - - def setUp(self): - super().setUp() - self.analysis = DihedralAnalysis() - - def test_get_dihedrals_united_atom(self): - """ - Test `_get_dihedrals` for 'united_atom' level. - - The function should: - - read dihedrals from `data_container.dihedrals` - - extract `.atoms` from each dihedral - - return a list of atom groups - - Expected behavior: - If dihedrals = [d1, d2, d3] and each dihedral has an `.atoms` - attribute, then the returned list must be: - [d1.atoms, d2.atoms, d3.atoms] - """ - data_container = MagicMock() - - # Mock dihedral objects with `.atoms` - d1 = MagicMock() - d1.atoms = "atoms1" - d2 = MagicMock() - d2.atoms = "atoms2" - d3 = MagicMock() - d3.atoms = "atoms3" - - data_container.dihedrals = [d1, d2, d3] - - result = self.analysis._get_dihedrals(data_container, level="united_atom") - - self.assertEqual(result, ["atoms1", "atoms2", "atoms3"]) - - def test_get_dihedrals_residue(self): - """ - Test `_get_dihedrals` for 'residue' level with 5 residues. - - The implementation: - - iterates over residues 4 → N - - for each, selects 4 bonded atom groups - - merges them using __add__ to form a single atom_group - - appends to result list - - For 5 residues (0–4), two dihedral groups should be created. - Expected: - - result of length 2 - - each item equal to the merged mock atom group - """ - data_container = MagicMock() - data_container.residues = [0, 1, 2, 3, 4] - - mock_atom_group = MagicMock() - mock_atom_group.__add__.return_value = mock_atom_group - - # Every MDAnalysis selection returns the same mock atom group - data_container.select_atoms.return_value = mock_atom_group - - result = self.analysis._get_dihedrals(data_container, level="residue") - - self.assertEqual(len(result), 2) - self.assertTrue(all(r is mock_atom_group for r in result)) - - def test_get_dihedrals_no_residue(self): - """ - Test `_get_dihedrals` for 'residue' level when fewer than - 4 residues exist (here: 3 residues). - - Expected: - - The function returns an empty list. - """ - data_container = MagicMock() - data_container.residues = [0, 1, 2] # Only 3 residues → too few - - result = self.analysis._get_dihedrals(data_container, level="residue") - - self.assertEqual(result, []) - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_identify_peaks_empty_dihedrals(self, Dihedral_patch): - """ - Test `_identify_peaks` returns an empty list when the - input dihedral list is empty. - - Expected: - - No angle extraction occurs. - - No histograms computed. - - Return value is an empty list. - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - peaks = analysis._identify_peaks( - data_container=MagicMock(), - molecules=[0], - dihedrals=[], - bin_width=10, - start=0, - end=360, - step=1, - ) - - assert peaks == [] - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_identify_peaks_negative_angles_become_positive(self, Dihedral_patch): - """ - Test that negative dihedral angles are converted into the - 0–360° range before histogramming. - - Scenario: - - A single dihedral produces a single angle: -15°. - - This should be converted to +345°. - - With 90° bins, it falls into the final bin → one peak. - - Expected: - - One peak detected. - - Peak center lies between 300° and 360°. - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - R = MagicMock() - R.results.angles = [[-15]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0] - universe_operations.get_molecule_container.return_value = mol - - peaks = analysis._identify_peaks( - MagicMock(), - [0], - dihedrals=[MagicMock()], - bin_width=90, - start=0, - end=360, - step=1, - ) - - assert len(peaks) == 1 - assert len(peaks[0]) == 1 - assert 300 <= peaks[0][0] <= 360 - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_identify_peaks_internal_peak_detection(self, Dihedral_patch): - """ - Test the detection of a peak located in a middle histogram bin. - - Scenario: - - Angles fall into bin #1 (45°, 50°, 55°). - - Bin 1 has higher population than its neighbors. - - Expected: - - Exactly one peak is detected. - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - R = MagicMock() - R.results.angles = [[45], [50], [55]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0, 1, 2] - universe_operations.get_molecule_container.return_value = mol - - peaks = analysis._identify_peaks( - MagicMock(), - [0], - dihedrals=[MagicMock()], - bin_width=90, - start=0, - end=360, - step=1, - ) - - assert len(peaks[0]) == 1 - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_identify_peaks_circular_boundary(self, Dihedral_patch): - """ - Test that `_identify_peaks` handles circular histogram boundaries - correctly when identifying peaks in the last bin. - - Setup: - - All angles are near 350°, falling into the final bin. - - Expected: - - The final bin is correctly identified as a peak. - """ - ops = MagicMock() - analysis = DihedralAnalysis(ops) - - R = MagicMock() - R.results.angles = [[350], [355], [349]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0, 1, 2] - ops.get_molecule_container.return_value = mol - - peaks = analysis._identify_peaks( - MagicMock(), - [0], - dihedrals=[MagicMock()], - bin_width=90, - start=0, - end=360, - step=1, - ) - - assert len(peaks[0]) == 1 - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_identify_peaks_circular_last_bin(self, Dihedral_patch): - """ - Test peak detection for circular histogram boundaries, where the - last bin compares against the first bin. - - Scenario: - - All angles near 350° fall into the final bin. - - Final bin should be considered a peak if it exceeds both - previous and first bins. - - Expected: - - One peak detected in the last bin. - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - R = MagicMock() - R.results.angles = [[350], [355], [349]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0, 1, 2] - universe_operations.get_molecule_container.return_value = mol - - peaks = analysis._identify_peaks( - MagicMock(), - [0], - dihedrals=[MagicMock()], - bin_width=90, - start=0, - end=360, - step=1, - ) - - assert len(peaks[0]) == 1 - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_assign_states_negative_angle_conversion(self, Dihedral_patch): - """ - Test `_assign_states` converts negative angles correctly and assigns - the dihedral to the nearest peak. - - Scenario: - - Angle returned = -10° → converted to 350°. - - Peak list contains [350]. - - Expected: - - Assigned state is "0". - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - R = MagicMock() - R.results.angles = [[-10]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0] - universe_operations.get_molecule_container.return_value = mol - - states = analysis._assign_states( - MagicMock(), - [0], - dihedrals=[MagicMock()], - peaks=[[350]], - start=0, - end=360, - step=1, - ) - - assert states == ["0"] - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_assign_states_closest_peak_selection(self, Dihedral_patch): - """ - Test that `_assign_states` selects the peak nearest to each dihedral - angle. - - Setup: - - Angle = 30°. - - Peaks = [20, 100]. - - Nearest peak = 20 (index 0). - - Expected: - - Returned state is ["0"]. - """ - ops = MagicMock() - analysis = DihedralAnalysis(ops) - - R = MagicMock() - R.results.angles = [[30]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0] - ops.get_molecule_container.return_value = mol - - states = analysis._assign_states( - MagicMock(), - [0], - dihedrals=[MagicMock()], - peaks=[[20, 100]], - start=0, - end=360, - step=1, - ) - - assert states == ["0"] - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_assign_states_closest_peak(self, Dihedral_patch): - """ - Test assignment to the correct peak based on minimum angular distance. - - Scenario: - - Angle = 30°. - - Peaks = [20, 100]. - - Closest peak is 20° → index 0. - - Expected: - - Returned state is "0". - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - R = MagicMock() - R.results.angles = [[30]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0] - universe_operations.get_molecule_container.return_value = mol - - states = analysis._assign_states( - MagicMock(), - [0], - dihedrals=[MagicMock()], - peaks=[[20, 100]], - start=0, - end=360, - step=1, - ) - - assert states == ["0"] - - @patch("CodeEntropy.dihedral_tools.Dihedral") - def test_assign_states_multiple_dihedrals(self, Dihedral_patch): - """ - Test concatenation of state labels across multiple dihedrals. - - Scenario: - - Two dihedrals, one frame: - dihedral 0 → 10° → closest peak 0 - dihedral 1 → 200° → closest peak 180 (index 0) - - Resulting frame state: "00". - - Expected: - - Returned list: ["00"]. - """ - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - R = MagicMock() - R.results.angles = [[10, 200]] - Dihedral_patch.return_value.run.return_value = R - - mol = MagicMock() - mol.trajectory = [0] - universe_operations.get_molecule_container.return_value = mol - - peaks = [[0, 180], [180, 300]] - - states = analysis._assign_states( - MagicMock(), - [0], - dihedrals=[MagicMock(), MagicMock()], - peaks=peaks, - start=0, - end=360, - step=1, - ) - - assert states == ["00"] - - def test_assign_states_multiple_molecules(self): - """ - Test that `_assign_states` generates different conformational state - labels for different molecules when their dihedral angle trajectories - differ. - - Molecule 0 is mocked to produce an angle near peak 0. - Molecule 1 is mocked to produce an angle near peak 1. - - Expected: - The returned state list reflects these differences as - ["0", "1"]. - """ - - universe_operations = MagicMock() - analysis = DihedralAnalysis(universe_operations) - - mol1 = MagicMock() - mol1.trajectory = [0] - - mol2 = MagicMock() - mol2.trajectory = [0] - - universe_operations.get_molecule_container.side_effect = [mol1, mol2] - - # Two different R objects - R1 = MagicMock() - R1.results.angles = [[10]] # peak index 0 - - R2 = MagicMock() - R2.results.angles = [[200]] # peak index 1 - - peaks = [[0, 180]] - - # Patch where Dihedral is *used* - with patch("CodeEntropy.dihedral_tools.Dihedral") as Dihedral_patch: - instance = Dihedral_patch.return_value - instance.run.side_effect = [R1, R2] - - states = analysis._assign_states( - MagicMock(), - molecules=[0, 1], - dihedrals=[MagicMock()], - peaks=peaks, - start=0, - end=360, - step=1, - ) - - assert states == ["0", "1"] - - def test_build_states_united_atom_no_dihedrals(self): - """ - Test that UA-level state building produces empty state lists when no - dihedrals are found for any residue. - """ - ops = MagicMock() - analysis = DihedralAnalysis(ops) - - mol = MagicMock() - mol.residues = [MagicMock()] - ops.get_molecule_container.return_value = mol - ops.new_U_select_atom.return_value = MagicMock() - - analysis._get_dihedrals = MagicMock(return_value=[]) - analysis._identify_peaks = MagicMock(return_value=[]) - analysis._assign_states = MagicMock(return_value=[]) - - groups = {0: [0]} - levels = {0: ["united_atom"]} - - states_ua, states_res = analysis.build_conformational_states( - MagicMock(), levels, groups, start=0, end=360, step=1, bin_width=10 - ) - - assert states_ua[(0, 0)] == [] - - def test_build_states_united_atom_accumulate(self): - """ - Test that UA-level state building assigns states independently to each - residue and accumulates them correctly. - """ - ops = MagicMock() - analysis = DihedralAnalysis(ops) - - mol = MagicMock() - mol.residues = [MagicMock(), MagicMock()] - ops.get_molecule_container.return_value = mol - ops.new_U_select_atom.return_value = MagicMock() - - analysis._get_dihedrals = MagicMock(return_value=[1]) - analysis._identify_peaks = MagicMock(return_value=[[10]]) - analysis._assign_states = MagicMock(return_value=["A"]) - - groups = {0: [0]} - levels = {0: ["united_atom"]} - - states_ua, _ = analysis.build_conformational_states( - MagicMock(), levels, groups, start=0, end=360, step=1, bin_width=10 - ) - - assert states_ua[(0, 0)] == ["A"] - assert states_ua[(0, 1)] == ["A"] - - def test_build_states_residue_no_dihedrals(self): - """ - Test that residue-level state building returns an empty list when - `_get_dihedrals` reports no available dihedral groups. - """ - ops = MagicMock() - analysis = DihedralAnalysis(ops) - - mol = MagicMock() - mol.residues = [MagicMock()] - ops.get_molecule_container.return_value = mol - - analysis._get_dihedrals = MagicMock(return_value=[]) - analysis._identify_peaks = MagicMock(return_value=[]) - analysis._assign_states = MagicMock(return_value=[]) - - groups = {0: [0]} - levels = {0: ["residue"]} - - _, states_res = analysis.build_conformational_states( - MagicMock(), levels, groups, start=0, end=360, step=1, bin_width=10 - ) - - assert states_res[0] == [] - - def test_build_states_residue_accumulate(self): - """ - Test that residue-level state building delegates all molecules in a group - to a single `_assign_states` call, and stores its returned list directly. - - Expected: - _assign_states returns ["A", "B"], so states_res[0] == ["A", "B"]. - """ - ops = MagicMock() - analysis = DihedralAnalysis(ops) - - mol1 = MagicMock() - mol1.residues = [MagicMock()] - mol2 = MagicMock() - mol2.residues = [MagicMock()] - - ops.get_molecule_container.side_effect = [mol1, mol2] - - analysis._get_dihedrals = MagicMock(return_value=[1]) - analysis._identify_peaks = MagicMock(return_value=[[10]]) - - # One call for the whole group → one return value - analysis._assign_states = MagicMock(return_value=["A", "B"]) - - groups = {0: [0, 1]} - levels = {0: ["residue"], 1: ["residue"]} - - _, states_res = analysis.build_conformational_states( - MagicMock(), levels, groups, start=0, end=360, step=1, bin_width=10 - ) - - assert states_res[0] == ["A", "B"] diff --git a/tests/test_CodeEntropy/test_entropy.py b/tests/test_CodeEntropy/test_entropy.py deleted file mode 100644 index 80fee4aa..00000000 --- a/tests/test_CodeEntropy/test_entropy.py +++ /dev/null @@ -1,2135 +0,0 @@ -import logging -import math -import os -import shutil -import tempfile -import unittest -from unittest.mock import MagicMock, PropertyMock, call, patch - -import MDAnalysis as mda -import numpy as np -import numpy.linalg as la -import pytest - -import tests.data as data -from CodeEntropy.entropy import ( - ConformationalEntropy, - EntropyManager, - OrientationalEntropy, - VibrationalEntropy, -) -from CodeEntropy.levels import LevelManager -from CodeEntropy.main import main -from CodeEntropy.mda_universe_operations import UniverseOperations -from CodeEntropy.results.reporter import DataLogger -from CodeEntropy.run import ConfigManager, RunManager -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestEntropyManager(BaseTestCase): - """ - Unit tests for EntropyManager. - """ - - def setUp(self): - super().setUp() - self.test_data_dir = os.path.dirname(data.__file__) - - # Disable MDAnalysis and commands file logging entirely - logging.getLogger("MDAnalysis").handlers = [logging.NullHandler()] - logging.getLogger("commands").handlers = [logging.NullHandler()] - - def test_execute_full_workflow(self): - # Setup universe and args - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - u = mda.Universe(tprfile, trrfile) - - args = MagicMock( - bin_width=0.1, temperature=300, selection_string="all", water_entropy=False - ) - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - dihedral_analysis = MagicMock() - entropy_manager = EntropyManager( - run_manager, - args, - u, - data_logger, - level_manager, - group_molecules, - dihedral_analysis, - universe_operations, - ) - - # Mocks for trajectory and molecules - entropy_manager._get_trajectory_bounds = MagicMock(return_value=(0, 10, 1)) - entropy_manager._get_number_frames = MagicMock(return_value=11) - entropy_manager._handle_water_entropy = MagicMock() - - mock_reduced_atom = MagicMock() - mock_reduced_atom.trajectory = [1] * 11 - - mock_groups = {0: [0], 1: [1], 2: [2]} - mock_levels = { - 0: ["united_atom", "polymer", "residue"], - 1: ["united_atom", "polymer", "residue"], - 2: ["united_atom", "polymer", "residue"], - } - - entropy_manager._initialize_molecules = MagicMock( - return_value=(mock_reduced_atom, 3, mock_levels, mock_groups) - ) - entropy_manager._level_manager.build_covariance_matrices = MagicMock( - return_value=( - "force_matrices", - "torque_matrices", - "forcetorque_avg", - "frame_counts", - ) - ) - entropy_manager._dihedral_analysis.build_conformational_states = MagicMock( - return_value=(["state_ua"], ["state_res"]) - ) - entropy_manager._compute_entropies = MagicMock() - entropy_manager._finalize_molecule_results = MagicMock() - entropy_manager._data_logger.log_tables = MagicMock() - - # Create mocks for VibrationalEntropy and ConformationalEntropy - ve = MagicMock() - ce = MagicMock() - - # Patch both VibrationalEntropy, ConformationalEntropy AND u.atoms.fragments - mock_molecule = MagicMock() - mock_molecule.residues = [] - - with ( - patch("CodeEntropy.entropy.VibrationalEntropy", return_value=ve), - patch("CodeEntropy.entropy.ConformationalEntropy", return_value=ce), - patch.object( - type(u.atoms), "fragments", new_callable=PropertyMock - ) as mock_fragments, - ): - mock_fragments.return_value = [mock_molecule] * 10 - entropy_manager.execute() - - # Assert the key calls happened with expected arguments - build_states = entropy_manager._dihedral_analysis.build_conformational_states - build_states.assert_called_once_with( - mock_reduced_atom, - mock_levels, - mock_groups, - 0, - 10, - 1, - args.bin_width, - ) - - entropy_manager._compute_entropies.assert_called_once_with( - mock_reduced_atom, - mock_levels, - mock_groups, - "force_matrices", - "torque_matrices", - "forcetorque_avg", - ["state_ua"], - ["state_res"], - "frame_counts", - 11, - ve, - ce, - ) - - entropy_manager._finalize_molecule_results.assert_called_once() - entropy_manager._data_logger.log_tables.assert_called_once() - - def test_execute_triggers_handle_water_entropy_minimal(self): - """ - Minimal test to ensure _handle_water_entropy line is executed. - """ - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - u = mda.Universe(tprfile, trrfile) - - args = MagicMock( - bin_width=0.1, temperature=300, selection_string="all", water_entropy=True - ) - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - dihedral_analysis = MagicMock() - entropy_manager = EntropyManager( - run_manager, - args, - u, - data_logger, - level_manager, - group_molecules, - dihedral_analysis, - universe_operations, - ) - - entropy_manager._get_trajectory_bounds = MagicMock(return_value=(0, 10, 1)) - entropy_manager._get_number_frames = MagicMock(return_value=11) - entropy_manager._initialize_molecules = MagicMock( - return_value=(MagicMock(), 3, {}, {0: [0]}) - ) - entropy_manager._level_manager.build_covariance_matrices = MagicMock( - return_value=( - "force_matrices", - "torque_matrices", - "forcetorque_avg", - "frame_counts", - ) - ) - entropy_manager._dihedral_analysis.build_conformational_states = MagicMock( - return_value=(["state_ua"], ["state_res"]) - ) - entropy_manager._compute_entropies = MagicMock() - entropy_manager._finalize_molecule_results = MagicMock() - entropy_manager._data_logger.log_tables = MagicMock() - - with ( - patch("CodeEntropy.entropy.VibrationalEntropy", return_value=MagicMock()), - patch( - "CodeEntropy.entropy.ConformationalEntropy", return_value=MagicMock() - ), - patch.object( - type(u.atoms), "fragments", new_callable=PropertyMock - ) as mock_fragments, - patch.object(u, "select_atoms") as mock_select_atoms, - patch.object( - entropy_manager, "_handle_water_entropy" - ) as mock_handle_water_entropy, - ): - mock_fragments.return_value = [MagicMock(residues=[MagicMock(resid=1)])] - mock_select_atoms.return_value = MagicMock(residues=[MagicMock(resid=1)]) - - entropy_manager.execute() - - mock_handle_water_entropy.assert_called_once() - - def test_water_entropy_sets_selection_string_when_all(self): - """ - If selection_string is 'all' and water entropy is enabled, - _handle_water_entropy should update it to 'not water'. - """ - mock_universe = MagicMock() - args = MagicMock(water_entropy=True, selection_string="all") - manager = EntropyManager( - MagicMock(), - args, - mock_universe, - DataLogger(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - manager._calculate_water_entropy = MagicMock() - manager._data_logger.add_group_label = MagicMock() - - water_groups = {0: [0, 1, 2]} - - manager._handle_water_entropy(0, 10, 1, water_groups) - - assert manager._args.selection_string == "not water" - manager._calculate_water_entropy.assert_called_once() - - def test_water_entropy_appends_to_custom_selection_string(self): - """ - If selection_string is custom and water entropy is enabled, - _handle_water_entropy appends ' and not water'. - """ - mock_universe = MagicMock() - args = MagicMock(water_entropy=True, selection_string="protein") - manager = EntropyManager( - MagicMock(), - args, - mock_universe, - DataLogger(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - manager._calculate_water_entropy = MagicMock() - manager._data_logger.add_group_label = MagicMock() - - water_groups = {0: [0, 1, 2]} - - manager._handle_water_entropy(0, 10, 1, water_groups) - - manager._calculate_water_entropy.assert_called_once() - assert args.selection_string == "protein and not water" - - def test_handle_water_entropy_returns_early(self): - """ - Verifies that _handle_water_entropy returns immediately if: - 1. water_groups is empty - 2. water_entropy is disabled - """ - mock_universe = MagicMock() - args = MagicMock(water_entropy=True, selection_string="protein") - manager = EntropyManager( - MagicMock(), - args, - mock_universe, - DataLogger(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - # Patch _calculate_water_entropy to track if called - manager._calculate_water_entropy = MagicMock() - - # Case 1: empty water_groups - result = manager._handle_water_entropy(0, 10, 1, {}) - assert result is None - manager._calculate_water_entropy.assert_not_called() - - # Case 2: water_entropy disabled - manager._args.water_entropy = False - result = manager._handle_water_entropy(0, 10, 1, {0: [0, 1, 2]}) - assert result is None - manager._calculate_water_entropy.assert_not_called() - - def test_initialize_molecules(self): - """ - Test _initialize_molecules returns expected tuple by mocking internal methods. - - - Ensures _get_reduced_universe is called and its return is used. - - Ensures _level_manager.select_levels is called with the reduced atom - selection. - - Ensures _group_molecules.grouping_molecules is called with the reduced atom - and grouping arg. - - Verifies the returned tuple matches the mocked values. - """ - - args = MagicMock( - bin_width=0.1, temperature=300, selection_string="all", water_entropy=False - ) - run_manager = RunManager("mock_folder/job001") - level_manager = LevelManager(MagicMock()) - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - MagicMock(), - MagicMock(), - ) - - # Mock dependencies - manager._get_reduced_universe = MagicMock(return_value="mock_reduced_atom") - manager._level_manager = MagicMock() - manager._level_manager.select_levels = MagicMock( - return_value=(5, ["level1", "level2"]) - ) - manager._group_molecules = MagicMock() - manager._group_molecules.grouping_molecules = MagicMock( - return_value=["groupA", "groupB"] - ) - manager._args = MagicMock() - manager._args.grouping = "custom_grouping" - - # Call the method under test - result = manager._initialize_molecules() - - # Assert calls - manager._get_reduced_universe.assert_called_once() - manager._level_manager.select_levels.assert_called_once_with( - "mock_reduced_atom" - ) - manager._group_molecules.grouping_molecules.assert_called_once_with( - "mock_reduced_atom", "custom_grouping" - ) - - # Assert return value - expected = ("mock_reduced_atom", 5, ["level1", "level2"], ["groupA", "groupB"]) - self.assertEqual(result, expected) - - def test_get_trajectory_bounds(self): - """ - Tests that `_get_trajectory_bounds` runs and returns expected types. - """ - - config_manager = ConfigManager() - - parser = config_manager.setup_argparse() - args, _ = parser.parse_known_args() - - entropy_manager = EntropyManager( - MagicMock(), - args, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - self.assertIsInstance(entropy_manager._args.start, int) - self.assertIsInstance(entropy_manager._args.end, int) - self.assertIsInstance(entropy_manager._args.step, int) - - self.assertEqual(entropy_manager._get_trajectory_bounds(), (0, 0, 1)) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - start=0, - end=-1, - step=1, - ), - ) - def test_get_number_frames(self, mock_args): - """ - Test `_get_number_frames` when the end index is -1. - - Ensures that the function correctly counts all frames from start to - the end of the trajectory. - """ - config_manager = ConfigManager() - parser = config_manager.setup_argparse() - args = parser.parse_args() - - # Mock universe with a trajectory of 10 frames - mock_universe = MagicMock() - mock_universe.trajectory = range(10) - - entropy_manager = EntropyManager( - MagicMock(), - args, - mock_universe, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - # Use _get_trajectory_bounds to convert end=-1 into the actual last frame - start, end, step = entropy_manager._get_trajectory_bounds() - number_frames = entropy_manager._get_number_frames(start, end, step) - - # Expect all frames to be counted - self.assertEqual(number_frames, 10) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - start=0, - end=20, - step=1, - ), - ) - def test_get_number_frames_sliced_trajectory(self, mock_args): - """ - Test `_get_number_frames` with a valid slicing range. - - Verifies that the function correctly calculates the number of frames - when slicing from 0 to 20 with a step of 1, expecting 21 frames. - """ - config_manager = ConfigManager() - parser = config_manager.setup_argparse() - args = parser.parse_args() - - # Mock universe with 30 frames - mock_universe = MagicMock() - mock_universe.trajectory = range(30) - - entropy_manager = EntropyManager( - MagicMock(), - args, - mock_universe, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - start, end, step = entropy_manager._get_trajectory_bounds() - number_frames = entropy_manager._get_number_frames(start, end, step) - - self.assertEqual(number_frames, 20) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - start=0, - end=-1, - step=5, - ), - ) - def test_get_number_frames_sliced_trajectory_step(self, mock_args): - """ - Test `_get_number_frames` with a step that skips frames. - - Ensures that the function correctly counts the number of frames - when a step size of 5 is applied. - """ - config_manager = ConfigManager() - parser = config_manager.setup_argparse() - args = parser.parse_args() - - # Mock universe with 20 frames - mock_universe = MagicMock() - mock_universe.trajectory = range(20) - - entropy_manager = EntropyManager( - MagicMock(), - args, - mock_universe, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - start, end, step = entropy_manager._get_trajectory_bounds() - number_frames = entropy_manager._get_number_frames(start, end, step) - - # Expect 20 frames divided by step of 5 = 4 frames - self.assertEqual(number_frames, 4) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - selection_string="all", - ), - ) - def test_get_reduced_universe_all(self, mock_args): - """ - Test `_get_reduced_universe` with 'all' selection. - - Verifies that the full universe is returned when the selection string - is set to 'all', and the number of atoms remains unchanged. - """ - # Load MDAnalysis Universe - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - u = mda.Universe(tprfile, trrfile) - - config_manager = ConfigManager() - - parser = config_manager.setup_argparse() - args = parser.parse_args() - - entropy_manager = EntropyManager( - MagicMock(), - args, - u, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - entropy_manager._get_reduced_universe() - - self.assertEqual(entropy_manager._universe.atoms.n_atoms, 254) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - selection_string="resname DA", - ), - ) - def test_get_reduced_universe_reduced(self, mock_args): - """ - Test `_get_reduced_universe` with a specific atom selection. - - Ensures that the reduced universe contains fewer atoms than the original - when a specific selection string is used. - """ - - # Load MDAnalysis Universe - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - u = mda.Universe(tprfile, trrfile) - - universe_operations = UniverseOperations() - - config_manager = ConfigManager() - run_manager = RunManager("mock_folder/job001") - - parser = config_manager.setup_argparse() - args = parser.parse_args() - - entropy_manager = EntropyManager( - run_manager, - args, - u, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - universe_operations, - ) - - reduced_u = entropy_manager._get_reduced_universe() - - # Assert that the reduced universe has fewer atoms - assert len(reduced_u.atoms) < len(u.atoms) - - @patch( - "argparse.ArgumentParser.parse_args", - return_value=MagicMock( - selection_string="all", - ), - ) - def test_process_united_atom_entropy(self, selection_string_mock): - """ - Tests that `_process_united_atom_entropy` correctly logs global and - residue-level entropy results for a mocked molecular system. - """ - # Setup managers and arguments - args = MagicMock(bin_width=0.1, temperature=300, selection_string="all") - universe_operations = UniverseOperations() - run_manager = MagicMock(universe_operations) - level_manager = MagicMock() - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # Mock molecule container with residues and atoms - n_residues = 3 - mock_residues = [MagicMock(resname=f"RES{i}") for i in range(n_residues)] - mock_atoms_per_mol = 3 - mock_atoms = [MagicMock() for _ in range(mock_atoms_per_mol)] # per molecule - mol_container = MagicMock(residues=mock_residues, atoms=mock_atoms) - - # Create dummy matrices and states - force_matrix = {(0, i): np.eye(3) for i in range(n_residues)} - torque_matrix = {(0, i): np.eye(3) * 2 for i in range(n_residues)} - states = {(0, i): np.ones((10, 3)) for i in range(n_residues)} - - # Mock entropy calculators - ve = MagicMock() - ce = MagicMock() - ve.vibrational_entropy_calculation.side_effect = lambda m, t, temp, high: ( - 1.0 if t == "force" else 2.0 - ) - ce.conformational_entropy_calculation.return_value = 3.0 - - # Manually add the group label so group_id=0 exists - data_logger.add_group_label( - 0, - "_".join(f"RES{i}" for i in range(n_residues)), # label - n_residues, # residue_count - len(mock_atoms) * n_residues, # total atoms for the group - ) - - # Run the method - manager._process_united_atom_entropy( - group_id=0, - mol_container=mol_container, - ve=ve, - ce=ce, - level="united_atom", - force_matrix=force_matrix, - torque_matrix=torque_matrix, - states=states, - highest=True, - number_frames=10, - frame_counts={(0, i): 10 for i in range(n_residues)}, - ) - - # Check molecule-level results - df = data_logger.molecule_data - assert len(df) == 3 # Trans, Rot, Conf - - # Check residue-level results - residue_df = data_logger.residue_data - assert len(residue_df) == 3 * n_residues # 3 types per residue - - # Check that all expected types are present - expected_types = {"Transvibrational", "Rovibrational", "Conformational"} - actual_types = set(entry[2] for entry in df) - assert actual_types == expected_types - - residue_types = set(entry[3] for entry in residue_df) - assert residue_types == expected_types - - # Check group label logging - group_label = data_logger.group_labels[0] # Access by group_id key - assert group_label["label"] == "_".join(f"RES{i}" for i in range(n_residues)) - assert group_label["residue_count"] == n_residues - assert group_label["atom_count"] == len(mock_atoms) * n_residues - - def test_process_vibrational_only_levels(self): - """ - Tests that `_process_vibrational_entropy` correctly logs vibrational - entropy results for a known molecular system using MDAnalysis. - """ - # Load a known test universe - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - u = mda.Universe(tprfile, trrfile) - - # Setup managers and arguments - args = MagicMock(bin_width=0.1, temperature=300, selection_string="all") - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - u, - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # Prepare mock molecule container - reduced_atom = manager._get_reduced_universe() - mol_container = universe_operations.get_molecule_container(reduced_atom, 0) - - # Simulate trajectory length - mol_container.trajectory = [None] * 10 # 10 frames - - # Create dummy matrices - force_matrix = np.eye(3) - torque_matrix = np.eye(3) * 2 - - # Mock entropy calculator - ve = MagicMock() - ve.vibrational_entropy_calculation.side_effect = [1.11, 2.22] - - forcetorque_matrix = np.eye(6) - - # Run the method - manager._process_vibrational_entropy( - group_id=0, - mol_container=mol_container, - number_frames=10, - ve=ve, - level="Vibrational", - force_matrix=force_matrix, - torque_matrix=torque_matrix, - forcetorque_matrix=forcetorque_matrix, - highest=True, - ) - - # Check that results were logged - df = data_logger.molecule_data - self.assertEqual(len(df), 2) # Transvibrational and Rovibrational - - expected_types = {"FTmat-Transvibrational", "FTmat-Rovibrational"} - actual_types = set(entry[2] for entry in df) - self.assertSetEqual(actual_types, expected_types) - - results = [entry[3] for entry in df] - self.assertIn(1.11, results) - self.assertIn(2.22, results) - - def test_process_vibrational_entropy_else_branch(self): - """ - Atomic unit test for EntropyManager._process_vibrational_entropy else-branch: - - forcetorque_matrix is None - - force/torque matrices are filtered - - ve.vibrational_entropy_calculation called for force & torque - - results logged as Transvibrational/Rovibrational - - group label added from mol_container residues/atoms - """ - manager = MagicMock() - manager._args = MagicMock(temperature=300) - - manager._level_manager = MagicMock() - manager._data_logger = MagicMock() - - force_matrix = np.eye(3) - torque_matrix = np.eye(3) * 2 - - filtered_force = np.eye(3) * 7 - filtered_torque = np.eye(3) * 9 - manager._level_manager.filter_zero_rows_columns.side_effect = [ - filtered_force, - filtered_torque, - ] - - ve = MagicMock() - ve.vibrational_entropy_calculation.side_effect = [1.11, 2.22] - - res1 = MagicMock(resname="ALA") - res2 = MagicMock(resname="GLY") - res3 = MagicMock(resname="ALA") - mol_container = MagicMock() - mol_container.residues = [res1, res2, res3] - mol_container.atoms = [MagicMock(), MagicMock(), MagicMock(), MagicMock()] - - EntropyManager._process_vibrational_entropy( - manager, - group_id=0, - mol_container=mol_container, - number_frames=10, - ve=ve, - level="Vibrational", - force_matrix=force_matrix, - torque_matrix=torque_matrix, - forcetorque_matrix=None, - highest=True, - ) - - filter_calls = manager._level_manager.filter_zero_rows_columns.call_args_list - assert len(filter_calls) == 2 - - np.testing.assert_array_equal(filter_calls[0].args[0], force_matrix) - np.testing.assert_array_equal(filter_calls[1].args[0], torque_matrix) - - ve_calls = ve.vibrational_entropy_calculation.call_args_list - assert len(ve_calls) == 2 - - np.testing.assert_array_equal(ve_calls[0].args[0], filtered_force) - assert ve_calls[0].args[1:] == ("force", 300, True) - - np.testing.assert_array_equal(ve_calls[1].args[0], filtered_torque) - assert ve_calls[1].args[1:] == ("torque", 300, True) - - manager._data_logger.add_results_data.assert_any_call( - 0, "Vibrational", "Transvibrational", 1.11 - ) - manager._data_logger.add_results_data.assert_any_call( - 0, "Vibrational", "Rovibrational", 2.22 - ) - manager._data_logger.add_group_label.assert_called_once_with(0, "ALA_GLY", 3, 4) - - def test_compute_entropies_polymer_branch(self): - """ - Test _compute_entropies triggers _process_vibrational_entropy for 'polymer' - level. - """ - args = MagicMock(bin_width=0.1) - run_manager = MagicMock() - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - reduced_atom = MagicMock() - number_frames = 5 - groups = {0: [0]} # One molecule only - levels = [["polymer"]] # One level for that molecule - - force_matrices = {"poly": {0: np.eye(3)}} - torque_matrices = {"poly": {0: np.eye(3) * 2}} - states_ua = {} - states_res = [] - frame_counts = 10 - - mol_mock = MagicMock() - mol_mock.residues = [] - universe_operations.get_molecule_container = MagicMock(return_value=mol_mock) - manager._process_vibrational_entropy = MagicMock() - - ve = MagicMock() - ve.vibrational_entropy_calculation.side_effect = [1.11] - - ce = MagicMock() - ce.conformational_entropy_calculation.return_value = 3.33 - - manager._compute_entropies( - reduced_atom, - levels, - groups, - force_matrices, - torque_matrices, - force_matrices, - states_ua, - states_res, - frame_counts, - number_frames, - ve, - ce, - ) - - manager._process_vibrational_entropy.assert_called_once() - - def test_process_conformational_residue_level(self): - """ - Tests that `_process_conformational_entropy` correctly logs conformational - entropy results at the residue level for a known molecular system using - MDAnalysis. - """ - # Load a known test universe - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - u = mda.Universe(tprfile, trrfile) - - # Setup managers and arguments - args = MagicMock(bin_width=0.1, temperature=300, selection_string="all") - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - u, - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # Create dummy states - states = {0: np.ones((10, 3))} - - # Mock entropy calculator - ce = MagicMock() - ce.conformational_entropy_calculation.return_value = 3.33 - - # Run the method - manager._process_conformational_entropy( - group_id=0, - mol_container=MagicMock(), - ce=ce, - level="residue", - states=states, - number_frames=10, - ) - - # Check that results were logged - df = data_logger.molecule_data - self.assertEqual(len(df), 1) - - expected_types = {"Conformational"} - actual_types = set(entry[2] for entry in df) - self.assertSetEqual(actual_types, expected_types) - - results = [entry[3] for entry in df] - self.assertIn(3.33, results) - - def test_process_conformational_entropy_no_states_entry(self): - """ - Tests that `_process_conformational_entropy` logs zero entropy when - the group_id is not present in the states dictionary. - """ - # Setup minimal mock universe - u = MagicMock() - - # Setup managers and arguments - args = MagicMock() - universe_operations = MagicMock() - run_manager = MagicMock() - level_manager = MagicMock() - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - u, - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # States dict does NOT contain group_id=1 - states = {0: np.ones((10, 3))} - - # Mock entropy calculator - ce = MagicMock() - - # Run method with group_id=1 (not in states) - manager._process_conformational_entropy( - group_id=1, - mol_container=MagicMock(), - ce=ce, - level="residue", - states=states, - number_frames=10, - ) - - # Assert entropy is zero - self.assertEqual(data_logger.molecule_data[0][3], 0) - - # Assert calculator was not called - ce.conformational_entropy_calculation.assert_not_called() - - def test_compute_entropies_united_atom(self): - """ - Test that _process_united_atom_entropy is called correctly for 'united_atom' - level with highest=False when it's the only level. - """ - args = MagicMock(bin_width=0.1) - universe_operations = UniverseOperations() - run_manager = MagicMock() - level_manager = MagicMock() - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - reduced_atom = MagicMock() - number_frames = 10 - groups = {0: [0]} - levels = [["united_atom"]] # single level - - force_matrices = {"ua": {0: "force_ua"}} - torque_matrices = {"ua": {0: "torque_ua"}} - states_ua = {} - states_res = [] - frame_counts = {"ua": {(0, 0): 10}} - - mol_mock = MagicMock() - mol_mock.residues = [] - universe_operations.get_molecule_container = MagicMock(return_value=mol_mock) - manager._process_united_atom_entropy = MagicMock() - - force_torque_matrices = MagicMock() - - ve = MagicMock() - ce = MagicMock() - - manager._compute_entropies( - reduced_atom, - levels, - groups, - force_matrices, - torque_matrices, - force_torque_matrices, - states_ua, - states_res, - frame_counts, - number_frames, - ve, - ce, - ) - - manager._process_united_atom_entropy.assert_called_once_with( - 0, - mol_mock, - ve, - ce, - "united_atom", - force_matrices["ua"], - torque_matrices["ua"], - states_ua, - frame_counts["ua"], - True, # highest is True since only level - number_frames, - ) - - def test_compute_entropies_residue(self): - """ - Test that _process_vibrational_entropy and _process_conformational_entropy - are called correctly for 'residue' level with highest=True when it's the - only level. - """ - # Setup - args = MagicMock(bin_width=0.1) - universe_operations = UniverseOperations() - run_manager = MagicMock() - level_manager = MagicMock() - data_logger = DataLogger() - group_molecules = MagicMock() - manager = EntropyManager( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - reduced_atom = MagicMock() - number_frames = 10 - groups = {0: [0]} - levels = [["residue"]] # single level - - force_matrices = {"res": {0: "force_res"}} - torque_matrices = {"res": {0: "torque_res"}} - states_ua = {} - states_res = ["states_res"] - - # Frame counts for residue level - frame_counts = {"res": {(0, 0): 10}} - - # Mock molecule - mol_mock = MagicMock() - mol_mock.residues = [] - universe_operations.get_molecule_container = MagicMock(return_value=mol_mock) - manager._process_vibrational_entropy = MagicMock() - manager._process_conformational_entropy = MagicMock() - - force_torque_matrices = MagicMock() - - # Mock entropy calculators - ve = MagicMock() - ce = MagicMock() - - # Call the method under test - manager._compute_entropies( - reduced_atom, - levels, - groups, - force_matrices, - torque_matrices, - force_torque_matrices, - states_ua, - states_res, - frame_counts, - number_frames, - ve, - ce, - ) - - # Assert that the per-level processing methods were called - manager._process_vibrational_entropy.assert_called() - manager._process_conformational_entropy.assert_called() - - def test_compute_entropies_polymer(self): - args = MagicMock(bin_width=0.1) - universe_operations = UniverseOperations() - run_manager = MagicMock() - level_manager = MagicMock() - data_logger = DataLogger() - group_molecules = MagicMock() - dihedral_analysis = MagicMock() - - manager = EntropyManager( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - dihedral_analysis, - universe_operations, - ) - - reduced_atom = MagicMock() - number_frames = 10 - groups = {0: [0]} - levels = [["polymer"]] - - force_matrices = {"poly": {0: "force_poly"}} - torque_matrices = {"poly": {0: "torque_poly"}} - force_torque_matrices = {"poly": {0: "ft_poly"}} - - states_ua = {} - states_res = [] - frame_counts = {"poly": {(0, 0): 10}} - - mol_mock = MagicMock() - mol_mock.residues = [] - universe_operations.get_molecule_container = MagicMock(return_value=mol_mock) - manager._process_vibrational_entropy = MagicMock() - - ve = MagicMock() - ce = MagicMock() - - manager._compute_entropies( - reduced_atom, - levels, - groups, - force_matrices, - torque_matrices, - force_torque_matrices, - states_ua, - states_res, - frame_counts, - number_frames, - ve, - ce, - ) - - manager._process_vibrational_entropy.assert_called_once_with( - 0, - mol_mock, - number_frames, - ve, - "polymer", - force_matrices["poly"][0], - torque_matrices["poly"][0], - force_torque_matrices["poly"][0], - True, - ) - - def test_finalize_molecule_results_aggregates_and_logs_total_entropy(self): - """ - Tests that `_finalize_molecule_results` correctly aggregates entropy values per - molecule from `molecule_data`, appends a 'Group Total' entry, and calls - `save_dataframes_as_json` with the expected DataFrame structure. - """ - # Setup - args = MagicMock(output_file="mock_output.json") - data_logger = DataLogger() - data_logger.molecule_data = [ - ("mol1", "united_atom", "Transvibrational", 1.0), - ("mol1", "united_atom", "Rovibrational", 2.0), - ("mol1", "united_atom", "Conformational", 3.0), - ("mol2", "polymer", "Transvibrational", 4.0), - ] - data_logger.residue_data = [] - - manager = EntropyManager(None, args, None, data_logger, None, None, None, None) - - # Patch save method - data_logger.save_dataframes_as_json = MagicMock() - - # Execute - manager._finalize_molecule_results() - - # Check that totals were added - totals = [ - entry for entry in data_logger.molecule_data if entry[1] == "Group Total" - ] - self.assertEqual(len(totals), 2) - - # Check correct aggregation - mol1_total = next(entry for entry in totals if entry[0] == "mol1")[3] - mol2_total = next(entry for entry in totals if entry[0] == "mol2")[3] - self.assertEqual(mol1_total, 6.0) - self.assertEqual(mol2_total, 4.0) - - # Check save was called - data_logger.save_dataframes_as_json.assert_called_once() - - @patch("CodeEntropy.entropy.logger") - def test_finalize_molecule_results_skips_invalid_entries(self, mock_logger): - """ - Tests that `_finalize_molecule_results` skips entries with non-numeric entropy - values and logs a warning without raising an exception. - """ - args = MagicMock(output_file="mock_output.json") - data_logger = DataLogger() - data_logger.molecule_data = [ - ("mol1", "united_atom", "Transvibrational", 1.0), - ( - "mol1", - "united_atom", - "Rovibrational", - "not_a_number", - ), # Should trigger ValueError - ("mol1", "united_atom", "Conformational", 2.0), - ] - data_logger.residue_data = [] - - manager = EntropyManager(None, args, None, data_logger, None, None, None, None) - - # Patch save method - data_logger.save_dataframes_as_json = MagicMock() - - # Run the method - manager._finalize_molecule_results() - - # Check that only valid values were aggregated - totals = [ - entry for entry in data_logger.molecule_data if entry[1] == "Group Total" - ] - self.assertEqual(len(totals), 1) - self.assertEqual(totals[0][3], 3.0) # 1.0 + 2.0 - - # Check that a warning was logged - mock_logger.warning.assert_called_once_with( - "Skipping invalid entry: mol1, not_a_number" - ) - - -class TestVibrationalEntropy(unittest.TestCase): - """ - Unit tests for the functionality of Vibrational entropy calculations. - """ - - def setUp(self): - """ - Set up test environment. - """ - self.test_dir = tempfile.mkdtemp(prefix="CodeEntropy_") - self.test_data_dir = os.path.dirname(data.__file__) - self.code_entropy = main - - # Change to test directory - self._orig_dir = os.getcwd() - os.chdir(self.test_dir) - - self.entropy_manager = EntropyManager( - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - def tearDown(self): - """ - Clean up after each test. - """ - os.chdir(self._orig_dir) - if os.path.exists(self.test_dir): - shutil.rmtree(self.test_dir) - - def test_vibrational_entropy_init(self): - """ - Test initialization of the `VibrationalEntropy` class. - - Verifies that the object is correctly instantiated and that key arguments - such as temperature and bin width are properly assigned. - """ - # Mock dependencies - universe = MagicMock() - args = MagicMock() - args.bin_width = 0.1 - args.temperature = 300 - args.selection_string = "all" - - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - dihedral_analysis = MagicMock() - - # Instantiate VibrationalEntropy - ve = VibrationalEntropy( - run_manager, - args, - universe, - data_logger, - level_manager, - group_molecules, - dihedral_analysis, - universe_operations, - ) - - # Basic assertions to check initialization - self.assertIsInstance(ve, VibrationalEntropy) - self.assertEqual(ve._args.temperature, 300) - self.assertEqual(ve._args.bin_width, 0.1) - - # test when lambda is zero - def test_frequency_calculation_0(self): - """ - Test `frequency_calculation` with zero eigenvalue. - - Ensures that the method returns 0 when the input eigenvalue (lambda) is zero. - """ - lambdas = [0] - temp = 298 - - run_manager = RunManager("mock_folder/job001") - - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - frequencies = ve.frequency_calculation(lambdas, temp) - - assert np.allclose(frequencies, [0.0]) - - def test_frequency_calculation_positive(self): - """ - Test `frequency_calculation` with positive eigenvalues. - - Verifies that the method correctly computes frequencies from a set of - positive eigenvalues at a given temperature. - """ - lambdas = np.array([585495.0917897299, 658074.5130064893, 782425.305888707]) - temp = 298 - - # Create a mock RunManager and set return value for get_KT2J - run_manager = RunManager("mock_folder/job001") - - # Instantiate VibrationalEntropy with mocks - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - # Call the method under test - frequencies = ve.frequency_calculation(lambdas, temp) - - assert frequencies == pytest.approx( - [1899594266400.4016, 2013894687315.6213, 2195940987139.7097] - ) - - def test_frequency_calculation_filters_invalid(self): - """ - Test `frequency_calculation` filters out invalid eigenvalues. - - Ensures that negative, complex, and near-zero eigenvalues are excluded, - and frequencies are calculated only for valid ones. - """ - lambdas = np.array( - [585495.0917897299, -658074.5130064893, 0.0, 782425.305888707] - ) - temp = 298 - - # Create a mock RunManager and set return value for get_KT2J - run_manager = MagicMock() - run_manager.get_KT2J.return_value = 2.479e-21 # example value in Joules - - # Instantiate VibrationalEntropy with mocks - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - # Call the method - frequencies = ve.frequency_calculation(lambdas, temp) - - # Expected: only two valid eigenvalues used - expected_lambdas = np.array([585495.0917897299, 782425.305888707]) - expected_frequencies = ( - 1 - / (2 * np.pi) - * np.sqrt(expected_lambdas / run_manager.get_KT2J.return_value) - ) - - # Assert frequencies match expected - np.testing.assert_allclose(frequencies, expected_frequencies, rtol=1e-5) - - def test_frequency_calculation_filters_invalid_with_warning(self): - """ - Test `frequency_calculation` filters out invalid eigenvalues and logs a warning. - - Ensures that negative, complex, and near-zero eigenvalues are excluded, - and a warning is logged about the exclusions. - """ - lambdas = np.array( - [585495.0917897299, -658074.5130064893, 0.0, 782425.305888707] - ) - temp = 298 - - run_manager = MagicMock() - run_manager.get_KT2J.return_value = 2.479e-21 # example value - - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - with self.assertLogs("CodeEntropy.entropy", level="WARNING") as cm: - frequencies = ve.frequency_calculation(lambdas, temp) - - # Check that warning was logged - warning_messages = "\n".join(cm.output) - self.assertIn("invalid eigenvalues excluded", warning_messages) - - # Check that only valid frequencies are returned - expected_lambdas = np.array([585495.0917897299, 782425.305888707]) - expected_frequencies = ( - 1 - / (2 * np.pi) - * np.sqrt(expected_lambdas / run_manager.get_KT2J.return_value) - ) - np.testing.assert_allclose(frequencies, expected_frequencies, rtol=1e-5) - - def test_vibrational_entropy_calculation_force_not_highest(self): - """ - Test `vibrational_entropy_calculation` for a force matrix with - `highest_level=False`. - - Verifies that the entropy is correctly computed using mocked frequency values - and a dummy identity matrix, excluding the first six modes. - """ - # Mock RunManager - run_manager = MagicMock() - run_manager.change_lambda_units.return_value = np.array([1e-20] * 12) - run_manager.get_KT2J.return_value = 2.47e-21 - - # Instantiate VibrationalEntropy with mocks - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - # Patch frequency_calculation to return known frequencies - ve.frequency_calculation = MagicMock(return_value=np.array([1.0] * 12)) - - # Create a dummy 12x12 matrix - matrix = np.identity(12) - - # Run the method - result = ve.vibrational_entropy_calculation( - matrix=matrix, matrix_type="force", temp=298, highest_level=False - ) - - # Manually compute expected entropy components - exponent = ve._PLANCK_CONST * 1.0 / 2.47e-21 - power_positive = np.exp(exponent) - power_negative = np.exp(-exponent) - S_component = exponent / (power_positive - 1) - np.log(1 - power_negative) - S_component *= ve._GAS_CONST - expected = S_component * 6 # sum of components[6:] - - self.assertAlmostEqual(result, expected, places=5) - - def test_vibrational_entropy_polymer_force(self): - """ - Test `vibrational_entropy_calculation` with a real force matrix and - `highest_level='yes'`. - - Ensures that the entropy is computed correctly for a small polymer system - using a known force matrix and temperature. - """ - matrix = np.array( - [ - [4.67476, -0.04069, -0.19714], - [-0.04069, 3.86300, -0.17922], - [-0.19714, -0.17922, 3.66307], - ] - ) - matrix_type = "force" - temp = 298 - highest_level = "yes" - - run_manager = RunManager("mock_folder/job001") - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - S_vib = ve.vibrational_entropy_calculation( - matrix, matrix_type, temp, highest_level - ) - - assert S_vib == pytest.approx(52.88123410327823) - - def test_vibrational_entropy_polymer_torque(self): - """ - Test `vibrational_entropy_calculation` with a torque matrix and - `highest_level='yes'`. - - Verifies that the entropy is computed correctly for a torque matrix, - simulating rotational degrees of freedom. - """ - matrix = np.array( - [ - [6.69611, 0.39754, 0.57763], - [0.39754, 4.63265, 0.38648], - [0.57763, 0.38648, 6.34589], - ] - ) - matrix_type = "torque" - temp = 298 - highest_level = "yes" - - run_manager = RunManager("mock_folder/job001") - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - S_vib = ve.vibrational_entropy_calculation( - matrix, matrix_type, temp, highest_level - ) - - assert S_vib == pytest.approx(48.45003266069881) - - def test_vibrational_entropy_calculation_forcetorqueTRANS(self): - """ - Test for matrix_type='forcetorqueTRANS': - - verifies S_vib_total = sum(S_components[:3]) - """ - run_manager = MagicMock() - run_manager.change_lambda_units.side_effect = lambda x: x - kT = 2.47e-21 - run_manager.get_KT2J.return_value = kT - - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - orig_eigvals = la.eigvals - la.eigvals = lambda m: np.array( - [1.0] * 6 - ) # length 6 -> 6 frequencies/components - - try: - freqs = np.array([6.0, 5.0, 4.0, 3.0, 2.0, 1.0]) - ve.frequency_calculation = MagicMock(return_value=freqs) - - matrix = np.identity(6) - - result = ve.vibrational_entropy_calculation( - matrix=matrix, - matrix_type="forcetorqueTRANS", - temp=298, - highest_level=True, - ) - - sorted_freqs = np.sort(freqs) - exponent = ve._PLANCK_CONST * sorted_freqs / kT - power_positive = np.exp(exponent) - power_negative = np.exp(-exponent) - S_components = exponent / (power_positive - 1) - np.log(1 - power_negative) - S_components *= ve._GAS_CONST - - expected = float(np.sum(S_components[:3])) - self.assertAlmostEqual(result, expected, places=6) - - finally: - la.eigvals = orig_eigvals - - def test_vibrational_entropy_calculation_forcetorqueROT(self): - """ - Test for matrix_type='forcetorqueROT': - - verifies S_vib_total = sum(S_components[3:]) - """ - run_manager = MagicMock() - run_manager.change_lambda_units.side_effect = lambda x: x - kT = 2.47e-21 - run_manager.get_KT2J.return_value = kT - - ve = VibrationalEntropy( - run_manager, - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - MagicMock(), - ) - - orig_eigvals = la.eigvals - la.eigvals = lambda m: np.array([1.0] * 6) - - try: - freqs = np.array([6.0, 5.0, 4.0, 3.0, 2.0, 1.0]) - ve.frequency_calculation = MagicMock(return_value=freqs) - - matrix = np.identity(6) - - result = ve.vibrational_entropy_calculation( - matrix=matrix, - matrix_type="forcetorqueROT", - temp=298, - highest_level=True, - ) - - sorted_freqs = np.sort(freqs) - exponent = ve._PLANCK_CONST * sorted_freqs / kT - power_positive = np.exp(exponent) - power_negative = np.exp(-exponent) - S_components = exponent / (power_positive - 1) - np.log(1 - power_negative) - S_components *= ve._GAS_CONST - - expected = float(np.sum(S_components[3:])) - self.assertAlmostEqual(result, expected, places=3) - - finally: - la.eigvals = orig_eigvals - - def test_calculate_water_orientational_entropy(self): - """ - Test that orientational entropy values are correctly extracted from Sorient_dict - and logged per residue. - """ - Sorient_dict = {1: {"mol1": [1.0, 2]}, 2: {"mol1": [3.0, 4]}} - group_id = 0 - - self.entropy_manager._data_logger = MagicMock() - - self.entropy_manager._calculate_water_orientational_entropy( - Sorient_dict, group_id - ) - - expected_calls = [ - call(group_id, "mol1", "Water", "Orientational", 2, 1.0), - call(group_id, "mol1", "Water", "Orientational", 4, 3.0), - ] - - self.entropy_manager._data_logger.add_residue_data.assert_has_calls( - expected_calls, any_order=False - ) - assert self.entropy_manager._data_logger.add_residue_data.call_count == 2 - - def test_calculate_water_vibrational_translational_entropy(self): - mock_vibrations = MagicMock() - mock_vibrations.translational_S = { - ("res1", 10): [1.0, 2.0], - ("resB_invalid", 10): 4.0, - ("res2", 10): 3.0, - } - mock_covariances = MagicMock() - mock_covariances.counts = { - ("res1", "WAT"): 10, - # resB_invalid and res2 will use default count = 1 - } - - group_id = 0 - self.entropy_manager._data_logger = MagicMock() - - self.entropy_manager._calculate_water_vibrational_translational_entropy( - mock_vibrations, group_id, mock_covariances - ) - - expected_calls = [ - call(group_id, "res1", "Water", "Transvibrational", 10, 3.0), - call(group_id, "resB", "Water", "Transvibrational", 1, 4.0), - call(group_id, "res2", "Water", "Transvibrational", 1, 3.0), - ] - - self.entropy_manager._data_logger.add_residue_data.assert_has_calls( - expected_calls, any_order=False - ) - assert self.entropy_manager._data_logger.add_residue_data.call_count == 3 - - def test_calculate_water_vibrational_rotational_entropy(self): - mock_vibrations = MagicMock() - mock_vibrations.rotational_S = { - ("resA_101", 14): [2.0, 3.0], - ("resB_invalid", 14): 4.0, - ("resC", 14): 5.0, - } - mock_covariances = MagicMock() - mock_covariances.counts = {("resA_101", "WAT"): 14} - - group_id = 0 - self.entropy_manager._data_logger = MagicMock() - - self.entropy_manager._calculate_water_vibrational_rotational_entropy( - mock_vibrations, group_id, mock_covariances - ) - - expected_calls = [ - call(group_id, "resA", "Water", "Rovibrational", 14, 5.0), - call(group_id, "resB", "Water", "Rovibrational", 1, 4.0), - call(group_id, "resC", "Water", "Rovibrational", 1, 5.0), - ] - - self.entropy_manager._data_logger.add_residue_data.assert_has_calls( - expected_calls, any_order=False - ) - assert self.entropy_manager._data_logger.add_residue_data.call_count == 3 - - def test_empty_vibrational_entropy_dicts(self): - mock_vibrations = MagicMock() - mock_vibrations.translational_S = {} - mock_vibrations.rotational_S = {} - - group_id = 0 - mock_covariances = MagicMock() - mock_covariances.counts = {} - - self.entropy_manager._data_logger = MagicMock() - - self.entropy_manager._calculate_water_vibrational_translational_entropy( - mock_vibrations, group_id, mock_covariances - ) - self.entropy_manager._calculate_water_vibrational_rotational_entropy( - mock_vibrations, group_id, mock_covariances - ) - - self.entropy_manager._data_logger.add_residue_data.assert_not_called() - - @patch( - "waterEntropy.recipes.interfacial_solvent.get_interfacial_water_orient_entropy" - ) - def test_calculate_water_entropy(self, mock_get_entropy): - mock_vibrations = MagicMock() - mock_vibrations.translational_S = {("res1", "mol1"): 2.0} - mock_vibrations.rotational_S = {("res1", "mol1"): 3.0} - - mock_get_entropy.return_value = ( - {1: {"mol1": [1.0, 5]}}, # orientational - MagicMock(counts={("res1", "WAT"): 1}), - mock_vibrations, - None, - 1, - ) - - mock_universe = MagicMock() - self.entropy_manager._data_logger = MagicMock() - - self.entropy_manager._calculate_water_entropy(mock_universe, 0, 10, 5) - - expected_calls = [ - call(None, "mol1", "Water", "Orientational", 5, 1.0), - call(None, "res1", "Water", "Transvibrational", 1, 2.0), - call(None, "res1", "Water", "Rovibrational", 1, 3.0), - ] - - self.entropy_manager._data_logger.add_residue_data.assert_has_calls( - expected_calls, any_order=False - ) - assert self.entropy_manager._data_logger.add_residue_data.call_count == 3 - - @patch( - "waterEntropy.recipes.interfacial_solvent.get_interfacial_water_orient_entropy" - ) - def test_calculate_water_entropy_minimal(self, mock_get_entropy): - mock_vibrations = MagicMock() - mock_vibrations.translational_S = {("ACE_1", "WAT"): 10.0} - mock_vibrations.rotational_S = {("ACE_1", "WAT"): 2.0} - - mock_get_entropy.return_value = ( - {}, # no orientational entropy - MagicMock(counts={("ACE_1", "WAT"): 1}), - mock_vibrations, - None, - 1, - ) - - mock_logger = MagicMock() - self.entropy_manager._data_logger = mock_logger - - mock_residue = MagicMock(resnames=["WAT"]) - mock_selection = MagicMock(residues=mock_residue, atoms=[MagicMock()]) - mock_universe = MagicMock() - mock_universe.select_atoms.return_value = mock_selection - - self.entropy_manager._calculate_water_entropy( - mock_universe, 0, 10, 1, group_id=None - ) - - mock_logger.add_group_label.assert_called_once_with( - None, "WAT", len(mock_selection.residues), len(mock_selection.atoms) - ) - - @patch( - "waterEntropy.recipes.interfacial_solvent.get_interfacial_water_orient_entropy" - ) - def test_calculate_water_entropy_adds_resname(self, mock_get_entropy): - mock_vibrations = MagicMock() - mock_vibrations.translational_S = {("res1", "WAT"): 2.0} - mock_vibrations.rotational_S = {("res1", "WAT"): 3.0} - - mock_get_entropy.return_value = ( - {1: {"WAT": [1.0, 5]}}, # orientational - MagicMock(counts={("res1", "WAT"): 1}), - mock_vibrations, - None, - 1, - ) - - mock_water_selection = MagicMock() - mock_residues_group = MagicMock() - mock_residues_group.resnames = ["WAT"] - mock_water_selection.residues = mock_residues_group - mock_water_selection.atoms = [1, 2, 3] - mock_universe = MagicMock() - mock_universe.select_atoms.return_value = mock_water_selection - - group_id = 0 - self.entropy_manager._data_logger = MagicMock() - - self.entropy_manager._calculate_water_entropy( - mock_universe, start=0, end=1, step=1, group_id=group_id - ) - - self.entropy_manager._data_logger.add_group_label.assert_called_with( - group_id, - "WAT", - len(mock_water_selection.residues), - len(mock_water_selection.atoms), - ) - - # TODO test for error handling on invalid inputs - - -class TestConformationalEntropy(unittest.TestCase): - """ - Unit tests for the functionality of conformational entropy calculations. - """ - - def setUp(self): - """ - Set up test environment. - """ - self.test_dir = tempfile.mkdtemp(prefix="CodeEntropy_") - self.test_data_dir = os.path.dirname(data.__file__) - self.code_entropy = main - - # Change to test directory - self._orig_dir = os.getcwd() - os.chdir(self.test_dir) - - def tearDown(self): - """ - Clean up after each test. - """ - os.chdir(self._orig_dir) - if os.path.exists(self.test_dir): - shutil.rmtree(self.test_dir) - - def test_confirmational_entropy_init(self): - """ - Test initialization of the `ConformationalEntropy` class. - - Verifies that the object is correctly instantiated and that key arguments - such as temperature and bin width are properly assigned during initialization. - """ - # Mock dependencies - universe = MagicMock() - args = MagicMock() - args.bin_width = 0.1 - args.temperature = 300 - args.selection_string = "all" - - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - - # Instantiate ConformationalEntropy - ce = ConformationalEntropy( - run_manager, - args, - universe, - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # Basic assertions to check initialization - self.assertIsInstance(ce, ConformationalEntropy) - self.assertEqual(ce._args.temperature, 300) - self.assertEqual(ce._args.bin_width, 0.1) - - def test_conformational_entropy_calculation(self): - """ - Test `conformational_entropy_calculation` method to verify - correct entropy calculation from a simple discrete state array. - """ - - # Setup managers and arguments - args = MagicMock(bin_width=0.1, temperature=300, selection_string="all") - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - - ce = ConformationalEntropy( - run_manager, - args, - MagicMock(), - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # Create a simple array of states with known counts - states = np.array([0, 0, 1, 1, 1, 2]) # 2x state 0, 3x state 1, 1x state 2 - - # Manually compute expected entropy - probs = np.array([2 / 6, 3 / 6, 1 / 6]) - expected_entropy = -np.sum(probs * np.log(probs)) * ce._GAS_CONST - - # Run the method under test - result = ce.conformational_entropy_calculation(states) - - # Assert the result is close to expected entropy - self.assertAlmostEqual(result, expected_entropy, places=6) - - -class TestOrientationalEntropy(unittest.TestCase): - """ - Unit tests for the functionality of orientational entropy calculations. - """ - - def setUp(self): - """ - Set up test environment. - """ - self.test_dir = tempfile.mkdtemp(prefix="CodeEntropy_") - self.code_entropy = main - - # Change to test directory - self._orig_dir = os.getcwd() - os.chdir(self.test_dir) - - def tearDown(self): - """ - Clean up after each test. - """ - os.chdir(self._orig_dir) - if os.path.exists(self.test_dir): - shutil.rmtree(self.test_dir) - - def test_orientational_entropy_init(self): - """ - Test initialization of the `OrientationalEntropy` class. - - Verifies that the object is correctly instantiated and that key arguments - such as temperature and bin width are properly assigned during initialization. - """ - # Mock dependencies - universe = MagicMock() - args = MagicMock() - args.bin_width = 0.1 - args.temperature = 300 - args.selection_string = "all" - - run_manager = RunManager("mock_folder/job001") - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - data_logger = DataLogger() - group_molecules = MagicMock() - - # Instantiate OrientationalEntropy - oe = OrientationalEntropy( - run_manager, - args, - universe, - data_logger, - level_manager, - group_molecules, - MagicMock(), - universe_operations, - ) - - # Basic assertions to check initialization - self.assertIsInstance(oe, OrientationalEntropy) - self.assertEqual(oe._args.temperature, 300) - self.assertEqual(oe._args.bin_width, 0.1) - - def test_orientational_entropy_calculation(self): - """ - Tests that `orientational_entropy_calculation` correctly computes the total - orientational entropy for a given dictionary of neighboring species using - the internal gas constant. - """ - # Setup a mock neighbours dictionary - neighbours_dict = { - "ligandA": 2, - "ligandB": 3, - } - - # Create an instance of OrientationalEntropy with dummy dependencies - oe = OrientationalEntropy(None, None, None, None, None, None, None, None) - - # Run the method - result = oe.orientational_entropy_calculation(neighbours_dict) - - # Manually compute expected result using the class's internal gas constant - expected = ( - math.log(math.sqrt((2**3) * math.pi)) - + math.log(math.sqrt((3**3) * math.pi)) - ) * oe._GAS_CONST - - # Assert the result is as expected - self.assertAlmostEqual(result, expected, places=6) - - def test_orientational_entropy_water_branch_is_covered(self): - """ - Tests that the placeholder branch for water molecules is executed to ensure - coverage of the `if neighbour in [...]` block. - """ - neighbours_dict = {"H2O": 1} # Matches the condition exactly - - oe = OrientationalEntropy(None, None, None, None, None, None, None, None) - result = oe.orientational_entropy_calculation(neighbours_dict) - - # Since the logic is skipped, total entropy should be 0.0 - self.assertEqual(result, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_CodeEntropy/test_group_molecules.py b/tests/test_CodeEntropy/test_group_molecules.py deleted file mode 100644 index 6f158058..00000000 --- a/tests/test_CodeEntropy/test_group_molecules.py +++ /dev/null @@ -1,78 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -import numpy as np - -from CodeEntropy.group_molecules import GroupMolecules -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestGroupMolecules(BaseTestCase): - """ - Unit tests for GroupMolecules. - """ - - def setUp(self): - super().setUp() - self.group_molecules = GroupMolecules() - - def test_by_none_returns_individual_groups(self): - """ - Test _by_none returns each molecule in its own group when grouping is 'each'. - """ - mock_universe = MagicMock() - # Simulate universe.atoms.fragments has 3 molecules - mock_universe.atoms.fragments = [MagicMock(), MagicMock(), MagicMock()] - - groups = self.group_molecules._by_none(mock_universe) - expected = {0: [0], 1: [1], 2: [2]} - self.assertEqual(groups, expected) - - def test_by_molecules_groups_by_chemical_type(self): - """ - Test _by_molecules groups molecules with identical atom counts and names - together. - """ - mock_universe = MagicMock() - - fragment0 = MagicMock() - fragment0.names = np.array(["H", "O", "H"]) - fragment1 = MagicMock() - fragment1.names = np.array(["H", "O", "H"]) - fragment2 = MagicMock() - fragment2.names = np.array(["C", "C", "H", "H"]) - - mock_universe.atoms.fragments = [fragment0, fragment1, fragment2] - - groups = self.group_molecules._by_molecules(mock_universe) - - # Expect first two grouped, third separate - self.assertIn(0, groups) - self.assertIn(2, groups) - self.assertCountEqual(groups[0], [0, 1]) - self.assertEqual(groups[2], [2]) - - def test_grouping_molecules_dispatches_correctly(self): - """ - Test grouping_molecules method dispatches to correct grouping strategy. - """ - mock_universe = MagicMock() - mock_universe.atoms.fragments = [MagicMock()] # Just 1 molecule to keep simple - - # When grouping='each', calls _by_none - groups = self.group_molecules.grouping_molecules(mock_universe, "each") - self.assertEqual(groups, {0: [0]}) - - # When grouping='molecules', calls _by_molecules (mock to test call) - self.group_molecules._by_molecules = MagicMock(return_value={"mocked": [42]}) - groups = self.group_molecules.grouping_molecules(mock_universe, "molecules") - self.group_molecules._by_molecules.assert_called_once_with(mock_universe) - self.assertEqual(groups, {"mocked": [42]}) - - # If grouping unknown, should return empty dict - groups = self.group_molecules.grouping_molecules(mock_universe, "unknown") - self.assertEqual(groups, {}) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_CodeEntropy/test_levels.py b/tests/test_CodeEntropy/test_levels.py deleted file mode 100644 index 4223d869..00000000 --- a/tests/test_CodeEntropy/test_levels.py +++ /dev/null @@ -1,1726 +0,0 @@ -from unittest.mock import MagicMock, patch - -import numpy as np - -from CodeEntropy.levels import LevelManager -from CodeEntropy.levels.axes import AxesManager -from CodeEntropy.mda_universe_operations import UniverseOperations -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestLevels(BaseTestCase): - """ - Unit tests for Levels. - """ - - def setUp(self): - super().setUp() - - def test_select_levels(self): - """ - Test `select_levels` with a mocked data container containing two molecules: - - The first molecule has 2 atoms and 1 residue (should return 'united_atom' and - 'residue'). - - The second molecule has 3 atoms and 2 residues (should return all three - levels). - - Asserts that the number of molecules and the levels list match expected values. - """ - # Create a mock data_container - data_container = MagicMock() - - # Mock fragments (2 molecules) - fragment1 = MagicMock() - fragment2 = MagicMock() - - # Mock select_atoms return values - atoms1 = MagicMock() - atoms1.__len__.return_value = 2 - atoms1.residues = [1] # 1 residue - - atoms2 = MagicMock() - atoms2.__len__.return_value = 3 - atoms2.residues = [1, 2] # 2 residues - - fragment1.select_atoms.return_value = atoms1 - fragment2.select_atoms.return_value = atoms2 - - data_container.atoms.fragments = [fragment1, fragment2] - - universe_operations = UniverseOperations() - - # Import the class and call the method - level_manager = LevelManager(universe_operations) - number_molecules, levels = level_manager.select_levels(data_container) - - # Assertions - self.assertEqual(number_molecules, 2) - self.assertEqual( - levels, [["united_atom", "residue"], ["united_atom", "residue", "polymer"]] - ) - - def test_get_matrices(self): - """ - Atomic unit test for LevelManager.get_matrices: - - AxesManager is mocked - - No inertia / MDAnalysis math - - Verifies block matrix construction and shape only - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - # Two beads - bead1 = MagicMock() - bead1.principal_axes.return_value = np.ones(3) - - bead2 = MagicMock() - bead2.principal_axes.return_value = np.ones(3) - - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - - # Deterministic 3x3 submatrix for every (i,j) call - I3 = np.identity(3) - level_manager.create_submatrix = MagicMock(return_value=I3) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.ones(3) - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.eye(3) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - force_matrix, torque_matrix = level_manager.get_matrices( - data_container=data_container, - level="residue", - highest_level=True, - force_matrix=None, - torque_matrix=None, - force_partitioning=0.5, - customised_axes=True, - ) - - # Shape: 2 beads × 3 dof => 6×6 - assert force_matrix.shape == (6, 6) - assert torque_matrix.shape == (6, 6) - - # Expected block structure when every block is I3: - expected = np.block([[I3, I3], [I3, I3]]) - np.testing.assert_array_equal(force_matrix, expected) - np.testing.assert_array_equal(torque_matrix, expected) - - # Lightweight behavioral assertions - level_manager.get_beads.assert_called_once_with(data_container, "residue") - assert axes.get_residue_axes.call_count == 2 - - # For 2 beads: (0,0), (0,1), (1,1) => 3 pairs; - # each pair calls create_submatrix twice (force+torque) - assert level_manager.create_submatrix.call_count == 6 - - def test_get_matrices_force_shape_mismatch(self): - """ - Test that get_matrices raises a ValueError when the provided force_matrix - has a shape mismatch with the computed force block matrix. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - # Two beads -> force_block will be 6x6 - bead1 = MagicMock() - bead1.principal_axes.return_value = np.ones(3) - - bead2 = MagicMock() - bead2.principal_axes.return_value = np.ones(3) - - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - - level_manager.create_submatrix = MagicMock(return_value=np.identity(3)) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.ones(3) - - bad_force_matrix = np.zeros((3, 3)) - correct_torque_matrix = np.zeros((6, 6)) - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.eye(3) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - with self.assertRaises(ValueError) as context: - level_manager.get_matrices( - data_container=data_container, - level="residue", - highest_level=True, - force_matrix=bad_force_matrix, - torque_matrix=correct_torque_matrix, - force_partitioning=0.5, - customised_axes=True, - ) - - assert "force matrix shape" in str(context.exception) - - def test_get_matrices_torque_shape_mismatch(self): - """ - Test that get_matrices raises a ValueError when the provided torque_matrix - has a shape mismatch with the computed torque block matrix. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock() - bead1.principal_axes.return_value = np.ones(3) - - bead2 = MagicMock() - bead2.principal_axes.return_value = np.ones(3) - - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - level_manager.create_submatrix = MagicMock(return_value=np.identity(3)) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.ones(3) - - correct_force_matrix = np.zeros((6, 6)) - bad_torque_matrix = np.zeros((3, 3)) # Incorrect shape (should be 6x6) - - # Mock AxesManager return tuple to satisfy unpacking - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.eye(3) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - with self.assertRaises(ValueError) as context: - level_manager.get_matrices( - data_container=data_container, - level="residue", - highest_level=True, - force_matrix=correct_force_matrix, - torque_matrix=bad_torque_matrix, - force_partitioning=0.5, - customised_axes=True, - ) - - assert "torque matrix shape" in str(context.exception) - - def test_get_matrices_torque_consistency(self): - """ - Test that get_matrices returns consistent force and torque matrices - when called multiple times with the same inputs. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock() - bead1.principal_axes.return_value = np.ones(3) - - bead2 = MagicMock() - bead2.principal_axes.return_value = np.ones(3) - - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - level_manager.create_submatrix = MagicMock(return_value=np.identity(3)) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.ones(3) - - initial_force_matrix = np.zeros((6, 6)) - initial_torque_matrix = np.zeros((6, 6)) - - # Mock AxesManager return tuple (unpacked by get_matrices) - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.eye(3) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - force_matrix_1, torque_matrix_1 = level_manager.get_matrices( - data_container=data_container, - level="residue", - highest_level=True, - force_matrix=initial_force_matrix.copy(), - torque_matrix=initial_torque_matrix.copy(), - force_partitioning=0.5, - customised_axes=True, - ) - - force_matrix_2, torque_matrix_2 = level_manager.get_matrices( - data_container=data_container, - level="residue", - highest_level=True, - force_matrix=initial_force_matrix.copy(), - torque_matrix=initial_torque_matrix.copy(), - force_partitioning=0.5, - customised_axes=True, - ) - - np.testing.assert_array_equal(force_matrix_1, force_matrix_2) - np.testing.assert_array_equal(torque_matrix_1, torque_matrix_2) - - assert force_matrix_1.shape == (6, 6) - assert torque_matrix_1.shape == (6, 6) - - def test_get_matrices_united_atom_customised_axes(self): - """ - Test that: level='united_atom' with customised_axes=True - Verifies: - - UA axes path is taken - - block matrix shape is correct for 1 bead (3x3) - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead = MagicMock() - level_manager.get_beads = MagicMock(return_value=[bead]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - - I3 = np.identity(3) - level_manager.create_submatrix = MagicMock(return_value=I3) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.ones(3) - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.array([1.0, 1.0, 1.0]) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_UA_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - force_matrix, torque_matrix = level_manager.get_matrices( - data_container=data_container, - level="united_atom", - highest_level=True, - force_matrix=None, - torque_matrix=None, - force_partitioning=0.5, - customised_axes=True, - ) - - assert force_matrix.shape == (3, 3) - assert torque_matrix.shape == (3, 3) - np.testing.assert_array_equal(force_matrix, I3) - np.testing.assert_array_equal(torque_matrix, I3) - - axes.get_UA_axes.assert_called_once() - assert axes.get_residue_axes.call_count == 0 - - def test_get_matrices_non_customised_axes_path_atomic(self): - """ - Tests that `customised_axes=False` triggers the non-customised axes path. - - Verifies that: - - translational axes are taken from `data_container.atoms.principal_axes()` - - rotational axes are taken from `bead.principal_axes()` (real-valued) - - bead moment of inertia and center of mass are queried - - force and torque matrices are assembled with size (3N, 3N) for N beads - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1, bead2 = MagicMock(), MagicMock() - bead1.principal_axes.return_value = np.eye(3) * (1 + 2j) - bead2.principal_axes.return_value = np.eye(3) * (1 + 2j) - bead1.center_of_mass.return_value = np.zeros(3) - bead2.center_of_mass.return_value = np.zeros(3) - bead1.moment_of_inertia.return_value = np.eye(3) - bead2.moment_of_inertia.return_value = np.eye(3) - - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - level_manager.create_submatrix = MagicMock(return_value=np.eye(3)) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.eye(3) - - with ( - patch("CodeEntropy.levels.make_whole", autospec=True), - patch( - "CodeEntropy.levels.np.linalg.eig", - return_value=(np.array([1.0, 3.0, 2.0]), None), - ), - ): - force_matrix, torque_matrix = level_manager.get_matrices( - data_container=data_container, - level="polymer", - highest_level=True, - force_matrix=None, - torque_matrix=None, - force_partitioning=0.5, - customised_axes=False, - ) - - data_container.atoms.principal_axes.assert_called() - bead1.principal_axes.assert_called() - bead2.principal_axes.assert_called() - bead1.center_of_mass.assert_called() - bead2.center_of_mass.assert_called() - bead1.moment_of_inertia.assert_called() - bead2.moment_of_inertia.assert_called() - - assert force_matrix.shape == (6, 6) - assert torque_matrix.shape == (6, 6) - - def test_get_matrices_accepts_existing_same_shape(self): - """ - Test that: if force_matrix and torque_matrix are provided with correct shape, - no error is raised and returned matrices match the newly computed blocks. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock() - bead2 = MagicMock() - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([0.5, 1.5, 2.5]) - ) - - I3 = np.identity(3) - level_manager.create_submatrix = MagicMock(return_value=I3) - - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.ones(3) - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.array([1.0, 1.0, 1.0]) - - existing_force = np.zeros((6, 6)) - existing_torque = np.zeros((6, 6)) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - force_matrix, torque_matrix = level_manager.get_matrices( - data_container=data_container, - level="residue", - highest_level=True, - force_matrix=existing_force, - torque_matrix=existing_torque, - force_partitioning=0.5, - customised_axes=True, - ) - - expected = np.block([[I3, I3], [I3, I3]]) - np.testing.assert_array_equal(force_matrix, expected) - np.testing.assert_array_equal(torque_matrix, expected) - - def test_get_combined_forcetorque_matrices_residue_customised_init(self): - """ - Test: level='residue', customised_axes=True uses AxesManager.get_residue_axes - and returns a (6N x 6N) block matrix for N beads. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock() - bead2 = MagicMock() - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - wf = np.array([1.0, 2.0, 3.0]) - wt = np.array([4.0, 5.0, 6.0]) - level_manager.get_weighted_forces = MagicMock(return_value=wf) - level_manager.get_weighted_torques = MagicMock(return_value=wt) - - I6 = np.identity(6) - level_manager.create_FTsubmatrix = MagicMock(return_value=I6) - - data_container = MagicMock() - data_container.atoms = MagicMock() - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.array([1.0, 1.0, 1.0]) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - ft_matrix = level_manager.get_combined_forcetorque_matrices( - data_container=data_container, - level="residue", - highest_level=True, - forcetorque_matrix=None, - force_partitioning=0.5, - customised_axes=True, - ) - - assert ft_matrix.shape == (12, 12) - - expected = np.block([[I6, I6], [I6, I6]]) - np.testing.assert_array_equal(ft_matrix, expected) - - assert axes.get_residue_axes.call_count == 2 - assert level_manager.create_FTsubmatrix.call_count == 3 - - def test_get_combined_forcetorque_matrices_noncustomised_axes_path(self): - """ - Test that: customised_axes=False forces else-path: - - make_whole(data_container.atoms) and make_whole(bead) called - - trans_axes = data_container.atoms.principal_axes() - - rot_axes, moment_of_inertia = AxesManager.get_vanilla_axes(bead) - - center = bead.center_of_mass(unwrap=True) - - FT block matrix assembled via create_FTsubmatrix and np.block - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock(name="bead1") - bead2 = MagicMock(name="bead2") - beads = [bead1, bead2] - - level_manager.get_beads = MagicMock(return_value=beads) - - data_container = MagicMock(name="data_container") - data_container.atoms = MagicMock(name="atoms") - data_container.atoms.principal_axes.return_value = np.eye(3) - - # Forces/torques are 3-vectors -> concatenated to length 6 - level_manager.get_weighted_forces = MagicMock( - side_effect=[ - np.array([1.0, 2.0, 3.0]), - np.array([1.1, 2.1, 3.1]), - ] - ) - level_manager.get_weighted_torques = MagicMock( - side_effect=[ - np.array([4.0, 5.0, 6.0]), - np.array([4.1, 5.1, 6.1]), - ] - ) - - level_manager.create_FTsubmatrix = MagicMock(return_value=np.identity(6)) - - rot_axes_expected = np.eye(3) - moi_expected = np.array([3.0, 2.0, 1.0]) - - with ( - patch("CodeEntropy.levels.make_whole", autospec=True) as mw_mock, - patch( - "CodeEntropy.axes.AxesManager.get_vanilla_axes", - autospec=True, - return_value=(rot_axes_expected, moi_expected), - ) as vanilla_mock, - ): - bead1.center_of_mass.return_value = np.zeros(3) - bead2.center_of_mass.return_value = np.zeros(3) - - ft_matrix = level_manager.get_combined_forcetorque_matrices( - data_container=data_container, - level="polymer", - highest_level=True, - forcetorque_matrix=None, - force_partitioning=0.5, - customised_axes=False, - ) - - data_container.atoms.principal_axes.assert_called() - bead1.center_of_mass.assert_called_with(unwrap=True) - bead2.center_of_mass.assert_called_with(unwrap=True) - - assert vanilla_mock.call_count == 2 # once per bead - - # make_whole is called twice per bead: on data_container.atoms and on bead - assert mw_mock.call_count == 4 - mw_mock.assert_any_call(data_container.atoms) - mw_mock.assert_any_call(bead1) - mw_mock.assert_any_call(bead2) - - # result shape: (6N, 6N) with N=2 - assert ft_matrix.shape == (12, 12) - - def test_get_combined_forcetorque_matrices_shape_mismatch_raises(self): - """ - Test that: raises ValueError when existing forcetorque_matrix has wrong shape. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock() - bead2 = MagicMock() - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([4.0, 5.0, 6.0]) - ) - level_manager.create_FTsubmatrix = MagicMock(return_value=np.identity(6)) - - data_container = MagicMock() - data_container.atoms = MagicMock() - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.array([1.0, 1.0, 1.0]) - - bad_existing = np.zeros((6, 6)) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - with self.assertRaises(ValueError) as ctx: - level_manager.get_combined_forcetorque_matrices( - data_container=data_container, - level="residue", - highest_level=True, - forcetorque_matrix=bad_existing, - force_partitioning=0.5, - customised_axes=True, - ) - - assert "forcetorque matrix shape" in str(ctx.exception) - - def test_get_combined_forcetorque_matrices_existing_same_shape(self): - """ - Test that: if existing forcetorque_matrix has correct shape, function returns - the newly computed block (no ValueError). - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - bead1 = MagicMock() - bead2 = MagicMock() - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) - - level_manager.get_weighted_forces = MagicMock( - return_value=np.array([1.0, 2.0, 3.0]) - ) - level_manager.get_weighted_torques = MagicMock( - return_value=np.array([4.0, 5.0, 6.0]) - ) - - I6 = np.identity(6) - level_manager.create_FTsubmatrix = MagicMock(return_value=I6) - - data_container = MagicMock() - data_container.atoms = MagicMock() - - dummy_trans_axes = np.eye(3) - dummy_rot_axes = np.eye(3) - dummy_center = np.zeros(3) - dummy_moi = np.array([1.0, 1.0, 1.0]) - - existing_ok = np.zeros((12, 12)) - - with patch("CodeEntropy.levels.AxesManager") as AxesManagerMock: - axes = AxesManagerMock.return_value - axes.get_residue_axes.return_value = ( - dummy_trans_axes, - dummy_rot_axes, - dummy_center, - dummy_moi, - ) - - ft_matrix = level_manager.get_combined_forcetorque_matrices( - data_container=data_container, - level="residue", - highest_level=True, - forcetorque_matrix=existing_ok, - force_partitioning=0.5, - customised_axes=True, - ) - - expected = np.block([[I6, I6], [I6, I6]]) - np.testing.assert_array_equal(ft_matrix, expected) - - def test_get_beads_polymer_level(self): - """ - Test `get_beads` for 'polymer' level. - Should return a single atom group representing the whole system. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data_container = MagicMock() - mock_atom_group = MagicMock() - - data_container.select_atoms.return_value = mock_atom_group - - result = level_manager.get_beads(data_container, level="polymer") - - self.assertEqual(len(result), 1) - self.assertEqual(result[0], mock_atom_group) - data_container.select_atoms.assert_called_once_with("all") - - def test_get_beads_residue_level(self): - """ - Test `get_beads` for 'residue' level. - Should return one atom group per residue. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data_container = MagicMock() - data_container.residues = [0, 1, 2] # 3 residues - mock_atom_group = MagicMock() - data_container.select_atoms.return_value = mock_atom_group - - result = level_manager.get_beads(data_container, level="residue") - - self.assertEqual(len(result), 3) - self.assertTrue(all(bead == mock_atom_group for bead in result)) - self.assertEqual(data_container.select_atoms.call_count, 3) - - def test_get_beads_united_atom_level(self): - """ - Test `get_beads` for 'united_atom' level. - Should return one bead per heavy atom, including bonded hydrogens. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data_container = MagicMock() - heavy_atoms = [MagicMock(index=i) for i in range(3)] - data_container.select_atoms.side_effect = [ - heavy_atoms, - "bead0", - "bead1", - "bead2", - ] - - result = level_manager.get_beads(data_container, level="united_atom") - - self.assertEqual(len(result), 3) - self.assertEqual(result, ["bead0", "bead1", "bead2"]) - self.assertEqual( - data_container.select_atoms.call_count, 4 - ) # 1 for heavy_atoms + 3 beads - - def test_get_beads_hydrogen_molecule(self): - """ - Test `get_beads` for 'united_atom' level. - Should return one bead for molecule with no heavy atoms. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data_container = MagicMock() - heavy_atoms = [] - data_container.select_atoms.side_effect = [ - heavy_atoms, - "hydrogen", - ] - - result = level_manager.get_beads(data_container, level="united_atom") - - self.assertEqual(len(result), 1) - self.assertEqual(result, ["hydrogen"]) - self.assertEqual( - data_container.select_atoms.call_count, 2 - ) # 1 for heavy_atoms + 1 beads - - def test_get_weighted_force_with_partitioning(self): - """ - Test correct weighted force calculation with partitioning enabled. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - atom = MagicMock() - atom.index = 0 - - bead = MagicMock() - bead.atoms = [atom] - bead.total_mass.return_value = 4.0 - - data_container = MagicMock() - data_container.atoms.__getitem__.return_value.force = np.array([2.0, 0.0, 0.0]) - - trans_axes = np.identity(3) - - result = level_manager.get_weighted_forces( - data_container, bead, trans_axes, highest_level=True, force_partitioning=0.5 - ) - - expected = (0.5 * np.array([2.0, 0.0, 0.0])) / np.sqrt(4.0) - np.testing.assert_array_almost_equal(result, expected) - - def test_get_weighted_force_without_partitioning(self): - """ - Test correct weighted force calculation with partitioning disabled. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - atom = MagicMock() - atom.index = 0 - - bead = MagicMock() - bead.atoms = [atom] - bead.total_mass.return_value = 1.0 - - data_container = MagicMock() - data_container.atoms.__getitem__.return_value.force = np.array([3.0, 0.0, 0.0]) - - trans_axes = np.identity(3) - - result = level_manager.get_weighted_forces( - data_container, - bead, - trans_axes, - highest_level=False, - force_partitioning=0.5, - ) - - expected = np.array([3.0, 0.0, 0.0]) / np.sqrt(1.0) - np.testing.assert_array_almost_equal(result, expected) - - def test_get_weighted_forces_zero_mass_raises_value_error(self): - """ - Test that a zero mass raises a ValueError. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - atom = MagicMock() - atom.index = 0 - - bead = MagicMock() - bead.atoms = [atom] - bead.total_mass.return_value = 0.0 - - data_container = MagicMock() - data_container.atoms.__getitem__.return_value.force = np.array([1.0, 0.0, 0.0]) - - trans_axes = np.identity(3) - - with self.assertRaises(ValueError): - level_manager.get_weighted_forces( - data_container, - bead, - trans_axes, - highest_level=True, - force_partitioning=0.5, - ) - - def test_get_weighted_forces_negative_mass_raises_value_error(self): - """ - Test that a negative mass raises a ValueError. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - atom = MagicMock() - atom.index = 0 - - bead = MagicMock() - bead.atoms = [atom] - bead.total_mass.return_value = -1.0 - - data_container = MagicMock() - data_container.atoms.__getitem__.return_value.force = np.array([1.0, 0.0, 0.0]) - - trans_axes = np.identity(3) - - with self.assertRaises(ValueError): - level_manager.get_weighted_forces( - data_container, - bead, - trans_axes, - highest_level=True, - force_partitioning=0.5, - ) - - def test_get_weighted_torques_weighted_torque_basic(self): - """ - Test basic weighted torque calculation for a single-atom bead. - - Setup: - r = [1, 0, 0], F = [0, 1, 0] => r x F = [0, 0, 1] - With force_partitioning=0.5, rot_axes=I, MOI=[1,1,1], - expected weighted torque is [0, 0, 0.5]. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - axes_manager = AxesManager() - - bead = MagicMock() - bead.positions = np.array([[1.0, 0.0, 0.0]]) - bead.forces = np.array([[0.0, 1.0, 0.0]]) - bead.dimensions = np.array([10.0, 10.0, 10.0]) - - rot_axes = np.eye(3) - center = np.zeros(3) - force_partitioning = 0.5 - moment_of_inertia = np.array([1.0, 1.0, 1.0]) - - with patch.object( - AxesManager, "get_vector", return_value=bead.positions - center - ) as gv_mock: - result = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - axes_manager=axes_manager, - ) - - gv_mock.assert_called() - - expected = np.array([0.0, 0.0, 0.5]) - np.testing.assert_allclose(result, expected) - - def test_get_weighted_torques_zero_torque_skips_division(self): - """ - Test that zero torque components skip division and remain zero. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - axes_manager = AxesManager() - - bead = MagicMock() - bead.positions = np.array([[0.0, 0.0, 0.0]]) - bead.forces = np.array([[0.0, 0.0, 0.0]]) - bead.dimensions = np.array([10.0, 10.0, 10.0]) - - rot_axes = np.identity(3) - center = np.array([0.0, 0.0, 0.0]) - force_partitioning = 0.5 - moment_of_inertia = np.array([1.0, 2.0, 3.0]) - - with patch.object( - AxesManager, "get_vector", return_value=bead.positions - center - ): - result = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - axes_manager=axes_manager, - ) - - np.testing.assert_array_equal(result, np.zeros(3)) - - def test_get_weighted_torques_zero_moi(self): - """ - Should set torque to 0 when moment of inertia is zero in a dimension - and torque in that dimension is non-zero. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - axes_manager = AxesManager() - - bead = MagicMock() - bead.positions = np.array([[1.0, 0.0, 0.0]]) - bead.forces = np.array([[0.0, 1.0, 0.0]]) - bead.dimensions = np.array([10.0, 10.0, 10.0]) - - rot_axes = np.identity(3) - center = np.array([0.0, 0.0, 0.0]) - force_partitioning = 0.5 - moment_of_inertia = np.array([1.0, 1.0, 0.0]) - - with patch.object( - AxesManager, "get_vector", return_value=bead.positions - center - ): - torque = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - axes_manager=axes_manager, - ) - - np.testing.assert_array_equal(torque, np.zeros(3)) - - def test_get_weighted_torques_negative_moi_sets_zero(self): - """ - Negative moment of inertia components should be skipped and set to 0 - even if the corresponding torque component is non-zero. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - axes_manager = AxesManager() - - bead = MagicMock() - bead.positions = np.array([[1.0, 0.0, 0.0]]) - bead.forces = np.array([[0.0, 1.0, 0.0]]) - bead.dimensions = np.array([10.0, 10.0, 10.0]) - - rot_axes = np.identity(3) - center = np.array([0.0, 0.0, 0.0]) - force_partitioning = 0.5 - moment_of_inertia = np.array([1.0, 1.0, -1.0]) - - with patch.object( - AxesManager, "get_vector", return_value=bead.positions - center - ): - result = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - axes_manager=axes_manager, - ) - - np.testing.assert_array_equal(result, np.zeros(3)) - - def test_create_submatrix_basic_outer_product(self): - """ - Test with known vectors to verify correct outer product. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data_i = np.array([1, 0, 0]) - data_j = np.array([0, 1, 0]) - - expected = np.outer(data_i, data_j) - result = level_manager.create_submatrix(data_i, data_j) - - np.testing.assert_array_equal(result, expected) - - def test_create_submatrix_zero_vectors_returns_zero_matrix(self): - """ - Test that all-zero input vectors return a zero matrix. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data_i = np.zeros(3) - data_j = np.zeros(3) - result = level_manager.create_submatrix(data_i, data_j) - - np.testing.assert_array_equal(result, np.zeros((3, 3))) - - def test_create_submatrix_single_frame(self): - """ - Test that one frame should return the outer product of the single pair of - vectors. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - vec_i = np.array([1, 2, 3]) - vec_j = np.array([4, 5, 6]) - expected = np.outer(vec_i, vec_j) - - result = level_manager.create_submatrix([vec_i], [vec_j]) - np.testing.assert_array_almost_equal(result, expected) - - def test_create_submatrix_symmetric_result_when_data_equal(self): - """ - Test that if data_i == data_j, the result is symmetric. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - data = np.array([1, 2, 3]) - result = level_manager.create_submatrix(data, data) - - self.assertTrue(np.allclose(result, result.T)) # Check symmetry - - def test_create_FTsubmatrix_basic_outer_product(self): - """ - Test that: - - create_FTsubmatrix returns the outer product of two 6D vectors - - shape is (6, 6) - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - data_i = np.array([1, 2, 3, 4, 5, 6], dtype=float) - data_j = np.array([6, 5, 4, 3, 2, 1], dtype=float) - - result = level_manager.create_FTsubmatrix(data_i, data_j) - - expected = np.outer(data_i, data_j) - - assert result.shape == (6, 6) - np.testing.assert_array_equal(result, expected) - - def test_create_FTsubmatrix_zero_input(self): - """ - Test that: - - if either input is zero, the result is a zero matrix - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - data_i = np.zeros(6) - data_j = np.array([1, 2, 3, 4, 5, 6], dtype=float) - - result = level_manager.create_FTsubmatrix(data_i, data_j) - - np.testing.assert_array_equal(result, np.zeros((6, 6))) - - def test_create_FTsubmatrix_transpose_property(self): - """ - Test that: - - outer(i, j).T == outer(j, i) - - required by block-matrix symmetry logic - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - data_i = np.arange(1, 7, dtype=float) - data_j = np.arange(7, 13, dtype=float) - - sub_ij = level_manager.create_FTsubmatrix(data_i, data_j) - sub_ji = level_manager.create_FTsubmatrix(data_j, data_i) - - np.testing.assert_array_equal(sub_ij.T, sub_ji) - - def test_create_FTsubmatrix_dtype(self): - """ - Test that: - - output dtype follows NumPy outer-product rules - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - data_i = np.ones(6, dtype=np.float64) - data_j = np.ones(6, dtype=np.float64) - - result = level_manager.create_FTsubmatrix(data_i, data_j) - - assert result.dtype == np.float64 - - def test_build_covariance_matrices_atomic(self): - """ - Test `build_covariance_matrices` to ensure it correctly orchestrates - calls and returns dictionaries with the expected structure. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - entropy_manager = MagicMock() - - # Fake atom with minimal attributes - atom = MagicMock() - atom.resname = "RES" - atom.resid = 1 - atom.segid = "A" - - fake_mol = MagicMock() - fake_mol.atoms = [atom] - - universe_operations.get_molecule_container = MagicMock(return_value=fake_mol) - - timestep1 = MagicMock() - timestep1.frame = 0 - timestep2 = MagicMock() - timestep2.frame = 1 - - reduced_atom = MagicMock() - reduced_atom.trajectory.__getitem__.return_value = [timestep1, timestep2] - - groups = {"ua": ["mol1", "mol2"]} - levels = {"mol1": ["level1", "level2"], "mol2": ["level1"]} - - level_manager.update_force_torque_matrices = MagicMock() - - force_matrices, torque_matrices, *_ = level_manager.build_covariance_matrices( - entropy_manager=entropy_manager, - reduced_atom=reduced_atom, - levels=levels, - groups=groups, - start=0, - end=2, - step=1, - number_frames=2, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - self.assertIsInstance(force_matrices, dict) - self.assertIsInstance(torque_matrices, dict) - self.assertSetEqual(set(force_matrices.keys()), {"ua", "res", "poly"}) - self.assertSetEqual(set(torque_matrices.keys()), {"ua", "res", "poly"}) - - self.assertIsInstance(force_matrices["res"], list) - self.assertIsInstance(force_matrices["poly"], list) - self.assertEqual(len(force_matrices["res"]), len(groups)) - self.assertEqual(len(force_matrices["poly"]), len(groups)) - - self.assertEqual(universe_operations.get_molecule_container.call_count, 4) - self.assertEqual(level_manager.update_force_torque_matrices.call_count, 6) - - def test_update_force_torque_matrices_united_atom(self): - """ - Test that update_force_torque_matrices() correctly initializes force and torque - matrices for the 'united_atom' level. - - Ensures: - - The matrices are initialized for each UA group key. - - Frame counts are incremented correctly. - """ - universe_operations = UniverseOperations() - universe_operations.new_U_select_atom = MagicMock() - - level_manager = LevelManager(universe_operations) - - entropy_manager = MagicMock() - run_manager = MagicMock() - entropy_manager._run_manager = run_manager - - mock_res = MagicMock() - mock_res.trajectory = MagicMock() - mock_res.trajectory.__getitem__.return_value = None - - universe_operations.new_U_select_atom.return_value = mock_res - - mock_residue1 = MagicMock() - mock_residue1.atoms.indices = [0, 2] - mock_residue2 = MagicMock() - mock_residue2.atoms.indices = [3, 5] - - mol = MagicMock() - mol.residues = [mock_residue1, mock_residue2] - - f_mat = np.array([[1]]) - t_mat = np.array([[2]]) - level_manager.get_matrices = MagicMock(return_value=(f_mat, t_mat)) - - force_avg = {"ua": {}, "res": [None], "poly": [None]} - torque_avg = {"ua": {}, "res": [None], "poly": [None]} - forcetorque_avg = {"ua": {}, "res": [None], "poly": [None]} - frame_counts = {"ua": {}, "res": [None], "poly": [None]} - - level_manager.update_force_torque_matrices( - entropy_manager=entropy_manager, - mol=mol, - group_id=0, - level="united_atom", - level_list=["residue", "united_atom"], - time_index=0, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - assert (0, 0) in force_avg["ua"] - assert (0, 1) in force_avg["ua"] - assert (0, 0) in torque_avg["ua"] - assert (0, 1) in torque_avg["ua"] - - np.testing.assert_array_equal(force_avg["ua"][(0, 0)], f_mat) - np.testing.assert_array_equal(force_avg["ua"][(0, 1)], f_mat) - np.testing.assert_array_equal(torque_avg["ua"][(0, 0)], t_mat) - np.testing.assert_array_equal(torque_avg["ua"][(0, 1)], t_mat) - - assert frame_counts["ua"][(0, 0)] == 1 - assert frame_counts["ua"][(0, 1)] == 1 - - assert forcetorque_avg["ua"] == {} - - def test_update_force_torque_matrices_united_atom_increment(self): - """ - Test that update_force_torque_matrices() correctly updates (increments) - existing force and torque matrices for the 'united_atom' level. - - Confirms correct incremental averaging behavior. - """ - universe_operations = UniverseOperations() - universe_operations.new_U_select_atom = MagicMock() - - level_manager = LevelManager(universe_operations) - - entropy_manager = MagicMock() - mol = MagicMock() - - residue = MagicMock() - residue.atoms.indices = [0, 1] - mol.residues = [residue] - mol.trajectory = MagicMock() - mol.trajectory.__getitem__.return_value = None - - selected_atoms = MagicMock() - selected_atoms.trajectory = MagicMock() - selected_atoms.trajectory.__getitem__.return_value = None - universe_operations.new_U_select_atom.return_value = selected_atoms - - f_mat_1 = np.array([[1.0]]) - t_mat_1 = np.array([[2.0]]) - - f_mat_2 = np.array([[3.0]]) - t_mat_2 = np.array([[4.0]]) - - level_manager.get_matrices = MagicMock( - side_effect=[(f_mat_1, t_mat_1), (f_mat_2, t_mat_2)] - ) - - force_avg = {"ua": {}, "res": [None], "poly": [None]} - torque_avg = {"ua": {}, "res": [None], "poly": [None]} - forcetorque_avg = {"ua": {}, "res": [None], "poly": [None]} - frame_counts = {"ua": {}, "res": [None], "poly": [None]} - - level_manager.update_force_torque_matrices( - entropy_manager=entropy_manager, - mol=mol, - group_id=0, - level="united_atom", - level_list=["residue", "united_atom"], - time_index=0, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - # Second update - level_manager.update_force_torque_matrices( - entropy_manager=entropy_manager, - mol=mol, - group_id=0, - level="united_atom", - level_list=["residue", "united_atom"], - time_index=1, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - expected_force = f_mat_1 + (f_mat_2 - f_mat_1) / 2 - expected_torque = t_mat_1 + (t_mat_2 - t_mat_1) / 2 - - np.testing.assert_array_almost_equal(force_avg["ua"][(0, 0)], expected_force) - np.testing.assert_array_almost_equal(torque_avg["ua"][(0, 0)], expected_torque) - assert frame_counts["ua"][(0, 0)] == 2 - - assert forcetorque_avg["ua"] == {} - - def test_update_force_torque_matrices_residue(self): - """ - Test that `update_force_torque_matrices` correctly updates force and torque - matrices for the 'residue' level, assigning whole-molecule matrices and - incrementing frame counts. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - entropy_manager = MagicMock() - mol = MagicMock() - mol.trajectory.__getitem__.return_value = None - - f_mat_mock = np.array([[1]]) - t_mat_mock = np.array([[2]]) - level_manager.get_matrices = MagicMock(return_value=(f_mat_mock, t_mat_mock)) - - force_avg = {"ua": {}, "res": [None], "poly": [None]} - torque_avg = {"ua": {}, "res": [None], "poly": [None]} - forcetorque_avg = {"ua": {}, "res": [None], "poly": [None]} - frame_counts = {"ua": {}, "res": [None], "poly": [None]} - - level_manager.update_force_torque_matrices( - entropy_manager=entropy_manager, - mol=mol, - group_id=0, - level="residue", - level_list=["residue", "united_atom"], - time_index=3, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - np.testing.assert_array_equal(force_avg["res"][0], f_mat_mock) - np.testing.assert_array_equal(torque_avg["res"][0], t_mat_mock) - assert frame_counts["res"][0] == 1 - - assert forcetorque_avg["res"][0] is None - - def test_update_force_torque_matrices_incremental_average(self): - """ - Test that `update_force_torque_matrices` correctly applies the incremental - mean formula when updating force and torque matrices over multiple frames. - - Ensures that float precision is maintained and no casting errors occur. - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - entropy_manager = MagicMock() - mol = MagicMock() - mol.trajectory.__getitem__.return_value = None - - # Ensure matrices are float64 to avoid casting errors - f_mat_1 = np.array([[1.0]], dtype=np.float64) - t_mat_1 = np.array([[2.0]], dtype=np.float64) - f_mat_2 = np.array([[3.0]], dtype=np.float64) - t_mat_2 = np.array([[4.0]], dtype=np.float64) - - level_manager.get_matrices = MagicMock( - side_effect=[(f_mat_1, t_mat_1), (f_mat_2, t_mat_2)] - ) - - force_avg = {"ua": {}, "res": [None], "poly": [None]} - torque_avg = {"ua": {}, "res": [None], "poly": [None]} - forcetorque_avg = {"ua": {}, "res": [None], "poly": [None]} - frame_counts = {"ua": {}, "res": [None], "poly": [None]} - - # First update - level_manager.update_force_torque_matrices( - entropy_manager=entropy_manager, - mol=mol, - group_id=0, - level="residue", - level_list=["residue", "united_atom"], - time_index=0, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - # Second update - level_manager.update_force_torque_matrices( - entropy_manager=entropy_manager, - mol=mol, - group_id=0, - level="residue", - level_list=["residue", "united_atom"], - time_index=1, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=False, - customised_axes=True, - ) - - expected_force = f_mat_1 + (f_mat_2 - f_mat_1) / 2 - expected_torque = t_mat_1 + (t_mat_2 - t_mat_1) / 2 - - np.testing.assert_array_almost_equal(force_avg["res"][0], expected_force) - np.testing.assert_array_almost_equal(torque_avg["res"][0], expected_torque) - - assert frame_counts["res"][0] == 2 - assert forcetorque_avg["res"][0] is None - - def test_update_force_torque_matrices_residue_combined_ft_init(self): - """ - Test that: When highest=True and combined_forcetorque=True at residue level, - update_force_torque_matrices should: - - call get_combined_forcetorque_matrices - - store ft_mat into forcetorque_avg['res'][group_id] - - set frame_counts['res'][group_id] = 1 - - NOT touch force_avg/torque_avg - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - mol = MagicMock() - mol.trajectory.__getitem__.return_value = None - - ft_mat = np.array([[1.0, 2.0], [3.0, 4.0]], dtype=float) - - level_manager.get_combined_forcetorque_matrices = MagicMock(return_value=ft_mat) - level_manager.get_matrices = MagicMock() - - force_avg = {"ua": {}, "res": [None], "poly": [None]} - torque_avg = {"ua": {}, "res": [None], "poly": [None]} - forcetorque_avg = {"ua": {}, "res": [None], "poly": [None]} - frame_counts = {"ua": {}, "res": [None], "poly": [None]} - - level_manager.update_force_torque_matrices( - entropy_manager=MagicMock(), - mol=mol, - group_id=0, - level="residue", - level_list=["united_atom", "residue"], - time_index=0, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=True, - customised_axes=True, - ) - - level_manager.get_combined_forcetorque_matrices.assert_called_once() - args = level_manager.get_combined_forcetorque_matrices.call_args.args - assert args[0] is mol - assert args[1] == "residue" - assert args[2] is True - assert args[3] is None - assert args[4] == 0.5 - assert args[5] is True - - np.testing.assert_array_equal(forcetorque_avg["res"][0], ft_mat) - assert frame_counts["res"][0] == 1 - - level_manager.get_matrices.assert_not_called() - - assert force_avg["res"][0] is None - assert torque_avg["res"][0] is None - - def test_update_force_torque_matrices_residue_combined_ft_incremental_avg_no_helper( - self, - ): - """ - Test that: highest=True and combined_forcetorque=True - - initializes forcetorque_avg on first call - - updates it via incremental mean on second call - - avoids asserting the mutable 'existing' arg passed into the mock - """ - universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - - mol = MagicMock() - mol.trajectory.__getitem__.return_value = None - - ft1 = np.array([[1.0, 1.0], [1.0, 1.0]], dtype=float) - ft2 = np.array([[3.0, 3.0], [3.0, 3.0]], dtype=float) - - level_manager.get_combined_forcetorque_matrices = MagicMock( - side_effect=[ft1, ft2] - ) - level_manager.get_matrices = MagicMock() - - force_avg = {"ua": {}, "res": [None], "poly": [None]} - torque_avg = {"ua": {}, "res": [None], "poly": [None]} - forcetorque_avg = {"ua": {}, "res": [None], "poly": [None]} - frame_counts = {"ua": {}, "res": [None], "poly": [None]} - - level_manager.update_force_torque_matrices( - entropy_manager=MagicMock(), - mol=mol, - group_id=0, - level="residue", - level_list=["united_atom", "residue"], - time_index=0, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=True, - customised_axes=True, - ) - - np.testing.assert_array_equal(forcetorque_avg["res"][0], ft1) - assert frame_counts["res"][0] == 1 - - level_manager.update_force_torque_matrices( - entropy_manager=MagicMock(), - mol=mol, - group_id=0, - level="residue", - level_list=["united_atom", "residue"], - time_index=1, - num_frames=10, - force_avg=force_avg, - torque_avg=torque_avg, - forcetorque_avg=forcetorque_avg, - frame_counts=frame_counts, - force_partitioning=0.5, - combined_forcetorque=True, - customised_axes=True, - ) - - expected = ft1 + (ft2 - ft1) / 2.0 - np.testing.assert_array_almost_equal(forcetorque_avg["res"][0], expected) - assert frame_counts["res"][0] == 2 - - level_manager.get_matrices.assert_not_called() - assert level_manager.get_combined_forcetorque_matrices.call_count == 2 - - def test_filter_zero_rows_columns_no_zeros(self): - """ - Test that matrix with no zero-only rows or columns should return unchanged. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - matrix = np.array([[1, 2], [3, 4]]) - result = level_manager.filter_zero_rows_columns(matrix) - np.testing.assert_array_equal(result, matrix) - - def test_filter_zero_rows_columns_remove_rows_and_columns(self): - """ - Test that matrix with zero-only rows and columns should return reduced matrix. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - matrix = np.array([[0, 0, 0], [0, 5, 0], [0, 0, 0]]) - expected = np.array([[5]]) - result = level_manager.filter_zero_rows_columns(matrix) - np.testing.assert_array_equal(result, expected) - - def test_filter_zero_rows_columns_all_zeros(self): - """ - Test that matrix with all zeros should return an empty matrix. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - matrix = np.zeros((3, 3)) - result = level_manager.filter_zero_rows_columns(matrix) - self.assertEqual(result.size, 0) - self.assertEqual(result.shape, (0, 0)) - - def test_filter_zero_rows_columns_partial_zero_removal(self): - """ - Matrix with zeros in specific rows/columns should remove only those. - """ - universe_operations = UniverseOperations() - - level_manager = LevelManager(universe_operations) - - matrix = np.array([[0, 0, 0], [1, 2, 3], [0, 0, 0]]) - expected = np.array([[1, 2, 3]]) - result = level_manager.filter_zero_rows_columns(matrix) - np.testing.assert_array_equal(result, expected) diff --git a/tests/test_CodeEntropy/test_logging_config.py b/tests/test_CodeEntropy/test_logging_config.py deleted file mode 100644 index 17b60cd1..00000000 --- a/tests/test_CodeEntropy/test_logging_config.py +++ /dev/null @@ -1,103 +0,0 @@ -import logging -import os -import unittest -from unittest.mock import MagicMock - -from CodeEntropy.core.logging import LoggingConfig -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestLoggingConfig(BaseTestCase): - """ - Unit tests for LoggingConfig. - """ - - def setUp(self): - super().setUp() - self.log_dir = self.logs_path - self.logging_config = LoggingConfig(folder=self.test_dir) - - self.mock_text = "Test console output" - self.logging_config.console.export_text = MagicMock(return_value=self.mock_text) - - def test_log_directory_created(self): - """Check if the log directory is created upon init""" - self.assertTrue(os.path.exists(self.log_dir)) - self.assertTrue(os.path.isdir(self.log_dir)) - - def test_setup_logging_returns_logger(self): - """Ensure setup_logging returns a logger instance""" - logger = self.logging_config.setup_logging() - self.assertIsInstance(logger, logging.Logger) - - def test_expected_log_files_created(self): - """Ensure log file paths are configured correctly in the logging config""" - self.logging_config.setup_logging() - - # Map expected filenames to the corresponding handler keys in LoggingConfig - expected_handlers = { - "program.log": "main", - "program.err": "error", - "program.com": "command", - "mdanalysis.log": "mdanalysis", - } - - for filename, handler_key in expected_handlers.items(): - expected_path = os.path.join(self.logging_config.log_dir, filename) - actual_path = self.logging_config.handlers[handler_key].baseFilename - self.assertEqual(actual_path, expected_path) - - def test_update_logging_level(self): - """Ensure logging levels are updated correctly""" - self.logging_config.setup_logging() - - # Update to DEBUG - self.logging_config.update_logging_level(logging.DEBUG) - root_logger = logging.getLogger() - self.assertEqual(root_logger.level, logging.DEBUG) - - # Check that at least one handler is DEBUG - handler_levels = [h.level for h in root_logger.handlers] - self.assertIn(logging.DEBUG, handler_levels) - - # Update to INFO - self.logging_config.update_logging_level(logging.INFO) - self.assertEqual(root_logger.level, logging.INFO) - - def test_mdanalysis_and_command_loggers_exist(self): - """Ensure specialized loggers are set up with correct configuration""" - log_level = logging.DEBUG - self.logging_config = LoggingConfig(folder=self.test_dir, level=log_level) - self.logging_config.setup_logging() - - mda_logger = logging.getLogger("MDAnalysis") - cmd_logger = logging.getLogger("commands") - - self.assertEqual(mda_logger.level, log_level) - self.assertEqual(cmd_logger.level, logging.INFO) - self.assertFalse(mda_logger.propagate) - self.assertFalse(cmd_logger.propagate) - - def test_save_console_log_writes_file(self): - """ - Test that save_console_log creates a log file in the expected location - and writes the console's recorded output correctly. - """ - filename = "test_log.txt" - self.logging_config.save_console_log(filename) - - output_path = os.path.join(self.test_dir, "logs", filename) - # Check file exists - self.assertTrue(os.path.exists(output_path)) - - # Read content and check it matches mocked export_text output - with open(output_path, "r", encoding="utf-8") as f: - content = f.read() - self.assertEqual(content, self.mock_text) - - # Ensure export_text was called once - self.logging_config.console.export_text.assert_called_once() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_CodeEntropy/test_main.py b/tests/test_CodeEntropy/test_main.py deleted file mode 100644 index 76feb528..00000000 --- a/tests/test_CodeEntropy/test_main.py +++ /dev/null @@ -1,134 +0,0 @@ -import os -import shutil -import subprocess -import sys -import unittest -from unittest.mock import MagicMock, patch - -from CodeEntropy.main import main -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestMain(BaseTestCase): - """ - Unit tests for the main functionality of CodeEntropy. - """ - - def setUp(self): - super().setUp() - self.code_entropy = main - - @patch("CodeEntropy.main.sys.exit") - @patch("CodeEntropy.main.RunManager") - def test_main_successful_run(self, mock_RunManager, mock_exit): - """ - Test that main runs successfully and does not call sys.exit. - """ - # Mock RunManager's methods to simulate successful execution - mock_run_manager_instance = MagicMock() - mock_RunManager.return_value = mock_run_manager_instance - - # Simulate that RunManager.create_job_folder returns a folder - mock_RunManager.create_job_folder.return_value = "mock_folder/job001" - - # Simulate the successful completion of the run_entropy_workflow method - mock_run_manager_instance.run_entropy_workflow.return_value = None - - # Run the main function - main() - - # Verify that sys.exit was not called - mock_exit.assert_not_called() - - # Verify that RunManager's methods were called correctly - mock_RunManager.create_job_folder.assert_called_once() - mock_run_manager_instance.run_entropy_workflow.assert_called_once() - - @patch("CodeEntropy.main.sys.exit") - @patch("CodeEntropy.main.RunManager") - @patch("CodeEntropy.main.logger") - def test_main_exception_triggers_exit( - self, mock_logger, mock_RunManager, mock_exit - ): - """ - Test that main logs a critical error and exits if RunManager - raises an exception. - """ - # Simulate an exception being raised in run_entropy_workflow - mock_run_manager_instance = MagicMock() - mock_RunManager.return_value = mock_run_manager_instance - - # Simulate that RunManager.create_job_folder returns a folder - mock_RunManager.create_job_folder.return_value = "mock_folder/job001" - - # Simulate an exception in the run_entropy_workflow method - mock_run_manager_instance.run_entropy_workflow.side_effect = Exception( - "Test exception" - ) - - # Run the main function and mock sys.exit to ensure it gets called - main() - - # Ensure sys.exit(1) was called due to the exception - mock_exit.assert_called_once_with(1) - - # Ensure that the logger logged the critical error with exception details - mock_logger.critical.assert_called_once_with( - "Fatal error during entropy calculation: Test exception", exc_info=True - ) - - def test_main_entry_point_runs(self): - """ - Test that the CLI entry point (main.py) runs successfully with minimal required - arguments. - """ - # Prepare input files - data_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "data") - ) - tpr_path = shutil.copy(os.path.join(data_dir, "md_A4_dna.tpr"), self.test_dir) - trr_path = shutil.copy( - os.path.join(data_dir, "md_A4_dna_xf.trr"), self.test_dir - ) - - config_path = os.path.join(self.test_dir, "config.yaml") - with open(config_path, "w") as f: - f.write("run1:\n" " end: 1\n" " selection_string: resname DA\n") - - citation_path = os.path.join(self.test_dir, "CITATION.cff") - with open(citation_path, "w") as f: - f.write("\n") - - result = subprocess.run( - [ - sys.executable, - "-X", - "utf8", - "-m", - "CodeEntropy.main", - "--top_traj_file", - tpr_path, - trr_path, - ], - cwd=self.test_dir, - capture_output=True, - encoding="utf-8", - ) - - self.assertEqual(result.returncode, 0) - - # Check for job folder and output file - job_dir = os.path.join(self.test_dir, "job001") - output_file = os.path.join(job_dir, "output_file.json") - - self.assertTrue(os.path.exists(job_dir)) - self.assertTrue(os.path.exists(output_file)) - - with open(output_file) as f: - content = f.read() - print(content) - self.assertIn("DA", content) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_CodeEntropy/test_mda_universe_operations.py b/tests/test_CodeEntropy/test_mda_universe_operations.py deleted file mode 100644 index 46e68a76..00000000 --- a/tests/test_CodeEntropy/test_mda_universe_operations.py +++ /dev/null @@ -1,304 +0,0 @@ -import logging -import os -from unittest.mock import MagicMock, patch - -import MDAnalysis as mda -import numpy as np - -import tests.data as data -from CodeEntropy.mda_universe_operations import UniverseOperations -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestUniverseOperations(BaseTestCase): - """ - Unit tests for UniverseOperations. - """ - - def setUp(self): - super().setUp() - self.test_data_dir = os.path.dirname(data.__file__) - - # Disable MDAnalysis and commands file logging entirely - logging.getLogger("MDAnalysis").handlers = [logging.NullHandler()] - logging.getLogger("commands").handlers = [logging.NullHandler()] - - @patch("CodeEntropy.mda_universe_operations.AnalysisFromFunction") - @patch("CodeEntropy.mda_universe_operations.mda.Merge") - def test_new_U_select_frame(self, MockMerge, MockAnalysisFromFunction): - """ - Unit test for UniverseOperations.new_U_select_frame(). - """ - # Mock Universe and its components - mock_universe = MagicMock() - mock_trajectory = MagicMock() - mock_trajectory.__len__.return_value = 10 - mock_universe.trajectory = mock_trajectory - - mock_select_atoms = MagicMock() - mock_universe.select_atoms.return_value = mock_select_atoms - - # Mock AnalysisFromFunction results for coordinates, forces, and dimensions - coords = np.random.rand(10, 100, 3) - forces = np.random.rand(10, 100, 3) - dims = np.random.rand(10, 6) - - mock_coords_analysis = MagicMock() - mock_coords_analysis.run.return_value.results = {"timeseries": coords} - - mock_forces_analysis = MagicMock() - mock_forces_analysis.run.return_value.results = {"timeseries": forces} - - mock_dims_analysis = MagicMock() - mock_dims_analysis.run.return_value.results = {"timeseries": dims} - - MockAnalysisFromFunction.side_effect = [ - mock_coords_analysis, - mock_forces_analysis, - mock_dims_analysis, - ] - - # Mock merge operation - mock_merged_universe = MagicMock() - MockMerge.return_value = mock_merged_universe - - ops = UniverseOperations() - result = ops.new_U_select_frame(mock_universe) - - # Basic behavior checks - mock_universe.select_atoms.assert_called_once_with("all", updating=True) - - # AnalysisFromFunction called 3 times (coords, forces, dimensions) - assert MockAnalysisFromFunction.call_count == 3 - mock_coords_analysis.run.assert_called_once() - mock_forces_analysis.run.assert_called_once() - mock_dims_analysis.run.assert_called_once() - - # Merge called with selected AtomGroup - MockMerge.assert_called_once_with(mock_select_atoms) - - assert result == mock_merged_universe - - @patch("CodeEntropy.mda_universe_operations.AnalysisFromFunction") - @patch("CodeEntropy.mda_universe_operations.mda.Merge") - def test_new_U_select_atom(self, MockMerge, MockAnalysisFromFunction): - """ - Unit test for UniverseOperations.new_U_select_atom(). - - Ensures that: - - The Universe is queried with the correct selection string - - Coordinates, forces, and dimensions are extracted via AnalysisFromFunction - - mda.Merge receives the AtomGroup from select_atoms - - The new universe is populated with the expected data via load_new() - - The returned universe is the object created by Merge - """ - # Mock Universe and its components - mock_universe = MagicMock() - mock_select_atoms = MagicMock() - mock_universe.select_atoms.return_value = mock_select_atoms - - # Mock AnalysisFromFunction results for coordinates, forces, and dimensions - coords = np.random.rand(10, 100, 3) - forces = np.random.rand(10, 100, 3) - dims = np.random.rand(10, 6) - - mock_coords_analysis = MagicMock() - mock_coords_analysis.run.return_value.results = {"timeseries": coords} - - mock_forces_analysis = MagicMock() - mock_forces_analysis.run.return_value.results = {"timeseries": forces} - - mock_dims_analysis = MagicMock() - mock_dims_analysis.run.return_value.results = {"timeseries": dims} - - MockAnalysisFromFunction.side_effect = [ - mock_coords_analysis, - mock_forces_analysis, - mock_dims_analysis, - ] - - # Mock the merge operation - mock_merged_universe = MagicMock() - MockMerge.return_value = mock_merged_universe - - ops = UniverseOperations() - - result = ops.new_U_select_atom(mock_universe, select_string="resid 1-10") - mock_universe.select_atoms.assert_called_once_with("resid 1-10", updating=True) - - # AnalysisFromFunction called for coords, forces, dimensions - assert MockAnalysisFromFunction.call_count == 3 - mock_coords_analysis.run.assert_called_once() - mock_forces_analysis.run.assert_called_once() - mock_dims_analysis.run.assert_called_once() - - # Merge called with the selected AtomGroup - MockMerge.assert_called_once_with(mock_select_atoms) - - # Returned universe should be the merged universe - assert result == mock_merged_universe - - def test_get_molecule_container(self): - """ - Integration test for UniverseOperations.get_molecule_container(). - - Uses a real MDAnalysis Universe loaded from test trajectory files. - Confirms that: - - The correct fragment for a given molecule index is selected - - The returned reduced Universe contains exactly the expected atom indices - - The number of atoms matches the original fragment - """ - tprfile = os.path.join(self.test_data_dir, "md_A4_dna.tpr") - trrfile = os.path.join(self.test_data_dir, "md_A4_dna_xf.trr") - - u = mda.Universe(tprfile, trrfile) - - ops = UniverseOperations() - - molecule_id = 0 - - fragment = u.atoms.fragments[molecule_id] - expected_indices = fragment.indices - - mol_u = ops.get_molecule_container(u, molecule_id) - - selected_indices = mol_u.atoms.indices - - self.assertSetEqual(set(selected_indices), set(expected_indices)) - self.assertEqual(len(selected_indices), len(expected_indices)) - - @patch("CodeEntropy.mda_universe_operations.AnalysisFromFunction") - @patch("CodeEntropy.mda_universe_operations.mda.Merge") - @patch("CodeEntropy.mda_universe_operations.mda.Universe") - def test_merge_forces(self, MockUniverse, MockMerge, MockAnalysisFromFunction): - """ - Unit test for UniverseOperations.merge_forces(). - """ - # Two Universes created: coords and forces - mock_u_coords = MagicMock() - mock_u_force = MagicMock() - MockUniverse.side_effect = [mock_u_coords, mock_u_force] - - # Each universe returns an AtomGroup from select_atoms("all") - mock_ag_coords = MagicMock() - mock_ag_force = MagicMock() - mock_u_coords.select_atoms.return_value = mock_ag_coords - mock_u_force.select_atoms.return_value = mock_ag_force - - coords = np.random.rand(5, 10, 3) - forces = np.random.rand(5, 10, 3) - dims = np.random.rand(5, 6) - - mock_coords_analysis = MagicMock() - mock_coords_analysis.run.return_value.results = {"timeseries": coords} - - mock_forces_analysis = MagicMock() - mock_forces_analysis.run.return_value.results = {"timeseries": forces} - - mock_dims_analysis = MagicMock() - mock_dims_analysis.run.return_value.results = {"timeseries": dims} - - MockAnalysisFromFunction.side_effect = [ - mock_coords_analysis, - mock_forces_analysis, - mock_dims_analysis, - ] - - mock_merged = MagicMock() - MockMerge.return_value = mock_merged - - ops = UniverseOperations() - result = ops.merge_forces( - tprfile="topol.tpr", - trrfile="traj.trr", - forcefile="forces.trr", - fileformat=None, - kcal=False, - ) - - # Universe construction - assert MockUniverse.call_count == 2 - - # Selection - mock_u_coords.select_atoms.assert_called_once_with("all") - mock_u_force.select_atoms.assert_called_once_with("all") - - # AnalysisFromFunction usage - assert MockAnalysisFromFunction.call_count == 3 - mock_coords_analysis.run.assert_called_once() - mock_forces_analysis.run.assert_called_once() - mock_dims_analysis.run.assert_called_once() - - # Merge called with coordinate AtomGroup - MockMerge.assert_called_once_with(mock_ag_coords) - - # Returned object is merged universe - assert result == mock_merged - - @patch("CodeEntropy.mda_universe_operations.AnalysisFromFunction") - @patch("CodeEntropy.mda_universe_operations.mda.Merge") - @patch("CodeEntropy.mda_universe_operations.mda.Universe") - def test_merge_forces_kcal_conversion( - self, MockUniverse, MockMerge, MockAnalysisFromFunction - ): - """ - Unit test for UniverseOperations.merge_forces() covering the kcal→kJ - conversion branch. - """ - mock_u_coords = MagicMock() - mock_u_force = MagicMock() - MockUniverse.side_effect = [mock_u_coords, mock_u_force] - - mock_ag_coords = MagicMock() - mock_ag_force = MagicMock() - mock_u_coords.select_atoms.return_value = mock_ag_coords - mock_u_force.select_atoms.return_value = mock_ag_force - - coords = np.ones((2, 3, 3)) - - original_forces = np.ones((2, 3, 3)) - mock_forces_array = original_forces.copy() - - dims = np.ones((2, 6)) - - # Mock AnalysisFromFunction return values - mock_coords_analysis = MagicMock() - mock_coords_analysis.run.return_value.results = {"timeseries": coords} - - mock_forces_analysis = MagicMock() - mock_forces_analysis.run.return_value.results = { - "timeseries": mock_forces_array - } - - mock_dims_analysis = MagicMock() - mock_dims_analysis.run.return_value.results = {"timeseries": dims} - - MockAnalysisFromFunction.side_effect = [ - mock_coords_analysis, - mock_forces_analysis, - mock_dims_analysis, - ] - - mock_merged = MagicMock() - MockMerge.return_value = mock_merged - - ops = UniverseOperations() - result = ops.merge_forces("t.tpr", "c.trr", "f.trr", kcal=True) - - # select_atoms("all") (your code uses no updating=True) - mock_u_coords.select_atoms.assert_called_once_with("all") - mock_u_force.select_atoms.assert_called_once_with("all") - - # AnalysisFromFunction called three times - assert MockAnalysisFromFunction.call_count == 3 - - # Forces are multiplied exactly once by 4.184 when kcal=True - np.testing.assert_allclose( - mock_forces_array, original_forces * 4.184, rtol=0, atol=0 - ) - - # Merge called with coordinate AtomGroup - MockMerge.assert_called_once_with(mock_ag_coords) - - # Returned universe is the merged universe - assert result == mock_merged diff --git a/tests/test_CodeEntropy/test_run.py b/tests/test_CodeEntropy/test_run.py deleted file mode 100644 index 0174df13..00000000 --- a/tests/test_CodeEntropy/test_run.py +++ /dev/null @@ -1,632 +0,0 @@ -import os -import unittest -from io import StringIO -from unittest.mock import MagicMock, mock_open, patch - -import requests -import yaml -from rich.console import Console - -from CodeEntropy.run import RunManager -from tests.test_CodeEntropy.test_base import BaseTestCase - - -class TestRunManager(BaseTestCase): - """ - Unit tests for the RunManager class. These tests verify the - correct behavior of run manager. - """ - - def setUp(self): - super().setUp() - self.config_file = os.path.join(self.test_dir, "CITATION.cff") - # Create mock config - with patch("builtins.open", new_callable=mock_open) as mock_file: - self.setup_citation_file(mock_file) - with open(self.config_file, "w") as f: - f.write(mock_file.return_value.read()) - self.run_manager = RunManager(folder=self.test_dir) - - def setup_citation_file(self, mock_file): - """ - Mock the contents of the CITATION.cff file. - """ - citation_content = """\ - authors: - - given-names: Alice - family-names: Smith - """ - - mock_file.return_value = mock_open(read_data=citation_content).return_value - - @patch("os.makedirs") - @patch("os.listdir") - def test_create_job_folder_empty_directory(self, mock_listdir, mock_makedirs): - """ - Test that 'job001' is created when the directory is initially empty. - """ - mock_listdir.return_value = [] - new_folder_path = RunManager.create_job_folder() - expected_path = os.path.join(self.test_dir, "job001") - self.assertEqual( - os.path.realpath(new_folder_path), os.path.realpath(expected_path) - ) - - @patch("os.makedirs") - @patch("os.listdir") - def test_create_job_folder_with_existing_folders(self, mock_listdir, mock_makedirs): - """ - Test that the next sequential job folder (e.g., 'job004') is created when - existing folders 'job001', 'job002', and 'job003' are present. - """ - mock_listdir.return_value = ["job001", "job002", "job003"] - new_folder_path = RunManager.create_job_folder() - expected_path = os.path.join(self.test_dir, "job004") - - # Normalize paths cross-platform - normalized_new = os.path.normcase( - os.path.realpath(os.path.normpath(new_folder_path)) - ) - normalized_expected = os.path.normcase( - os.path.realpath(os.path.normpath(expected_path)) - ) - - self.assertEqual(normalized_new, normalized_expected) - - called_args, called_kwargs = mock_makedirs.call_args - normalized_called = os.path.normcase( - os.path.realpath(os.path.normpath(called_args[0])) - ) - self.assertEqual(normalized_called, normalized_expected) - self.assertTrue(called_kwargs.get("exist_ok", False)) - - @patch("os.makedirs") - @patch("os.listdir") - def test_create_job_folder_with_non_matching_folders( - self, mock_listdir, mock_makedirs - ): - """ - Test that 'job001' is created when the directory contains only non-job-related - folders. - """ - mock_listdir.return_value = ["folderA", "another_one"] - - new_folder_path = RunManager.create_job_folder() - expected_path = os.path.join(self.test_dir, "job001") - - normalized_new = os.path.normcase( - os.path.realpath(os.path.normpath(new_folder_path)) - ) - normalized_expected = os.path.normcase( - os.path.realpath(os.path.normpath(expected_path)) - ) - self.assertEqual(normalized_new, normalized_expected) - - called_args, called_kwargs = mock_makedirs.call_args - normalized_called = os.path.normcase( - os.path.realpath(os.path.normpath(called_args[0])) - ) - self.assertEqual(normalized_called, normalized_expected) - self.assertTrue(called_kwargs.get("exist_ok", False)) - - @patch("os.makedirs") - @patch("os.listdir") - def test_create_job_folder_mixed_folder_names(self, mock_listdir, mock_makedirs): - """ - Test that the correct next job folder (e.g., 'job003') is created when both - job and non-job folders exist in the directory. - """ - mock_listdir.return_value = ["job001", "abc", "job002", "random"] - new_folder_path = RunManager.create_job_folder() - expected_path = os.path.join(self.test_dir, "job003") - - normalized_new = os.path.normcase( - os.path.realpath(os.path.normpath(new_folder_path)) - ) - normalized_expected = os.path.normcase( - os.path.realpath(os.path.normpath(expected_path)) - ) - self.assertEqual(normalized_new, normalized_expected) - - called_args, called_kwargs = mock_makedirs.call_args - normalized_called = os.path.normcase( - os.path.realpath(os.path.normpath(called_args[0])) - ) - self.assertEqual(normalized_called, normalized_expected) - self.assertTrue(called_kwargs.get("exist_ok", False)) - - @patch("os.makedirs") - @patch("os.listdir") - def test_create_job_folder_with_invalid_job_suffix( - self, mock_listdir, mock_makedirs - ): - """ - Test that invalid job folder names like 'jobABC' are ignored when determining - the next job number. - """ - # Simulate existing folders, one of which is invalid - mock_listdir.return_value = ["job001", "jobABC", "job002"] - - new_folder_path = RunManager.create_job_folder() - expected_path = os.path.join(self.test_dir, "job003") - - normalized_new = os.path.normcase( - os.path.realpath(os.path.normpath(new_folder_path)) - ) - normalized_expected = os.path.normcase( - os.path.realpath(os.path.normpath(expected_path)) - ) - self.assertEqual(normalized_new, normalized_expected) - - called_args, called_kwargs = mock_makedirs.call_args - normalized_called = os.path.normcase( - os.path.realpath(os.path.normpath(called_args[0])) - ) - self.assertEqual(normalized_called, normalized_expected) - self.assertTrue(called_kwargs.get("exist_ok", False)) - - @patch("requests.get") - def test_load_citation_data_success(self, mock_get): - """Should return parsed dict when CITATION.cff loads successfully.""" - mock_yaml = """ - authors: - - given-names: Alice - family-names: Smith - title: TestProject - version: 1.0 - date-released: 2025-01-01 - """ - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = mock_yaml - mock_get.return_value = mock_response - - instance = RunManager("dummy") - data = instance.load_citation_data() - - self.assertIsInstance(data, dict) - self.assertEqual(data["title"], "TestProject") - self.assertEqual(data["authors"][0]["given-names"], "Alice") - - @patch("requests.get") - def test_load_citation_data_network_error(self, mock_get): - """Should return None if network request fails.""" - mock_get.side_effect = requests.exceptions.ConnectionError("Network down") - - instance = RunManager("dummy") - data = instance.load_citation_data() - - self.assertIsNone(data) - - @patch("requests.get") - def test_load_citation_data_http_error(self, mock_get): - """Should return None if HTTP response is non-200.""" - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError() - mock_get.return_value = mock_response - - instance = RunManager("dummy") - data = instance.load_citation_data() - - self.assertIsNone(data) - - @patch("requests.get") - def test_load_citation_data_invalid_yaml(self, mock_get): - """Should raise YAML error if file content is invalid YAML.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.text = "invalid: [oops" - mock_get.return_value = mock_response - - instance = RunManager("dummy") - - with self.assertRaises(yaml.YAMLError): - instance.load_citation_data() - - @patch.object(RunManager, "load_citation_data") - def test_show_splash_with_citation(self, mock_load): - """Should render full splash screen when citation data is present.""" - mock_load.return_value = { - "title": "TestProject", - "version": "1.0", - "date-released": "2025-01-01", - "url": "https://example.com", - "abstract": "This is a test abstract.", - "authors": [ - {"given-names": "Alice", "family-names": "Smith", "affiliation": "Uni"} - ], - } - - buf = StringIO() - test_console = Console(file=buf, force_terminal=False, width=80) - - instance = RunManager("dummy") - with patch("CodeEntropy.run.console", test_console): - instance.show_splash() - - output = buf.getvalue() - - self.assertIn("Version 1.0", output) - self.assertIn("2025-01-01", output) - self.assertIn("https://example.com", output) - self.assertIn("This is a test abstract.", output) - self.assertIn("Alice Smith", output) - - @patch.object(RunManager, "load_citation_data", return_value=None) - def test_show_splash_without_citation(self, mock_load): - """Should render minimal splash screen when no citation data.""" - buf = StringIO() - test_console = Console(file=buf, force_terminal=False, width=80) - - instance = RunManager("dummy") - with patch("CodeEntropy.run.console", test_console): - instance.show_splash() - - output = buf.getvalue() - - self.assertNotIn("Version", output) - self.assertNotIn("Contributors", output) - self.assertIn("Welcome to CodeEntropy", output) - - @patch.object(RunManager, "load_citation_data") - def test_show_splash_missing_fields(self, mock_load): - """Should gracefully handle missing optional fields in citation data.""" - mock_load.return_value = { - "title": "PartialProject", - # no version, no date, no authors, no abstract - } - - buf = StringIO() - test_console = Console(file=buf, force_terminal=False, width=80) - - instance = RunManager("dummy") - with patch("CodeEntropy.run.console", test_console): - instance.show_splash() - - output = buf.getvalue() - - self.assertIn("Version ?", output) - self.assertIn("No description available.", output) - - def test_run_entropy_workflow(self): - """ - Test the run_entropy_workflow method to ensure it initializes and executes - correctly with mocked dependencies. - """ - run_manager = RunManager("mock_folder/job001") - run_manager._logging_config = MagicMock() - run_manager._config_manager = MagicMock() - run_manager.load_citation_data = MagicMock() - run_manager._data_logger = MagicMock() - run_manager.folder = self.test_dir - - mock_logger = MagicMock() - run_manager._logging_config.setup_logging.return_value = mock_logger - - run_manager._config_manager.load_config.return_value = { - "test_run": { - "top_traj_file": ["/path/to/tpr", "/path/to/trr"], - "force_file": None, - "file_format": None, - "selection_string": "all", - "output_file": "output.json", - "verbose": True, - } - } - - run_manager.load_citation_data.return_value = { - "cff-version": "1.2.0", - "title": "CodeEntropy", - "message": ( - "If you use this software, please cite it using the " - "metadata from this file." - ), - "type": "software", - "authors": [ - { - "given-names": "Forename", - "family-names": "Sirname", - "email": "test@email.ac.uk", - } - ], - } - - mock_args = MagicMock() - mock_args.output_file = "output.json" - mock_args.verbose = True - mock_args.top_traj_file = ["/path/to/tpr", "/path/to/trr"] - mock_args.force_file = None - mock_args.file_format = None - mock_args.selection_string = "all" - parser = run_manager._config_manager.setup_argparse.return_value - parser.parse_known_args.return_value = (mock_args, []) - - run_manager._config_manager.merge_configs.return_value = mock_args - - mock_entropy_manager = MagicMock() - with ( - unittest.mock.patch( - "CodeEntropy.run.EntropyManager", return_value=mock_entropy_manager - ), - unittest.mock.patch("CodeEntropy.run.mda.Universe") as mock_universe, - ): - - run_manager.run_entropy_workflow() - - mock_universe.assert_called_once_with( - "/path/to/tpr", ["/path/to/trr"], format=None - ) - mock_entropy_manager.execute.assert_called_once() - - def test_run_entropy_workflow_with_forcefile(self): - """ - Test the else-branch in run_entropy_workflow where forcefile is not None. - """ - run_manager = RunManager("mock_folder/job001") - run_manager._logging_config = MagicMock() - run_manager._config_manager = MagicMock() - run_manager.load_citation_data = MagicMock() - run_manager.show_splash = MagicMock() - run_manager._data_logger = MagicMock() - run_manager.folder = self.test_dir - - # Logger mock - mock_logger = MagicMock() - run_manager._logging_config.setup_logging.return_value = mock_logger - - # Config contains force_file - run_manager._config_manager.load_config.return_value = { - "test_run": { - "top_traj_file": ["/path/to/tpr", "/path/to/trr"], - "force_file": "/path/to/forces", - "file_format": "gro", - "kcal_force_units": "kcal", - "selection_string": "all", - "output_file": "output.json", - "verbose": False, - } - } - - # Parse args mock - mock_args = MagicMock() - mock_args.output_file = "output.json" - mock_args.verbose = False - mock_args.top_traj_file = ["/path/to/tpr", "/path/to/trr"] - mock_args.force_file = "/path/to/forces" - mock_args.file_format = "gro" - mock_args.kcal_force_units = "kcal" - mock_args.selection_string = "all" - - parser = run_manager._config_manager.setup_argparse.return_value - parser.parse_known_args.return_value = (mock_args, []) - run_manager._config_manager.merge_configs.return_value = mock_args - - # Mock UniverseOperations.merge_forces - with ( - unittest.mock.patch( - "CodeEntropy.run.EntropyManager", return_value=MagicMock() - ) as Entropy_patch, - unittest.mock.patch("CodeEntropy.run.UniverseOperations") as UOps_patch, - unittest.mock.patch("CodeEntropy.run.mda.Universe") as mock_universe, - ): - mock_universe_ops = UOps_patch.return_value - mock_universe_ops.merge_forces.return_value = MagicMock() - - run_manager.run_entropy_workflow() - - # Ensure merge_forces is used - mock_universe_ops.merge_forces.assert_called_once_with( - "/path/to/tpr", - ["/path/to/trr"], - "/path/to/forces", - "gro", - "kcal", - ) - - mock_universe.assert_not_called() - - Entropy_patch.return_value.execute.assert_called_once() - - def test_run_configuration_warning(self): - """ - Test that a warning is logged when the config entry is not a dictionary. - """ - run_manager = RunManager("mock_folder/job001") - run_manager._logging_config = MagicMock() - run_manager._config_manager = MagicMock() - run_manager.load_citation_data = MagicMock() - run_manager._data_logger = MagicMock() - run_manager.folder = self.test_dir - - mock_logger = MagicMock() - run_manager._logging_config.setup_logging.return_value = mock_logger - - run_manager._config_manager.load_config.return_value = { - "invalid_run": "this_should_be_a_dict" - } - - run_manager.load_citation_data.return_value = { - "cff-version": "1.2.0", - "title": "CodeEntropy", - "message": ( - "If you use this software, please cite it using the " - "metadata from this file." - ), - "type": "software", - "authors": [ - { - "given-names": "Forename", - "family-names": "Sirname", - "email": "test@email.ac.uk", - } - ], - } - - mock_args = MagicMock() - mock_args.output_file = "output.json" - mock_args.verbose = False - - parser = run_manager._config_manager.setup_argparse.return_value - parser.parse_known_args.return_value = (mock_args, []) - run_manager._config_manager.merge_configs.return_value = mock_args - - run_manager.run_entropy_workflow() - - mock_logger.warning.assert_called_with( - "Run configuration for invalid_run is not a dictionary." - ) - - def test_run_entropy_workflow_missing_traj_file(self): - """ - Test that a ValueError is raised when 'top_traj_file' is missing. - """ - run_manager = RunManager("mock_folder/job001") - run_manager._logging_config = MagicMock() - run_manager._config_manager = MagicMock() - run_manager.load_citation_data = MagicMock() - run_manager._data_logger = MagicMock() - run_manager.folder = self.test_dir - - mock_logger = MagicMock() - run_manager._logging_config.setup_logging.return_value = mock_logger - - run_manager._config_manager.load_config.return_value = { - "test_run": { - "top_traj_file": None, - "output_file": "output.json", - "verbose": False, - } - } - - run_manager.load_citation_data.return_value = { - "cff-version": "1.2.0", - "title": "CodeEntropy", - "message": ( - "If you use this software, please cite it using the " - "metadata from this file." - ), - "type": "software", - "authors": [ - { - "given-names": "Forename", - "family-names": "Sirname", - "email": "test@email.ac.uk", - } - ], - } - - mock_args = MagicMock() - mock_args.output_file = "output.json" - mock_args.verbose = False - mock_args.top_traj_file = None - mock_args.selection_string = None - - parser = run_manager._config_manager.setup_argparse.return_value - parser.parse_known_args.return_value = (mock_args, []) - run_manager._config_manager.merge_configs.return_value = mock_args - - with self.assertRaisesRegex(ValueError, "Missing 'top_traj_file' argument."): - run_manager.run_entropy_workflow() - - def test_run_entropy_workflow_missing_selection_string(self): - """ - Test that a ValueError is raised when 'selection_string' is missing. - """ - run_manager = RunManager("mock_folder/job001") - run_manager._logging_config = MagicMock() - run_manager._config_manager = MagicMock() - run_manager.load_citation_data = MagicMock() - run_manager._data_logger = MagicMock() - run_manager.folder = self.test_dir - - mock_logger = MagicMock() - run_manager._logging_config.setup_logging.return_value = mock_logger - - run_manager._config_manager.load_config.return_value = { - "test_run": { - "top_traj_file": ["/path/to/tpr", "/path/to/trr"], - "output_file": "output.json", - "verbose": False, - } - } - - run_manager.load_citation_data.return_value = { - "cff-version": "1.2.0", - "title": "CodeEntropy", - "message": ( - "If you use this software, please cite it using the " - "metadata from this file." - ), - "type": "software", - "authors": [ - { - "given-names": "Forename", - "family-names": "Sirname", - "email": "test@email.ac.uk", - } - ], - } - - mock_args = MagicMock() - mock_args.output_file = "output.json" - mock_args.verbose = False - mock_args.top_traj_file = ["/path/to/tpr", "/path/to/trr"] - mock_args.selection_string = None - - parser = run_manager._config_manager.setup_argparse.return_value - parser.parse_known_args.return_value = (mock_args, []) - run_manager._config_manager.merge_configs.return_value = mock_args - - with self.assertRaisesRegex(ValueError, "Missing 'selection_string' argument."): - run_manager.run_entropy_workflow() - - @patch("CodeEntropy.run.pickle.dump") - @patch("CodeEntropy.run.open", create=True) - def test_write_universe(self, mock_open, mock_pickle_dump): - # Mock Universe - mock_universe = MagicMock() - - # Mock the file object returned by open - mock_file = MagicMock() - mock_open.return_value = mock_file - - run_manager = RunManager("mock_folder/job001") - result = run_manager.write_universe(mock_universe, name="test_universe") - - mock_open.assert_called_once_with("test_universe.pkl", "wb") - - # Ensure pickle.dump() was called - mock_pickle_dump.assert_called_once_with(mock_universe, mock_file) - - # Ensure the method returns the correct filename - self.assertEqual(result, "test_universe") - - @patch("CodeEntropy.run.pickle.load") - @patch("CodeEntropy.run.open", create=True) - def test_read_universe(self, mock_open, mock_pickle_load): - # Mock the file object returned by open - mock_file = MagicMock() - mock_open.return_value = mock_file - - # Mock Universe to return when pickle.load is called - mock_universe = MagicMock() - mock_pickle_load.return_value = mock_universe - - # Path to the mock file - path = "test_universe.pkl" - - run_manager = RunManager("mock_folder/job001") - result = run_manager.read_universe(path) - - mock_open.assert_called_once_with(path, "rb") - - # Ensure pickle.load() was called with the mock file object - mock_pickle_load.assert_called_once_with(mock_file) - - # Ensure the method returns the correct mock universe - self.assertEqual(result, mock_universe) - - -if __name__ == "__main__": - unittest.main() From cb22762349b99c149f13392d9280acb4dffec976 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 24 Feb 2026 14:16:13 +0000 Subject: [PATCH 084/101] update `results/reporter.py` to have enriched data output: - Arguments are added to the `output_file.json` - Provenance added to the `output_file.json` - Tidied output logging in the `output_file.json` --- CodeEntropy/results/reporter.py | 366 ++++++++---- .../unit/CodeEntropy/results/test_reporter.py | 523 +++++++++++++----- 2 files changed, 661 insertions(+), 228 deletions(-) diff --git a/CodeEntropy/results/reporter.py b/CodeEntropy/results/reporter.py index 75082ae0..3db950b2 100644 --- a/CodeEntropy/results/reporter.py +++ b/CodeEntropy/results/reporter.py @@ -1,4 +1,5 @@ -"""Utilities for logging entropy results and exporting data. +""" +Utilities for logging entropy results and exporting data. This module provides the ResultsReporter class, which is responsible for: @@ -9,9 +10,16 @@ - Exporting results to JSON """ +from __future__ import annotations + import json import logging +import os +import platform import re +import subprocess +import sys +from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import numpy as np @@ -28,62 +36,46 @@ class ResultsReporter: """Collect, format, and output entropy calculation results.""" def __init__(self, console: Optional[Console] = None) -> None: - """Initialize the logger. - - Args: - console: Optional Rich Console instance. If None, the global - console from LoggingConfig is used. - """ self.console: Console = console or Console() self.molecule_data: List[Tuple[Any, Any, Any, Any]] = [] self.residue_data: List[List[Any]] = [] self.group_labels: Dict[Any, Dict[str, Any]] = {} - def save_dataframes_as_json( - self, molecule_df, residue_df, output_file: str - ) -> None: - """Save molecule and residue DataFrames into a JSON file. - - Args: - molecule_df: Pandas DataFrame containing molecule results. - residue_df: Pandas DataFrame containing residue results. - output_file: Path to JSON output file. - """ - data = { - "molecule_data": molecule_df.to_dict(orient="records"), - "residue_data": residue_df.to_dict(orient="records"), - } - - with open(output_file, "w") as out: - json.dump(data, out, indent=4) - @staticmethod def clean_residue_name(resname: Any) -> str: - """Clean residue name by removing dash-like characters. + """Clean residue name by removing dash-like characters.""" + return re.sub(r"[-–—]", "", str(resname)) - Args: - resname: Residue name input. + @staticmethod + def _gid_sort_key(x: Any) -> Tuple[int, Any]: + """ + Stable sort key for group IDs that may be numeric strings, ints, or other + objects. - Returns: - Cleaned residue name string. + Returns (rank, value): + - numeric IDs -> (0, int_value) + - non-numeric -> (1, str_value) """ - return re.sub(r"[-–—]", "", str(resname)) + sx = str(x) + try: + return (0, int(sx)) + except Exception: + return (1, sx) + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + """Convert value to float if possible; otherwise return None.""" + try: + if isinstance(value, bool): + return None + return float(value) + except Exception: + return None def add_results_data( - self, - group_id: Any, - level: str, - entropy_type: str, - value: Any, + self, group_id: Any, level: str, entropy_type: str, value: Any ) -> None: - """Add molecule-level entropy result. - - Args: - group_id: Group identifier. - level: Hierarchy level (e.g., united_atom, residue). - entropy_type: Entropy category. - value: Computed entropy value. - """ + """Add molecule-level entropy result.""" self.molecule_data.append((group_id, level, entropy_type, value)) def add_residue_data( @@ -95,21 +87,10 @@ def add_residue_data( frame_count: Any, value: Any, ) -> None: - """Add residue-level entropy result. - - Args: - group_id: Group identifier. - resname: Residue name. - level: Hierarchy level. - entropy_type: Entropy category. - frame_count: Frame count or array. - value: Computed entropy value. - """ + """Add residue-level entropy result.""" resname = self.clean_residue_name(resname) - if isinstance(frame_count, np.ndarray): frame_count = frame_count.tolist() - self.residue_data.append( [group_id, resname, level, entropy_type, frame_count, value] ) @@ -121,14 +102,7 @@ def add_group_label( residue_count: Optional[int] = None, atom_count: Optional[int] = None, ) -> None: - """Store metadata label for a group. - - Args: - group_id: Group identifier. - label: Descriptive label. - residue_count: Optional residue count. - atom_count: Optional atom count. - """ + """Store metadata label for a group.""" self.group_labels[group_id] = { "label": label, "residue_count": residue_count, @@ -136,65 +110,261 @@ def add_group_label( } def log_tables(self) -> None: - """Render all collected data as Rich tables.""" - - self._log_molecule_table() - self._log_residue_table() + """Render all collected data as Rich tables (grouped by group id).""" + self._log_grouped_results_tables() + self._log_residue_table_grouped() self._log_group_label_table() - def _log_molecule_table(self) -> None: - """Render molecule entropy table.""" + def _log_grouped_results_tables(self) -> None: + """ + Print molecule-level results grouped by group_id with components + total + together. + """ if not self.molecule_data: return - table = Table(title="Molecule Entropy Results", show_lines=True, expand=True) - table.add_column("Group ID", justify="center", style="bold cyan") - table.add_column("Level", justify="center", style="magenta") - table.add_column("Type", justify="center", style="green") - table.add_column("Result (J/mol/K)", justify="center", style="yellow") - + grouped: Dict[Any, List[Tuple[Any, Any, Any, Any]]] = {} for row in self.molecule_data: - table.add_row(*[str(cell) for cell in row]) - - console.print(table) - - def _log_residue_table(self) -> None: - """Render residue entropy table.""" + gid = row[0] + grouped.setdefault(gid, []).append(row) + + for gid in sorted(grouped.keys(), key=self._gid_sort_key): + label = self.group_labels.get(gid, {}).get("label", "") + title = f"Entropy Results — Group {gid}" + (f" ({label})" if label else "") + + table = Table(title=title, show_lines=True, expand=True) + table.add_column("Level", justify="center", style="magenta") + table.add_column("Type", justify="center", style="green") + table.add_column("Result (J/mol/K)", justify="center", style="yellow") + + rows = grouped[gid] + non_total: List[Tuple[str, str, Any]] = [] + totals: List[Tuple[str, str, Any]] = [] + + for _gid, level, typ, val in rows: + level_s = str(level) + typ_s = str(typ) + is_total = level_s.lower().startswith( + "group total" + ) or typ_s.lower().startswith("group total") + if is_total: + totals.append((level_s, typ_s, val)) + else: + non_total.append((level_s, typ_s, val)) + + for level_s, typ_s, val in sorted(non_total, key=lambda r: (r[0], r[1])): + table.add_row(level_s, typ_s, str(val)) + + for level_s, typ_s, val in totals: + table.add_row(level_s, typ_s, str(val)) + + console.print(table) + + def _log_residue_table_grouped(self) -> None: + """Render residue entropy table grouped by group id.""" if not self.residue_data: return - table = Table(title="Residue Entropy Results", show_lines=True, expand=True) - table.add_column("Group ID", justify="center", style="bold cyan") - table.add_column("Residue Name", justify="center", style="cyan") - table.add_column("Level", justify="center", style="magenta") - table.add_column("Type", justify="center", style="green") - table.add_column("Count", justify="center", style="green") - table.add_column("Result (J/mol/K)", justify="center", style="yellow") - + grouped: Dict[Any, List[List[Any]]] = {} for row in self.residue_data: - table.add_row(*[str(cell) for cell in row]) + gid = row[0] + grouped.setdefault(gid, []).append(row) - console.print(table) + for gid in sorted(grouped.keys(), key=self._gid_sort_key): + label = self.group_labels.get(gid, {}).get("label", "") + title = f"Residue Entropy — Group {gid}" + (f" ({label})" if label else "") + + table = Table(title=title, show_lines=True, expand=True) + table.add_column("Residue Name", justify="center", style="cyan") + table.add_column("Level", justify="center", style="magenta") + table.add_column("Type", justify="center", style="green") + table.add_column("Count", justify="center", style="green") + table.add_column("Result (J/mol/K)", justify="center", style="yellow") + + for _gid, resname, level, typ, count, val in grouped[gid]: + table.add_row(str(resname), str(level), str(typ), str(count), str(val)) + + console.print(table) def _log_group_label_table(self) -> None: """Render group label metadata table.""" if not self.group_labels: return - table = Table( - title="Group ID to Residue Label Mapping", show_lines=True, expand=True - ) + table = Table(title="Group Metadata", show_lines=True, expand=True) table.add_column("Group ID", justify="center", style="bold cyan") - table.add_column("Residue Label", justify="center", style="green") + table.add_column("Label", justify="center", style="green") table.add_column("Residue Count", justify="center", style="magenta") table.add_column("Atom Count", justify="center", style="yellow") - for group_id, info in self.group_labels.items(): + for group_id in sorted(self.group_labels.keys(), key=self._gid_sort_key): + info = self.group_labels[group_id] table.add_row( str(group_id), - info["label"], + str(info.get("label", "")), str(info.get("residue_count", "")), str(info.get("atom_count", "")), ) console.print(table) + + def save_dataframes_as_json( + self, + molecule_df, + residue_df, + output_file: str, + *, + args: Optional[Any] = None, + include_raw_tables: bool = False, + ) -> None: + """ + Save results to a grouped JSON structure. + + JSON contains: + - args: arguments used (serialized) + - provenance: version, python, platform, optional git sha + - groups: { "": { components: {...}, total: ... } } + + Args: + molecule_df: Pandas DataFrame containing molecule results. + residue_df: Pandas DataFrame containing residue results. + output_file: Path to JSON output file. + args: Optional argparse Namespace or dict of arguments used. + include_raw_tables: If True, also include old "molecule_data"/"residue_data" + arrays for debugging/backwards-compat. + """ + payload = self._build_grouped_payload( + molecule_df=molecule_df, + residue_df=residue_df, + args=args, + include_raw_tables=include_raw_tables, + ) + + with open(output_file, "w") as out: + json.dump(payload, out, indent=2) + + def _build_grouped_payload( + self, + *, + molecule_df, + residue_df, + args: Optional[Any], + include_raw_tables: bool, + ) -> Dict[str, Any]: + mol_rows = molecule_df.to_dict(orient="records") + res_rows = residue_df.to_dict(orient="records") + + groups: Dict[str, Dict[str, Any]] = {} + + for row in mol_rows: + gid = str(row.get("Group ID")) + level = str(row.get("Level")) + typ = str(row.get("Type")) + + raw_val = row.get("Result (J/mol/K)") + val = self._safe_float(raw_val) + if val is None: + continue + + groups.setdefault(gid, {"components": {}, "total": None}) + + is_total = level.lower().startswith( + "group total" + ) or typ.lower().startswith("group total") + if is_total: + groups[gid]["total"] = val + else: + key = f"{level}:{typ}" + groups[gid]["components"][key] = val + + for _gid, g in groups.items(): + if g["total"] is None: + comps = g["components"].values() + g["total"] = float(sum(comps)) if comps else 0.0 + + payload: Dict[str, Any] = { + "args": self._serialize_args(args), + "provenance": self._provenance(), + "groups": groups, + } + + if include_raw_tables: + payload["molecule_data"] = mol_rows + payload["residue_data"] = res_rows + + return payload + + @staticmethod + def _serialize_args(args: Optional[Any]) -> Dict[str, Any]: + """Turn argparse Namespace / dict / object into a JSON-serializable dict.""" + if args is None: + return {} + + if isinstance(args, dict): + base = dict(args) + else: + base = getattr(args, "__dict__", None) + if not base: + try: + base = dict(args) + except Exception: + return {} + + out: Dict[str, Any] = {} + for k, v in base.items(): + if isinstance(v, np.ndarray): + out[k] = v.tolist() + elif isinstance(v, Path): + out[k] = str(v) + else: + out[k] = v + return out + + @staticmethod + def _provenance() -> Dict[str, Any]: + prov: Dict[str, Any] = { + "python": sys.version.split()[0], + "platform": platform.platform(), + } + + try: + from importlib.metadata import version + + prov["codeentropy_version"] = version("CodeEntropy") + except Exception: + prov["codeentropy_version"] = None + + prov["git_sha"] = ResultsReporter._try_get_git_sha() + return prov + + @staticmethod + def _try_get_git_sha() -> Optional[str]: + env_sha = os.environ.get("CODEENTROPY_GIT_SHA") + if env_sha: + return env_sha + + try: + here = Path(__file__).resolve() + repo_guess = here.parents[2] + + if not (repo_guess / ".git").exists(): + for p in here.parents: + if (p / ".git").exists(): + repo_guess = p + break + else: + return None + + proc = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=str(repo_guess), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if proc.returncode != 0: + return None + sha = (proc.stdout or "").strip() + return sha or None + except Exception: + return None diff --git a/tests/unit/CodeEntropy/results/test_reporter.py b/tests/unit/CodeEntropy/results/test_reporter.py index e63b6682..b0014712 100644 --- a/tests/unit/CodeEntropy/results/test_reporter.py +++ b/tests/unit/CodeEntropy/results/test_reporter.py @@ -1,224 +1,487 @@ import json +from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock import numpy as np +import pandas as pd +from rich.console import Console -import CodeEntropy.results.reporter as reporter +import CodeEntropy.results.reporter as reporter_mod from CodeEntropy.results.reporter import ResultsReporter -class _FakeTable: - """Tiny Table stand-in: records columns and rows added.""" - - def __init__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs +class FakeTable: + def __init__(self, title=None, show_lines=None, expand=None): + self.title = title self.columns = [] self.rows = [] def add_column(self, *args, **kwargs): self.columns.append((args, kwargs)) - def add_row(self, *cells): - self.rows.append(cells) + def add_row(self, *args, **kwargs): + self.rows.append((args, kwargs)) + + +def test_init_uses_provided_console(): + c = Console() + rr = ResultsReporter(console=c) + assert rr.console is c -def test_clean_residue_name_removes_dash_like_characters(): - assert ResultsReporter.clean_residue_name("A-LA") == "ALA" - assert ResultsReporter.clean_residue_name("A–LA") == "ALA" - assert ResultsReporter.clean_residue_name("A—LA") == "ALA" +def test_clean_residue_name_removes_dash_like(): + assert ResultsReporter.clean_residue_name("ALA-GLY") == "ALAGLY" + assert ResultsReporter.clean_residue_name("ALA–GLY") == "ALAGLY" + assert ResultsReporter.clean_residue_name("ALA—GLY") == "ALAGLY" assert ResultsReporter.clean_residue_name(123) == "123" -def test_add_results_data_appends_molecule_tuple(): +def test_add_results_data_appends(): rr = ResultsReporter() - rr.add_results_data(group_id=7, level="residue", entropy_type="vib", value=1.23) + rr.add_results_data(group_id=1, level="L", entropy_type="T", value=1.23) + assert rr.molecule_data == [(1, "L", "T", 1.23)] - assert rr.molecule_data == [(7, "residue", "vib", 1.23)] - -def test_add_residue_data_cleans_resname_and_converts_ndarray_count_to_list(): +def test_add_residue_data_converts_ndarray_frame_count_to_list(): rr = ResultsReporter() + rr.add_residue_data( + group_id=1, + resname="ALA-1", + level="L", + entropy_type="T", + frame_count=np.array([1, 2, 3]), + value=9.0, + ) + assert rr.residue_data == [[1, "ALA1", "L", "T", [1, 2, 3], 9.0]] + +def test_add_residue_data_keeps_scalar_frame_count(): + rr = ResultsReporter() rr.add_residue_data( group_id=1, - resname="A-LA", - level="residue", - entropy_type="conf", - frame_count=np.array([1, 2, 3], dtype=int), + resname="ALA-1", + level="L", + entropy_type="T", + frame_count=7, value=9.0, ) + assert rr.residue_data == [[1, "ALA1", "L", "T", 7, 9.0]] - assert rr.residue_data == [[1, "ALA", "residue", "conf", [1, 2, 3], 9.0]] +def test_add_group_label_stores_metadata(): + rr = ResultsReporter() + rr.add_group_label(1, "protein", residue_count=10, atom_count=100) + assert rr.group_labels[1]["label"] == "protein" + assert rr.group_labels[1]["residue_count"] == 10 + assert rr.group_labels[1]["atom_count"] == 100 -def test_add_residue_data_keeps_non_ndarray_count_as_is(): + +def test_gid_sort_key_numeric_before_string_and_numeric_order(): rr = ResultsReporter() + gids = ["10", "2", "A", "1", "B"] + out = sorted(gids, key=rr._gid_sort_key) + assert out == ["1", "2", "10", "A", "B"] - rr.add_residue_data( - group_id=2, - resname="GLY", - level="residue", - entropy_type="conf", - frame_count=5, - value=3.14, + +def test_safe_float_valid_invalid(): + assert ResultsReporter._safe_float("1.25") == 1.25 + assert ResultsReporter._safe_float(3) == 3.0 + assert ResultsReporter._safe_float("bad") is None + assert ResultsReporter._safe_float(None) is None + assert ResultsReporter._safe_float(True) is None + + +def test_build_grouped_payload_components_and_total_from_sum(monkeypatch): + rr = ResultsReporter() + mol = pd.DataFrame( + [ + {"Group ID": 1, "Level": "Trans", "Type": "A", "Result (J/mol/K)": 1.0}, + {"Group ID": 1, "Level": "Rovib", "Type": "B", "Result (J/mol/K)": 2.0}, + ] + ) + res = pd.DataFrame([]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + payload = rr._build_grouped_payload( + molecule_df=mol, residue_df=res, args=None, include_raw_tables=False ) + comps = payload["groups"]["1"]["components"] + assert comps == {"Trans:A": 1.0, "Rovib:B": 2.0} or comps == { + "Rovib:B": 2.0, + "Trans:A": 1.0, + } + assert payload["groups"]["1"]["total"] == 3.0 - assert rr.residue_data == [[2, "GLY", "residue", "conf", 5, 3.14]] +def test_build_grouped_payload_prefers_explicit_total(monkeypatch): + rr = ResultsReporter() + mol = pd.DataFrame( + [ + {"Group ID": 1, "Level": "Trans", "Type": "A", "Result (J/mol/K)": 1.0}, + { + "Group ID": 1, + "Level": "Group Total", + "Type": "Total", + "Result (J/mol/K)": 99.0, + }, + {"Group ID": 1, "Level": "Rovib", "Type": "B", "Result (J/mol/K)": 2.0}, + ] + ) + res = pd.DataFrame([]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + payload = rr._build_grouped_payload( + molecule_df=mol, residue_df=res, args=None, include_raw_tables=False + ) + assert payload["groups"]["1"]["total"] == 99.0 + assert payload["groups"]["1"]["components"]["Trans:A"] == 1.0 + assert payload["groups"]["1"]["components"]["Rovib:B"] == 2.0 -def test_add_group_label_stores_metadata_with_optionals(): + +def test_build_grouped_payload_skips_non_numeric_results(monkeypatch): rr = ResultsReporter() - rr.add_group_label(group_id="G0", label="protein", residue_count=10, atom_count=100) + mol = pd.DataFrame( + [ + {"Group ID": 1, "Level": "Trans", "Type": "A", "Result (J/mol/K)": 1.0}, + {"Group ID": 1, "Level": "Rovib", "Type": "B", "Result (J/mol/K)": "bad"}, + { + "Group ID": 1, + "Level": "Group Total", + "Type": "Total", + "Result (J/mol/K)": None, + }, + ] + ) + res = pd.DataFrame([]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + payload = rr._build_grouped_payload( + molecule_df=mol, residue_df=res, args=None, include_raw_tables=False + ) + assert payload["groups"]["1"]["components"] == {"Trans:A": 1.0} + assert payload["groups"]["1"]["total"] == 1.0 - assert rr.group_labels["G0"] == { - "label": "protein", - "residue_count": 10, - "atom_count": 100, - } + +def test_build_grouped_payload_invalid_total_row_skipped(monkeypatch): + rr = ResultsReporter() + mol = pd.DataFrame( + [ + { + "Group ID": 1, + "Level": "Group Total", + "Type": "Total", + "Result (J/mol/K)": "bad", + }, + ] + ) + res = pd.DataFrame([]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + payload = rr._build_grouped_payload( + molecule_df=mol, residue_df=res, args=None, include_raw_tables=False + ) + assert payload["groups"] == {} -def test_save_dataframes_as_json_writes_expected_payload(tmp_path): +def test_build_grouped_payload_include_raw_tables(monkeypatch): rr = ResultsReporter() + mol = pd.DataFrame( + [{"Group ID": 1, "Level": "Trans", "Type": "A", "Result (J/mol/K)": 1.0}] + ) + res = pd.DataFrame([{"Group ID": 1, "Residue": "ALA", "Result": 0.5}]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + payload = rr._build_grouped_payload( + molecule_df=mol, residue_df=res, args=None, include_raw_tables=True + ) + assert "molecule_data" in payload + assert "residue_data" in payload + assert payload["molecule_data"][0]["Group ID"] == 1 + assert payload["residue_data"][0]["Group ID"] == 1 - molecule_df = MagicMock() - residue_df = MagicMock() - molecule_df.to_dict.return_value = [{"a": 1}] - residue_df.to_dict.return_value = [{"b": 2}] +def test_serialize_args_dict_converts_ndarray_and_path(): + p = Path("x/y") + args = {"arr": np.array([1, 2]), "p": p, "n": 3} + out = ResultsReporter._serialize_args(args) + assert out == {"arr": [1, 2], "p": str(p), "n": 3} - out = tmp_path / "out.json" - rr.save_dataframes_as_json( - molecule_df=molecule_df, residue_df=residue_df, output_file=str(out) + +def test_serialize_args_namespace_converts_types(): + ns = SimpleNamespace(a=np.array([1]), b=Path("z")) + assert ResultsReporter._serialize_args(ns) == {"a": [1], "b": "z"} + + +def test_serialize_args_falls_back_to_dict_protocol(): + class PairIterable: + def __iter__(self): + return iter([("k", 1)]) + + assert ResultsReporter._serialize_args(PairIterable()) == {"k": 1} + + +def test_serialize_args_unserializable_returns_empty(): + class Unserializable: + __slots__ = () + + assert ResultsReporter._serialize_args(Unserializable()) == {} + + +def test_provenance_sets_version_none_on_failure(monkeypatch): + import importlib.metadata + + monkeypatch.setattr( + importlib.metadata, + "version", + lambda _: (_ for _ in ()).throw(Exception("nope")), ) + monkeypatch.setattr(ResultsReporter, "_try_get_git_sha", staticmethod(lambda: None)) + prov = ResultsReporter._provenance() + assert "python" in prov + assert "platform" in prov + assert prov["codeentropy_version"] is None + assert prov["git_sha"] is None - data = json.loads(out.read_text()) - assert data == {"molecule_data": [{"a": 1}], "residue_data": [{"b": 2}]} - molecule_df.to_dict.assert_called_once_with(orient="records") - residue_df.to_dict.assert_called_once_with(orient="records") +def test_provenance_sets_version_on_success(monkeypatch): + import importlib.metadata + monkeypatch.setattr(importlib.metadata, "version", lambda _: "9.9.9") + monkeypatch.setattr( + ResultsReporter, "_try_get_git_sha", staticmethod(lambda: "sha") + ) + prov = ResultsReporter._provenance() + assert prov["codeentropy_version"] == "9.9.9" + assert prov["git_sha"] == "sha" -def test_log_tables_calls_each_internal_table_renderer(monkeypatch): - rr = ResultsReporter() - mol_spy = MagicMock() - res_spy = MagicMock() - grp_spy = MagicMock() +def test_try_get_git_sha_env_override(monkeypatch): + monkeypatch.setenv("CODEENTROPY_GIT_SHA", "abc123") + assert ResultsReporter._try_get_git_sha() == "abc123" - monkeypatch.setattr(rr, "_log_molecule_table", mol_spy) - monkeypatch.setattr(rr, "_log_residue_table", res_spy) - monkeypatch.setattr(rr, "_log_group_label_table", grp_spy) - rr.log_tables() +def test_try_get_git_sha_subprocess_success(monkeypatch, tmp_path): + monkeypatch.delenv("CODEENTROPY_GIT_SHA", raising=False) + fake_file = tmp_path / "a" / "b" / "c.py" + fake_file.parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "a" / ".git").mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(reporter_mod, "__file__", str(fake_file)) + mock_run = MagicMock() + mock_run.return_value = SimpleNamespace( + returncode=0, stdout="deadbeef\n", stderr="" + ) + monkeypatch.setattr(reporter_mod.subprocess, "run", mock_run) + assert ResultsReporter._try_get_git_sha() == "deadbeef" + + +def test_try_get_git_sha_subprocess_failure(monkeypatch, tmp_path): + monkeypatch.delenv("CODEENTROPY_GIT_SHA", raising=False) + fake_file = tmp_path / "a" / "b" / "c.py" + fake_file.parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "a" / ".git").mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(reporter_mod, "__file__", str(fake_file)) + mock_run = MagicMock() + mock_run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="err") + monkeypatch.setattr(reporter_mod.subprocess, "run", mock_run) + assert ResultsReporter._try_get_git_sha() is None + + +def test_try_get_git_sha_returns_none_when_no_git_anywhere(monkeypatch, tmp_path): + monkeypatch.delenv("CODEENTROPY_GIT_SHA", raising=False) + fake_file = tmp_path / "a" / "b" / "c.py" + fake_file.parent.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(reporter_mod, "__file__", str(fake_file)) + monkeypatch.setattr( + reporter_mod.subprocess, + "run", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("subprocess.run should not be called") + ), + ) + assert ResultsReporter._try_get_git_sha() is None - mol_spy.assert_called_once() - res_spy.assert_called_once() - grp_spy.assert_called_once() +def test_try_get_git_sha_executes_subprocess_kwargs_block(monkeypatch, tmp_path): + monkeypatch.delenv("CODEENTROPY_GIT_SHA", raising=False) + fake_file = tmp_path / "a" / "b" / "c.py" + fake_file.parent.mkdir(parents=True, exist_ok=True) + (tmp_path / "a" / ".git").mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(reporter_mod, "__file__", str(fake_file)) -def test_log_molecule_table_returns_early_when_no_data(monkeypatch): - rr = ResultsReporter() + original_resolve = reporter_mod.Path.resolve - fake_console = SimpleNamespace(print=MagicMock()) - monkeypatch.setattr(reporter, "console", fake_console) - monkeypatch.setattr(reporter, "Table", _FakeTable) + def fake_resolve(self): + if str(self) == str(fake_file): + return fake_file + return original_resolve(self) - rr._log_molecule_table() + monkeypatch.setattr(reporter_mod.Path, "resolve", fake_resolve) - fake_console.print.assert_not_called() + mock_run = MagicMock() + mock_run.return_value = SimpleNamespace(returncode=0, stdout="sha\n", stderr="") + monkeypatch.setattr(reporter_mod.subprocess, "run", mock_run) + assert ResultsReporter._try_get_git_sha() == "sha" + _args, kwargs = mock_run.call_args + assert "stdout" in kwargs + assert "stderr" in kwargs + assert kwargs.get("text") is True -def test_log_residue_table_returns_early_when_no_data(monkeypatch): - rr = ResultsReporter() - fake_console = SimpleNamespace(print=MagicMock()) - monkeypatch.setattr(reporter, "console", fake_console) - monkeypatch.setattr(reporter, "Table", _FakeTable) +def test_log_grouped_results_tables_hits_non_total_add_row(monkeypatch): + rr = ResultsReporter() + rr.add_results_data("1", "AAA", "BBB", 123.0) + printed = [] + monkeypatch.setattr(reporter_mod, "Table", FakeTable) + monkeypatch.setattr(reporter_mod.console, "print", lambda t: printed.append(t)) + rr._log_grouped_results_tables() + assert len(printed) == 1 + assert printed[0].rows == [(("AAA", "BBB", "123.0"), {})] - rr._log_residue_table() - fake_console.print.assert_not_called() +def test_log_grouped_results_tables_prints_in_sorted_gid_order(monkeypatch): + rr = ResultsReporter() + rr.add_results_data("10", "L", "T", 1.0) + rr.add_results_data("2", "L", "T", 2.0) + rr.add_results_data("A", "L", "T", 3.0) + printed_titles = [] + monkeypatch.setattr( + reporter_mod.console, + "print", + lambda obj: printed_titles.append(getattr(obj, "title", None)), + ) + rr._log_grouped_results_tables() + assert printed_titles[0].startswith("Entropy Results — Group 2") + assert printed_titles[1].startswith("Entropy Results — Group 10") + assert printed_titles[2].startswith("Entropy Results — Group A") -def test_log_group_label_table_returns_early_when_no_labels(monkeypatch): +def test_log_residue_table_grouped_prints_table(monkeypatch): rr = ResultsReporter() + rr.add_group_label("2", "ResidLabel") + rr.residue_data.append(["2", "ALA", "LevelX", "TypeY", 10, 0.5]) + printed = [] + monkeypatch.setattr(reporter_mod.console, "print", lambda obj: printed.append(obj)) + rr._log_residue_table_grouped() + assert len(printed) == 1 + assert getattr(printed[0], "title", "").startswith("Residue Entropy — Group 2") - fake_console = SimpleNamespace(print=MagicMock()) - monkeypatch.setattr(reporter, "console", fake_console) - monkeypatch.setattr(reporter, "Table", _FakeTable) +def test_log_group_label_table_hits_label_add_column(monkeypatch): + rr = ResultsReporter() + rr.add_group_label("1", "LabelHere", residue_count=2, atom_count=3) + printed = [] + monkeypatch.setattr(reporter_mod, "Table", FakeTable) + monkeypatch.setattr(reporter_mod.console, "print", lambda t: printed.append(t)) rr._log_group_label_table() - - fake_console.print.assert_not_called() + assert len(printed) == 1 + assert printed[0].columns[1][0][0] == "Label" -def test_log_molecule_table_builds_rows_and_prints_table(monkeypatch): +def test_log_tables_calls_all_subtables(monkeypatch): rr = ResultsReporter() - rr.molecule_data = [ - (1, "residue", "conf", 1.0), - (2, "polymer", "vib", 2.0), - ] + m1 = MagicMock() + m2 = MagicMock() + m3 = MagicMock() + monkeypatch.setattr(rr, "_log_grouped_results_tables", m1) + monkeypatch.setattr(rr, "_log_residue_table_grouped", m2) + monkeypatch.setattr(rr, "_log_group_label_table", m3) + rr.log_tables() + m1.assert_called_once() + m2.assert_called_once() + m3.assert_called_once() - fake_console = SimpleNamespace(print=MagicMock()) - monkeypatch.setattr(reporter, "console", fake_console) - monkeypatch.setattr(reporter, "Table", _FakeTable) - rr._log_molecule_table() +def test_save_dataframes_as_json_writes_file(tmp_path, monkeypatch): + rr = ResultsReporter() + mol = pd.DataFrame( + [{"Group ID": 1, "Level": "Trans", "Type": "A", "Result (J/mol/K)": 1.0}] + ) + res = pd.DataFrame([]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + out = tmp_path / "out.json" + rr.save_dataframes_as_json( + mol, res, str(out), args={"x": 1}, include_raw_tables=False + ) + data = json.loads(out.read_text()) + assert data["args"] == {"x": 1} + assert data["provenance"] == {"git_sha": None} + assert data["groups"]["1"]["components"] == {"Trans:A": 1.0} + assert data["groups"]["1"]["total"] == 1.0 - fake_console.print.assert_called_once() - table = fake_console.print.call_args.args[0] - assert isinstance(table, _FakeTable) - # 4 columns defined, 2 rows added - assert len(table.columns) == 4 - assert len(table.rows) == 2 - # cells were stringified - assert table.rows[0] == ("1", "residue", "conf", "1.0") +def test_save_dataframes_as_json_uses_default_include_raw_tables(tmp_path, monkeypatch): + rr = ResultsReporter() + mol = pd.DataFrame( + [{"Group ID": 1, "Level": "L", "Type": "T", "Result (J/mol/K)": 1.0}] + ) + res = pd.DataFrame([]) + monkeypatch.setattr( + ResultsReporter, "_provenance", staticmethod(lambda: {"git_sha": None}) + ) + out = tmp_path / "out.json" + rr.save_dataframes_as_json(mol, res, str(out), args={"x": 1}) + assert out.exists() -def test_log_residue_table_builds_rows_and_prints_table(monkeypatch): +def test_log_grouped_results_tables_returns_when_empty(monkeypatch): rr = ResultsReporter() - rr.residue_data = [ - [1, "ALA", "residue", "conf", [1, 2], 9.0], - ] + monkeypatch.setattr( + reporter_mod.console, + "print", + lambda *_: (_ for _ in ()).throw(AssertionError("should not print")), + ) + rr._log_grouped_results_tables() - fake_console = SimpleNamespace(print=MagicMock()) - monkeypatch.setattr(reporter, "console", fake_console) - monkeypatch.setattr(reporter, "Table", _FakeTable) - rr._log_residue_table() +def test_log_grouped_results_tables_handles_total_row(monkeypatch): + rr = ResultsReporter() + rr.add_results_data("1", "A", "B", 1.0) + rr.add_results_data("1", "Group Total", "Group Total", 3.0) + + printed = [] + monkeypatch.setattr(reporter_mod, "Table", FakeTable) + monkeypatch.setattr(reporter_mod.console, "print", lambda t: printed.append(t)) - fake_console.print.assert_called_once() - table = fake_console.print.call_args.args[0] + rr._log_grouped_results_tables() - assert isinstance(table, _FakeTable) - # 6 columns defined, 1 row added - assert len(table.columns) == 6 - assert len(table.rows) == 1 - assert table.rows[0] == ("1", "ALA", "residue", "conf", "[1, 2]", "9.0") + assert len(printed) == 1 + assert ("Group Total", "Group Total", "3.0") in [r[0] for r in printed[0].rows] -def test_log_group_label_table_adds_rows_for_each_label_and_prints(monkeypatch): +def test_log_residue_table_grouped_returns_when_empty(monkeypatch): rr = ResultsReporter() - rr.group_labels = { - 7: {"label": "protein", "residue_count": 10, "atom_count": 100}, - 8: {"label": "water", "residue_count": None, "atom_count": None}, - } + monkeypatch.setattr( + reporter_mod.console, + "print", + lambda *_: (_ for _ in ()).throw(AssertionError("should not print")), + ) + rr._log_residue_table_grouped() - fake_console = SimpleNamespace(print=MagicMock()) - monkeypatch.setattr(reporter, "console", fake_console) - monkeypatch.setattr(reporter, "Table", _FakeTable) +def test_log_group_label_table_returns_when_empty(monkeypatch): + rr = ResultsReporter() + monkeypatch.setattr( + reporter_mod.console, + "print", + lambda *_: (_ for _ in ()).throw(AssertionError("should not print")), + ) rr._log_group_label_table() - fake_console.print.assert_called_once() - table = fake_console.print.call_args.args[0] - assert isinstance(table, _FakeTable) - assert len(table.columns) == 4 - assert len(table.rows) == 2 +def test_try_get_git_sha_returns_none_on_exception(monkeypatch): + monkeypatch.delenv("CODEENTROPY_GIT_SHA", raising=False) + + def boom(self): + raise RuntimeError("boom") - assert table.rows[0] == ("7", "protein", "10", "100") - assert table.rows[1] == ("8", "water", "None", "None") + monkeypatch.setattr(reporter_mod.Path, "resolve", boom) + assert ResultsReporter._try_get_git_sha() is None From 226b37f7b206adba1b60253c41c7a0d467e75a58 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 24 Feb 2026 15:46:51 +0000 Subject: [PATCH 085/101] update `entropy/workflow.py` to use update `save_dataframes_as_json` --- CodeEntropy/entropy/workflow.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CodeEntropy/entropy/workflow.py b/CodeEntropy/entropy/workflow.py index 46f5f85f..dd30073c 100644 --- a/CodeEntropy/entropy/workflow.py +++ b/CodeEntropy/entropy/workflow.py @@ -348,5 +348,9 @@ def _finalize_molecule_results(self) -> None: ], ) self._reporter.save_dataframes_as_json( - molecule_df, residue_df, self._args.output_file + molecule_df, + residue_df, + self._args.output_file, + args=self._args, + include_raw_tables=False, ) From b4891b2295300806d83c48a686d3f50d09439ce7 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 10:50:18 +0000 Subject: [PATCH 086/101] feat(testing): add end-to-end regression testing suite Implement regression framework with: - baseline JSON comparisons - automatic dataset download from filestore - .testdata cache - slow test markers - config-driven system tests - CI workflows for quick PR checks and weekly full regression This provides reproducible validation of scientific results across releases. --- .gitignore | 2 + tests/data/__init__.py | 0 tests/data/md_A4_dna.tpr | Bin 85916 -> 0 bytes tests/data/md_A4_dna_xf.trr | Bin 935664 -> 0 bytes tests/pytest.ini | 10 + .../configs/benzaldehyde/config.yaml | 12 + tests/regression/configs/benzene/config.yaml | 12 + .../configs/cyclohexane/config.yaml | 12 + tests/regression/configs/dna/config.yaml | 10 + .../configs/ethyl-acetate/config.yaml | 12 + tests/regression/configs/methane/config.yaml | 13 + tests/regression/configs/methanol/config.yaml | 12 + tests/regression/configs/octonol/config.yaml | 12 + tests/regression/conftest.py | 46 ++ tests/regression/helpers.py | 407 ++++++++++++++++++ tests/regression/test_regression.py | 154 +++++++ tests/unit/CodeEntropy/pytest.ini | 3 - 17 files changed, 714 insertions(+), 3 deletions(-) delete mode 100644 tests/data/__init__.py delete mode 100644 tests/data/md_A4_dna.tpr delete mode 100644 tests/data/md_A4_dna_xf.trr create mode 100644 tests/pytest.ini create mode 100644 tests/regression/configs/benzaldehyde/config.yaml create mode 100644 tests/regression/configs/benzene/config.yaml create mode 100644 tests/regression/configs/cyclohexane/config.yaml create mode 100644 tests/regression/configs/dna/config.yaml create mode 100644 tests/regression/configs/ethyl-acetate/config.yaml create mode 100644 tests/regression/configs/methane/config.yaml create mode 100644 tests/regression/configs/methanol/config.yaml create mode 100644 tests/regression/configs/octonol/config.yaml create mode 100644 tests/regression/conftest.py create mode 100644 tests/regression/helpers.py create mode 100644 tests/regression/test_regression.py delete mode 100644 tests/unit/CodeEntropy/pytest.ini diff --git a/.gitignore b/.gitignore index a4773328..08b05df0 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,5 @@ job* *.err *.com *.txt + +.testdata/ \ No newline at end of file diff --git a/tests/data/__init__.py b/tests/data/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/data/md_A4_dna.tpr b/tests/data/md_A4_dna.tpr deleted file mode 100644 index 1557a12ecd145875ae5f73d250137ddda86e44c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 85916 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

wiv8XLx+uh$p-2n`D9Nbc2MpEwdWGfYcpJi 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%0ADmTS+>ISiw>?vs?xu%V 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°m&3Z7R?#qE8A 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%0D|m@q3Rr19yz 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%0DY6O9tEz$brG8Yv?=cS8}(u1WO%H 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

B=_pSc9i(*De%ZMKY&ZKUAwfeL|1g0M{ct|>8b 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 None: + parser.addoption( + "--run-slow", + action="store_true", + default=False, + help="Run slow regression tests (20-30+ minutes).", + ) + parser.addoption( + "--update-baselines", + action="store_true", + default=False, + help="Overwrite regression baselines with the newly produced outputs.", + ) + parser.addoption( + "--codeentropy-debug", + action="store_true", + default=False, + help="Print CodeEntropy stdout/stderr and paths for easier debugging.", + ) + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "regression: end-to-end regression tests") + config.addinivalue_line("markers", "slow: long-running tests (20-30+ minutes)") + + +def pytest_collection_modifyitems( + config: pytest.Config, items: list[pytest.Item] +) -> None: + """ + Regression tests are collected and runnable by default. + + Only @pytest.mark.slow tests are skipped unless you pass --run-slow. + """ + if config.getoption("--run-slow"): + return + + skip_slow = pytest.mark.skip(reason="Skipped slow test (use --run-slow to run).") + for item in items: + if "slow" in item.keywords: + item.add_marker(skip_slow) diff --git a/tests/regression/helpers.py b/tests/regression/helpers.py new file mode 100644 index 00000000..0404b967 --- /dev/null +++ b/tests/regression/helpers.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +import json +import os +import subprocess +import tarfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + +DEFAULT_TESTDATA_BASE_URL = "https://www.ccpbiosim.ac.uk/file-store/codeentropy-testing" + + +@dataclass(frozen=True) +class RunResult: + """Holds outputs and metadata from a single CodeEntropy regression run. + + Attributes: + workdir: Working directory used to run CodeEntropy. + job_dir: The most recent job directory created by CodeEntropy. + output_json: Path to the JSON output produced by CodeEntropy. + payload: Parsed JSON payload. + stdout: Captured stdout from the CodeEntropy process. + stderr: Captured stderr from the CodeEntropy process. + """ + + workdir: Path + job_dir: Path + output_json: Path + payload: Dict[str, Any] + stdout: str + stderr: str + + +def _repo_root_from_this_file() -> Path: + """Return repository root inferred from this file location. + + Returns: + Repository root path. + """ + return Path(__file__).resolve().parents[2] + + +def _testdata_root() -> Path: + """Return the local on-disk cache root for regression input datasets. + + Returns: + Path to the local test data cache root. + """ + return _repo_root_from_this_file() / ".testdata" + + +def _is_within_directory(base: Path, target: Path) -> bool: + """Check whether a target path is within a base directory. + + Args: + base: Base directory. + target: Target path. + + Returns: + True if target is within base, otherwise False. + """ + base = base.resolve() + try: + target = target.resolve() + return str(target).startswith(str(base) + os.sep) or target == base + except FileNotFoundError: + return _is_within_directory(base, target.parent) + + +def _safe_extract_tar_gz(tar_gz: Path, dest_dir: Path) -> None: + """Extract a .tar.gz file safely into dest_dir. + + This prevents path traversal by validating extracted member paths. + + Args: + tar_gz: Path to the tar.gz archive. + dest_dir: Destination directory. + """ + dest_dir.mkdir(parents=True, exist_ok=True) + with tarfile.open(tar_gz, "r:gz") as tf: + for member in tf.getmembers(): + member_path = dest_dir / member.name + if not _is_within_directory(dest_dir, member_path): + raise RuntimeError(f"Unsafe path in tarball: {member.name}") + tf.extractall(dest_dir) + + +def _download(url: str, dst: Path) -> None: + """Download a URL to a local file path. + + Args: + url: Source URL. + dst: Destination file path. + """ + dst.parent.mkdir(parents=True, exist_ok=True) + try: + with urllib.request.urlopen(url) as r, dst.open("wb") as f: + f.write(r.read()) + except Exception as e: + raise RuntimeError(f"Failed to download from {url}: {e}") from e + + +def ensure_testdata_for_system(system: str, *, required_paths: list[Path]) -> Path: + """Ensure the filestore dataset for a system exists locally. + + This downloads and extracts .tar.gz from the CCPBioSim HTTPS filestore + into /.testdata. The archive is expected to contain a top-level + '/' directory. + + Args: + system: System name (e.g., 'methane'). + required_paths: Absolute paths that must exist after extraction. + + Returns: + Path to the system directory under the local cache. + + Raises: + RuntimeError: If download/extraction fails or required files remain missing. + """ + root = _testdata_root() + system_dir = root / system + tar_path = root / f"{system}.tar.gz" + url = f"{DEFAULT_TESTDATA_BASE_URL.rstrip('/')}/{system}.tar.gz" + + def all_required_exist() -> bool: + return all(p.exists() for p in required_paths) + + if required_paths and all_required_exist(): + return system_dir + + root.mkdir(parents=True, exist_ok=True) + _download(url, tar_path) + + if system_dir.exists(): + for p in sorted(system_dir.rglob("*"), reverse=True): + try: + if p.is_file() or p.is_symlink(): + p.unlink() + elif p.is_dir(): + p.rmdir() + except OSError: + pass + try: + system_dir.rmdir() + except OSError: + pass + + _safe_extract_tar_gz(tar_path, root) + + if not system_dir.exists(): + raise RuntimeError( + f"Extraction did not create expected folder {system_dir}. " + f"Tarball may not contain '{system}/'. url={url}" + ) + + if required_paths and not all_required_exist(): + found = [ + str(p.relative_to(system_dir)) for p in system_dir.rglob("*") if p.is_file() + ] + found.sort() + raise RuntimeError( + "Regression data extracted but required files are missing.\n" + f"system={system}\n" + f"expected:\n - " + "\n - ".join(str(p) for p in required_paths) + "\n" + f"found in {system_dir}:\n - " + + ("\n - ".join(found) if found else "") + + "\n" + f"url={url}\n" + ) + + return system_dir + + +def _find_latest_job_dir(workdir: Path) -> Path: + """Find the most recent CodeEntropy job directory in workdir. + + Args: + workdir: Working directory. + + Returns: + Path to the latest job directory. + + Raises: + FileNotFoundError: If no job directory exists. + """ + job_dirs = sorted( + [p for p in workdir.iterdir() if p.is_dir() and p.name.startswith("job")] + ) + if not job_dirs: + raise FileNotFoundError(f"No job*** folder created in {workdir}") + return job_dirs[-1] + + +def _pick_output_json(job_dir: Path) -> Path: + """Pick the primary JSON output file from a CodeEntropy job directory. + + Args: + job_dir: CodeEntropy job directory. + + Returns: + Path to the chosen JSON output. + + Raises: + FileNotFoundError: If no JSON output is found. + """ + for name in ("output.json", "output_file.json"): + p = job_dir / name + if p.exists(): + return p + jsons = sorted(job_dir.glob("*.json")) + if not jsons: + raise FileNotFoundError(f"No JSON output found in job dir: {job_dir}") + return jsons[0] + + +def _resolve_path(value: Any, *, base_dir: Path) -> Optional[str]: + """Resolve a path-like config value to an absolute path string. + + Paths beginning with '.testdata/' are resolved relative to the repository root. + Other relative paths are resolved relative to base_dir. + + Args: + value: Path-like config value. + base_dir: Directory to resolve relative paths against. + + Returns: + Absolute path string or None. + """ + if value is None: + return None + s = str(value) + if not s: + return None + + s_norm = s.replace("\\", "/") + if s_norm.startswith(".testdata/"): + repo_root = _repo_root_from_this_file() + return str((repo_root / s_norm).resolve()) + + p = Path(s) + if p.is_absolute(): + return str(p) + return str((base_dir / p).resolve()) + + +def _resolve_path_list(value: Any, *, base_dir: Path) -> list[str]: + """Resolve a config value representing a path or list of paths. + + Args: + value: Path-like value or list of path-like values. + base_dir: Directory to resolve relative paths against. + + Returns: + List of absolute path strings. + """ + if value is None: + return [] + if isinstance(value, (list, tuple)): + out: list[str] = [] + for v in value: + rp = _resolve_path(v, base_dir=base_dir) + if rp: + out.append(rp) + return out + rp = _resolve_path(value, base_dir=base_dir) + return [rp] if rp else [] + + +def _abspathify_config_paths( + config: Dict[str, Any], *, base_dir: Path +) -> Dict[str, Any]: + """Convert configured input paths into absolute paths. + + Args: + config: Parsed config mapping. + base_dir: Base directory for resolving relative paths. + + Returns: + A new config dict with resolved paths. + """ + path_keys = {"force_file"} + list_path_keys = {"top_traj_file"} + + out: Dict[str, Any] = {} + for run_name, run_cfg in config.items(): + if not isinstance(run_cfg, dict): + out[run_name] = run_cfg + continue + + run_cfg2 = dict(run_cfg) + for k in list(run_cfg2.keys()): + if k in path_keys: + run_cfg2[k] = _resolve_path(run_cfg2.get(k), base_dir=base_dir) + if k in list_path_keys: + run_cfg2[k] = _resolve_path_list(run_cfg2.get(k), base_dir=base_dir) + + out[run_name] = run_cfg2 + + return out + + +def _assert_inputs_exist(cooked: Dict[str, Any]) -> None: + """Assert that required input files referenced in cooked config exist.""" + run1 = cooked.get("run1") + if not isinstance(run1, dict): + return + + for p in run1.get("top_traj_file") or []: + if isinstance(p, str) and p: + assert Path(p).exists(), f"Missing input file: {p}" + + ff = run1.get("force_file") + if isinstance(ff, str) and ff.strip(): + assert Path(ff).exists(), f"Missing force file: {ff}" + + +def run_codeentropy_with_config(*, workdir: Path, config_src: Path) -> RunResult: + """Run CodeEntropy using a regression config file. + + This function loads the YAML config, resolves input paths, ensures required + dataset files exist by downloading from the filestore if needed, then runs + CodeEntropy and returns the parsed output JSON. + + Args: + workdir: Temporary working directory for running CodeEntropy. + config_src: Path to the YAML regression config. + + Returns: + RunResult containing outputs and metadata. + + Raises: + RuntimeError: If CodeEntropy fails or required data cannot be fetched. + ValueError: If config does not parse as a dict or output JSON lacks expected + keys. + FileNotFoundError: If job output files cannot be found. + """ + workdir.mkdir(parents=True, exist_ok=True) + + raw = yaml.safe_load(config_src.read_text()) + if not isinstance(raw, dict): + raise ValueError( + f"Config must parse to a dict. Got {type(raw)} from {config_src}" + ) + + system = config_src.parent.name + cooked = _abspathify_config_paths(raw, base_dir=config_src.parent) + + required: list[Path] = [] + run1 = cooked.get("run1") + if isinstance(run1, dict): + ff = run1.get("force_file") + if isinstance(ff, str) and ff: + required.append(Path(ff)) + for p in run1.get("top_traj_file") or []: + if isinstance(p, str) and p: + required.append(Path(p)) + + if required: + ensure_testdata_for_system(system, required_paths=required) + + _assert_inputs_exist(cooked) + + (workdir / "config.yaml").write_text(yaml.safe_dump(cooked, sort_keys=False)) + + proc = subprocess.run( + ["python", "-m", "CodeEntropy"], + cwd=str(workdir), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={**os.environ}, + ) + + (workdir / "codeentropy_stdout.txt").write_text(proc.stdout or "") + (workdir / "codeentropy_stderr.txt").write_text(proc.stderr or "") + + if proc.returncode != 0: + raise RuntimeError( + "CodeEntropy regression run failed\n" + f"cwd={workdir}\n" + f"stdout saved to: {workdir / 'codeentropy_stdout.txt'}\n" + f"stderr saved to: {workdir / 'codeentropy_stderr.txt'}\n" + ) + + job_dir = _find_latest_job_dir(workdir) + out_json = _pick_output_json(job_dir) + payload = json.loads(out_json.read_text()) + + (workdir / "codeentropy_output.json").write_text(json.dumps(payload, indent=2)) + + if "groups" not in payload: + raise ValueError( + f"Regression output JSON did not contain 'groups'. output={out_json}" + ) + + return RunResult( + workdir=workdir, + job_dir=job_dir, + output_json=out_json, + payload=payload, + stdout=proc.stdout or "", + stderr=proc.stderr or "", + ) diff --git a/tests/regression/test_regression.py b/tests/regression/test_regression.py new file mode 100644 index 00000000..d5ca7f10 --- /dev/null +++ b/tests/regression/test_regression.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + +import numpy as np +import pytest + +from tests.regression.helpers import run_codeentropy_with_config + + +def _group_index(payload: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: + """Return the groups mapping from a regression payload. + + Args: + payload: Parsed JSON payload. + + Returns: + Mapping of group id to group data. + + Raises: + TypeError: If payload["groups"] is not a dict. + """ + groups = payload.get("groups", {}) + if not isinstance(groups, dict): + raise TypeError("payload['groups'] must be a dict") + return groups + + +def _compare_grouped( + *, + got_payload: Dict[str, Any], + baseline_payload: Dict[str, Any], + rtol: float, + atol: float, +) -> None: + """Compare grouped regression outputs against baseline values. + + Args: + got_payload: Newly produced payload. + baseline_payload: Baseline payload. + rtol: Relative tolerance. + atol: Absolute tolerance. + + Raises: + AssertionError: If any required group/component differs from baseline. + """ + got_groups = _group_index(got_payload) + base_groups = _group_index(baseline_payload) + + missing_groups = sorted(set(base_groups.keys()) - set(got_groups.keys())) + assert not missing_groups, f"Missing groups in output: {missing_groups}" + + mismatches: list[str] = [] + + for gid, base_g in base_groups.items(): + got_g = got_groups[gid] + + base_components = base_g.get("components", {}) + got_components = got_g.get("components", {}) + + if not isinstance(base_components, dict) or not isinstance( + got_components, dict + ): + mismatches.append(f"group {gid}: components must be dicts") + continue + + missing_keys = sorted(set(base_components.keys()) - set(got_components.keys())) + if missing_keys: + mismatches.append(f"group {gid}: missing component keys: {missing_keys}") + continue + + for k, expected in base_components.items(): + actual = got_components[k] + try: + np.testing.assert_allclose( + float(actual), float(expected), rtol=rtol, atol=atol + ) + except AssertionError: + mismatches.append( + f"group {gid} component {k}: expected={expected} got={actual}" + ) + + if "total" in base_g: + try: + np.testing.assert_allclose( + float(got_g.get("total", 0.0)), + float(base_g["total"]), + rtol=rtol, + atol=atol, + ) + except AssertionError: + mismatches.append( + f"group {gid} total: expected={base_g['total']} " + f"got={got_g.get('total')}" + ) + + assert not mismatches, "Mismatches:\n" + "\n".join(" " + m for m in mismatches) + + +@pytest.mark.regression +@pytest.mark.parametrize( + "system", + [ + pytest.param("benzaldehyde", marks=pytest.mark.slow), + pytest.param("benzene", marks=pytest.mark.slow), + pytest.param("cyclohexane", marks=pytest.mark.slow), + "dna", + pytest.param("ethyl-acetate", marks=pytest.mark.slow), + "methane", + "methanol", + pytest.param("octonol", marks=pytest.mark.slow), + ], +) +def test_regression_matches_baseline( + tmp_path: Path, system: str, request: pytest.FixtureRequest +) -> None: + """Run a regression test for one system and compare to its baseline. + + Args: + tmp_path: Pytest-provided temporary directory. + system: System name parameter. + request: Pytest request fixture for reading CLI options. + """ + repo_root = Path(__file__).resolve().parents[2] + config_path = ( + repo_root / "tests" / "regression" / "configs" / system / "config.yaml" + ) + baseline_path = repo_root / "tests" / "regression" / "baselines" / f"{system}.json" + + assert config_path.exists(), f"Missing config: {config_path}" + assert baseline_path.exists(), f"Missing baseline: {baseline_path}" + + baseline_payload = json.loads(baseline_path.read_text()) + run = run_codeentropy_with_config(workdir=tmp_path, config_src=config_path) + + if request.config.getoption("--codeentropy-debug"): + print("\n[CodeEntropy regression debug]") + print("workdir:", run.workdir) + print("job_dir:", run.job_dir) + print("output_json:", run.output_json) + print("payload copy saved:", run.workdir / "codeentropy_output.json") + + if request.config.getoption("--update-baselines"): + baseline_path.write_text(json.dumps(run.payload, indent=2)) + pytest.skip(f"Baseline updated for {system}: {baseline_path}") + + _compare_grouped( + got_payload=run.payload, + baseline_payload=baseline_payload, + rtol=1e-9, + atol=1e-8, + ) diff --git a/tests/unit/CodeEntropy/pytest.ini b/tests/unit/CodeEntropy/pytest.ini deleted file mode 100644 index 8adab832..00000000 --- a/tests/unit/CodeEntropy/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -testpaths = CodeEntropy/tests/unit -addopts = -q \ No newline at end of file From 7dba3ebb4f957fecdc7483a7a3a5d8542e00b8b8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 10:52:42 +0000 Subject: [PATCH 087/101] ci: update workflows for unit, quick regression, and weekly full regression: Run unit tests across all supported OS and Python versions, add quick regression suite to PRs with .testdata caching, and configure weekly workflow to run full regression including slow tests. Simplify docs builds to latest environment. --- .github/workflows/daily.yaml | 66 +++++++++ .../mdanalysis-compatibility-failure.md | 11 -- .../workflows/mdanalysis-compatibility.yaml | 49 ------- .github/workflows/pr.yaml | 138 ++++++++++++++++++ .github/workflows/project-ci.yaml | 85 ----------- .github/workflows/weekly-docs.yaml | 46 ++++++ .github/workflows/weekly-regression.yaml | 52 +++++++ 7 files changed, 302 insertions(+), 145 deletions(-) create mode 100644 .github/workflows/daily.yaml delete mode 100644 .github/workflows/mdanalysis-compatibility-failure.md delete mode 100644 .github/workflows/mdanalysis-compatibility.yaml create mode 100644 .github/workflows/pr.yaml delete mode 100644 .github/workflows/project-ci.yaml create mode 100644 .github/workflows/weekly-docs.yaml create mode 100644 .github/workflows/weekly-regression.yaml diff --git a/.github/workflows/daily.yaml b/.github/workflows/daily.yaml new file mode 100644 index 00000000..a08113fe --- /dev/null +++ b/.github/workflows/daily.yaml @@ -0,0 +1,66 @@ +name: CodeEntropy Daily Checks + +on: + schedule: + - cron: '0 8 * * 1-5' + workflow_dispatch: + +concurrency: + group: daily-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Daily unit tests + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025, macos-15] + python-version: ["3.12", "3.13", "3.14"] + timeout-minutes: 30 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install CodeEntropy and its testing dependencies + run: | + pip install --upgrade pip + pip install -e .[testing] + + - name: Run unit test suite + run: pytest tests/unit -q + + coverage: + name: Coverage (ubuntu, latest python) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.14" + cache: pip + + - name: Install CodeEntropy and its testing dependencies + run: | + pip install --upgrade pip + pip install -e .[testing] + + - name: Run unit test suite with coverage + run: pytest tests/unit --cov CodeEntropy --cov-report term-missing --cov-report xml -q + + - name: Coveralls GitHub Action + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage.xml \ No newline at end of file diff --git a/.github/workflows/mdanalysis-compatibility-failure.md b/.github/workflows/mdanalysis-compatibility-failure.md deleted file mode 100644 index 5815a9c7..00000000 --- a/.github/workflows/mdanalysis-compatibility-failure.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: "CI Failure: MDAnalysis v{{ env.MDA_VERSION }} / Python {{ env.PYTHON_VERSION }}" -labels: - - "CI Failure" - - "MDAnalysis Compatibility" ---- - -Automated MDAnalysis Compatibility Test Failure -MDAnalysis version: {{ env.MDA_VERSION }} -Python version: {{ env.PYTHON_VERSION }} -Workflow Run: [Run #{{ env.RUN_NUMBER }}]({{ env.RUN_URL }}) diff --git a/.github/workflows/mdanalysis-compatibility.yaml b/.github/workflows/mdanalysis-compatibility.yaml deleted file mode 100644 index 8b671e3f..00000000 --- a/.github/workflows/mdanalysis-compatibility.yaml +++ /dev/null @@ -1,49 +0,0 @@ -name: MDAnalysis Compatibility - -on: - schedule: - - cron: '0 8 * * 1' # Weekly Monday checks - workflow_dispatch: - -jobs: - mdanalysis-compatibility: - name: MDAnalysis Compatibility Tests - runs-on: ${{ matrix.os }} - - strategy: - matrix: - os: [ubuntu-24.04, windows-2025, macos-15] - python-version: ["3.11", "3.12", "3.13", "3.14"] - mdanalysis-version: ["2.10.0"] - - steps: - - name: Checkout repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies with MDAnalysis ${{ matrix.mdanalysis-version }} - run: | - pip install --upgrade pip - pip install -e .[testing] - pip install "MDAnalysis==${{ matrix.mdanalysis-version }}" - - - name: Run compatibility tests - run: pytest --cov CodeEntropy --cov-report=term-missing --cov-append - - - name: Create Issue on Failure - if: failure() - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PYTHON_VERSION: ${{ matrix.python-version }} - MDA_VERSION: ${{ matrix.mdanalysis-version }} - RUN_NUMBER: ${{ github.run_number }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - with: - filename: .github/workflows/mdanalysis-compatibility-failure.md - update_existing: true - search_existing: open diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 00000000..e55f8494 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,138 @@ +name: CodeEntropy PR + +on: + pull_request: + +concurrency: + group: pr-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit: + name: Unit tests (py${{ matrix.python-version }} • ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14, windows-2025] + python-version: ["3.12", "3.13", "3.14"] + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install CodeEntropy and its testing dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[testing] + + - name: Run unit test suite + run: | + python -m pytest tests/unit -q + + regression-quick: + name: Quick regression (py${{ matrix.python-version }} • ${{ matrix.os }}) + needs: unit + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14, windows-2025] + python-version: ["3.12", "3.13", "3.14"] + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Cache regression test data downloads + uses: actions/cache@v4 + with: + path: .testdata + key: codeentropy-testdata-v1-${{ runner.os }}-py${{ matrix.python-version }} + + - name: Install CodeEntropy and its testing dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[testing] + + - name: Run quick regression suite (slow excluded) + run: | + python -m pytest tests/regression -q + + - name: Upload regression artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: quick-regression-failure-${{ matrix.os }}-py${{ matrix.python-version }} + path: | + .testdata/** + tests/regression/**/.pytest_cache/** + /tmp/pytest-of-*/pytest-*/**/config.yaml + /tmp/pytest-of-*/pytest-*/**/codeentropy_stdout.txt + /tmp/pytest-of-*/pytest-*/**/codeentropy_stderr.txt + /tmp/pytest-of-*/pytest-*/**/codeentropy_output.json + + docs: + name: Docs build (latest) + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.14" + cache: pip + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[docs] + + - name: Build docs + run: | + cd docs + make + + pre-commit: + name: Pre-commit + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.14" + cache: pip + + - name: Install python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[pre-commit] + + - name: Run pre-commit + shell: bash + run: | + pre-commit install + pre-commit run --all-files || { + git status --short + git diff + exit 1 + } \ No newline at end of file diff --git a/.github/workflows/project-ci.yaml b/.github/workflows/project-ci.yaml deleted file mode 100644 index ae8ab9a0..00000000 --- a/.github/workflows/project-ci.yaml +++ /dev/null @@ -1,85 +0,0 @@ -name: CodeEntropy CI - -on: - push: - branches: [main] - pull_request: - schedule: - - cron: '0 8 * * 1-5' - workflow_dispatch: - -jobs: - tests: - name: Run tests - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-24.04, windows-2025, macos-15] - python-version: ["3.12", "3.13", "3.14"] - steps: - - name: Checkout repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: ${{ matrix.python-version }} - - - name: Install CodeEntropy and its testing dependencies - run: pip install -e .[testing] - - - name: Run test suite - run: pytest --cov CodeEntropy --cov-report term-missing --cov-append . - - - name: Coveralls GitHub Action - uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - parallel: true - - docs: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-24.04, windows-2025, macos-15] - python-version: ["3.12", "3.13", "3.14"] - timeout-minutes: 15 - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: ${{ matrix.python-version }} - - name: Install python dependencies - run: | - pip install --upgrade pip - pip install -e .[docs] - - name: Build docs - run: cd docs && make - - pre-commit: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-24.04, windows-2025, macos-15] - python-version: ["3.12", "3.13", "3.14"] - timeout-minutes: 15 - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: ${{ matrix.python-version }} - - name: Install python dependencies - run: | - pip install --upgrade pip - pip install -e .[pre-commit] - - name: Run pre-commit - shell: bash - run: | - pre-commit install - pre-commit run --all-files || { - git status --short - git diff - exit 1 - } diff --git a/.github/workflows/weekly-docs.yaml b/.github/workflows/weekly-docs.yaml new file mode 100644 index 00000000..09514929 --- /dev/null +++ b/.github/workflows/weekly-docs.yaml @@ -0,0 +1,46 @@ +name: CodeEntropy Weekly Docs + +on: + schedule: + - cron: '0 8 * * 1' + workflow_dispatch: + +concurrency: + group: weekly-docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + docs: + name: Docs build (${{ matrix.os }}, python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025, macos-15] + python-version: ["3.12", "3.13", "3.14"] + timeout-minutes: 30 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install python dependencies + run: | + pip install --upgrade pip + pip install -e .[docs] + + - name: Build docs + run: cd docs && make + + - name: Upload docs artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: docs-${{ matrix.os }}-py${{ matrix.python-version }}-failure + path: | + docs/_build/** \ No newline at end of file diff --git a/.github/workflows/weekly-regression.yaml b/.github/workflows/weekly-regression.yaml new file mode 100644 index 00000000..0f390d1c --- /dev/null +++ b/.github/workflows/weekly-regression.yaml @@ -0,0 +1,52 @@ +name: CodeEntropy Weekly Regression + +on: + schedule: + - cron: '0 8 * * 1' # Weekly Monday checks + workflow_dispatch: + +concurrency: + group: weekly-regression-${{ github.ref }} + cancel-in-progress: true + +jobs: + regression: + name: Regression tests (including slow) + runs-on: ubuntu-24.04 + timeout-minutes: 180 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + + - name: Set up Python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.14" + cache: pip + + - name: Cache regression test data downloads + uses: actions/cache@v4 + with: + path: .testdata + key: codeentropy-testdata-${{ runner.os }}-py314 + + - name: Install CodeEntropy and its testing dependencies + run: | + pip install --upgrade pip + pip install -e .[testing] + + - name: Run regression test suite + run: pytest tests/regression -q --run-slow + + - name: Upload regression artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: regression-failure-artifacts + path: | + .testdata/** + tests/regression/**/.pytest_cache/** + /tmp/pytest-of-*/pytest-*/**/config.yaml + /tmp/pytest-of-*/pytest-*/**/codeentropy_stdout.txt + /tmp/pytest-of-*/pytest-*/**/codeentropy_stderr.txt + /tmp/pytest-of-*/pytest-*/**/codeentropy_output.json \ No newline at end of file From 305be6afbae119eb536175a4cc634b025375d87c Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 10:58:03 +0000 Subject: [PATCH 088/101] docs(badges): update CI badge set for new workflow structure: - Add badges for PR checks, daily tests, weekly regression, and weekly docs. - Remove obsolete workflow badges and align README with current CI setup. --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2eadcccf..63d1e41b 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ CodeEntropy | Category | Badges | |----------------|--------| -| **Build** | [![CodeEntropy CI](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/project-ci.yaml/badge.svg)](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/project-ci.yaml) | -| **Documentation** | [![Docs - Status](https://app.readthedocs.org/projects/codeentropy/badge/?version=latest)](https://codeentropy.readthedocs.io/en/latest/?badge=latest) | +| **Build** | [![PR Checks](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/pr.yaml/badge.svg)](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/pr.yaml) [![Daily Tests](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/daily.yaml/badge.svg)](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/daily.yaml) | +| **Regression** | [![Weekly Regression](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/weekly-regression.yaml/badge.svg)](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/weekly-regression.yaml) | +| **Documentation** | [![Weekly Docs](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/weekly-docs.yaml/badge.svg)](https://github.com/CCPBioSim/CodeEntropy/actions/workflows/weekly-docs.yaml) [![Docs - Status](https://app.readthedocs.org/projects/codeentropy/badge/?version=latest)](https://codeentropy.readthedocs.io/en/latest/?badge=latest) | | **Citation** | [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.17570721.svg)](https://doi.org/10.5281/zenodo.17570721) | -| **PyPI** | ![PyPI - Status](https://img.shields.io/pypi/status/codeentropy?logo=pypi&logoColor=white) ![PyPI - Version](https://img.shields.io/pypi/v/codeentropy?logo=pypi&logoColor=white) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/CodeEntropy) ![PyPI - Total Downloads](https://img.shields.io/pepy/dt/codeentropy?logo=pypi&logoColor=white&color=blue) ![PyPI - Monthly Downloads](https://img.shields.io/pypi/dm/CodeEntropy?logo=pypi&logoColor=white&color=blue)| +| **PyPI** | ![PyPI - Status](https://img.shields.io/pypi/status/codeentropy?logo=pypi&logoColor=white) ![PyPI - Version](https://img.shields.io/pypi/v/codeentropy?logo=pypi&logoColor=white) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/CodeEntropy) ![PyPI - Total Downloads](https://img.shields.io/pepy/dt/codeentropy?logo=pypi&logoColor=white&color=blue) ![PyPI - Monthly Downloads](https://img.shields.io/pypi/dm/CodeEntropy?logo=pypi&logoColor=white&color=blue) | | **Quality** | [![Coverage Status](https://coveralls.io/repos/github/CCPBioSim/CodeEntropy/badge.svg?branch=main)](https://coveralls.io/github/CCPBioSim/CodeEntropy?branch=main) | CodeEntropy is a Python package for computing the configurational entropy of macromolecular systems using forces sampled from molecular dynamics (MD) simulations. It implements the multiscale cell correlation method to provide accurate and efficient entropy estimates, supporting a wide range of applications in molecular simulation and statistical mechanics. From 58c084aebdad9e30e6e7491e7d88a69fbfcd284f Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 11:13:04 +0000 Subject: [PATCH 089/101] ci(pre-commit): migrate linting to Ruff and update hooks: - Replace black, flake8, and isort with Ruff for linting and formatting. - Update pre-commit configuration and dependencies, add Ruff config to pyproject.toml, and apply automatic fixes across the codebase. --- .github/CONTRIBUTING.md | 32 +++++++++---------- .github/ISSUE_TEMPLATE/bug_report.md | 6 ++-- .github/PULL_REQUEST_TEMPLATE.md | 8 ++--- .github/renovate.json | 2 +- .github/workflows/daily.yaml | 2 +- .github/workflows/pr.yaml | 2 +- .github/workflows/release.yaml | 1 - .github/workflows/weekly-docs.yaml | 4 +-- .github/workflows/weekly-regression.yaml | 2 +- .gitignore | 2 +- .pre-commit-config.yaml | 31 +++++++----------- CODE_OF_CONDUCT.md | 2 +- CodeEntropy/entropy/nodes/configurational.py | 4 ++- CodeEntropy/levels/axes.py | 5 ++- CodeEntropy/levels/nodes/covariance.py | 2 +- README.md | 4 +-- docs/Makefile | 2 +- docs/README.md | 3 +- docs/_static/README.md | 4 +-- docs/_static/custom.css | 2 +- docs/_templates/README.md | 4 +-- docs/api.rst | 3 +- docs/config.yaml | 2 +- docs/developer_guide.rst | 2 +- docs/faq.rst | 10 +++--- docs/getting_started.rst | 4 +-- docs/images/biosim-codeentropy_logo_grey.svg | 2 +- docs/science.rst | 18 +++++------ pyproject.toml | 22 ++++++------- readthedocs.yml | 2 +- tests/pytest.ini | 2 +- .../configs/benzaldehyde/config.yaml | 2 +- tests/regression/configs/benzene/config.yaml | 2 +- .../configs/cyclohexane/config.yaml | 2 +- tests/regression/configs/dna/config.yaml | 2 +- .../configs/ethyl-acetate/config.yaml | 2 +- tests/regression/configs/methane/config.yaml | 2 +- tests/regression/configs/methanol/config.yaml | 2 +- tests/regression/configs/octonol/config.yaml | 2 +- 39 files changed, 97 insertions(+), 110 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 5b233393..1b5a27e1 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -10,7 +10,7 @@ This guide explains how to set up your environment, make changes, and submit the ## Getting Started -Before contributing, please review the [Developer Guide](https://codeentropy.readthedocs.io/en/latest/developer_guide.html). +Before contributing, please review the [Developer Guide](https://codeentropy.readthedocs.io/en/latest/developer_guide.html). It covers CodeEntropy’s architecture, setup instructions, and contribution workflow. If you’re new to the project, we also recommend: @@ -23,19 +23,19 @@ If you’re new to the project, we also recommend: When you’re ready to submit your work: -1. **Push your branch** to GitHub. -2. **Open a [pull request](https://help.github.com/articles/using-pull-requests/)** against the `main` branch. +1. **Push your branch** to GitHub. +2. **Open a [pull request](https://help.github.com/articles/using-pull-requests/)** against the `main` branch. 3. **Fill out the PR template**, including: - - A concise summary of what your PR does - - A list of all changes introduced - - Details on how these changes affect the repository (features, tests, documentation, etc.) + - A concise summary of what your PR does + - A list of all changes introduced + - Details on how these changes affect the repository (features, tests, documentation, etc.) 4. **Verify before submission**: - - All tests pass - - Pre-commit checks succeed - - Documentation is updated where applicable + - All tests pass + - Pre-commit checks succeed + - Documentation is updated where applicable 5. **Review process**: - - Your PR will be reviewed by the core development team. - - At least **one approval** is required before merging. + - Your PR will be reviewed by the core development team. + - At least **one approval** is required before merging. We aim to provide constructive feedback quickly and appreciate your patience during the review process. @@ -45,12 +45,12 @@ We aim to provide constructive feedback quickly and appreciate your patience dur Found a bug or have a feature request? -1. **Open a new issue** on GitHub. -2. Provide a **clear and descriptive title**. +1. **Open a new issue** on GitHub. +2. Provide a **clear and descriptive title**. 3. Include: - - Steps to reproduce the issue (if applicable) - - Expected vs. actual behavior - - Relevant logs, screenshots, or input files + - Steps to reproduce the issue (if applicable) + - Expected vs. actual behavior + - Relevant logs, screenshots, or input files Well-documented issues help us address problems faster and keep CodeEntropy stable and robust. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b7215b1f..4492a51e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -12,11 +12,11 @@ assignees: '' --- -## To Reproduce +## To Reproduce ### YAML configuration - ```yaml # Paste the YAML snippet here @@ -46,7 +46,7 @@ Remove unrelated fields to make it minimal. --> - Python Version: - Package list: - If using conda, run: `conda list > packages.txt` and paste the contents here. - + ``` bash # Paste packages.txt here ``` diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 41e7b7c8..6b7db578 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,16 +5,16 @@ ### Change 1 : - -- +- ### Change 2 : - -- +- ### Change 3 : - -- +- ## Impact - -- \ No newline at end of file +- diff --git a/.github/renovate.json b/.github/renovate.json index 02834d3c..371a631f 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -18,4 +18,4 @@ "automerge": false } ] -} \ No newline at end of file +} diff --git a/.github/workflows/daily.yaml b/.github/workflows/daily.yaml index a08113fe..41e63804 100644 --- a/.github/workflows/daily.yaml +++ b/.github/workflows/daily.yaml @@ -63,4 +63,4 @@ jobs: uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 with: github-token: ${{ secrets.GITHUB_TOKEN }} - file: coverage.xml \ No newline at end of file + file: coverage.xml diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e55f8494..87f64355 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -135,4 +135,4 @@ jobs: git status --short git diff exit 1 - } \ No newline at end of file + } diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 7279ae57..05102594 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -146,4 +146,3 @@ jobs: env: FLIT_USERNAME: __token__ FLIT_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - diff --git a/.github/workflows/weekly-docs.yaml b/.github/workflows/weekly-docs.yaml index 09514929..6c38e5b3 100644 --- a/.github/workflows/weekly-docs.yaml +++ b/.github/workflows/weekly-docs.yaml @@ -2,7 +2,7 @@ name: CodeEntropy Weekly Docs on: schedule: - - cron: '0 8 * * 1' + - cron: '0 8 * * 1' workflow_dispatch: concurrency: @@ -43,4 +43,4 @@ jobs: with: name: docs-${{ matrix.os }}-py${{ matrix.python-version }}-failure path: | - docs/_build/** \ No newline at end of file + docs/_build/** diff --git a/.github/workflows/weekly-regression.yaml b/.github/workflows/weekly-regression.yaml index 0f390d1c..7c8fa5a3 100644 --- a/.github/workflows/weekly-regression.yaml +++ b/.github/workflows/weekly-regression.yaml @@ -49,4 +49,4 @@ jobs: /tmp/pytest-of-*/pytest-*/**/config.yaml /tmp/pytest-of-*/pytest-*/**/codeentropy_stdout.txt /tmp/pytest-of-*/pytest-*/**/codeentropy_stderr.txt - /tmp/pytest-of-*/pytest-*/**/codeentropy_output.json \ No newline at end of file + /tmp/pytest-of-*/pytest-*/**/codeentropy_output.json diff --git a/.gitignore b/.gitignore index 08b05df0..d75442b8 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,4 @@ job* *.com *.txt -.testdata/ \ No newline at end of file +.testdata/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6be5d3ec..ca087bfa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,28 +1,19 @@ repos: - - repo: https://github.com/psf/black - rev: 25.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.2 hooks: - - id: black + - id: ruff-check + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - - id: check-ast - - id: check-case-conflict - - id: check-executables-have-shebangs - id: check-merge-conflict - - id: check-toml - id: check-yaml - - - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 - hooks: - - id: flake8 - additional_dependencies: [flake8-pyproject] - - - repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - args: ["--profile=black"] \ No newline at end of file + - id: check-toml + - id: check-case-conflict + - id: check-ast + - id: end-of-file-fixer + - id: trailing-whitespace diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 729da91b..2b7c3c7f 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -125,4 +125,4 @@ enforcement ladder](https://github.com/mozilla/diversity). For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. \ No newline at end of file +https://www.contributor-covenant.org/translations. diff --git a/CodeEntropy/entropy/nodes/configurational.py b/CodeEntropy/entropy/nodes/configurational.py index 957aede3..6fbba735 100644 --- a/CodeEntropy/entropy/nodes/configurational.py +++ b/CodeEntropy/entropy/nodes/configurational.py @@ -103,7 +103,9 @@ def _build_entropy_engine(self) -> ConformationalEntropy: """Create the entropy calculation engine.""" return ConformationalEntropy() - def _get_state_containers(self, shared_data: Mapping[str, Any]) -> Tuple[ + def _get_state_containers( + self, shared_data: Mapping[str, Any] + ) -> Tuple[ Dict[StateKey, StateSequence], Union[Dict[GroupId, StateSequence], Sequence[Optional[StateSequence]]], ]: diff --git a/CodeEntropy/levels/axes.py b/CodeEntropy/levels/axes.py index de1ab2e8..07f1918f 100644 --- a/CodeEntropy/levels/axes.py +++ b/CodeEntropy/levels/axes.py @@ -107,8 +107,7 @@ def get_residue_axes(self, data_container, index: int, residue=None): center = residue.atoms.center_of_mass(unwrap=True) atom_set = data_container.select_atoms( - f"(resindex {index_prev} or resindex {index_next}) " - f"and bonded resid {index}" + f"(resindex {index_prev} or resindex {index_next}) and bonded resid {index}" ) if len(atom_set) == 0: @@ -476,7 +475,7 @@ def get_custom_moment_of_inertia( translated_coords = self.get_vector(center_of_mass, UA.positions, dimensions) custom_moment_of_inertia = np.zeros(3, dtype=float) - for coord, mass in zip(translated_coords, UA.masses): + for coord, mass in zip(translated_coords, UA.masses, strict=True): axis_component = np.sum( np.cross(custom_rotation_axes, coord) ** 2 * mass, axis=1 ) diff --git a/CodeEntropy/levels/nodes/covariance.py b/CodeEntropy/levels/nodes/covariance.py index 1771df46..35d050ca 100644 --- a/CodeEntropy/levels/nodes/covariance.py +++ b/CodeEntropy/levels/nodes/covariance.py @@ -503,7 +503,7 @@ def _build_ft_block( raise ValueError("No bead vectors available to build an FT matrix.") bead_vecs: List[np.ndarray] = [] - for Fi, Ti in zip(force_vecs, torque_vecs): + for Fi, Ti in zip(force_vecs, torque_vecs, strict=True): Fi = np.asarray(Fi, dtype=float).reshape(-1) Ti = np.asarray(Ti, dtype=float).reshape(-1) if Fi.size != 3 or Ti.size != 3: diff --git a/README.md b/README.md index 63d1e41b..5728743f 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,8 @@ CodeEntropy is a Python package for computing the configurational entropy of mac See [CodeEntropy’s documentation](https://codeentropy.readthedocs.io/en/latest/) for more information. ## Acknowledgements - -Project based on + +Project based on - [arghya90/CodeEntropy](https://github.com/arghya90/CodeEntropy) version 0.3 - [jkalayan/PoseidonBeta](https://github.com/jkalayan/PoseidonBeta) diff --git a/docs/Makefile b/docs/Makefile index 08b52d2c..1f4fb783 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -17,4 +17,4 @@ help: # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/README.md b/docs/README.md index cc030c72..79687e62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,11 +14,10 @@ Once installed, you can use the `Makefile` in this directory to compile static H make html ``` -The compiled docs will be in the `_build` directory and can be viewed by opening `index.html` (which may itself +The compiled docs will be in the `_build` directory and can be viewed by opening `index.html` (which may itself be inside a directory called `html/` depending on what version of Sphinx is installed). A configuration file for [Read The Docs](https://readthedocs.org/) (readthedocs.yaml) is included in the top level of the repository. To use Read the Docs to host your documentation, go to https://readthedocs.org/ and connect this repository. You may need to change your default branch to `main` under Advanced Settings for the project. If you would like to use Read The Docs with `autodoc` (included automatically) and your package has dependencies, you will need to include those dependencies in your documentation yaml file (`docs/requirements.yaml`). - diff --git a/docs/_static/README.md b/docs/_static/README.md index 2f0cf843..122b610b 100644 --- a/docs/_static/README.md +++ b/docs/_static/README.md @@ -1,11 +1,11 @@ # Static Doc Directory Add any paths that contain custom static files (such as style sheets) here, -relative to the `conf.py` file's directory. +relative to the `conf.py` file's directory. They are copied after the builtin static files, so a file named "default.css" will overwrite the builtin "default.css". -The path to this folder is set in the Sphinx `conf.py` file in the line: +The path to this folder is set in the Sphinx `conf.py` file in the line: ```python templates_path = ['_static'] ``` diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 07d420be..7d2e89e6 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -1,3 +1,3 @@ .tight-table td { white-space: normal !important; -} \ No newline at end of file +} diff --git a/docs/_templates/README.md b/docs/_templates/README.md index 3f4f8043..485f82ad 100644 --- a/docs/_templates/README.md +++ b/docs/_templates/README.md @@ -1,11 +1,11 @@ # Templates Doc Directory -Add any paths that contain templates here, relative to +Add any paths that contain templates here, relative to the `conf.py` file's directory. They are copied after the builtin template files, so a file named "page.html" will overwrite the builtin "page.html". -The path to this folder is set in the Sphinx `conf.py` file in the line: +The path to this folder is set in the Sphinx `conf.py` file in the line: ```python html_static_path = ['_templates'] ``` diff --git a/docs/api.rst b/docs/api.rst index de67a2a3..cc91d767 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -57,7 +57,7 @@ Vibrational Entropy CodeEntropy.entropy.VibrationalEntropy CodeEntropy.entropy.VibrationalEntropy.frequency_calculation CodeEntropy.entropy.VibrationalEntropy.vibrational_entropy_calculation - + Conformational Entropy ^^^^^^^^^^^^^^^^^^^^^^ @@ -67,4 +67,3 @@ Conformational Entropy CodeEntropy.entropy.ConformationalEntropy CodeEntropy.entropy.ConformationalEntropy.assign_conformation CodeEntropy.entropy.ConformationalEntropy.conformational_entropy_calculation - diff --git a/docs/config.yaml b/docs/config.yaml index 3f6fd8f8..3b1240ae 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -3,7 +3,7 @@ run1: top_traj_file: ["1AKI_prod.tpr", "1AKI_prod.trr"] force_file: - file_formate: + file_formate: selection_string: 'all' start: 0 end: 500 diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst index 416c46e1..69a5fe14 100644 --- a/docs/developer_guide.rst +++ b/docs/developer_guide.rst @@ -53,7 +53,7 @@ This ensures: - **Import sorting** via ``isort`` with the ``black`` profile - **Linting** via ``flake8`` with ``flake8-pyproject`` - **Basic checks** via ``pre-commit-hooks``, including: - + - Detection of large added files - AST validity checks - Case conflict detection diff --git a/docs/faq.rst b/docs/faq.rst index a119a272..7ef18da6 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -4,15 +4,15 @@ Frequently asked questions Why do I get a ``WARNING`` about invalid eigenvalues? ----------------------------------------------------- -Insufficient sampling might introduce noise and cause matrix elements to deviate to values that would not reflect the uncorrelated nature of force-force covariance of distantly positioned residues. -Try increasing the sampling time. -This is especially true at the residue level. +Insufficient sampling might introduce noise and cause matrix elements to deviate to values that would not reflect the uncorrelated nature of force-force covariance of distantly positioned residues. +Try increasing the sampling time. +This is especially true at the residue level. -For example in a lysozyme system, the residue level contains the largest force and torque covariance matrices because at this level we have the largest number of beads (which is equal to the number of residues in a protein) compared to the molecule level (3 beads) and united-atom level (~10 beads per amino acid). +For example in a lysozyme system, the residue level contains the largest force and torque covariance matrices because at this level we have the largest number of beads (which is equal to the number of residues in a protein) compared to the molecule level (3 beads) and united-atom level (~10 beads per amino acid). What do I do if there is an error from MDAnalysis not recognising the file type? ------------------------------------------------------------------------------- -Use the file_format option. +Use the file_format option. The MDAnalysis documentation has a list of acceptable formats: https://userguide.mdanalysis.org/1.1.1/formats/index.html#id1. The first column of their table gives you the string you need (case sensitive). diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 06f6d9ef..2f300213 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -179,7 +179,7 @@ The ``top_traj_file`` argument is required; other arguments have default values. - ``molecules`` - ``str`` * - ``--kcal_force_units`` - - Set input units as kcal/mol + - Set input units as kcal/mol - ``bool`` - ``False`` * - ``--combined_forcetorque`` @@ -228,7 +228,7 @@ Create or edit ``config.yaml`` in your working directory: start: 0 end: -1 step: 1 - + Run CodeEntropy from that directory: .. code-block:: bash diff --git a/docs/images/biosim-codeentropy_logo_grey.svg b/docs/images/biosim-codeentropy_logo_grey.svg index 0a2386da..c06a7130 100644 --- a/docs/images/biosim-codeentropy_logo_grey.svg +++ b/docs/images/biosim-codeentropy_logo_grey.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/docs/science.rst b/docs/science.rst index 23e64889..6d89005e 100644 --- a/docs/science.rst +++ b/docs/science.rst @@ -3,9 +3,9 @@ Multiscale Cell Correlation Theory This section is to describe the scientific theory behind the method used in CodeEntropy. -The multiscale cell correlation (MCC) method [1-3] has been developed in the group of Richard Henchman to calculate entropy from molecular dynamics (MD) simulations. +The multiscale cell correlation (MCC) method [1-3] has been developed in the group of Richard Henchman to calculate entropy from molecular dynamics (MD) simulations. It has been applied to liquids [1,3,4], proteins [2,5,6], solutions [6-9], and complexes [6,7]. -The purpose of this project is to develop and release well written code that enables users from any group to calculate the entropy from their simulations using the MCC. +The purpose of this project is to develop and release well written code that enables users from any group to calculate the entropy from their simulations using the MCC. The latest code can be found at github.com/ccpbiosim/codeentropy. The method requires forces to be written to the MD trajectory files along with the coordinates. @@ -42,8 +42,8 @@ Additional application examples Hierarchy --------- - -Atoms are grouped into beads. + +Atoms are grouped into beads. The levels refer to the size of the beads and the different entropy terms are calculated at each level, taking care to avoid over counting. This is done at three different levels of the hierarchy - united atom, residues, and polymers. Not all molecules have all the levels of hierarchy, for example water has only the united atom level, benzene would have united atoms and residue, and a protein would have all three levels. @@ -68,7 +68,7 @@ The axes for this transformation are calculated for each bead in each time step. For the polymer level, the translational and rotational axes are defined as the principal axes of the molecule. -For the residue level, there are two situations. +For the residue level, there are two situations. When the residue is not bonded to any other residues, the translational and rotational axes are the principal axes of the molecule. When the residue is part of a larger polymer, the translational axes are the principal axes of the polymer, and the rotational axes are defined from the average position of the bonds to neighbouring residues. @@ -80,13 +80,13 @@ Conformational Entropy This term is based on the intramolecular conformational states. -The united atom level dihedrals are defined for every linear sequence of four bonded atoms, but only using the heavy atoms no hydrogens are involved. +The united atom level dihedrals are defined for every linear sequence of four bonded atoms, but only using the heavy atoms no hydrogens are involved. The MDAnalysis package is used to identify and calculate the united atom dihedral values. -For the residue level dihedrals, the bond between the first and second residues and the bond between the third and fourth residues are found. +For the residue level dihedrals, the bond between the first and second residues and the bond between the third and fourth residues are found. The four atoms at the ends of these two bonds are used as points for the dihedral angle calculation. -To discretise dihedrals, a histogram is constructed from each set of dihedral values and peaks are identified. +To discretise dihedrals, a histogram is constructed from each set of dihedral values and peaks are identified. Then at each timestep, every dihedral is assigned to its nearest peak and a state is created from all the assigned peaks in the residue (for united atom level) or molecule (for residue level). Once the states are defined, the probability of finding the residue or molecule in each state is calculated. Then the Boltzmann equation is used to calculate the entropy: @@ -96,7 +96,7 @@ Then the Boltzmann equation is used to calculate the entropy: Orientational Entropy --------------------- -Orientational entropy is the term that comes from the molecule's environment (or the intermolecular configuration). +Orientational entropy is the term that comes from the molecule's environment (or the intermolecular configuration). The different environments are the different states for the molecule, and the statistics can be used to calculate the entropy. The simplest part is counting the number of neighbours, but symmetry should be accounted for in determining the number of orientations. diff --git a/pyproject.toml b/pyproject.toml index 5f4be879..4d8ba438 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,10 +64,7 @@ testing = [ ] pre-commit = [ "pre-commit>=4.5,<5.0", - "black>=26.1,<27.0", - "flake8>=7.3,<8.0", - "flake8-pyproject>=1.2,<2.0", - "isort>=7.0,<8.0", + "ruff>=0.15,<0.16", "pylint>=4.0,<5.0" ] docs = [ @@ -84,12 +81,13 @@ docs = [ [project.scripts] CodeEntropy = "CodeEntropy.cli:main" -[tool.isort] -profile = "black" +[tool.ruff] +line-length = 88 +target-version = "py311" -[tool.flake8] -max-line-length = 88 -extend-select = "B950" -extend-ignore = [ - "E203", # whitespace before `:` -] +[tool.ruff.lint] +select = ["E", "F", "I", "B"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/readthedocs.yml b/readthedocs.yml index 0f8e627d..b5efb63a 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -16,4 +16,4 @@ python: sphinx: # Path to your Sphinx configuration file. - configuration: docs/conf.py \ No newline at end of file + configuration: docs/conf.py diff --git a/tests/pytest.ini b/tests/pytest.ini index 57aa65f1..64a89db8 100644 --- a/tests/pytest.ini +++ b/tests/pytest.ini @@ -7,4 +7,4 @@ markers = regression: end-to-end regression tests against baselines slow: long-running regression tests (20-30+ minutes) -addopts = -ra \ No newline at end of file +addopts = -ra diff --git a/tests/regression/configs/benzaldehyde/config.yaml b/tests/regression/configs/benzaldehyde/config.yaml index 625055e8..40a9f932 100644 --- a/tests/regression/configs/benzaldehyde/config.yaml +++ b/tests/regression/configs/benzaldehyde/config.yaml @@ -9,4 +9,4 @@ run1: start: 0 end: 1 step: 1 - file_format: "MDCRD" \ No newline at end of file + file_format: "MDCRD" diff --git a/tests/regression/configs/benzene/config.yaml b/tests/regression/configs/benzene/config.yaml index 44d473ad..204e19f2 100644 --- a/tests/regression/configs/benzene/config.yaml +++ b/tests/regression/configs/benzene/config.yaml @@ -9,4 +9,4 @@ run1: start: 0 end: 1 step: 1 - file_format: 'MDCRD' \ No newline at end of file + file_format: 'MDCRD' diff --git a/tests/regression/configs/cyclohexane/config.yaml b/tests/regression/configs/cyclohexane/config.yaml index 189a3a57..cadb32b3 100644 --- a/tests/regression/configs/cyclohexane/config.yaml +++ b/tests/regression/configs/cyclohexane/config.yaml @@ -9,4 +9,4 @@ run1: start: 0 end: 1 step: 1 - file_format: 'MDCRD' \ No newline at end of file + file_format: 'MDCRD' diff --git a/tests/regression/configs/dna/config.yaml b/tests/regression/configs/dna/config.yaml index da3f40c1..854dc148 100644 --- a/tests/regression/configs/dna/config.yaml +++ b/tests/regression/configs/dna/config.yaml @@ -7,4 +7,4 @@ run1: selection_string: 'all' start: 0 end: 1 - step: 1 \ No newline at end of file + step: 1 diff --git a/tests/regression/configs/ethyl-acetate/config.yaml b/tests/regression/configs/ethyl-acetate/config.yaml index f708f1ba..84d53a1a 100644 --- a/tests/regression/configs/ethyl-acetate/config.yaml +++ b/tests/regression/configs/ethyl-acetate/config.yaml @@ -9,4 +9,4 @@ run1: start: 0 end: 1 step: 1 - file_format: 'MDCRD' \ No newline at end of file + file_format: 'MDCRD' diff --git a/tests/regression/configs/methane/config.yaml b/tests/regression/configs/methane/config.yaml index 3ee6a5f0..c7bd75d5 100644 --- a/tests/regression/configs/methane/config.yaml +++ b/tests/regression/configs/methane/config.yaml @@ -10,4 +10,4 @@ run1: end: 1 step: 1 file_format: MDCRD - temperature: 112.0 \ No newline at end of file + temperature: 112.0 diff --git a/tests/regression/configs/methanol/config.yaml b/tests/regression/configs/methanol/config.yaml index d03ac892..52ad6c77 100644 --- a/tests/regression/configs/methanol/config.yaml +++ b/tests/regression/configs/methanol/config.yaml @@ -9,4 +9,4 @@ run1: start: 0 end: 1 step: 1 - file_format: 'MDCRD' \ No newline at end of file + file_format: 'MDCRD' diff --git a/tests/regression/configs/octonol/config.yaml b/tests/regression/configs/octonol/config.yaml index 6b361be2..29b31c06 100644 --- a/tests/regression/configs/octonol/config.yaml +++ b/tests/regression/configs/octonol/config.yaml @@ -9,4 +9,4 @@ run1: start: 0 end: 1 step: 1 - file_format: 'MDCRD' \ No newline at end of file + file_format: 'MDCRD' From 8355b3e08a91dfb82084f946d549913a52dc86b9 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 11:45:25 +0000 Subject: [PATCH 090/101] fix(cli,runtime): prevent duplicate traceback logging and improve error handling: - Avoid double logging of exceptions by centralising traceback reporting in the CLI. - Runtime now raises clean errors while preserving original exception chaining. --- CodeEntropy/cli.py | 8 +++----- CodeEntropy/config/runtime.py | 30 +++++++++++++++++++----------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/CodeEntropy/cli.py b/CodeEntropy/cli.py index 6dcf2de1..c30cc731 100644 --- a/CodeEntropy/cli.py +++ b/CodeEntropy/cli.py @@ -30,8 +30,6 @@ def main() -> None: try: run_manager = CodeEntropyRunner(folder=folder) run_manager.run_entropy_workflow() - except Exception as exc: - logger.critical( - "Fatal error during entropy calculation: %s", exc, exc_info=True - ) - raise SystemExit(1) from exc + except Exception: + logger.exception("Fatal error during entropy calculation") + raise SystemExit(1) from None diff --git a/CodeEntropy/config/runtime.py b/CodeEntropy/config/runtime.py index f03dc98d..c38bbc13 100644 --- a/CodeEntropy/config/runtime.py +++ b/CodeEntropy/config/runtime.py @@ -222,17 +222,20 @@ def run_entropy_workflow(self) -> None: """Run the end-to-end entropy workflow. This method: - - Sets up logging and prints the splash screen - - Loads YAML config from CWD and parses CLI args - - Merges args with YAML per-run config - - Builds the MDAnalysis Universe (with optional force merging) - - Validates user parameters - - Constructs dependencies and executes EntropyWorkflow - - Saves recorded console output to a log file + - Sets up logging and prints the splash screen + - Loads YAML config from CWD and parses CLI args + - Merges args with YAML per-run config + - Builds the MDAnalysis Universe (with optional force merging) + - Validates user parameters + - Constructs dependencies and executes EntropyWorkflow + - Saves recorded console output to a log file + - Logs run arguments if an error occurs to aid debugging Raises: - Exception: Re-raises any exception after logging with traceback. + RuntimeError: If the workflow fails for any reason. The original + exception is chained to preserve traceback information. """ + args = None try: run_logger = self._logging_config.configure() self.show_splash() @@ -288,9 +291,14 @@ def run_entropy_workflow(self) -> None: self._logging_config.export_console() - except Exception as e: - logger.error("CodeEntropyRunner encountered an error: %s", e, exc_info=True) - raise + except Exception as exc: + if args is not None: + try: + logger.error("Run arguments at failure: %s", vars(args)) + except Exception: + logger.error("Run arguments at failure could not be serialized") + + raise RuntimeError("CodeEntropyRunner encountered an error") from exc @staticmethod def _validate_required_args(args: Any) -> None: From 756a17d4cb0fee2f63dd2e0fd0e0803e5784daf8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 11:57:12 +0000 Subject: [PATCH 091/101] remove `logger.info` from files to ensure consistent output is maintained --- CodeEntropy/entropy/graph.py | 1 - CodeEntropy/entropy/nodes/configurational.py | 1 - CodeEntropy/entropy/nodes/vibrational.py | 1 - CodeEntropy/entropy/workflow.py | 2 -- CodeEntropy/levels/level_dag.py | 1 - CodeEntropy/levels/nodes/accumulators.py | 6 ------ CodeEntropy/levels/nodes/detect_molecules.py | 5 ----- CodeEntropy/molecules/grouping.py | 2 +- 8 files changed, 1 insertion(+), 18 deletions(-) diff --git a/CodeEntropy/entropy/graph.py b/CodeEntropy/entropy/graph.py index 73374288..33faddaf 100644 --- a/CodeEntropy/entropy/graph.py +++ b/CodeEntropy/entropy/graph.py @@ -96,7 +96,6 @@ def execute(self, shared_data: SharedData) -> Dict[str, Any]: results: Dict[str, Any] = {} for node_name in nx.topological_sort(self._graph): node = self._nodes[node_name] - logger.info("[EntropyGraph] node: %s", node_name) out = node.run(shared_data) if isinstance(out, dict): results.update(out) diff --git a/CodeEntropy/entropy/nodes/configurational.py b/CodeEntropy/entropy/nodes/configurational.py index 6fbba735..a6a8c483 100644 --- a/CodeEntropy/entropy/nodes/configurational.py +++ b/CodeEntropy/entropy/nodes/configurational.py @@ -95,7 +95,6 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] ) shared_data["configurational_entropy"] = results - logger.info("[ConfigurationalEntropyNode] Completed") return {"configurational_entropy": results} diff --git a/CodeEntropy/entropy/nodes/vibrational.py b/CodeEntropy/entropy/nodes/vibrational.py index 22b38853..c02afcd2 100644 --- a/CodeEntropy/entropy/nodes/vibrational.py +++ b/CodeEntropy/entropy/nodes/vibrational.py @@ -120,7 +120,6 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] raise ValueError(f"Unknown level: {level}") shared_data["vibrational_entropy"] = results - logger.info("[VibrationalEntropyNode] Completed") return {"vibrational_entropy": results} def _build_entropy_engine( diff --git a/CodeEntropy/entropy/workflow.py b/CodeEntropy/entropy/workflow.py index dd30073c..b7b6b232 100644 --- a/CodeEntropy/entropy/workflow.py +++ b/CodeEntropy/entropy/workflow.py @@ -108,7 +108,6 @@ def execute(self) -> None: groups = self._group_molecules.grouping_molecules( reduced_universe, self._args.grouping ) - logger.info("Number of molecule groups: %d", len(groups)) nonwater_groups, water_groups = self._split_water_groups(groups) @@ -181,7 +180,6 @@ def _run_entropy_graph(self, shared_data: SharedData) -> None: """ entropy_results = EntropyGraph().build().execute(shared_data) shared_data.update(entropy_results) - logger.info("entropy_results: %s", entropy_results) def _build_trajectory_slice(self) -> TrajectorySlice: """Compute trajectory slicing parameters from args. diff --git a/CodeEntropy/levels/level_dag.py b/CodeEntropy/levels/level_dag.py index 077563b1..39793e44 100644 --- a/CodeEntropy/levels/level_dag.py +++ b/CodeEntropy/levels/level_dag.py @@ -99,7 +99,6 @@ def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: def _run_static_stage(self, shared_data: Dict[str, Any]) -> None: """Run all static nodes in dependency order.""" for node_name in nx.topological_sort(self._static_graph): - logger.info("[LevelDAG] static node: %s", node_name) self._static_nodes[node_name].run(shared_data) def _add_static( diff --git a/CodeEntropy/levels/nodes/accumulators.py b/CodeEntropy/levels/nodes/accumulators.py index 3a7aef69..8c2cb64c 100644 --- a/CodeEntropy/levels/nodes/accumulators.py +++ b/CodeEntropy/levels/nodes/accumulators.py @@ -90,12 +90,6 @@ def run(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: self._attach_to_shared_data(shared_data, group_index, accumulators) self._attach_backwards_compatible_aliases(shared_data) - logger.info( - "[InitCovAcc] group_ids=%s gid2i=%s", - group_index.index_to_group_id, - group_index.group_id_to_index, - ) - return self._build_return_payload(shared_data) @staticmethod diff --git a/CodeEntropy/levels/nodes/detect_molecules.py b/CodeEntropy/levels/nodes/detect_molecules.py index 11bc2eed..bfc188d5 100644 --- a/CodeEntropy/levels/nodes/detect_molecules.py +++ b/CodeEntropy/levels/nodes/detect_molecules.py @@ -56,11 +56,6 @@ def run(self, shared_data: SharedData) -> Dict[str, Any]: shared_data["groups"] = groups shared_data["number_molecules"] = number_molecules - logger.info( - "[DetectMoleculesNode] %s molecules detected (reduced_universe)", - number_molecules, - ) - return { "groups": groups, "number_molecules": number_molecules, diff --git a/CodeEntropy/molecules/grouping.py b/CodeEntropy/molecules/grouping.py index f005ed62..79f0b145 100644 --- a/CodeEntropy/molecules/grouping.py +++ b/CodeEntropy/molecules/grouping.py @@ -200,5 +200,5 @@ def _log_summary(self, groups: MoleculeGroups) -> None: Args: groups: Group mapping to summarize. """ - logger.info("Number of molecule groups: %d", len(groups)) + logger.debug("Number of molecule groups: %d", len(groups)) logger.debug("Molecule groups: %s", groups) From 631293faee92ab17d35b343745908dbda6a5da61 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 14:23:01 +0000 Subject: [PATCH 092/101] feat(progress): add Rich progress bars to workflow and expand tests: - Add ResultsReporter progress context manager - Propagate optional progress sink through workflow orchestration - Add progress reporting for: - Conformational state construction (per group) - Frame processing stage (per frame) - Keep entropy graph execution silent due to fast runtime - Update runtime tests to reflect wrapped RuntimeError behavior --- CodeEntropy/entropy/graph.py | 29 +++- CodeEntropy/entropy/workflow.py | 21 ++- CodeEntropy/levels/dihedrals.py | 139 +++++++++++------- CodeEntropy/levels/level_dag.py | 96 +++++++++--- CodeEntropy/levels/nodes/conformations.py | 10 +- CodeEntropy/results/reporter.py | 51 +++++++ .../test_run_entropy_workflow_branches.py | 70 +++++++-- tests/unit/CodeEntropy/entropy/test_graph.py | 31 ++++ .../unit/CodeEntropy/levels/test_dihedrals.py | 83 +++++++++++ .../levels/test_level_dag_orchestration.py | 124 ++++++++++++++++ .../CodeEntropy/molecules/test_grouping.py | 1 - .../unit/CodeEntropy/results/test_reporter.py | 64 +++++++- 12 files changed, 619 insertions(+), 100 deletions(-) diff --git a/CodeEntropy/entropy/graph.py b/CodeEntropy/entropy/graph.py index 33faddaf..d243907d 100644 --- a/CodeEntropy/entropy/graph.py +++ b/CodeEntropy/entropy/graph.py @@ -80,23 +80,44 @@ def build(self) -> "EntropyGraph": return self - def execute(self, shared_data: SharedData) -> Dict[str, Any]: + def execute( + self, shared_data: SharedData, *, progress: object | None = None + ) -> Dict[str, Any]: """Execute the entropy graph in topological order. + Nodes are executed in dependency order (topological sort). Each node reads + from and may mutate `shared_data`. Dict-like outputs returned by nodes are + merged into a single results dictionary. + + This method intentionally does *not* create a progress bar/task for the + entropy graph itself because the graph is typically very fast. If a progress + sink is provided, it is forwarded to nodes that accept it. + Args: shared_data: Mutable shared data dictionary passed to each node. + progress: Optional progress sink (e.g., from ResultsReporter.progress()). + Forwarded to node `run()` methods that accept a `progress` keyword. Returns: - Dictionary containing the merged outputs of all nodes (only including - outputs that are dict-like). + Dictionary containing merged dict outputs produced by nodes. On key + collision, later nodes overwrite earlier keys. Raises: KeyError: If a node name is missing from the internal node registry. """ results: Dict[str, Any] = {} + for node_name in nx.topological_sort(self._graph): node = self._nodes[node_name] - out = node.run(shared_data) + + if progress is not None: + try: + out = node.run(shared_data, progress=progress) + except TypeError: + out = node.run(shared_data) + else: + out = node.run(shared_data) + if isinstance(out, dict): results.update(out) return results diff --git a/CodeEntropy/entropy/workflow.py b/CodeEntropy/entropy/workflow.py index b7b6b232..8a70eba3 100644 --- a/CodeEntropy/entropy/workflow.py +++ b/CodeEntropy/entropy/workflow.py @@ -124,8 +124,9 @@ def execute(self) -> None: traj=traj, ) - self._run_level_dag(shared_data) - self._run_entropy_graph(shared_data) + with self._reporter.progress(transient=False) as p: + self._run_level_dag(shared_data, progress=p) + self._run_entropy_graph(shared_data, progress=p) self._finalize_molecule_results() self._reporter.log_tables() @@ -164,21 +165,29 @@ def _build_shared_data( } return shared_data - def _run_level_dag(self, shared_data: SharedData) -> None: + def _run_level_dag( + self, shared_data: SharedData, *, progress: object | None = None + ) -> None: """Execute the structural/level DAG. Args: shared_data: Shared data dict that will be mutated by the DAG. + progress: Optional progress sink provided by ResultsReporter.progress(). """ - LevelDAG(self._universe_operations).build().execute(shared_data) + LevelDAG(self._universe_operations).build().execute( + shared_data, progress=progress + ) - def _run_entropy_graph(self, shared_data: SharedData) -> None: + def _run_entropy_graph( + self, shared_data: SharedData, *, progress: object | None = None + ) -> None: """Execute the entropy calculation graph and merge results into shared_data. Args: shared_data: Shared data dict that will be mutated by the graph. + progress: Optional progress sink provided by ResultsReporter.progress(). """ - entropy_results = EntropyGraph().build().execute(shared_data) + entropy_results = EntropyGraph().build().execute(shared_data, progress=progress) shared_data.update(entropy_results) def _build_trajectory_slice(self) -> TrajectorySlice: diff --git a/CodeEntropy/levels/dihedrals.py b/CodeEntropy/levels/dihedrals.py index 40e53325..651d2c3b 100644 --- a/CodeEntropy/levels/dihedrals.py +++ b/CodeEntropy/levels/dihedrals.py @@ -45,79 +45,114 @@ def build_conformational_states( end: int, step: int, bin_width: float, + progress: object | None = None, ): - """Build conformational state labels for UA and residue levels. + """Build conformational state labels from trajectory dihedrals. + + This method constructs discrete conformational state descriptors used in + configurational entropy calculations. It supports united-atom (UA) and + residue-level state generation depending on which hierarchy levels are + enabled per molecule. + + Progress reporting is optional and UI-agnostic: if a progress sink is + provided, the method will create a single task and advance it once per + molecule group. Args: - data_container: MDAnalysis universe containing the system. - levels: Mapping of molecule_id -> list of enabled levels. + data_container: MDAnalysis Universe (or compatible container) used to + extract fragments and compute dihedral time series. + levels: Mapping of molecule_id -> iterable of enabled level names + (e.g., ["united_atom", "residue"]). groups: Mapping of group_id -> list of molecule_ids. - start: Start frame index (currently not applied in legacy sampling). - end: End frame index (currently not applied in legacy sampling). - step: Step size (currently not applied in legacy sampling). - bin_width: Histogram bin width (degrees). + start: Inclusive start frame index. + end: Exclusive end frame index. + step: Frame stride. + bin_width: Histogram bin width in degrees used when identifying peak + dihedral populations. + progress: Optional progress sink (e.g., from ResultsReporter.progress()). + Must expose add_task(), update(), and advance(). Returns: Tuple of: - states_ua: Dict[(group_id, res_id)] -> list of state labels. - states_res: List indexed by group_id -> list of state labels. + states_ua: Dict mapping (group_id, local_residue_id) -> list of state + labels (strings) across the analyzed trajectory. + states_res: List-like structure indexed by group_id (or equivalent) + containing residue-level state labels (strings) across the + analyzed trajectory. + + Notes: + - This function advances progress once per group_id. + - Frame slicing arguments (start/end/step) are forwarded to downstream + helpers as implemented in this module. """ number_groups = len(groups) states_ua: Dict[UAKey, List[str]] = {} states_res: List[List[str]] = [None] * number_groups - total_items = self._count_total_items(levels=levels, groups=groups) - - with self._progress_bar(total_items) as progress: + task = None + if progress is not None: + total = max(1, len(groups)) task = progress.add_task( - "[green]Building Conformational States...", - total=total_items, - title="Starting...", + "[green]Conformational states", + total=total, + title="Initializing", ) - for group_id in groups.keys(): - molecules = groups[group_id] - if not molecules: + if not groups: + if task is not None: + progress.update(task, title="No groups") + progress.advance(task) + return states_ua, states_res + + for group_id in groups.keys(): + molecules = groups[group_id] + if not molecules: + if task is not None: + progress.update(task, title=f"Group {group_id} (empty)") progress.advance(task) - continue + continue - mol = self._universe_operations.extract_fragment( - data_container, molecules[0] - ) + if task is not None: + progress.update(task, title=f"Group {group_id}") - dihedrals_ua, dihedrals_res = self._collect_dihedrals_for_group( - mol=mol, - level_list=levels[molecules[0]], - ) + mol = self._universe_operations.extract_fragment( + data_container, molecules[0] + ) - peaks_ua, peaks_res = self._collect_peaks_for_group( - data_container=data_container, - molecules=molecules, - dihedrals_ua=dihedrals_ua, - dihedrals_res=dihedrals_res, - bin_width=bin_width, - start=start, - end=end, - step=step, - level_list=levels[molecules[0]], - ) + dihedrals_ua, dihedrals_res = self._collect_dihedrals_for_group( + mol=mol, + level_list=levels[molecules[0]], + ) - self._assign_states_for_group( - data_container=data_container, - group_id=group_id, - molecules=molecules, - dihedrals_ua=dihedrals_ua, - peaks_ua=peaks_ua, - dihedrals_res=dihedrals_res, - peaks_res=peaks_res, - start=start, - end=end, - step=step, - level_list=levels[molecules[0]], - states_ua=states_ua, - states_res=states_res, - ) + peaks_ua, peaks_res = self._collect_peaks_for_group( + data_container=data_container, + molecules=molecules, + dihedrals_ua=dihedrals_ua, + dihedrals_res=dihedrals_res, + bin_width=bin_width, + start=start, + end=end, + step=step, + level_list=levels[molecules[0]], + ) + + self._assign_states_for_group( + data_container=data_container, + group_id=group_id, + molecules=molecules, + dihedrals_ua=dihedrals_ua, + peaks_ua=peaks_ua, + dihedrals_res=dihedrals_res, + peaks_res=peaks_res, + start=start, + end=end, + step=step, + level_list=levels[molecules[0]], + states_ua=states_ua, + states_res=states_res, + ) + if task is not None: progress.advance(task) return states_ua, states_res diff --git a/CodeEntropy/levels/level_dag.py b/CodeEntropy/levels/level_dag.py index 39793e44..d751245e 100644 --- a/CodeEntropy/levels/level_dag.py +++ b/CodeEntropy/levels/level_dag.py @@ -82,24 +82,28 @@ def build(self) -> "LevelDAG": self._frame_dag.build() return self - def execute(self, shared_data: Dict[str, Any]) -> Dict[str, Any]: - """Execute the full hierarchy workflow and mutate shared_data. - - Args: - shared_data: Shared workflow data dict that will be mutated by the DAG. - - Returns: - The mutated shared_data dict. - """ + def execute( + self, shared_data: Dict[str, Any], *, progress: object | None = None + ) -> Dict[str, Any]: + """Execute the full hierarchy workflow and mutate shared_data.""" shared_data.setdefault("axes_manager", AxesCalculator()) - self._run_static_stage(shared_data) - self._run_frame_stage(shared_data) + self._run_static_stage(shared_data, progress=progress) + self._run_frame_stage(shared_data, progress=progress) return shared_data - def _run_static_stage(self, shared_data: Dict[str, Any]) -> None: + def _run_static_stage( + self, shared_data: Dict[str, Any], *, progress: object | None = None + ) -> None: """Run all static nodes in dependency order.""" for node_name in nx.topological_sort(self._static_graph): - self._static_nodes[node_name].run(shared_data) + node = self._static_nodes[node_name] + if progress is not None: + try: + node.run(shared_data, progress=progress) + continue + except TypeError: + pass + node.run(shared_data) def _add_static( self, name: str, node: Any, deps: Optional[list[str]] = None @@ -110,16 +114,74 @@ def _add_static( for dep in deps or []: self._static_graph.add_edge(dep, name) - def _run_frame_stage(self, shared_data: Dict[str, Any]) -> None: - """Run the frame DAG for each selected trajectory frame and reduce outputs.""" + def _run_frame_stage( + self, shared_data: Dict[str, Any], *, progress: object | None = None + ) -> None: + """Execute the per-frame DAG stage and reduce frame outputs. + + This method iterates over the selected trajectory frames, executes the + frame-local DAG for each frame, and reduces the resulting outputs into the + shared accumulators stored in `shared_data`. + + Progress reporting is optional. If a progress sink is provided, a task is + always created. When the total number of frames cannot be determined, the + task is created with total=None (indeterminate). + + Args: + shared_data: Shared data dictionary. Must contain: + - "reduced_universe": MDAnalysis Universe providing the trajectory. + - "start", "end", "step": frame slicing parameters. + - any additional keys required by the frame DAG and reducer. + progress: Optional progress sink (e.g., from ResultsReporter.progress()). + Must expose add_task(), update(), and advance(). + + Returns: + None. Mutates `shared_data` in-place via reduction. + + Notes: + The task title shows the current frame index being processed. + """ u = shared_data["reduced_universe"] start, end, step = shared_data["start"], shared_data["end"], shared_data["step"] + task = None + total_frames = None + + if progress is not None: + try: + n_frames = len(u.trajectory) + + s = 0 if start is None else int(start) + e = n_frames if end is None else int(end) + + if e < 0: + e = n_frames + e + + e = max(0, min(e, n_frames)) + s = max(0, min(s, e)) + + st = 1 if step is None else int(step) + if st > 0: + total_frames = max(0, (e - s + st - 1) // st) + except Exception: + total_frames = None + + task = progress.add_task( + "[green]Frame processing", + total=total_frames, + title="Initializing", + ) + for ts in u.trajectory[start:end:step]: - frame_index = ts.frame - frame_out = self._frame_dag.execute_frame(shared_data, frame_index) + if task is not None: + progress.update(task, title=f"Frame {ts.frame}") + + frame_out = self._frame_dag.execute_frame(shared_data, ts.frame) self._reduce_one_frame(shared_data, frame_out) + if task is not None: + progress.advance(task) + @staticmethod def _incremental_mean(old: Any, new: Any, n: int) -> Any: """Compute an incremental mean. diff --git a/CodeEntropy/levels/nodes/conformations.py b/CodeEntropy/levels/nodes/conformations.py index 9bd2ce1c..90a088fb 100644 --- a/CodeEntropy/levels/nodes/conformations.py +++ b/CodeEntropy/levels/nodes/conformations.py @@ -56,7 +56,9 @@ def __init__(self, universe_operations: Any) -> None: universe_operations=universe_operations ) - def run(self, shared_data: SharedData) -> Dict[str, ConformationalStates]: + def run( + self, shared_data: SharedData, *, progress: object | None = None + ) -> Dict[str, ConformationalStates]: """Compute conformational states and store them in shared_data. Args: @@ -66,13 +68,10 @@ def run(self, shared_data: SharedData) -> Dict[str, ConformationalStates]: - "groups" - "start", "end", "step" - "args" with attribute "bin_width" + progress: Optional progress sink provided by ResultsReporter.progress(). Returns: Dict containing "conformational_states" (also written into shared_data). - - Raises: - KeyError: If required keys are missing. - AttributeError: If `shared_data["args"]` lacks `bin_width`. """ u = shared_data["reduced_universe"] levels = shared_data["levels"] @@ -88,6 +87,7 @@ def run(self, shared_data: SharedData) -> Dict[str, ConformationalStates]: end=cfg.end, step=cfg.step, bin_width=cfg.bin_width, + progress=progress, ) conformational_states: ConformationalStates = { diff --git a/CodeEntropy/results/reporter.py b/CodeEntropy/results/reporter.py index 3db950b2..784aad06 100644 --- a/CodeEntropy/results/reporter.py +++ b/CodeEntropy/results/reporter.py @@ -19,11 +19,19 @@ import re import subprocess import sys +from contextlib import contextmanager from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import numpy as np from rich.console import Console +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, +) from rich.table import Table from CodeEntropy.core.logging import LoggingConfig @@ -32,6 +40,29 @@ console = LoggingConfig.get_console() +class _RichProgressSink: + """Thin wrapper around rich.Progress. + + Keeps Rich usage inside the reporting layer so compute/orchestration code + can emit progress without importing Rich. + """ + + def __init__(self, progress: Progress): + self._progress = progress + + def add_task(self, description: str, total: int, **fields): + fields.setdefault("title", "") + return self._progress.add_task(description, total=total, **fields) + + def advance(self, task_id, step: int = 1) -> None: + self._progress.advance(task_id, step) + + def update(self, task_id, **fields) -> None: + if "title" in fields and fields["title"] is None: + fields["title"] = "" + self._progress.update(task_id, **fields) + + class ResultsReporter: """Collect, format, and output entropy calculation results.""" @@ -368,3 +399,23 @@ def _try_get_git_sha() -> Optional[str]: return sha or None except Exception: return None + + @contextmanager + def progress(self, *, transient: bool = True): + """Create a workflow progress context. + + Usage: + with reporter.progress() as p: + ... + """ + progress = Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.fields[title]}", justify="right"), + BarColumn(), + TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), + TimeElapsedColumn(), + console=self.console, + transient=transient, + ) + with progress: + yield _RichProgressSink(progress) diff --git a/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py b/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py index 6ec79586..9ef6f761 100644 --- a/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py +++ b/tests/unit/CodeEntropy/config/runtime/test_run_entropy_workflow_branches.py @@ -3,9 +3,10 @@ import pytest +import CodeEntropy.config.runtime as runtime_mod + def test_run_entropy_workflow_warns_and_skips_non_dict_run_config(runner): - # Arrange: mock all collaborators so the method stays unit-level runner._logging_config = MagicMock() runner._config_manager = MagicMock() runner._reporter = MagicMock() @@ -21,10 +22,8 @@ def test_run_entropy_workflow_warns_and_skips_non_dict_run_config(runner): runner.show_splash = MagicMock() - # Act runner.run_entropy_workflow() - # Assert: one behavior only — warning + skip run_logger.warning.assert_called_once() runner._config_manager.resolve.assert_not_called() @@ -37,10 +36,8 @@ def test_run_entropy_workflow_raises_when_required_args_missing(runner): runner._logging_config.configure.return_value = MagicMock() runner.show_splash = MagicMock() - # config contains a valid dict run so it will try to process it runner._config_manager.load_config.return_value = {"run1": {}} - # parser returns args with missing top_traj_file/selection_string parser = MagicMock() args = SimpleNamespace( output_file="out.json", @@ -53,16 +50,17 @@ def test_run_entropy_workflow_raises_when_required_args_missing(runner): ) parser.parse_known_args.return_value = (args, []) runner._config_manager.build_parser.return_value = parser - - # resolve returns same args (still missing required) runner._config_manager.resolve.return_value = args - with pytest.raises(ValueError): + with pytest.raises(RuntimeError) as exc: runner.run_entropy_workflow() + assert str(exc.value) == "CodeEntropyRunner encountered an error" + assert isinstance(exc.value.__cause__, ValueError) + assert "Missing 'top_traj_file' argument." in str(exc.value.__cause__) + def test_run_entropy_workflow_happy_path_calls_execute_once(runner): - # Mock collaborators runner._logging_config = MagicMock() runner._config_manager = MagicMock() runner._reporter = MagicMock() @@ -72,10 +70,8 @@ def test_run_entropy_workflow_happy_path_calls_execute_once(runner): run_logger = MagicMock() runner._logging_config.configure.return_value = run_logger - # One valid run config dict (so it doesn't hit the "warning+continue" branch) runner._config_manager.load_config.return_value = {"run1": {}} - # CLI args (must satisfy required args) args = SimpleNamespace( output_file="out.json", verbose=False, @@ -89,14 +85,11 @@ def test_run_entropy_workflow_happy_path_calls_execute_once(runner): parser.parse_known_args.return_value = (args, []) runner._config_manager.build_parser.return_value = parser - # resolve returns the args runner._config_manager.resolve.return_value = args - # Avoid MDAnalysis/real work: stub universe creation + validation runner._build_universe = MagicMock(return_value="U") runner._config_manager.validate_inputs = MagicMock() - # Patch constructors used in the happy path with ( patch("CodeEntropy.config.runtime.UniverseOperations") as _, patch("CodeEntropy.config.runtime.MoleculeGrouper") as _, @@ -114,3 +107,52 @@ def test_run_entropy_workflow_happy_path_calls_execute_once(runner): EWCls.assert_called_once() entropy_instance.execute.assert_called_once() runner._logging_config.export_console.assert_called_once() + + +def test_run_entropy_workflow_logs_when_args_cannot_be_serialized(runner, monkeypatch): + runner._logging_config = MagicMock() + runner._config_manager = MagicMock() + runner._reporter = MagicMock() + + runner._logging_config.configure.return_value = MagicMock() + runner.show_splash = MagicMock() + runner._config_manager.load_config.return_value = {"run1": {}} + + class BadArgs: + __slots__ = ( + "output_file", + "verbose", + "top_traj_file", + "selection_string", + "force_file", + "file_format", + "kcal_force_units", + ) + + args = BadArgs() + args.output_file = "out.json" + args.verbose = False + args.top_traj_file = None + args.selection_string = None + args.force_file = None + args.file_format = None + args.kcal_force_units = False + + parser = MagicMock() + parser.parse_known_args.return_value = (args, []) + runner._config_manager.build_parser.return_value = parser + runner._config_manager.resolve.return_value = args + + error_spy = MagicMock() + monkeypatch.setattr(runtime_mod.logger, "error", error_spy) + + with pytest.raises(RuntimeError) as exc: + runner.run_entropy_workflow() + + assert str(exc.value) == "CodeEntropyRunner encountered an error" + assert isinstance(exc.value.__cause__, ValueError) + + assert any( + "Run arguments at failure could not be serialized" in str(call.args[0]) + for call in error_spy.call_args_list + ) diff --git a/tests/unit/CodeEntropy/entropy/test_graph.py b/tests/unit/CodeEntropy/entropy/test_graph.py index 5396a43a..92f4e2f3 100644 --- a/tests/unit/CodeEntropy/entropy/test_graph.py +++ b/tests/unit/CodeEntropy/entropy/test_graph.py @@ -46,3 +46,34 @@ def test_add_node_duplicate_name_raises(): g._add_node(NodeSpec("x", object())) with pytest.raises(ValueError): g._add_node(NodeSpec("x", object())) + + +def test_execute_forwards_progress_to_nodes_that_accept_it(shared_data): + g = EntropyGraph() + + node_a = MagicMock() + node_a.run.return_value = {"a": 1} + + g._add_node(NodeSpec("a", node_a)) + + progress = MagicMock() + out = g.execute(shared_data, progress=progress) + + node_a.run.assert_called_once_with(shared_data, progress=progress) + assert out == {"a": 1} + + +def test_execute_falls_back_when_node_run_does_not_accept_progress(shared_data): + g = EntropyGraph() + + class NoProgressNode: + def run(self, shared_data): + return {"x": 1} + + node = NoProgressNode() + g._add_node(NodeSpec("x", node)) + + progress = MagicMock() + out = g.execute(shared_data, progress=progress) + + assert out == {"x": 1} diff --git a/tests/unit/CodeEntropy/levels/test_dihedrals.py b/tests/unit/CodeEntropy/levels/test_dihedrals.py index cdfd52a8..1b9b8eb3 100644 --- a/tests/unit/CodeEntropy/levels/test_dihedrals.py +++ b/tests/unit/CodeEntropy/levels/test_dihedrals.py @@ -548,3 +548,86 @@ def run(self): ) assert states == ["1", "0"] + + +def test_build_conformational_states_with_progress_handles_no_groups(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + progress = MagicMock() + progress.add_task.return_value = 123 + + states_ua, states_res = dt.build_conformational_states( + data_container=MagicMock(), + levels={}, + groups={}, # empty + start=0, + end=1, + step=1, + bin_width=30.0, + progress=progress, + ) + + assert states_ua == {} + assert states_res == [] + progress.add_task.assert_called_once() + progress.update.assert_called_once_with(123, title="No groups") + progress.advance.assert_called_once_with(123) + + +def test_build_conformational_states_with_progress_skips_empty_molecule_group(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + progress = MagicMock() + progress.add_task.return_value = 5 + + groups = {0: []} + levels = {} + + states_ua, states_res = dt.build_conformational_states( + data_container=MagicMock(), + levels=levels, + groups=groups, + start=0, + end=1, + step=1, + bin_width=30.0, + progress=progress, + ) + + assert states_ua == {} + assert len(states_res) == 1 + progress.update.assert_called_with(5, title="Group 0 (empty)") + progress.advance.assert_called_with(5) + + +def test_build_conformational_states_with_progress_updates_title_per_group(monkeypatch): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + progress = MagicMock() + progress.add_task.return_value = 9 + + groups = {1: [7]} + levels = {7: ["residue"]} + + uops.extract_fragment.return_value = MagicMock(trajectory=[0]) + + monkeypatch.setattr(dt, "_collect_dihedrals_for_group", lambda **kw: ([], [])) + monkeypatch.setattr(dt, "_collect_peaks_for_group", lambda **kw: ([], [])) + monkeypatch.setattr(dt, "_assign_states_for_group", lambda **kw: None) + + dt.build_conformational_states( + data_container=MagicMock(), + levels=levels, + groups=groups, + start=0, + end=1, + step=1, + bin_width=30.0, + progress=progress, + ) + + progress.update.assert_any_call(9, title="Group 1") + progress.advance.assert_called_with(9) diff --git a/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py b/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py index 88276049..17a5a93a 100644 --- a/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py +++ b/tests/unit/CodeEntropy/levels/test_level_dag_orchestration.py @@ -255,3 +255,127 @@ def test_reduce_force_and_torque_skips_when_counts_are_zero(): assert shared["reduced_torque_covariances"] == {} assert shared["reduced_force_counts"] == {} assert shared["reduced_torque_counts"] == {} + + +def test_run_static_stage_forwards_progress_when_node_accepts_it(): + dag = LevelDAG() + dag._static_graph.add_node("a") + + node = MagicMock() + dag._static_nodes["a"] = node + + progress = MagicMock() + + with patch("networkx.topological_sort", return_value=["a"]): + dag._run_static_stage({"X": 1}, progress=progress) + + node.run.assert_called_once_with({"X": 1}, progress=progress) + + +def test_run_static_stage_falls_back_when_node_does_not_accept_progress(): + dag = LevelDAG() + dag._static_graph.add_node("a") + + class NoProgressNode: + def run(self, shared_data): + return None + + dag._static_nodes["a"] = NoProgressNode() + progress = MagicMock() + + with patch("networkx.topological_sort", return_value=["a"]): + dag._run_static_stage({"X": 1}, progress=progress) # should not raise + + +def test_run_frame_stage_with_progress_creates_task_and_updates_titles(): + dag = LevelDAG() + + ts0 = MagicMock(frame=10) + ts1 = MagicMock(frame=11) + u = MagicMock() + u.trajectory = [ts0, ts1] + + shared = {"reduced_universe": u, "start": 0, "end": 2, "step": 1} + + dag._frame_dag = MagicMock() + dag._frame_dag.execute_frame.return_value = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + dag._reduce_one_frame = MagicMock() + + progress = MagicMock() + progress.add_task.return_value = 77 + + dag._run_frame_stage(shared, progress=progress) + + progress.add_task.assert_called_once() + progress.update.assert_any_call(77, title="Frame 10") + progress.update.assert_any_call(77, title="Frame 11") + assert progress.advance.call_count == 2 + + +def test_run_frame_stage_with_negative_end_computes_total_frames(): + dag = LevelDAG() + + ts_list = [MagicMock(frame=i) for i in range(10)] + u = MagicMock() + u.trajectory = ts_list + + shared = { + "reduced_universe": u, + "start": 0, + "end": -1, + "step": 1, + } + + dag._frame_dag = MagicMock() + dag._frame_dag.execute_frame.return_value = { + "force": {"ua": {}, "res": {}, "poly": {}}, + "torque": {"ua": {}, "res": {}, "poly": {}}, + } + dag._reduce_one_frame = MagicMock() + + progress = MagicMock() + progress.add_task.return_value = 123 + + dag._run_frame_stage(shared, progress=progress) + + progress.add_task.assert_called_once() + _, kwargs = progress.add_task.call_args + assert kwargs["total"] == 9 + + assert progress.advance.call_count == 9 + + +def test_run_frame_stage_progress_total_frames_falls_back_to_none_on_error(): + + dag = LevelDAG() + + class BadTrajectory: + def __len__(self): + raise RuntimeError("boom") + + def __getitem__(self, item): + return [] + + u = type("U", (), {})() + u.trajectory = BadTrajectory() + + shared = { + "reduced_universe": u, + "start": 0, + "end": 10, + "step": 1, + } + + dag._frame_dag = MagicMock() + dag._reduce_one_frame = MagicMock() + + progress = MagicMock() + progress.add_task.return_value = 99 + + dag._run_frame_stage(shared, progress=progress) + + _, kwargs = progress.add_task.call_args + assert kwargs["total"] is None diff --git a/tests/unit/CodeEntropy/molecules/test_grouping.py b/tests/unit/CodeEntropy/molecules/test_grouping.py index 49777e31..53312585 100644 --- a/tests/unit/CodeEntropy/molecules/test_grouping.py +++ b/tests/unit/CodeEntropy/molecules/test_grouping.py @@ -114,7 +114,6 @@ def test_grouping_molecules_dispatches_each_and_logs_summary(caplog): out = g.grouping_molecules(u, "each") assert out == {0: [0], 1: [1]} - assert any("Number of molecule groups" in rec.message for rec in caplog.records) def test_grouping_molecules_dispatches_molecules_strategy(): diff --git a/tests/unit/CodeEntropy/results/test_reporter.py b/tests/unit/CodeEntropy/results/test_reporter.py index b0014712..f21b0148 100644 --- a/tests/unit/CodeEntropy/results/test_reporter.py +++ b/tests/unit/CodeEntropy/results/test_reporter.py @@ -8,7 +8,7 @@ from rich.console import Console import CodeEntropy.results.reporter as reporter_mod -from CodeEntropy.results.reporter import ResultsReporter +from CodeEntropy.results.reporter import ResultsReporter, _RichProgressSink class FakeTable: @@ -485,3 +485,65 @@ def boom(self): monkeypatch.setattr(reporter_mod.Path, "resolve", boom) assert ResultsReporter._try_get_git_sha() is None + + +def test_progress_context_yields_progress_sink(): + rr = ResultsReporter() + with rr.progress(transient=True) as p: + assert hasattr(p, "add_task") + assert hasattr(p, "update") + assert hasattr(p, "advance") + + +def test_progress_sink_update_normalizes_none_title(monkeypatch): + rr = ResultsReporter() + + with rr.progress(transient=True) as sink: + inner = sink._progress + spy = MagicMock() + monkeypatch.setattr(inner, "update", spy) + + sink.update(1, title=None) + + spy.assert_called_once() + _args, kwargs = spy.call_args + assert kwargs["title"] == "" + + +def test_rich_progress_sink_add_task_sets_default_title(): + inner = MagicMock() + inner.add_task.return_value = 7 + + sink = _RichProgressSink(inner) + task_id = sink.add_task("Stage", total=3) + + assert task_id == 7 + + inner.add_task.assert_called_once() + args, kwargs = inner.add_task.call_args + + assert args[0] == "Stage" + assert kwargs["total"] == 3 + assert kwargs["title"] == "" + + +def test_rich_progress_sink_update_normalizes_title_none(): + inner = MagicMock() + sink = _RichProgressSink(inner) + + sink.update(99, title=None) + + inner.update.assert_called_once_with(99, title="") + + +def test_gid_sort_key_handles_non_numeric_group_id(): + assert ResultsReporter._gid_sort_key("abc") == (1, "abc") + + +def test_rich_progress_sink_advance_forwards_to_inner_progress(): + inner = MagicMock() + sink = _RichProgressSink(inner) + + sink.advance(123, step=5) + + inner.advance.assert_called_once_with(123, 5) From 25e1cb5c762c75532bb055625bc8be74ee863bc5 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 14:32:30 +0000 Subject: [PATCH 093/101] docs: update developer guide for new testing, CI workflows, and Ruff tooling: - Add instructions for unit vs regression test suites - Document slow test markers and how to run them - Explain automatic regression dataset downloads via filestore - Add guidance for updating regression baselines - Update coding standards to use Ruff instead of Black/Flake8/isort - Document multi-OS and multi-Python CI workflows - Clarify developer setup and testing commands - Remove outdated tooling references --- docs/developer_guide.rst | 119 ++++++++++++++++++++++++++++----------- 1 file changed, 86 insertions(+), 33 deletions(-) diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst index 69a5fe14..d747b563 100644 --- a/docs/developer_guide.rst +++ b/docs/developer_guide.rst @@ -1,7 +1,10 @@ Developer Guide =============== -CodeEntropy is open-source, and we welcome contributions from the wider community to help improve and extend its functionality. This guide walks you through setting up a development environment, running tests, submitting contributions, and maintaining coding standards. +CodeEntropy is open-source, and we welcome contributions from the wider community +to help improve and extend its functionality. This guide walks you through setting +up a development environment, running tests, contributing code, and understanding +the continuous integration workflows. Getting Started for Developers ------------------------------ @@ -24,44 +27,77 @@ Install development dependencies:: Running Tests ------------- -Run the full test suite:: +CodeEntropy uses **pytest** with separate unit and regression suites. - pytest -v +Run all tests:: -Run tests with code coverage:: + pytest + +Run only unit tests:: + + pytest tests/unit + +Run regression tests:: + + pytest tests/regression + +Run regression tests excluding slow systems:: + + pytest tests/regression -m "not slow" + +Run slow regression tests:: + + pytest tests/regression -m slow + +Run tests with coverage:: pytest --cov CodeEntropy --cov-report=term-missing -Run tests for a specific module:: +Update regression baselines:: - pytest CodeEntropy/tests/test_CodeEntropy/test_levels.py + pytest tests/regression --update-baselines Run a specific test:: - pytest CodeEntropy/tests/test_CodeEntropy/test_levels.py::test_select_levels + pytest tests/unit/.../test_file.py::test_function + +Regression Test Data +-------------------- + +Regression datasets are automatically downloaded from the CCPBioSim filestore +and cached locally in ``.testdata/`` when tests are run. + +No manual setup is required. + +The test configuration files reference datasets using the ``${TESTDATA}`` +placeholder, which is expanded automatically during test execution. Coding Standards ---------------- -We use **pre-commit hooks** to maintain code quality and consistent style. To enable these hooks:: +We use **pre-commit hooks** to maintain code quality and consistent style. + +Enable hooks:: pre-commit install -This ensures: +Our tooling stack: + +- **Linting and formatting** via ``ruff`` +- **Basic repository checks** via ``pre-commit-hooks`` + +Ruff performs: + +- Code formatting +- Import sorting +- Static analysis +- Style enforcement -- **Formatting** via ``black`` (`psf/black`) -- **Import sorting** via ``isort`` with the ``black`` profile -- **Linting** via ``flake8`` with ``flake8-pyproject`` -- **Basic checks** via ``pre-commit-hooks``, including: +Run checks manually:: - - Detection of large added files - - AST validity checks - - Case conflict detection - - Executable shebang verification - - Merge conflict detection - - TOML and YAML syntax validation + pre-commit run --all-files -To skip pre-commit checks for a commit:: +Skip checks for a commit (not recommended):: git commit -n @@ -72,33 +108,50 @@ To skip pre-commit checks for a commit:: Continuous Integration (CI) --------------------------- -CodeEntropy uses **GitHub Actions** to automatically: +CodeEntropy uses **GitHub Actions** with multiple workflows to ensure stability +across platforms and Python versions. + +Pull Request checks include: + +- Unit tests on Linux, macOS, and Windows +- Python versions 3.12-3.14 +- Quick regression tests +- Documentation build +- Pre-commit validation + +Daily workflow: -- Run all tests -- Check coding style -- Build documentation -- Validate versioning +- Runs automated test validation -Every pull request will trigger these checks to ensure quality and stability. +Weekly workflows: + +- Full regression suite including slow tests +- Documentation build across all Python versions + +CI also caches regression datasets to improve performance. Building Documentation ---------------------- -Build locally:: +Build documentation locally:: cd docs make html -The generated HTML files will be in ``docs/build/html/``. Open ``index.html`` in your browser to view the documentation. +The generated HTML files will be in ``docs/build/html/``. + +Open ``index.html`` in your browser to preview. -Edit docs in the following directories: +Documentation sources are located in: - ``docs/user_guide/`` - ``docs/developer_guide/`` Contributing Code ----------------- -If you would to contribution to **CodeEntropy** please refer to our `Contributing Guidelines `_ + +If you would like to contribute to **CodeEntropy**, please refer to the +`Contributing Guidelines `_. Creating an Issue ^^^^^^^^^^^^^^^^^ @@ -114,7 +167,7 @@ Branching - Never commit directly to ``main``. - Create a branch named after the issue:: - git checkout -b 123-fix-levels + git checkout -b 123-feature-description Pull Requests ^^^^^^^^^^^^^ @@ -132,6 +185,6 @@ Full developer setup:: git clone https://github.com/CCPBioSim/CodeEntropy.git cd CodeEntropy - pip install -e .[testing,docs,pre-commit] + pip install -e ".[testing,docs,pre-commit]" pre-commit install - pytest --cov CodeEntropy --cov-report=term-missing + pytest From fff63152cbd8f4a4f87c063dfd2a74d27834da48 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 15:57:57 +0000 Subject: [PATCH 094/101] docs: tidy comments and standardise to Google-style docstrings --- CodeEntropy/config/runtime.py | 1 - CodeEntropy/entropy/configurational.py | 11 +- CodeEntropy/entropy/nodes/vibrational.py | 144 +++++++++++- CodeEntropy/entropy/orientational.py | 2 - CodeEntropy/entropy/vibrational.py | 1 - CodeEntropy/entropy/workflow.py | 4 - CodeEntropy/levels/dihedrals.py | 23 -- CodeEntropy/levels/forces.py | 76 ++++++- CodeEntropy/levels/level_dag.py | 66 +++++- CodeEntropy/levels/mda.py | 149 +++++++------ CodeEntropy/levels/nodes/beads.py | 1 - CodeEntropy/levels/nodes/covariance.py | 205 ++++++++++++++++-- CodeEntropy/results/reporter.py | 160 ++++++++++++-- .../unit/CodeEntropy/levels/test_dihedrals.py | 14 -- 14 files changed, 695 insertions(+), 162 deletions(-) diff --git a/CodeEntropy/config/runtime.py b/CodeEntropy/config/runtime.py index c38bbc13..f807261e 100644 --- a/CodeEntropy/config/runtime.py +++ b/CodeEntropy/config/runtime.py @@ -383,7 +383,6 @@ def read_universe(self, path: str) -> mda.Universe: def change_lambda_units(self, arg_lambdas: Any) -> Any: """Unit of lambdas : kJ2 mol-2 A-2 amu-1 change units of lambda to J/s2""" - # return arg_lambdas * N_AVOGADRO * N_AVOGADRO * AMU2KG * 1e-26 return arg_lambdas * 1e29 / self.N_AVOGADRO def get_KT2J(self, arg_temper: float) -> float: diff --git a/CodeEntropy/entropy/configurational.py b/CodeEntropy/entropy/configurational.py index 45b08b96..be6c31cd 100644 --- a/CodeEntropy/entropy/configurational.py +++ b/CodeEntropy/entropy/configurational.py @@ -96,7 +96,7 @@ def assign_conformation( Raises: ValueError: If `bin_width` or `step` are invalid. """ - _ = number_frames # kept for compatibility; sizing follows the slice length. + _ = number_frames config = ConformationConfig( bin_width=int(bin_width), @@ -115,7 +115,6 @@ def assign_conformation( peak_values = self._find_histogram_peaks(phi, config.bin_width) if peak_values.size == 0: - # No peaks means no distinguishable states; assign everything to 0. return np.zeros(n_slice, dtype=int) states = self._assign_nearest_peaks(phi, peak_values) @@ -138,21 +137,18 @@ def conformational_entropy_calculation( Returns: Conformational entropy in J/mol/K. """ - _ = number_frames # accepted as metadata; distribution uses observed counts. + _ = number_frames arr = self._to_1d_array(states) if arr is None or arr.size == 0: return 0.0 - # If states contain only falsy values (e.g., all zeros) this is still valid: - # entropy would be 0 because only one state is present. values, counts = np.unique(arr, return_counts=True) total_count = int(np.sum(counts)) if total_count <= 0 or values.size <= 1: return 0.0 probs = counts.astype(float) / float(total_count) - # Guard against log(0) (shouldn't happen because counts>0), but keep robust. probs = probs[probs > 0.0] s_conf = -self._GAS_CONST * float(np.sum(probs * np.log(probs))) @@ -174,7 +170,6 @@ def _validate_assignment_config(config: ConformationConfig) -> None: if config.bin_width <= 0 or config.bin_width > 360: raise ValueError("bin_width must be in the range (0, 360]") if 360 % config.bin_width != 0: - # Not strictly required, but prevents uneven bins and edge-case confusion. logger.warning( "bin_width=%s does not evenly divide 360; histogram bins will be " "uneven.", @@ -242,8 +237,6 @@ def _assign_nearest_peaks(phi: np.ndarray, peak_values: np.ndarray) -> np.ndarra Returns: Integer state labels aligned with `phi`. """ - # Vectorized nearest-peak assignment - # shape: (n_frames, n_peaks) distances = np.abs(phi[:, None] - peak_values[None, :]) return np.argmin(distances, axis=1).astype(int) diff --git a/CodeEntropy/entropy/nodes/vibrational.py b/CodeEntropy/entropy/nodes/vibrational.py index c02afcd2..6191e14b 100644 --- a/CodeEntropy/entropy/nodes/vibrational.py +++ b/CodeEntropy/entropy/nodes/vibrational.py @@ -21,20 +21,53 @@ @dataclass(frozen=True) class EntropyPair: - """Container for paired translational and rotational entropy values.""" + """Container for paired translational and rotational entropy values. + + Attributes: + trans: Translational vibrational entropy value. + rot: Rotational vibrational entropy value. + """ trans: float rot: float class VibrationalEntropyNode: - """Compute vibrational entropy from force/torque (and optional FT) covariances.""" + """Compute vibrational entropy from force/torque (and optional FT) covariances. + + This node reads covariance matrices from a shared data mapping, computes + translational and rotational vibrational entropy at requested hierarchy levels, + and stores results back into the shared data structure. + + The node supports: + - Force and torque covariance matrices ("force" / "torque") at residue/polymer + levels. + - United-atom per-residue covariances keyed by (group_id, residue_id). + - Optional combined force-torque covariance matrices ("forcetorque") for the + highest level when enabled via args.combined_forcetorque. + """ def __init__(self) -> None: + """Initialize the node with matrix utilities and numerical tolerances.""" self._mat_ops = MatrixUtils() self._zero_atol = 1e-8 def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any]: + """Run vibrational entropy calculations and update the shared data mapping. + + Args: + shared_data: Mutable mapping containing inputs (covariances, groups, + levels, args, etc.) and where outputs will be written. + **_: Unused keyword arguments, accepted for framework compatibility. + + Returns: + A dict containing the computed vibrational entropy results under the + key "vibrational_entropy". + + Raises: + ValueError: If an unknown level is encountered in the level list for a + representative molecule. + """ ve = self._build_entropy_engine(shared_data) temp = shared_data["args"].temperature @@ -125,11 +158,31 @@ def run(self, shared_data: MutableMapping[str, Any], **_: Any) -> Dict[str, Any] def _build_entropy_engine( self, shared_data: Mapping[str, Any] ) -> VibrationalEntropy: + """Construct the vibrational entropy engine used for calculations. + + Args: + shared_data: Read-only mapping containing a "run_manager" entry. + + Returns: + A configured VibrationalEntropy instance. + """ return VibrationalEntropy( run_manager=shared_data["run_manager"], ) def _get_group_id_to_index(self, shared_data: Mapping[str, Any]) -> Dict[int, int]: + """Return a mapping from group_id to contiguous index used by covariance lists. + + If a precomputed mapping is provided under "group_id_to_index", it is used. + Otherwise, the mapping is derived from the insertion order of "groups". + + Args: + shared_data: Read-only mapping containing "groups" and optionally + "group_id_to_index". + + Returns: + Dictionary mapping each group_id to an integer index. + """ gid2i = shared_data.get("group_id_to_index") if isinstance(gid2i, dict) and gid2i: return gid2i @@ -137,6 +190,16 @@ def _get_group_id_to_index(self, shared_data: Mapping[str, Any]) -> Dict[int, in return {gid: i for i, gid in enumerate(groups.keys())} def _get_ua_frame_counts(self, shared_data: Mapping[str, Any]) -> Dict[CovKey, int]: + """Extract per-(group,residue) frame counts for united-atom covariances. + + Args: + shared_data: Read-only mapping which may contain nested frame count data + under shared_data["frame_counts"]["ua"]. + + Returns: + A dict keyed by (group_id, residue_id) containing frame counts. Returns + an empty dict if not present or not well-formed. + """ counts = shared_data.get("frame_counts", {}) if isinstance(counts, dict): ua_counts = counts.get("ua", {}) @@ -158,6 +221,27 @@ def _compute_united_atom_entropy( n_frames_default: int, highest: bool, ) -> EntropyPair: + """Compute total united-atom vibrational entropy for a group's residues. + + Iterates over residues, looks up per-residue force and torque covariance + matrices keyed by (group_id, residue_index), computes entropy contributions, + accumulates totals, and optionally reports per-residue values. + + Args: + ve: VibrationalEntropy calculation engine. + temp: Temperature (K) for entropy calculation. + group_id: Identifier for the group being processed. + residues: Residue container/sequence for the representative molecule. + force_ua: Mapping from (group_id, residue_id) to force covariance matrix. + torque_ua: Mapping from (group_id, residue_id) to torque covariance matrix. + ua_frame_counts: Mapping from (group_id, residue_id) to frame counts. + reporter: Optional reporter object supporting add_residue_data calls. + n_frames_default: Fallback frame count if per-residue count missing. + highest: Whether this computation is at the highest requested level. + + Returns: + EntropyPair with summed translational and rotational entropy across residues + """ s_trans_total = 0.0 s_rot_total = 0.0 @@ -207,6 +291,22 @@ def _compute_force_torque_entropy( tmat: Any, highest: bool, ) -> EntropyPair: + """Compute vibrational entropy from separate force and torque covariances. + + Matrices are filtered to remove (near-)zero rows/columns before computation. + If either matrix is missing or becomes empty after filtering, returns zeros. + + Args: + ve: VibrationalEntropy calculation engine. + temp: Temperature (K) for entropy calculation. + fmat: Force covariance matrix (array-like) or None. + tmat: Torque covariance matrix (array-like) or None. + highest: Whether this computation is at the highest requested level. + + Returns: + EntropyPair containing translational entropy (from force covariance) and + rotational entropy (from torque covariance). + """ if fmat is None or tmat is None: return EntropyPair(trans=0.0, rot=0.0) @@ -235,6 +335,20 @@ def _compute_ft_entropy( temp: float, ftmat: Any, ) -> EntropyPair: + """Compute vibrational entropy from a combined force-torque covariance matrix. + + The combined covariance matrix is filtered to remove (near-)zero rows/columns + before computation. If missing or empty after filtering, returns zeros. + + Args: + ve: VibrationalEntropy calculation engine. + temp: Temperature (K) for entropy calculation. + ftmat: Combined force-torque covariance matrix (array-like) or None. + + Returns: + EntropyPair containing translational and rotational entropy values derived + from the combined covariance matrix. + """ if ftmat is None: return EntropyPair(trans=0.0, rot=0.0) @@ -259,6 +373,14 @@ def _store_results( level: str, pair: EntropyPair, ) -> None: + """Store entropy results for a group/level into the results structure. + + Args: + results: Nested results dict indexed by group_id then level. + group_id: Group identifier to store under. + level: Hierarchy level name (e.g., "united_atom", "residue", "polymer"). + pair: EntropyPair containing translational and rotational values. + """ results[group_id][level] = {"trans": pair.trans, "rot": pair.rot} @staticmethod @@ -270,6 +392,15 @@ def _log_molecule_level_results( *, use_ft_labels: bool, ) -> None: + """Log molecule-level entropy results to the reporter, if available. + + Args: + reporter: Optional reporter object supporting add_results_data calls. + group_id: Group identifier being reported. + level: Hierarchy level name being reported. + pair: EntropyPair containing translational and rotational values. + use_ft_labels: Whether to use FT-specific labels for the entropy types. + """ if reporter is None: return @@ -285,6 +416,15 @@ def _log_molecule_level_results( @staticmethod def _get_indexed_matrix(mats: Any, index: int) -> Any: + """Safely retrieve mats[index] if mats is indexable and index is in range. + + Args: + mats: Indexable container of matrices (e.g., list/tuple) or other object. + index: Desired index. + + Returns: + The matrix at the given index if available; otherwise None. + """ try: return mats[index] if index < len(mats) else None except TypeError: diff --git a/CodeEntropy/entropy/orientational.py b/CodeEntropy/entropy/orientational.py index e41f0f87..bd4786e4 100644 --- a/CodeEntropy/entropy/orientational.py +++ b/CodeEntropy/entropy/orientational.py @@ -95,7 +95,6 @@ def calculate(self, neighbours: Mapping[str, int]) -> OrientationalEntropyResult total = 0.0 for species, count in neighbours.items(): if self._is_water(species): - # Water handling can be added later (e.g., via a strategy). logger.debug( "Skipping water species %s in orientational entropy.", species ) @@ -141,7 +140,6 @@ def _entropy_contribution(self, neighbour_count: int) -> float: return 0.0 omega = self._omega(neighbour_count) - # omega should always be > 0 when neighbour_count > 0, but guard anyway. if omega <= 0.0: return 0.0 diff --git a/CodeEntropy/entropy/vibrational.py b/CodeEntropy/entropy/vibrational.py index a7762d70..f1baf132 100644 --- a/CodeEntropy/entropy/vibrational.py +++ b/CodeEntropy/entropy/vibrational.py @@ -200,7 +200,6 @@ def _entropy_components_from_frequencies( kT = float(self._run_manager.get_KT2J(temp)) exponent = (self._planck_const * frequencies) / kT - # Numerically stable enough for typical ranges; callers filter eigenvalues. exp_pos = np.exp(exponent) exp_neg = np.exp(-exponent) diff --git a/CodeEntropy/entropy/workflow.py b/CodeEntropy/entropy/workflow.py index 8a70eba3..c5a76652 100644 --- a/CodeEntropy/entropy/workflow.py +++ b/CodeEntropy/entropy/workflow.py @@ -114,7 +114,6 @@ def execute(self) -> None: if self._args.water_entropy and water_groups: self._compute_water_entropy(traj, water_groups) else: - # If water entropy isn't computed, include water in the remaining groups. nonwater_groups.update(water_groups) shared_data = self._build_shared_data( @@ -297,8 +296,6 @@ def _compute_water_entropy( water_entropy = WaterEntropy(self._args) for group_id in water_groups.keys(): - # WaterEntropy currently exposes a concrete API; keep this manager - # as an orchestrator and avoid duplicating internals here. water_entropy._calculate_water_entropy( universe=self._universe, start=traj.start, @@ -307,7 +304,6 @@ def _compute_water_entropy( group_id=group_id, ) - # Exclude water from subsequent analysis when water entropy has been computed. self._args.selection_string = ( f"{self._args.selection_string} and not water" if self._args.selection_string != "all" diff --git a/CodeEntropy/levels/dihedrals.py b/CodeEntropy/levels/dihedrals.py index 651d2c3b..ff5bf853 100644 --- a/CodeEntropy/levels/dihedrals.py +++ b/CodeEntropy/levels/dihedrals.py @@ -10,13 +10,6 @@ import numpy as np from MDAnalysis.analysis.dihedrals import Dihedral -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) logger = logging.getLogger(__name__) @@ -497,19 +490,3 @@ def _assign_states( logger.debug("States: %s", states) return states - - @staticmethod - def _count_total_items(levels, groups) -> int: - """Count total progress items.""" - return sum(len(levels[mol_id]) for mols in groups.values() for mol_id in mols) - - @staticmethod - def _progress_bar(total_items: int) -> Progress: - """Create a Rich progress bar.""" - return Progress( - SpinnerColumn(), - TextColumn("[bold blue]{task.fields[title]}", justify="right"), - BarColumn(), - TextColumn("[progress.percentage]{task.percentage:>3.1f}%"), - TimeElapsedColumn(), - ) diff --git a/CodeEntropy/levels/forces.py b/CodeEntropy/levels/forces.py index 92f2c665..3b382b0c 100644 --- a/CodeEntropy/levels/forces.py +++ b/CodeEntropy/levels/forces.py @@ -44,7 +44,15 @@ class TorqueInputs: class ForceTorqueCalculator: - """Computes weighted generalized forces/torques and per-frame second moments.""" + """Computes weighted generalized forces/torques and per-frame second moments. + + This class provides: + - Mass-weighted generalized translational forces from per-atom forces. + - Moment-of-inertia-weighted generalized torques from per-atom positions + and forces, optionally using an axes_manager for PBC-aware displacements. + - Per-frame second-moment (outer product) matrices for concatenated bead + vectors, used downstream for covariance/entropy calculations. + """ def get_weighted_forces( self, @@ -140,7 +148,24 @@ def _compute_weighted_force( apply_partitioning: bool, force_partitioning: float, ) -> Vector3: - """Implementation of translational generalized force computation.""" + """Compute a translational generalized force vector for a bead. + + The bead's atomic forces are transformed by trans_axes, summed, optionally + scaled by force_partitioning, and then mass-weighted by 1/sqrt(mass). + + Args: + bead: Bead-like object with .atoms and .total_mass(). Each atom must + provide .force with shape (3,). + trans_axes: Transform matrix for translational forces, shape (3, 3). + apply_partitioning: Whether to apply the force_partitioning scaling. + force_partitioning: Scaling factor applied when apply_partitioning is True. + + Returns: + Mass-weighted generalized force vector of shape (3,). + + Raises: + ValueError: If trans_axes is not (3,3) or bead mass is non-positive. + """ trans_axes = np.asarray(trans_axes, dtype=float) if trans_axes.shape != (3, 3): raise ValueError(f"trans_axes must be (3,3), got {trans_axes.shape}") @@ -159,7 +184,23 @@ def _compute_weighted_force( return forces_trans / np.sqrt(mass) def _compute_weighted_torque(self, bead: Any, inputs: TorqueInputs) -> Vector3: - """Implementation of rotational generalized torque computation.""" + """Compute a rotational generalized torque vector for a bead. + + Positions are displaced relative to inputs.center (optionally PBC-aware), + rotated into the bead frame, and crossed with rotated (and scaled) forces + to form a torque vector. Each component is then weighted by 1/sqrt(I_d) + where I_d is the corresponding principal moment of inertia. + + Args: + bead: Bead-like object with .positions (N,3) and .forces (N,3). + inputs: TorqueInputs containing axes, center, scaling, and inertia. + + Returns: + Moment-of-inertia-weighted torque vector of shape (3,). + + Raises: + ValueError: If rot_axes is not (3,3) or moment_of_inertia is not length 3. + """ rot_axes = np.asarray(inputs.rot_axes, dtype=float) if rot_axes.shape != (3, 3): raise ValueError(f"rot_axes must be (3,3), got {rot_axes.shape}") @@ -200,7 +241,16 @@ def _compute_frame_second_moments( force_vectors: Sequence[Vector3], torque_vectors: Sequence[Vector3], ) -> Tuple[Matrix, Matrix]: - """Build outer products for concatenated force/torque vectors.""" + """Build outer-product second-moment matrices for a single frame. + + Args: + force_vectors: Sequence of per-bead force vectors of shape (3,). + torque_vectors: Sequence of per-bead torque vectors of shape (3,). + + Returns: + Tuple (F, T) where each is the outer-product second moment of the + concatenated vectors, with shape (3N, 3N). + """ f = self._outer_second_moment(force_vectors) t = self._outer_second_moment(torque_vectors) return f, t @@ -213,8 +263,22 @@ def _displacements_relative_to_center( axes_manager: Optional[Any], box: Optional[np.ndarray], ) -> np.ndarray: - """ - Compute displacement vectors from center to positions. + """Compute displacement vectors from center to positions. + + This method delegates displacement computation to axes_manager.get_vector, + which is expected to handle periodic boundary conditions if applicable. + + Args: + center: Reference center position of shape (3,). + positions: Array of positions of shape (N, 3). + axes_manager: Object providing get_vector(center, positions, box). + box: Periodic box passed through to axes_manager.get_vector. + + Returns: + Displacement vectors of shape (N, 3). + + Raises: + AttributeError: If axes_manager does not provide get_vector. """ return axes_manager.get_vector(center, positions, box) diff --git a/CodeEntropy/levels/level_dag.py b/CodeEntropy/levels/level_dag.py index d751245e..39b4a1da 100644 --- a/CodeEntropy/levels/level_dag.py +++ b/CodeEntropy/levels/level_dag.py @@ -61,6 +61,9 @@ def __init__(self, universe_operations: Optional[Any] = None) -> None: def build(self) -> "LevelDAG": """Build the static and frame DAG topology. + This registers all static nodes and their dependencies, and builds the + internal FrameGraph used for per-frame execution. + Returns: Self, to allow fluent chaining. """ @@ -85,7 +88,21 @@ def build(self) -> "LevelDAG": def execute( self, shared_data: Dict[str, Any], *, progress: object | None = None ) -> Dict[str, Any]: - """Execute the full hierarchy workflow and mutate shared_data.""" + """Execute the full hierarchy workflow and mutate shared_data. + + This method ensures required shared components exist, runs the static stage + once, then iterates through trajectory frames to run the per-frame stage and + reduce outputs into running means. + + Args: + shared_data: Shared workflow data dict. This mapping is mutated in-place + by both static and frame stages. + progress: Optional progress sink passed through to nodes and used for + per-frame progress reporting when supported. + + Returns: + The same shared_data mapping passed in, after mutation. + """ shared_data.setdefault("axes_manager", AxesCalculator()) self._run_static_stage(shared_data, progress=progress) self._run_frame_stage(shared_data, progress=progress) @@ -94,7 +111,15 @@ def execute( def _run_static_stage( self, shared_data: Dict[str, Any], *, progress: object | None = None ) -> None: - """Run all static nodes in dependency order.""" + """Run all static nodes in dependency order. + + Nodes are executed in topological order of the static DAG. If a progress + object is provided, it is passed to node.run when the node accepts it. + + Args: + shared_data: Shared workflow data dict to be mutated by static nodes. + progress: Optional progress sink to pass to nodes that support it. + """ for node_name in nx.topological_sort(self._static_graph): node = self._static_nodes[node_name] if progress is not None: @@ -108,7 +133,16 @@ def _run_static_stage( def _add_static( self, name: str, node: Any, deps: Optional[list[str]] = None ) -> None: - """Register a static node and its dependencies in the static DAG.""" + """Register a static node and its dependencies in the static DAG. + + Args: + name: Unique node name used in the static DAG. + node: Node object exposing a run(shared_data, **kwargs) method. + deps: Optional list of upstream node names that must run before this node. + + Returns: + None. Mutates the internal static graph and node registry. + """ self._static_nodes[name] = node self._static_graph.add_node(name) for dep in deps or []: @@ -213,7 +247,18 @@ def _reduce_one_frame( def _reduce_force_and_torque( self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] ) -> None: - """Reduce force/torque covariance outputs into shared accumulators.""" + """Reduce force/torque covariance outputs into shared accumulators. + + Args: + shared_data: Shared workflow data dict containing: + - "force_covariances", "torque_covariances": accumulator structures. + - "frame_counts": running sample counts for each accumulator slot. + - "group_id_to_index": mapping from group id to accumulator index. + frame_out: Frame-local outputs containing "force" and "torque" sections. + + Returns: + None. Mutates accumulator values and counts in shared_data in-place. + """ f_cov = shared_data["force_covariances"] t_cov = shared_data["torque_covariances"] counts = shared_data["frame_counts"] @@ -262,7 +307,18 @@ def _reduce_force_and_torque( def _reduce_forcetorque( self, shared_data: Dict[str, Any], frame_out: Dict[str, Any] ) -> None: - """Reduce combined force-torque covariance outputs into shared accumulators.""" + """Reduce combined force-torque covariance outputs into shared accumulators. + + Args: + shared_data: Shared workflow data dict containing: + - "forcetorque_covariances": accumulator structures. + - "forcetorque_counts": running sample counts for each accumulator slot. + - "group_id_to_index": mapping from group id to accumulator index. + frame_out: Frame-local outputs that may include a "forcetorque" section. + + Returns: + None. Mutates accumulator values and counts in shared_data in-place. + """ if "forcetorque" not in frame_out: return diff --git a/CodeEntropy/levels/mda.py b/CodeEntropy/levels/mda.py index d0226ca3..c76b6f50 100644 --- a/CodeEntropy/levels/mda.py +++ b/CodeEntropy/levels/mda.py @@ -20,7 +20,13 @@ class UniverseOperations: - """Functions to create and manipulate MDAnalysis Universe objects.""" + """Functions to create and manipulate MDAnalysis Universe objects. + + This helper provides methods to: + - Build reduced universes by selecting subsets of frames or atoms. + - Extract a single fragment (molecule) into a standalone universe. + - Merge coordinates from one trajectory with forces from another trajectory. + """ def __init__(self) -> None: """Initialise the operations helper.""" @@ -35,21 +41,14 @@ def select_frames( ) -> mda.Universe: """Create a reduced universe by dropping frames according to user selection. - Parameters - ---------- - u: - A Universe object with topology, coordinates and (optionally) forces. - start: - Frame index to start analysis. If None, defaults to 0. - end: - Frame index to stop analysis (Python slicing semantics). If None, defaults - to the full trajectory length. - step: - Step size between frames. - - Returns - ------- - mda.Universe: + Args: + u: A Universe object with topology, coordinates and (optionally) forces. + start: Frame index to start analysis. If None, defaults to 0. + end: Frame index to stop analysis (Python slicing semantics). If None, + defaults to the full trajectory length. + step: Step size between frames. + + Returns: A reduced universe containing the selected frames, with coordinates, forces (if present) and unit cell dimensions loaded into memory. """ @@ -82,16 +81,11 @@ def select_frames( def select_atoms(self, u: mda.Universe, select_string: str = "all") -> mda.Universe: """Create a reduced universe by dropping atoms according to user selection. - Parameters - ---------- - u: - A Universe object with topology, coordinates and (optionally) forces. - select_string: - MDAnalysis `select_atoms` selection string. + Args: + u: A Universe object with topology, coordinates and (optionally) forces. + select_string: MDAnalysis `select_atoms` selection string. - Returns - ------- - mda.Universe: + Returns: A reduced universe containing only the selected atoms. Coordinates, forces (if present) and dimensions are loaded into memory. """ @@ -118,14 +112,11 @@ def extract_fragment( """Extract a single molecule (fragment) as a standalone reduced universe. Args: - universe: - The source universe. - molecule_id: - Fragment index in `universe.atoms.fragments`. + universe: The source universe. + molecule_id: Fragment index in `universe.atoms.fragments`. Returns: - mda.Universe: - A reduced universe containing only the atoms of the selected fragment. + A reduced universe containing only the atoms of the selected fragment. """ frag = universe.atoms.fragments[molecule_id] selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}" @@ -156,29 +147,22 @@ def merge_forces( - otherwise, the underlying `NoDataError` is raised. Args: - tprfile: - Topology input file. - trrfile: - Coordinate trajectory file(s). This can be a single path or a list, - as accepted by MDAnalysis. - forcefile: - Trajectory containing forces. - fileformat: - Optional file format for the coordinate trajectory, as recognised by - MDAnalysis. - kcal: - If True, scale the force array by 4.184 to convert from kcal to kJ. - force_format: - Optional file format for the force trajectory. If not provided, uses - `fileformat`. - fallback_to_positions_if_no_forces: - If True, and the force trajectory has no accessible forces, use - positions from the force trajectory as a fallback (legacy behaviour). + tprfile: Topology input file. + trrfile: Coordinate trajectory file(s). This can be a single path or a + list, as accepted by MDAnalysis. + forcefile: Trajectory containing forces. + fileformat: Optional file format for the coordinate trajectory, as + recognised by MDAnalysis. + kcal: If True, scale the force array by 4.184 to convert from kcal to kJ. + force_format: Optional file format for the force trajectory. If not + provided, uses `fileformat`. + fallback_to_positions_if_no_forces: If True, and the force trajectory has + no accessible forces, use positions from the force trajectory as a + fallback (legacy behaviour). Returns: - mda.Universe: - A new Universe containing coordinates, forces and dimensions loaded - into memory. + A new Universe containing coordinates, forces and dimensions loaded into + memory. """ logger.debug("Loading coordinate Universe with %s", trrfile) u = mda.Universe(tprfile, trrfile, format=fileformat) @@ -215,18 +199,19 @@ def _extract_timeseries(self, atomgroup, *, kind: str): """Extract a time series array for the requested kind from an AtomGroup. Args: - atomgroup: - MDAnalysis AtomGroup (may be updating). - kind: - One of {"positions", "forces", "dimensions"}. + atomgroup: MDAnalysis AtomGroup (may be updating). + kind: One of {"positions", "forces", "dimensions"}. Returns: - np.ndarray: - Time series with shape: - - positions: (n_frames, n_atoms, 3) - - forces: (n_frames, n_atoms, 3) if available, else raises - NoDataError - - dimensions:(n_frames, 6) or (n_frames, 3) depending on reader + Time series with shape: + - positions: (n_frames, n_atoms, 3) + - forces: (n_frames, n_atoms, 3) if available, else raises NoDataError + - dimensions: (n_frames, 6) or (n_frames, 3) depending on reader + + Raises: + ValueError: If kind is not one of the supported values. + NoDataError: If kind is "forces" and the trajectory does not provide + forces via the configured reader. """ if kind == "positions": func = self._positions_copy @@ -240,15 +225,36 @@ def _extract_timeseries(self, atomgroup, *, kind: str): return AnalysisFromFunction(func, atomgroup).run().results["timeseries"] def _positions_copy(self, ag): - """Return a copy of positions for AnalysisFromFunction.""" + """Return a copy of positions for AnalysisFromFunction. + + Args: + ag: MDAnalysis AtomGroup. + + Returns: + Copy of ag.positions. + """ return ag.positions.copy() def _forces_copy(self, ag): - """Return a copy of forces for AnalysisFromFunction.""" + """Return a copy of forces for AnalysisFromFunction. + + Args: + ag: MDAnalysis AtomGroup. + + Returns: + Copy of ag.forces. + """ return ag.forces.copy() def _dimensions_copy(self, ag): - """Return a copy of box dimensions for AnalysisFromFunction.""" + """Return a copy of box dimensions for AnalysisFromFunction. + + Args: + ag: MDAnalysis AtomGroup. + + Returns: + Copy of ag.dimensions. + """ return ag.dimensions.copy() def _extract_force_timeseries_with_fallback( @@ -262,6 +268,19 @@ def _extract_force_timeseries_with_fallback( This isolates the behaviour that changed your runtime outcome: older code used positions from the force trajectory, which never triggered `NoDataError`. This method keeps that behaviour available for backwards compatibility. + + Args: + atomgroup_force: MDAnalysis AtomGroup sourced from the force trajectory. + fallback_to_positions_if_no_forces: If True, fall back to extracting + positions when forces are unavailable; otherwise re-raise NoDataError. + + Returns: + A time series array of shape (n_frames, n_atoms, 3). The returned array + contains forces when available, otherwise positions if fallback is enabled. + + Raises: + NoDataError: If forces are unavailable and + fallback_to_positions_if_no_forces is False. """ try: return self._extract_timeseries(atomgroup_force, kind="forces") diff --git a/CodeEntropy/levels/nodes/beads.py b/CodeEntropy/levels/nodes/beads.py index 8f47c86f..abd5b122 100644 --- a/CodeEntropy/levels/nodes/beads.py +++ b/CodeEntropy/levels/nodes/beads.py @@ -228,5 +228,4 @@ def _infer_local_residue_id(mol, bead) -> int: if int(res.resindex) == target_resindex: return local_i - # Conservative fallback: bucket into residue 0 rather than dropping. return 0 diff --git a/CodeEntropy/levels/nodes/covariance.py b/CodeEntropy/levels/nodes/covariance.py index 35d050ca..1085aad4 100644 --- a/CodeEntropy/levels/nodes/covariance.py +++ b/CodeEntropy/levels/nodes/covariance.py @@ -33,9 +33,21 @@ class FrameCovarianceNode: - """Build per-frame covariance-like (second-moment) matrices for each group.""" + """Build per-frame covariance-like (second-moment) matrices for each group. + + This node computes per-frame second-moment matrices (outer products) for + force and torque generalized vectors at hierarchy levels: + - united_atom + - residue + - polymer + + Within a single frame, outputs are incrementally averaged across molecules + that belong to the same group. Frame-to-frame accumulation is handled + elsewhere (by a higher-level reducer). + """ def __init__(self) -> None: + """Initialise the frame covariance node.""" self._ft = ForceTorqueCalculator() def run(self, ctx: FrameCtx) -> Dict[str, Any]: @@ -45,7 +57,7 @@ def run(self, ctx: FrameCtx) -> Dict[str, Any]: ctx: Frame context dict expected to include: - "shared": dict containing reduced_universe, groups, levels, beads, args - - MUST include shared["axes_manager"] (created in static stage) + - shared["axes_manager"] (created in static stage) Returns: The frame covariance payload also stored at ctx["frame_covariance"]. @@ -162,6 +174,30 @@ def _process_united_atom( out_torque: Dict[str, Dict[Any, Matrix]], molcount: Dict[Tuple[int, int], int], ) -> None: + """Compute UA-level force/torque second moments for one molecule. + + For each residue in the molecule, bead vectors are computed for all UA + beads in that residue. The resulting second-moment matrices are then + incrementally averaged across molecules in the same group for this frame. + + Args: + u: MDAnalysis Universe (or compatible) providing atom access. + mol: Molecule/fragment object providing residues/atoms. + mol_id: Molecule id used for bead keying. + group_id: Group identifier used for within-frame averaging. + beads: Mapping from bead keys to lists of atom indices. + axes_manager: Axes manager used to determine axes/centers/MOI. + box: Optional box vector used for PBC-aware displacements. + force_partitioning: Force scaling factor applied at highest level. + customised_axes: Whether to use customised axes methods when available. + is_highest: Whether the UA level is the highest level for the molecule. + out_force: Output accumulator for UA force second moments. + out_torque: Output accumulator for UA torque second moments. + molcount: Per-(group_id, local_res_i) molecule counters for averaging. + + Returns: + None. Mutates out_force/out_torque and molcount in-place. + """ for local_res_i, res in enumerate(mol.residues): bead_key = (mol_id, "united_atom", local_res_i) bead_idx_list = beads.get(bead_key, []) @@ -209,8 +245,34 @@ def _process_residue( molcount: Dict[int, int], combined: bool, ) -> None: - """ - Compute residue-level force/torque (and optional FT) moments for one molecule. + """Compute residue-level force/torque (and optional FT) moments for one + molecule. + + Residue bead vectors are constructed for the molecule and used to compute + per-frame force and torque second-moment matrices. Outputs are then + incrementally averaged across molecules in the same group for this frame. + If combined FT matrices are enabled and this is the highest level, a + force-torque block matrix is also constructed and averaged. + + Args: + u: MDAnalysis Universe (or compatible) providing atom access. + mol: Molecule/fragment object providing atoms/residues. + mol_id: Molecule id used for bead keying. + group_id: Group identifier used for within-frame averaging. + beads: Mapping from bead keys to lists of atom indices. + axes_manager: Axes manager used to determine axes/centers/MOI. + box: Optional box vector used for PBC-aware displacements. + customised_axes: Whether to use customised axes methods when available. + force_partitioning: Force scaling factor applied at highest level. + is_highest: Whether residue level is the highest level for the molecule. + out_force: Output accumulator for residue force second moments. + out_torque: Output accumulator for residue torque second moments. + out_ft: Optional output accumulator for residue combined FT matrices. + molcount: Per-group molecule counter for within-frame averaging. + combined: Whether combined force-torque matrices are enabled. + + Returns: + None. Mutates output dictionaries and molcount in-place. """ bead_key = (mol_id, "residue") bead_idx_list = beads.get(bead_key, []) @@ -264,8 +326,34 @@ def _process_polymer( molcount: Dict[int, int], combined: bool, ) -> None: - """ - Compute polymer-level force/torque (and optional FT) moments for one molecule. + """Compute polymer-level force/torque (and optional FT) moments for one + molecule. + + Polymer level uses a single bead. Translation/rotation axes, center, and + principal moments of inertia are computed, then used to build the + generalized force and torque vectors. Outputs are incrementally averaged + across molecules in the same group for this frame. If combined FT matrices + are enabled and this is the highest level, a force-torque block matrix is + also constructed and averaged. + + Args: + u: MDAnalysis Universe (or compatible) providing atom access. + mol: Molecule/fragment object providing atoms. + mol_id: Molecule id used for bead keying. + group_id: Group identifier used for within-frame averaging. + beads: Mapping from bead keys to lists of atom indices. + axes_manager: Axes manager used to determine axes/centers/MOI. + box: Optional box vector used for PBC-aware displacements. + force_partitioning: Force scaling factor applied at highest level. + is_highest: Whether polymer level is the highest level for the molecule. + out_force: Output accumulator for polymer force second moments. + out_torque: Output accumulator for polymer torque second moments. + out_ft: Optional output accumulator for polymer combined FT matrices. + molcount: Per-group molecule counter for within-frame averaging. + combined: Whether combined force-torque matrices are enabled. + + Returns: + None. Mutates output dictionaries and molcount in-place. """ bead_key = (mol_id, "polymer") bead_idx_list = beads.get(bead_key, []) @@ -330,7 +418,21 @@ def _build_ua_vectors( customised_axes: bool, is_highest: bool, ) -> Tuple[List[np.ndarray], List[np.ndarray]]: - """Build force/torque vectors for UA-level beads of one residue.""" + """Build force/torque vectors for UA-level beads of one residue. + + Args: + bead_groups: List of UA bead AtomGroups for the residue. + residue_atoms: AtomGroup for the residue atoms (used for axes when vanilla). + axes_manager: Axes manager used to determine axes/centers/MOI. + box: Optional box vector used for PBC-aware displacements. + force_partitioning: Force scaling factor applied at highest level. + customised_axes: Whether to use customised axes methods when available. + is_highest: Whether UA level is the highest level for the molecule. + + Returns: + A tuple (force_vecs, torque_vecs), each a list of (3,) vectors ordered + by UA bead index within the residue. + """ force_vecs: List[np.ndarray] = [] torque_vecs: List[np.ndarray] = [] @@ -380,7 +482,21 @@ def _build_residue_vectors( force_partitioning: float, is_highest: bool, ) -> Tuple[List[np.ndarray], List[np.ndarray]]: - """Build force/torque vectors for residue-level beads of one molecule.""" + """Build force/torque vectors for residue-level beads of one molecule. + + Args: + mol: Molecule/fragment object providing residues/atoms. + bead_groups: List of residue bead AtomGroups for the molecule. + axes_manager: Axes manager used to determine axes/centers/MOI. + box: Optional box vector used for PBC-aware displacements. + customised_axes: Whether to use customised axes methods when available. + force_partitioning: Force scaling factor applied at highest level. + is_highest: Whether residue level is the highest level for the molecule. + + Returns: + A tuple (force_vecs, torque_vecs), each a list of (3,) vectors ordered + by residue index within the molecule. + """ force_vecs: List[np.ndarray] = [] torque_vecs: List[np.ndarray] = [] @@ -424,7 +540,22 @@ def _get_residue_axes( axes_manager: Any, customised_axes: bool, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Get translation/rotation axes, center and MOI for a residue bead.""" + """Get translation/rotation axes, center and MOI for a residue bead. + + Args: + mol: Molecule/fragment object providing residues/atoms. + bead: Residue bead AtomGroup. + local_res_i: Residue index within the molecule. + axes_manager: Axes manager used to determine axes/centers/MOI. + customised_axes: Whether to use customised axes methods when available. + + Returns: + Tuple (trans_axes, rot_axes, center, moi) where: + - trans_axes: (3, 3) translation axes + - rot_axes: (3, 3) rotation axes + - center: (3,) center of mass + - moi: (3,) principal moments of inertia + """ if customised_axes: res = mol.residues[local_res_i] return axes_manager.get_residue_axes(mol, local_res_i, residue=res.atoms) @@ -449,7 +580,17 @@ def _get_polymer_axes( bead: Any, axes_manager: Any, ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Get translation/rotation axes, center and MOI for a polymer bead.""" + """Get translation/rotation axes, center and MOI for a polymer bead. + + Args: + mol: Molecule/fragment object providing atoms. + bead: Polymer bead AtomGroup. + axes_manager: Axes manager used to determine axes/centers/MOI. + + Returns: + Tuple (trans_axes, rot_axes, center, moi) with shapes (3,3), (3,3), (3,), + and (3,) respectively. + """ make_whole(mol.atoms) make_whole(bead) @@ -466,14 +607,32 @@ def _get_polymer_axes( @staticmethod def _get_shared(ctx: FrameCtx) -> Dict[str, Any]: - """Fetch shared context from a frame context dict.""" + """Fetch shared context from a frame context dict. + + Args: + ctx: Frame context dictionary expected to contain a "shared" key. + + Returns: + The shared context dict stored at ctx["shared"]. + + Raises: + KeyError: If "shared" is not present in ctx. + """ if "shared" not in ctx: raise KeyError("FrameCovarianceNode expects ctx['shared'].") return ctx["shared"] @staticmethod def _try_get_box(u: Any) -> Optional[np.ndarray]: - """Extract a (3,) box vector from an MDAnalysis universe when available.""" + """Extract a (3,) box vector from an MDAnalysis universe when available. + + Args: + u: MDAnalysis Universe (or compatible) that may expose dimensions. + + Returns: + A numpy array of shape (3,) containing box lengths, or None if not + available. + """ try: return np.asarray(u.dimensions[:3], dtype=float) except Exception: @@ -481,7 +640,16 @@ def _try_get_box(u: Any) -> Optional[np.ndarray]: @staticmethod def _inc_mean(old: Optional[np.ndarray], new: np.ndarray, n: int) -> np.ndarray: - """Compute an incremental mean (streaming average).""" + """Compute an incremental mean (streaming average). + + Args: + old: Previous running mean value, or None for the first sample. + new: New sample to incorporate. + n: 1-based sample count after adding the new sample. + + Returns: + Updated running mean. + """ if old is None: return new.copy() return old + (new - old) / float(n) @@ -494,6 +662,17 @@ def _build_ft_block( For each bead i, create a 6-vector [Fi, Ti]. The block matrix is built from outer products of these 6-vectors. + + Args: + force_vecs: List of per-bead force vectors, each of shape (3,). + torque_vecs: List of per-bead torque vectors, each of shape (3,). + + Returns: + A block matrix of shape (6N, 6N) where N is the number of beads. + + Raises: + ValueError: If force_vecs and torque_vecs have different lengths, if no + bead vectors are provided, or if any input vector is not length 3. """ if len(force_vecs) != len(torque_vecs): raise ValueError("force_vecs and torque_vecs must have the same length.") diff --git a/CodeEntropy/results/reporter.py b/CodeEntropy/results/reporter.py index 784aad06..f9579f38 100644 --- a/CodeEntropy/results/reporter.py +++ b/CodeEntropy/results/reporter.py @@ -48,25 +48,69 @@ class _RichProgressSink: """ def __init__(self, progress: Progress): + """Initialise a progress sink that delegates to a rich.Progress instance. + + Args: + progress: Rich Progress instance used to create/update/advance tasks. + """ self._progress = progress def add_task(self, description: str, total: int, **fields): + """Add a progress task to the underlying rich.Progress instance. + + Args: + description: Task description shown by Rich. + total: Total number of steps for the task. + **fields: Additional Rich task fields (e.g., title). + + Returns: + The task id returned by rich.Progress.add_task. + """ fields.setdefault("title", "") return self._progress.add_task(description, total=total, **fields) def advance(self, task_id, step: int = 1) -> None: + """Advance a progress task by a number of steps. + + Args: + task_id: Rich task identifier. + step: Number of steps to advance the task by. + """ self._progress.advance(task_id, step) def update(self, task_id, **fields) -> None: + """Update fields for an existing progress task. + + Args: + task_id: Rich task identifier. + **fields: Task fields to update. If "title" is provided as None, it is + coerced to an empty string for compatibility with Rich rendering. + """ if "title" in fields and fields["title"] is None: fields["title"] = "" self._progress.update(task_id, **fields) class ResultsReporter: - """Collect, format, and output entropy calculation results.""" + """Collect, format, and output entropy calculation results. + + This reporter accumulates: + - Molecule-level results (group_id, level, entropy_type, value) + - Residue-level results (group_id, resname, level, entropy_type, frame_count, + value) + - Group metadata labels (label, residue_count, atom_count) + + It can render tables using Rich and export grouped results to JSON with basic + provenance metadata. + """ def __init__(self, console: Optional[Console] = None) -> None: + """Initialise a ResultsReporter. + + Args: + console: Optional Rich Console to use for rendering. If None, a default + Console instance is created. + """ self.console: Console = console or Console() self.molecule_data: List[Tuple[Any, Any, Any, Any]] = [] self.residue_data: List[List[Any]] = [] @@ -74,18 +118,31 @@ def __init__(self, console: Optional[Console] = None) -> None: @staticmethod def clean_residue_name(resname: Any) -> str: - """Clean residue name by removing dash-like characters.""" + """Clean residue name by removing dash-like characters. + + Args: + resname: Residue name (any type, will be converted to str). + + Returns: + Residue name with dash-like characters removed. + """ return re.sub(r"[-–—]", "", str(resname)) @staticmethod def _gid_sort_key(x: Any) -> Tuple[int, Any]: - """ - Stable sort key for group IDs that may be numeric strings, ints, or other - objects. + """Stable sort key for group IDs. - Returns (rank, value): + Group IDs may be numeric strings, ints, or other objects. + + Returns a tuple (rank, value): - numeric IDs -> (0, int_value) - non-numeric -> (1, str_value) + + Args: + x: Group identifier. + + Returns: + Tuple used as a stable sorting key. """ sx = str(x) try: @@ -95,7 +152,15 @@ def _gid_sort_key(x: Any) -> Tuple[int, Any]: @staticmethod def _safe_float(value: Any) -> Optional[float]: - """Convert value to float if possible; otherwise return None.""" + """Convert value to float if possible; otherwise return None. + + Args: + value: Value to convert. + + Returns: + Float representation of value, or None if conversion is not possible or + value is a boolean. + """ try: if isinstance(value, bool): return None @@ -106,7 +171,14 @@ def _safe_float(value: Any) -> Optional[float]: def add_results_data( self, group_id: Any, level: str, entropy_type: str, value: Any ) -> None: - """Add molecule-level entropy result.""" + """Add molecule-level entropy result. + + Args: + group_id: Group identifier. + level: Hierarchy level label. + entropy_type: Entropy component/type label. + value: Result value to store (kept as-is). + """ self.molecule_data.append((group_id, level, entropy_type, value)) def add_residue_data( @@ -118,7 +190,16 @@ def add_residue_data( frame_count: Any, value: Any, ) -> None: - """Add residue-level entropy result.""" + """Add residue-level entropy result. + + Args: + group_id: Group identifier. + resname: Residue name (will be cleaned to remove dash-like characters). + level: Hierarchy level label. + entropy_type: Entropy component/type label. + frame_count: Number of frames contributing to the value (may be ndarray). + value: Result value to store (kept as-is). + """ resname = self.clean_residue_name(resname) if isinstance(frame_count, np.ndarray): frame_count = frame_count.tolist() @@ -133,7 +214,14 @@ def add_group_label( residue_count: Optional[int] = None, atom_count: Optional[int] = None, ) -> None: - """Store metadata label for a group.""" + """Store metadata label for a group. + + Args: + group_id: Group identifier. + label: Human-readable label for the group. + residue_count: Optional residue count for the group. + atom_count: Optional atom count for the group. + """ self.group_labels[group_id] = { "label": label, "residue_count": residue_count, @@ -147,9 +235,9 @@ def log_tables(self) -> None: self._log_group_label_table() def _log_grouped_results_tables(self) -> None: - """ - Print molecule-level results grouped by group_id with components + total - together. + """Print molecule-level results grouped by group_id with components + total. + + Results are grouped by group_id and rendered as separate tables per group. """ if not self.molecule_data: return @@ -248,8 +336,7 @@ def save_dataframes_as_json( args: Optional[Any] = None, include_raw_tables: bool = False, ) -> None: - """ - Save results to a grouped JSON structure. + """Save results to a grouped JSON structure. JSON contains: - args: arguments used (serialized) @@ -282,6 +369,17 @@ def _build_grouped_payload( args: Optional[Any], include_raw_tables: bool, ) -> Dict[str, Any]: + """Build a grouped JSON-serializable payload from result dataframes. + + Args: + molecule_df: Pandas DataFrame containing molecule results. + residue_df: Pandas DataFrame containing residue results. + args: Optional argparse Namespace or dict of arguments used. + include_raw_tables: If True, include raw dataframe record arrays in payload. + + Returns: + Dictionary payload suitable for JSON serialization. + """ mol_rows = molecule_df.to_dict(orient="records") res_rows = residue_df.to_dict(orient="records") @@ -327,7 +425,15 @@ def _build_grouped_payload( @staticmethod def _serialize_args(args: Optional[Any]) -> Dict[str, Any]: - """Turn argparse Namespace / dict / object into a JSON-serializable dict.""" + """Turn argparse Namespace / dict / object into a JSON-serializable dict. + + Args: + args: argparse Namespace, dict, or other object with __dict__/iterable. + + Returns: + JSON-serializable dict of argument values. Unsupported/unreadable inputs + return an empty dict. + """ if args is None: return {} @@ -353,6 +459,12 @@ def _serialize_args(args: Optional[Any]) -> Dict[str, Any]: @staticmethod def _provenance() -> Dict[str, Any]: + """Build a provenance dictionary for exported results. + + Returns: + Dictionary with python version, platform string, CodeEntropy package + version (if available), and git sha (if available). + """ prov: Dict[str, Any] = { "python": sys.version.split()[0], "platform": platform.platform(), @@ -370,6 +482,16 @@ def _provenance() -> Dict[str, Any]: @staticmethod def _try_get_git_sha() -> Optional[str]: + """Try to determine the current git commit SHA. + + The SHA is obtained from: + 1) Environment variable CODEENTROPY_GIT_SHA, if set. + 2) A git repository discovered by walking up from this file path and + running `git rev-parse HEAD`. + + Returns: + Git SHA string if found, otherwise None. + """ env_sha = os.environ.get("CODEENTROPY_GIT_SHA") if env_sha: return env_sha @@ -407,6 +529,12 @@ def progress(self, *, transient: bool = True): Usage: with reporter.progress() as p: ... + + Args: + transient: Whether the progress display should be removed on exit. + + Yields: + A _RichProgressSink that exposes add_task(), update(), and advance(). """ progress = Progress( SpinnerColumn(), diff --git a/tests/unit/CodeEntropy/levels/test_dihedrals.py b/tests/unit/CodeEntropy/levels/test_dihedrals.py index 1b9b8eb3..8ea0f1fe 100644 --- a/tests/unit/CodeEntropy/levels/test_dihedrals.py +++ b/tests/unit/CodeEntropy/levels/test_dihedrals.py @@ -254,7 +254,6 @@ def test_assign_states_for_group_sets_empty_lists_and_delegates_for_nonempty(): def test_build_conformational_states_runs_group_and_skips_empty_group(monkeypatch): uops = MagicMock() dt = ConformationStateBuilder(universe_operations=uops) - monkeypatch.setattr(dt, "_progress_bar", _fake_progress_bar) groups = {0: [], 1: [7]} levels = {7: ["residue"]} @@ -279,19 +278,6 @@ def test_build_conformational_states_runs_group_and_skips_empty_group(monkeypatc assert len(states_res) == 2 -def test_count_total_items_counts_all_levels_across_grouped_molecules(): - levels = {10: ["residue"], 11: ["united_atom", "residue"]} - groups = {0: [10], 1: [11]} - assert ( - ConformationStateBuilder._count_total_items(levels=levels, groups=groups) == 3 - ) - - -def test_progress_bar_constructs_rich_progress_instance(): - prog = ConformationStateBuilder._progress_bar(total_items=1) - assert hasattr(prog, "add_task") - - def test_identify_peaks_handles_multiple_dihedrals_and_calls_histogram_each_time(): uops = MagicMock() dt = ConformationStateBuilder(universe_operations=uops) From b1a3ea127888291ce066441462ddb1a7aed10cd1 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 17:03:47 +0000 Subject: [PATCH 095/101] ci: simplify job naming and limit regression tests to latest Python on Ubuntu --- .github/workflows/pr.yaml | 53 +++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 87f64355..b20ec99d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -1,4 +1,4 @@ -name: CodeEntropy PR +name: CodeEntropy CI on: pull_request: @@ -9,7 +9,7 @@ concurrency: jobs: unit: - name: Unit tests (py${{ matrix.python-version }} • ${{ matrix.os }}) + name: Unit runs-on: ${{ matrix.os }} timeout-minutes: 25 strategy: @@ -18,7 +18,7 @@ jobs: os: [ubuntu-24.04, macos-14, windows-2025] python-version: ["3.12", "3.13", "3.14"] steps: - - name: Checkout repo + - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Set up Python ${{ matrix.python-version }} @@ -27,55 +27,48 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - name: Install CodeEntropy and its testing dependencies + - name: Install (testing) run: | python -m pip install --upgrade pip python -m pip install -e .[testing] - - name: Run unit test suite - run: | - python -m pytest tests/unit -q + - name: Pytest (unit) • ${{ matrix.os }} • py${{ matrix.python-version }} + run: python -m pytest tests/unit -q regression-quick: - name: Quick regression (py${{ matrix.python-version }} • ${{ matrix.os }}) + name: Regression (quick) needs: unit - runs-on: ${{ matrix.os }} + runs-on: ubuntu-24.04 timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - os: [ubuntu-24.04, macos-14, windows-2025] - python-version: ["3.12", "3.13", "3.14"] steps: - - name: Checkout repo + - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.14 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ matrix.python-version }} + python-version: "3.14" cache: pip - - name: Cache regression test data downloads + - name: Cache testdata uses: actions/cache@v4 with: path: .testdata - key: codeentropy-testdata-v1-${{ runner.os }}-py${{ matrix.python-version }} + key: codeentropy-testdata-v1-${{ runner.os }}-py3.14 - - name: Install CodeEntropy and its testing dependencies + - name: Install (testing) run: | python -m pip install --upgrade pip python -m pip install -e .[testing] - - name: Run quick regression suite (slow excluded) - run: | - python -m pytest tests/regression -q + - name: Pytest (regression quick) + run: python -m pytest tests/regression -q - - name: Upload regression artifacts on failure + - name: Upload artifacts (failure) if: failure() uses: actions/upload-artifact@v4 with: - name: quick-regression-failure-${{ matrix.os }}-py${{ matrix.python-version }} + name: quick-regression-failure path: | .testdata/** tests/regression/**/.pytest_cache/** @@ -85,11 +78,11 @@ jobs: /tmp/pytest-of-*/pytest-*/**/codeentropy_output.json docs: - name: Docs build (latest) + name: Docs runs-on: ubuntu-24.04 timeout-minutes: 25 steps: - - name: Checkout repo + - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Set up Python 3.14 @@ -98,7 +91,7 @@ jobs: python-version: "3.14" cache: pip - - name: Install python dependencies + - name: Install (docs) run: | python -m pip install --upgrade pip python -m pip install -e .[docs] @@ -113,7 +106,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - - name: Checkout repo + - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Set up Python 3.14 @@ -122,7 +115,7 @@ jobs: python-version: "3.14" cache: pip - - name: Install python dependencies + - name: Install (pre-commit) run: | python -m pip install --upgrade pip python -m pip install -e .[pre-commit] From ef7c342998a6f0a6dad31df88b71c45e003213b8 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 25 Feb 2026 17:05:22 +0000 Subject: [PATCH 096/101] test: add regression baselines and whitelist them in .gitignore --- .gitignore | 3 + tests/regression/baselines/benzaldehyde.json | 43 ++++++++++++++ tests/regression/baselines/benzene.json | 43 ++++++++++++++ tests/regression/baselines/cyclohexane.json | 43 ++++++++++++++ tests/regression/baselines/dna.json | 58 +++++++++++++++++++ tests/regression/baselines/ethyl-acetate.json | 43 ++++++++++++++ tests/regression/baselines/methane.json | 40 +++++++++++++ tests/regression/baselines/methanol.json | 43 ++++++++++++++ tests/regression/baselines/octonol.json | 43 ++++++++++++++ 9 files changed, 359 insertions(+) create mode 100644 tests/regression/baselines/benzaldehyde.json create mode 100644 tests/regression/baselines/benzene.json create mode 100644 tests/regression/baselines/cyclohexane.json create mode 100644 tests/regression/baselines/dna.json create mode 100644 tests/regression/baselines/ethyl-acetate.json create mode 100644 tests/regression/baselines/methane.json create mode 100644 tests/regression/baselines/methanol.json create mode 100644 tests/regression/baselines/octonol.json diff --git a/.gitignore b/.gitignore index d75442b8..20fa8d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ job* *.txt .testdata/ + +!tests/regression/baselines/ +!tests/regression/baselines/*.json diff --git a/tests/regression/baselines/benzaldehyde.json b/tests/regression/baselines/benzaldehyde.json new file mode 100644 index 00000000..54103991 --- /dev/null +++ b/tests/regression/baselines/benzaldehyde.json @@ -0,0 +1,43 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/benzaldehyde/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/benzaldehyde/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/benzaldehyde/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-60/test_regression_matches_baseli0/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 158.90339720185818, + "united_atom:Rovibrational": 143.87250586343512, + "residue:FTmat-Transvibrational": 106.71035236014967, + "residue:FTmat-Rovibrational": 95.07735227595549, + "united_atom:Conformational": 0.0, + "residue:Conformational": 0.0 + }, + "total": 504.56360770139844 + } + } +} diff --git a/tests/regression/baselines/benzene.json b/tests/regression/baselines/benzene.json new file mode 100644 index 00000000..3517db61 --- /dev/null +++ b/tests/regression/baselines/benzene.json @@ -0,0 +1,43 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/benzene/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/benzene/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/benzene/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-64/test_regression_matches_baseli0/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 93.55450341182438, + "united_atom:Rovibrational": 143.68264201362132, + "residue:FTmat-Transvibrational": 108.34125737284016, + "residue:FTmat-Rovibrational": 95.57598285903227, + "united_atom:Conformational": 0.0, + "residue:Conformational": 0.0 + }, + "total": 441.15438565731813 + } + } +} diff --git a/tests/regression/baselines/cyclohexane.json b/tests/regression/baselines/cyclohexane.json new file mode 100644 index 00000000..aea6f560 --- /dev/null +++ b/tests/regression/baselines/cyclohexane.json @@ -0,0 +1,43 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/cyclohexane/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/cyclohexane/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/cyclohexane/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-60/test_regression_matches_baseli2/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 109.02761125847158, + "united_atom:Rovibrational": 227.2888326629934, + "residue:FTmat-Transvibrational": 106.06698045971194, + "residue:FTmat-Rovibrational": 99.10449330958527, + "united_atom:Conformational": 0.0, + "residue:Conformational": 0.0 + }, + "total": 541.4879176907622 + } + } +} diff --git a/tests/regression/baselines/dna.json b/tests/regression/baselines/dna.json new file mode 100644 index 00000000..f1d58088 --- /dev/null +++ b/tests/regression/baselines/dna.json @@ -0,0 +1,58 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/dna/md_A4_dna.tpr", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/dna/md_A4_dna_xf.trr" + ], + "force_file": null, + "file_format": null, + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-60/test_regression_matches_baseli3/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 0.0, + "united_atom:Rovibrational": 0.002160679012128457, + "residue:Transvibrational": 0.0, + "residue:Rovibrational": 3.376800684085249, + "polymer:FTmat-Transvibrational": 12.341104347192612, + "polymer:FTmat-Rovibrational": 0.0, + "united_atom:Conformational": 7.269386795471401, + "residue:Conformational": 0.0 + }, + "total": 22.989452505761392 + }, + "1": { + "components": { + "united_atom:Transvibrational": 0.0, + "united_atom:Rovibrational": 0.01846427765949586, + "residue:Transvibrational": 0.0, + "residue:Rovibrational": 2.3863201082544565, + "polymer:FTmat-Transvibrational": 11.11037253388596, + "polymer:FTmat-Rovibrational": 0.0, + "united_atom:Conformational": 6.410455987098191, + "residue:Conformational": 0.46183561256411515 + }, + "total": 20.387448519462218 + } + } +} diff --git a/tests/regression/baselines/ethyl-acetate.json b/tests/regression/baselines/ethyl-acetate.json new file mode 100644 index 00000000..e9614b14 --- /dev/null +++ b/tests/regression/baselines/ethyl-acetate.json @@ -0,0 +1,43 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/ethyl-acetate/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/ethyl-acetate/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/ethyl-acetate/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-60/test_regression_matches_baseli4/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 119.77870290196522, + "united_atom:Rovibrational": 144.2366436580796, + "residue:FTmat-Transvibrational": 103.5819666889598, + "residue:FTmat-Rovibrational": 95.68311953660015, + "united_atom:Conformational": 8.140778318198597, + "residue:Conformational": 0.0 + }, + "total": 471.4212111038034 + } + } +} diff --git a/tests/regression/baselines/methane.json b/tests/regression/baselines/methane.json new file mode 100644 index 00000000..482088b7 --- /dev/null +++ b/tests/regression/baselines/methane.json @@ -0,0 +1,40 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/methane/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/methane/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/methane/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 112.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-60/test_regression_matches_baseli5/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 75.73291215434239, + "united_atom:Rovibrational": 68.80103728327107, + "united_atom:Conformational": 0.0 + }, + "total": 144.53394943761344 + } + } +} diff --git a/tests/regression/baselines/methanol.json b/tests/regression/baselines/methanol.json new file mode 100644 index 00000000..c5f1c8f3 --- /dev/null +++ b/tests/regression/baselines/methanol.json @@ -0,0 +1,43 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/methanol/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/methanol/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/methanol/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-60/test_regression_matches_baseli6/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 0.0, + "united_atom:Rovibrational": 85.74870264018092, + "residue:FTmat-Transvibrational": 93.59616431728384, + "residue:FTmat-Rovibrational": 59.61417719536213, + "united_atom:Conformational": 0.0, + "residue:Conformational": 0.0 + }, + "total": 238.9590441528269 + } + } +} diff --git a/tests/regression/baselines/octonol.json b/tests/regression/baselines/octonol.json new file mode 100644 index 00000000..1856b344 --- /dev/null +++ b/tests/regression/baselines/octonol.json @@ -0,0 +1,43 @@ +{ + "args": { + "top_traj_file": [ + "/home/tdo96567/BioSim/CodeEntropy/tests/data/octonol/molecules.top", + "/home/tdo96567/BioSim/CodeEntropy/tests/data/octonol/trajectory.crd" + ], + "force_file": "/home/tdo96567/BioSim/CodeEntropy/tests/data/octonol/forces.frc", + "file_format": "MDCRD", + "kcal_force_units": false, + "selection_string": "all", + "start": 0, + "end": 1, + "step": 1, + "bin_width": 30, + "temperature": 298.0, + "verbose": false, + "output_file": "/tmp/pytest-of-tdo96567/pytest-65/test_regression_matches_baseli0/job001/output_file.json", + "force_partitioning": 0.5, + "water_entropy": true, + "grouping": "molecules", + "combined_forcetorque": true, + "customised_axes": true + }, + "provenance": { + "python": "3.14.0", + "platform": "Linux-6.6.87.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "codeentropy_version": "1.0.7", + "git_sha": "226b37f7b206adba1b60253c41c7a0d467e75a58" + }, + "groups": { + "0": { + "components": { + "united_atom:Transvibrational": 222.4800037654818, + "united_atom:Rovibrational": 345.86413400118744, + "residue:FTmat-Transvibrational": 101.79847675768119, + "residue:FTmat-Rovibrational": 92.71423842383722, + "united_atom:Conformational": 20.4159084259166, + "residue:Conformational": 0.0 + }, + "total": 783.2727613741043 + } + } +} From 8e5a9012e15bf6c1bac62efd0f790e2758d66cab Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 26 Feb 2026 08:46:40 +0000 Subject: [PATCH 097/101] ensure macos version within `.github/workflows/pr.yaml` have up to date versions --- .github/workflows/pr.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b20ec99d..c84e59e4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-24.04, macos-14, windows-2025] + os: [ubuntu-24.04, macos-15, windows-2025] python-version: ["3.12", "3.13", "3.14"] steps: - name: Checkout From 1093fab5c60ebc593591e513786cee13e8301211 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 26 Feb 2026 10:54:29 +0000 Subject: [PATCH 098/101] feat(docs): add light and dark mode logos --- README.md | 3 ++- docs/conf.py | 5 ++++- docs/images/biosim-codeentropy_logo_dark.png | Bin 0 -> 288904 bytes docs/images/biosim-codeentropy_logo_grey.svg | 1 - docs/images/biosim-codeentropy_logo_light.png | Bin 0 -> 440378 bytes 5 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 docs/images/biosim-codeentropy_logo_dark.png delete mode 100644 docs/images/biosim-codeentropy_logo_grey.svg create mode 100644 docs/images/biosim-codeentropy_logo_light.png diff --git a/README.md b/README.md index 5728743f..00a9a818 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ CodeEntropy CodeEntropy is a Python package for computing the configurational entropy of macromolecular systems using forces sampled from molecular dynamics (MD) simulations. It implements the multiscale cell correlation method to provide accurate and efficient entropy estimates, supporting a wide range of applications in molecular simulation and statistical mechanics.

-CodeEntropy logo + CodeEntropy logo + CodeEntropy logo

See [CodeEntropy’s documentation](https://codeentropy.readthedocs.io/en/latest/) for more information. diff --git a/docs/conf.py b/docs/conf.py index 2ee1e608..cf51cf86 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -101,7 +101,10 @@ # a list of builtin themes. # html_theme = "furo" -html_logo = "images/biosim-codeentropy_logo_grey.svg" +html_theme_options = { + "light_logo": "images/biosim-codeentropy_logo_light.png", + "dark_logo": "images/biosim-codeentropy_logo_dark.png", +} # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/docs/images/biosim-codeentropy_logo_dark.png b/docs/images/biosim-codeentropy_logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..b1856eb2790d571df20987faf75891b4b40d5f09 GIT binary patch literal 288904 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 z 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ⅅ!tl*T#lM={$aseZF!_YOP3jw6=ML%E^u0 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≶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
)!qvTqz{CPZT4I_is3ulH$ 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 Date: Thu, 26 Feb 2026 11:06:24 +0000 Subject: [PATCH 099/101] ci: prevent Coveralls upload failures from blocking PRs: Add fail-on-error: false to the Coveralls GitHub Action so that coverage reporting issues do not fail otherwise successful builds. --- .github/workflows/daily.yaml | 28 ---------------------------- .github/workflows/pr.yaml | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 28 deletions(-) diff --git a/.github/workflows/daily.yaml b/.github/workflows/daily.yaml index 41e63804..06ef5cd2 100644 --- a/.github/workflows/daily.yaml +++ b/.github/workflows/daily.yaml @@ -36,31 +36,3 @@ jobs: - name: Run unit test suite run: pytest tests/unit -q - - coverage: - name: Coverage (ubuntu, latest python) - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - name: Checkout repo - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - - name: Set up Python 3.14 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.14" - cache: pip - - - name: Install CodeEntropy and its testing dependencies - run: | - pip install --upgrade pip - pip install -e .[testing] - - - name: Run unit test suite with coverage - run: pytest tests/unit --cov CodeEntropy --cov-report term-missing --cov-report xml -q - - - name: Coveralls GitHub Action - uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e # v2.3.7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - file: coverage.xml diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index c84e59e4..82bd786d 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -129,3 +129,38 @@ jobs: git diff exit 1 } + + coverage: + name: Coverage (ubuntu, latest python) + needs: unit + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Checkout repo + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + + - name: Set up Python 3.14 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 + with: + python-version: "3.14" + cache: pip + + - name: Install (testing) + run: | + python -m pip install --upgrade pip + python -m pip install -e .[testing] + + - name: Run unit test suite with coverage + run: | + pytest tests/unit \ + --cov CodeEntropy \ + --cov-report term-missing \ + --cov-report xml \ + -q + + - name: Upload to Coveralls + uses: coverallsapp/github-action@5cbfd81b66ca5d10c19b062c04de0199c215fb6e + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + file: coverage.xml + fail-on-error: false From c14cc0a921ec18bc056952b81aa309186614e817 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 26 Feb 2026 11:12:06 +0000 Subject: [PATCH 100/101] ci: standardize daily job naming with PR matrix format --- .github/workflows/daily.yaml | 20 ++++++++++---------- .github/workflows/pr.yaml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/daily.yaml b/.github/workflows/daily.yaml index 06ef5cd2..218e8345 100644 --- a/.github/workflows/daily.yaml +++ b/.github/workflows/daily.yaml @@ -1,4 +1,4 @@ -name: CodeEntropy Daily Checks +name: CodeEntropy Daily on: schedule: @@ -11,16 +11,16 @@ concurrency: jobs: unit: - name: Daily unit tests + name: Unit (${{ matrix.os }}, ${{ matrix.python-version }}) runs-on: ${{ matrix.os }} + timeout-minutes: 30 strategy: fail-fast: false matrix: - os: [ubuntu-24.04, windows-2025, macos-15] + os: [ubuntu-24.04, macos-15, windows-2025] python-version: ["3.12", "3.13", "3.14"] - timeout-minutes: 30 steps: - - name: Checkout repo + - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Set up Python ${{ matrix.python-version }} @@ -29,10 +29,10 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - name: Install CodeEntropy and its testing dependencies + - name: Install (testing) run: | - pip install --upgrade pip - pip install -e .[testing] + python -m pip install --upgrade pip + python -m pip install -e .[testing] - - name: Run unit test suite - run: pytest tests/unit -q + - name: Pytest (unit) • ${{ matrix.os }} • py${{ matrix.python-version }} + run: python -m pytest tests/unit -q diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 82bd786d..ed9d8180 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -32,7 +32,7 @@ jobs: python -m pip install --upgrade pip python -m pip install -e .[testing] - - name: Pytest (unit) • ${{ matrix.os }} • py${{ matrix.python-version }} + - name: Pytest (unit) • ${{ matrix.os }}, ${{ matrix.python-version }} run: python -m pytest tests/unit -q regression-quick: From c1ec4779efb3ff2044f26655519e313498ed561c Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Thu, 26 Feb 2026 11:19:07 +0000 Subject: [PATCH 101/101] update naming within `.github/workflows` --- .github/workflows/pr.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index ed9d8180..a53493f4 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -27,7 +27,7 @@ jobs: python-version: ${{ matrix.python-version }} cache: pip - - name: Install (testing) + - name: Install testing dependencies run: | python -m pip install --upgrade pip python -m pip install -e .[testing] @@ -131,7 +131,7 @@ jobs: } coverage: - name: Coverage (ubuntu, latest python) + name: Coverage needs: unit runs-on: ubuntu-24.04 timeout-minutes: 30

d;E)QU-!>Ptk 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 H7q9;xIuF=_ literal 0 HcmV?d00001 diff --git a/docs/images/biosim-codeentropy_logo_grey.svg b/docs/images/biosim-codeentropy_logo_grey.svg deleted file mode 100644 index c06a7130..00000000 --- a/docs/images/biosim-codeentropy_logo_grey.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/images/biosim-codeentropy_logo_light.png b/docs/images/biosim-codeentropy_logo_light.png new file mode 100644 index 0000000000000000000000000000000000000000..2e4c2841c33182a899c84bd47749f8c68950bf91 GIT binary patch literal 440378 zcmeFZhgVZ;6EB=|jtzT2fdp*X5mAwngeFZ9HVF`r4pPNXLX|32J&F<}0XLvXC@O@Y zR0RR)iikm)BowJCReBQ;rQ8jk_kMrG{aA~|TAMtR>GPX;CW*MHr?qD%dM5&b*h3;} z7$Oiz6#RQ&$3O6sZvkU#@E^XbL<=7TLK4{c{VO0{%^!Z)d{Ng}Q|qd3X=!PHe}7wB z+v@5ni^WP$Pp_}9@9phfUS9t2;X`q8G5m0SeLW*1V{UG4W@ct!VBq=l=Nt~__wV2D zYu>N_jTjpngHI(VCoe24&?3J6jW9PihtIaRw?{FeUcGwN)6>(`)WqZQ5)%_k zOG+!Q`9FE`q_(!Uva%9E|2p|MA~q&AEiJA3ZM9d{+w$`AghvU82UCbw>sxEq5ov4d zdAY=#@A;AU0-wD$=l)#Zwd3vddZn4!dVBj-FRziH!11_PmD|}#!U`N%wB5djdPa!4 zk00K2JyN#3$D493@(e^Ejv`1J=ZyopCf@$_7yPQ5_t)~Kc5Eo_|Nr@aRs%OX%MyD{ zA=#Zz$k{q>!Whu$B5jvCCYCe3esz!Vl^I*n54~rUToohSV+@)yh=t9$fB!$Ls|~?Z zYJNA_PG3LRLy*3K65MzY;$CL+!Rvq~70qN%gslN#gh|O-uN9AP^&vKW9kXl^29`8e zG^yI9+^kl1ZS{3_2m4N}n)Eoi+`_MKtWXntk#@*O|aGmv=Xu)r-lV3>mg~Sop5qVCk%-iOG7a7<4o27{5i% zSZZ!rb87CdjMDFWI(|h~xD@^&T2Ra2;#l1;KGCCPP@{Rfg}9&MdR><})5-DTbp=Jy z7q$fkH_h}~Q({Kk^9|*HRm|sGm|R$q!5-aSW<~LAAZ1R8v{}B|Bv$qMBe_1GS}lP{ zh=DFBcrzam8jB=&&XZ<8>xYjoHv~`axr-qcNkO|8kX3KL$Vp{o=4QGaxp$!KBj@KM z9Q5lu^=D_X#v+_}R_3!Fil14Jb5L;YBJw11$$TWI#Kj@#v-eU*L%q`tIx%rX{tnRj zQ}{IY!UwD+D*g*zDm;_(`QXX1)xHEOo_r*{_5CsNgrd&}%I0WZa8_HB8CO{KkvW(! z7k&MoMp6RhT4?^BV~o|2sjOz59|_Y%O%sRXM`W z3KVQ3J&wwz*Y8>nw8+zoI(Txpg;RMxU%{TzhkhRUE-GH@>}xtApN#h|Efec*5e;k!BxU}3hB0io1z8Xbt)xvs zw!o!pY89JCNVA82=yJUr+Ds{RgU{AGd0Tx|Xh(1Qf#{vi7QcHt-A zG)s&pn@BY%VIpI=33dI)P89FN8u5fw&!EkzJ)_e)a%V{tOhilUuMv|6{J5AtfyK=Rc zKYnOAX~0F=dv;6AaEJmw)7We9_;7laK%?Ye3Ado>Np?v{8S}XU*D=u1%BDJ~F#E3P zL5<<~d%xP7IG1%ADaV7hA`nwen#^t^XxW1)W;IF=(^TMR&zm1QHv40|hb84F5p&-n zc(BQiWxWNM3>YSwjG|Q5%Z*J|t=|`NiJ`{mgx-&!7p&{or-dS_m+YbE!rqff&}#fA z(TZ*lkxq_Lo{!qxdw1tKtzjbbM z!LKO5IN+I&L?FIemJlr-J}n1?N~};8Sj^?0RtJc``Z!uHFMrefn+L23Bl5f+5k`_) ztZeQ`dul+uGuUO-7)4QeP&2?!4*)DLr>>hj-uKgAoum^>_8 zo*fu0s)*hRviFi6Gbj;WfpP|+*QC8K;N~3PC0jgxAB^iwEmjeTQts~~bSMGI?U$AUy`jvXBiW-5zzO`ec%UO|HzrV(U{;Cdu{RL z&A`YHfiMrbserqvVbJ2PQhq?Z{@s; zKpiK?K7AW}?#(TppK_~Z7)P`9#W~*AV;;h(E132VA8v)myZo#0iP=FX<*HpX)4?-limzqZPEsRsao{S!$V!=jXAZRwto50gGkUZ z29j`H_UoMMu1A+}?)`u7Ja}IIoG@+_HT%)5%!Jtm6uA({*_M8lgHZ|Q*ue&luJl~F z;k)tt&M)z5Sz7>CF~ETox54wdU#IT9siS%8Ex42r(^h_0geqp`7FN#82OH&l=|Ruyx2rWZli?1IeYNbPY%sG75NX4DYqxPi995(_jS3*zLlf{68?l6KzAm|rzsOEBR7wQ8|4@ysg5;GcYcHi7Z&K_ zb>rx^qe0G2CM(x^&!~Kt>&vDs_vr3x{mH|U>CRyc<{m|-Vwd_l&Lp_0sF^3hGy(b# z6(G~UUQ^7A<|g`5Np`D;^9#Fc1S9+|e0UHK&D9TEt(E6B3~C)6J!cSHJu0FVo@u{7sk*0tKBw=3QqB z0BbtC#}`-4KyT=4T}j1rp>`+eww#$PFxwv!gp6F0;nVV-;D&tO%VE&4Me*1W^`oTRgiJ7-=i8=i;?~YyBkvb`qA6R2t(3Jp9;U|%G zM||@b2?wbaOk_rI+Zl9(DxQ1!uhS(yg_V{^Z_q&a@XdXGG+8P8Adnp&w5n5yHvvl5 zKk+lLd%@ac_uH>0C8Ma0JI#Sb`fhhX1E{+V9V~O9x;(>n2 z>3|1d%9yO0%mpA+E;Ry-QT<+|)0lO=nPX4j$D``?os$MC^jc_-jv>nk)?lh-EkbTT-S=_#bqdSR2YF3ZV~VRWjT%iw)*`ry zKmGxRM0(gLBm*Xbcs!i73$%v=@S$^gDsp~(v=aJ2{UsFv8Y((4vV}(RG2j74`K1g# z0&Y&fZbu98?1xiy%ll8E;B`nq7AEjUYa4J!n~P6PzJ*M<*V=!g>nHOT8GJ%@Cepm@b6_2K^ zFUFXR?~B~h%SN?R|CqITCuqz}fJob2)lOjAU23Ws0<_Q5G%$5L;26w4IDl#FcSm1K z7VDKGoANVQ0l}!VVBa^n#$JnzW9W?zQ1z5>gc{y3k&P6&^NjjCQ_ZBmnXa4#(!l<+ z9CM9^dp|+}jBrB9#n|8_m(*rD#o}F_PET=s{!V!uzi%Qtk~F2UkrCc#DVMCAF0hEKQX3wpcW?Jg+8Gqe5q!lTDXWJ` zP4-McM+nl5btbdk4blW|1{?>T2ZOawpqGZK1WH^F7QAiPJ|7Hj%?E{UW2|30!mf7`V9?QStLo)C{^t5FvG*j7SO2K$j zYUoj%pYN)4@g3)4>>XK_&%U+op0+ZbR={I)z}lp+)s1v&SFHDO7YG~kbL)k=-{n(J z`@WT#;epb%Z))Q(KZ0VK4;{gk0mAjIuJ=-)O4qQ}teLl*GhYpuPl~CI!^A(XSD{+c zdR>~o!bzhkTp`}MJdOn?%}ms2rHOLc7Jt-j$SH?2;lTVN#lx17uiR&CT|9W*9^2~9 z{Qb(E)xQ-6d5Fd3!MSVvbd4&qQTMj?Qn_m(g+k09w#urHh#J8cfM0=S@y=K`>A9XX zYT0SR)OmD9_`;FX=E=um2r7WXl3FVC^)2yh40N@d;L%K6TIT3%j4~!#{9+>EEb?;x z*hjHrxI;4GRGTKc?-$p-ty}zD!tJo0U0)=4aX#tG1NDIPxCKtPlMIX^w+aHii2TCyt%5_IEXnJ1eWv4_4Mr7nsMI+M%C{- zUFu(;JFm{aJ8(|iR&ABT@f0yX@swj_ALH+c%_4HEw^AJz*m%IBg(I!b4Yi(K-1Npt zQt?I2kM7JmcVby`eA^%^$DMDkrBdDDT-cqcs)BM|m*W<~%AC?J&M1-B~-uLC7Re&znOKrmx z#^fQH5vndaRg-AXgPaKJZN&W}Yy6vSKp&n}PhB?)rT3&$ zs(8cGPQ;qH7cMWa;61t?c{-djR4S&vVt~)k7TqV0jGLAD4!-BmD!RtA8yXhqEN8o9 z9c{GDm%>A7Lzwfi$iT$f1JVD{8zvR&J*IQI33CIVB<($UbU)x@1ATWmbtM5Q!1%QF zMQ&dC%Q8aqeHLn$=A80Uu`BaV4(j(lQZaR9oDi*#rJh#OvP|bIC*vb|8xp_KOYwbh zN{)-i@qsQa^)BNUe(OWh_*C6J;}Z=;>v{gXG1&c*{Llqz`0h7daK6!#&X4FOTrlL` zXXX4}G<<&`Ex3)Ly*9%ScXNo~tB=$^C&ISs8$WoArl@Pw-3eW4qJN(yFp9qI_)R#L z)ZK2uMyW$2b?*8n{R=SmDQdyb)r2xl4%PQ!0hJ!-I=P#pTE{G zaJ+9?xzNB_6%vvCW8P_f6(1-ECUXI}RF|hIl~ptnoa|cB*Fbxy3HOvCjzMy6k-dtW80#OOnm1&eG|$6r`HiOHJ(gc541Q z`|@K)*)Jv*lP<8*uCnYOZ(#I*C75U#fzJ_i$+*&Ho#wSoyF3Bu>8#OCSFU`G+xj9V z@^dOmb)OQU$$(ia_~O@PVnMl{fD&q1EqElq`xL*XK?yLVNEcqX$+@ZBueX1F-D~ok zir&Gk+a`8xtuW1XR}Z>-r)Fw(CZ1}QI-IE0>(pygS$`oIWLFdJgrGVBqh^9kHTr5W9InZFJZu3TjjV^ww$VE)zuZ4R9QDMH5J&hVi#&%s84L zPe+CE2)Wl-^3lS4l%OK{=P8g#OBgt7VOYMY{hd=V(-D^u6m^sb8Np6$X`CXr~sG4YL}uYYqpDX^xa&-@#ES0 zPkEXjeLQ#pC&Vvr5>$}RyA}qi6j6OY5M5bFDH5>mA|G6+gIx*&lbl#;sb~B7)GvJ( zu4A74esH$fCEpFJJ>Ux5sKXiN`O;TXQo|PbLOx$+&V|6?Uo#!qy=#NbH(rl@=ammO z6RXH+4*Lg=&W@XPTQzWn-LYr&2HbhiY((oydiKqC2Bgxu*+>uMnefj7|DLEZ)5?F_ zd7y}TR+^!FJ$N*q&|L~HerCVjgcgXa&XOIjK`F>1m(jC2MDIzVx^$fyCb+_crtXi? zv;x-$M1ex27E5cePmb&?{Jh|NisR!HNLtUx9vxUanckrp)$`^on(dYG%j{q322@9U zeNt}CGXR6)IWI2OB;QYz{lyVvJaR`<(bNhrX7_fXmGi0Ka{Xj=@7@hCydnv!D|BlXQfs!P@BH@h!yh@HagEcR{5;O>kdVlu=Flu)a*0PeFEIxTUJdPAN=L2swv19td_lb*hPDT&+9OUtpxEY=IRR-hc95 z%^9F21^POz6aZqVXqeY?#dOi&!X>?=+6t3L8`u$n*jv|onK`KfRHN)ioI{#v;-WHz zn354xou!!~KM`25?F|omRe4?3&92ZPtFSOSz*6>|3-sAz+9s=FsK!DU*!MJ?Dwi_Z z8Ze@_q4ZYc|P)XNL~FKm9QfY$_uUoHb9#*g&QT1x(dLNLjX-R;4WRs7v)LX*bi~A2XGtERU3y&sY$O`&K(JQ-)sQwcuR3y*p_1O6686Yv5 z=T=1?803#5Y!JLT0NKWB^L$kRQ$DKCvFL!)m2y@CY-f#IV%L(hClPn36qR^zCuU8W z@ru#8SrZDUFTAKOZ1hYUsq zz`r9XEzsiz)}7H7`qVqnm0%F;C-jw6CsqF!KGMUxc@rh%D;dGbHuk;PU$5M48t)is z)U`4HC2;4SmGL)A^Z%ez_oTN(bCH}F0|Q?H1O7r%0*=lR&})B85aUd8t1c3@S2+xcy#{x8l7 zk#e`4t_V#LEB4DaXr}dZ^ZE-8mulj^;O+PMz9Sm#^NlvouIE#CgFE2cN=d^5*mPxjK!!LGb-7j^(6UF?LjH-4WZZIpY20)Jo%rI|Ki zc~A^l^3ByGru;!E?NNHd7>XLf%l?-7j*#$0y5Ggj)uQTDEj7P|Kt9Cav0Cx!+s9od zDAS!m;5HlN7oF;>QXACXKmpCMI=t^G(A#qi%h7`#{O%|(Ii$SK6&0XGnyj;Z9L?0* z*>Z^{pry;B$@g|^Q761qe#O3L#pb{^vOxC}PJ2qVQI!Fpo1iu;pr&ROvO)Lez}02> zDls^mK2SCO$%-Y?Rr|{^xmRkke}c+D)z}U%=0W|^Os_^h)o<|=ZQwlbO*SBn`ri~o*8b&^S zSo3rK{gACBOSWol|LMCm?bNU0ydi&v#d&HYW{6r$-Kj75^2FM= zu}yV~-s#9wZgfWrJ$fmLEa*MmPsqu-%phTY>Gt5&lN|5dcl5!p3NqrK*VP{?D3X_dsKvB*&AF8N!o|eS(sc}lq zJ})l~VEMnH_$Dygj62jz@6)ELPgtM68!(Bv{G_%$(zhb(ax*cc(kuYQ2ymceXMAL@%g~@tetyf@&vQIM12RH1^&gaI6YXo}ZB;lad@2r%h09ytor^n2OuV zUft*>;V}I>{=frdFCH`EtkFV?$&aY(e6-W_ib#tCFjf58fwlT>|9I*NYk=NL?!ot$ znk7?f9!BZb!EbZ~TTJHNxc*!}7v6>VvkNeH$h=zTC_SkT@2z!YxsZNftID5Js`WL9 z+>Y;WgFf@Dj7W5~y3ambdH07VP~^*sdcdIVZ1)WYX}s+7 z*COM(r@3yi?1b-ECIi1IZE%dZuj<(W|KEFPl1H?tNq0@s>%U~)cyF4X0+|+u@CnW+ za;5ACWY_eM#_m1{J$2H!cY@^3`@T;Xq2nRdR-z2()Kc$Ve;qHisep68B%Y%>-{JdZ z@AaszzPHIp{$6SF4K>>c2(Xcd{e+Z9!3hk`(n!l1m^x9Ug%`{q!AYux^LJlX>;hc^ zl`$GUE4)dG)qI@fpfMz=1&C;ti!-XaU5lu6OYF5_r0fvtsWYV|)f6P`mUIOJ1D4W{g^tmayb~Q=lGp`wpUp zUQxQ1V3$`*z-2x1h@`!G7Y5t2v%qQKQ}X zS(1?vInj#8)#G4^W4d)rk^>SQn-m%+kJxTzrJJpRjj_`x>TOBWfrCiv>|{wy5E!@G z2v48`Kyh@!I;&d)ZKh&T)B23L)SXbvXbmeMTsfG1;~j1eHdjn4q!|)ob~$O-7*5gy zgga}%VPM0=H(N_sqnml@1_P{1uz4CSyf1>(^w~_Q9-j)+Z7jR5k$;qAG~N#BZADI7 zv)MBJoELs= z<3@yJK9qV=K5iJ)?O0B%5+;WBgZ|@4YEYZsi=8G|Qv!~|W4$I{XyJrp7HpdPr1*c3 zbDL*m7?cwX>c+7TI%H`t%K{%!T5%mRskZ*9>~!ZruQj?h24ig$xn|NUYahiH3m(6y zM9Op0NYo=Sdv`nkxp(HDgD2%v&o@IYQX(U#11E#Z&?-&v7oda6c+TF`$`K?!~P3iTW?R ziHY?K%&W~bQ_b=mn}s8A6DUx!SJS`?x_S55$7kEmW7CPFq`39<*WJeUzeT&f_HNxH zRw!tl#|k$k6&AvfdBWbWZ$C0~RufR_hV@vkgW_Xg5=tQ%i63&_sbWMxB(37Pw4p7t z)_qCUo8L`2C8~m4a56fK2^FvZ`(YFNAshz1n>mS7-(m^lAOpvE#)YJH044L@52;RD z9BOKBr?61Ku86pET;oQ}BkXzfGNzyKAf1}zIk-ZAY4-f`fY!#ZQ7cc8WBu;?a`Eb0 zH%5<$bG_y5ne?P!(CP*;OBqOY)+j1yy6{X8X0{J+$kiNHn|Oh~^QypsMS2*lSqXl= zfO`nVNZ=fGkGHw;zOUDkUbH;kTSPXB;?)S5HSai>y+IY>=d*sP(1{{keK9XyE98i) zlEzTBM~venIzkyZPNjSm7k&U08eyxt%RqNZYVDaORyX0d7qg2?cuP?C3Y#3)Y~qT- zO~+Y1-l%Tl4bja$5)lVQ27{MM)bN}gn%Erf`MdXXXDa|YFXYizVN_ZmS}K;# zCGeM;rx6%IG1WxhvcnE6Ax-!N!c{hUAMxJS-Go%9!8>W2Y+l4))1O99t_C#|KdCt) zpGTy$3mv~L(OiG3aqbduT@Ck#51xz>n}4)-fyw{R@r&=9xeR4 zAga)sVknDIjKH43Joiq#>xBm7R}aF&=4yO($7X+fu1(F_Wg5lDrP+=j3N!5w1#-p( zBNnOcv__BS9b$-G`-}>fItZv{+T-U5`Hql+{(Q6q@VJ;fPsiE!FScfQF>f!>-C!e? ziT{ZX*?Hp|&BSLGd$vJXqJXyfm3rDdJuV1b>$6X)t?qqgoGs`AiosH{sP_sgsC{gB zs#$eaP(s;uT8=#83JU+}m7XPcjHv)NtIll!d5IZ(ve~Mn0#&|d&X_Q2Czm7O^!80- zLCP382<&S(218QgH|WCLsT%574|^1q@zU$;rDjfW5tXv<^v6xlKKH(-Ba1Gf0vHM* z#|b)|+A3NlGUHyu<(gvaM2pB2q+{?I6=kZI&)=IN*}k#YRPF znOEA}lJI`eO?Zpl3;ajFBCNT(V4qO2BAdr)tSug+~1;RA93l`NUTKy_>c@_g>A~CTj{c zI~Q&>_24hG;!u_f@RFEcKz`?z7s2k_ukk99(%_pn*4g(mVmJt}W~n#R3g}4=F)6h+ z^&R!2S5<)N&+J(?B;@!9HDnIwE~X%5H3X(xo}pu&IcZ?2<$AYpbLXc*?Q0{CU6Hs? z#QGL;JQ5x6)Y`=2_!sC&dq3mzE6BX9fm<9l#|&_SfN^||xUj)3 z;_Cw13BAJ+-DVwH(8GYR^Qbxtu*g4HYgB;m$gX_OQZTsNf;r?KjkEt+%XiGIlsupU z94gox(IR9|0~Z!lfK&1?JoG%oHX=~5$-anyBcpxW*Pzz4eNWN;$ zE7hWELW=_9-sje0azVMWH+pfoZKR)|R^l`m+D)*Yq0cOA%xV!ArR4pV=m91aTBc^0 zHZJ^X<3uZrnfkG@snWQMqA!~-#Kvm&TfsvWWIAATrtbN%r&n41DUi)QKpYS}9Bln2 z2oBw3Bh5Y=gFk@ilqq<X)UA)g1PKsT`TkS;kgEPTIdfJT_ppzqdhf|Nn(wm*7&V(-A3b7yMKIS&y% zSI*LYK}T=Ht)fpS9;Yh~Y;(|vZlPx#jS)XSB68LUSOHM)XySH^u-H?@)HUu^^zM(G z2Od0HK5}VF=ufyjv2hr@whDXa$$o9~1y@en&ppgf(*170Z#FUx3Y}tF4Q1+ee1~if zJ*P55N@)3XV( zji(LIdRLZ}bcqClm1$0cDp%2q&?Syc5SZOfxL$Itk>^|d2f^QT3CsyeWFKOwregUG zWcvGlTch2DsUx90j&vG*!iQI*31nGod~gY~z{W)pNLyXS%wv6DnWoXVOmoJLl#D1$ z^urSdPu>BvMe@11TewXxx{32Handl${&f!`ubd;F7LXm;7XYf}(m8&8t%}^A?#z}5 z48!r`B-faSw@0%BL6ppC;3a{({v!qIg=KCF1lLqA{&65!=xG#+@?lK+XEIvt62LFl zg~yyW@5qPv^^@*~I%pK;*4_cGzkz$8n0CnPolO3kPg=#gnBF|69w{eIZdyT;%k|{`J z4nV%BT059N=FX%C!*%=#owlO8zEDQ6EhYW!#%s zqG!s+_tN^_r(AjRY&_9dMc>D>^ zudR7YJ*MbosVQ_3-ycJeyl8Yd<2QRj%WW@{KSDH9JJT%*Y~o4;qW>3rCCh)Q0MrjekvaVNWnmGyZ0 zdTzNjT5-?sp2ID8dk)vvC4P9an(zPSuEC{!GjxUCcjJKeLB8TaG?~K-5F&NrZaLMw zxzL1*nWwwq4r+t>fy_)ULH%Tc0&MBQn<{`*x;?~^5C3?d@ zYSwV_wLArGygw6`=owJR_7Sy~R->^7g7qo`Ta zs98nkGkV*5=b8%V^x|aAH;%>5VUHyR9VqW4F=`V^;M`U#X}X&yoQ$Y^RlQuz^*i3uGn$5x3N7pLqD8F>V)=>RbfTaz$1hFQ78;GMjU4F$x_dpAm3E6F_ezvy+W zGT`)p0tZNHV_zzvT3_fNLxC1H()Mks#HRy1LgF}~EePZuHdRK5ZCdl87y5Dftmps4 zjQ`yQFEfRKuX2OxG!nI&+A#Cdoc#;|9e-s2AO7~uW6=E*hm5u**pJzg7a$Gc=h`Sz zHe7eb99U}BF!(!h{QttB^2#C;Paa1REn_&)i@9z7BYC@u!MtSiCh$vhzSPKeS^Q;| zAw#up{6Sv<>Ny#1yzC>h2;tXD-GmdPD8;Kxu}=1AJ8@6=rVXCBIIzZ2kjLq}TO!?r zEsW#d<5DBd7%51!?3teLD5g2qqMz`7sBcdhk)QqCKhbfkLrwLlFP!P)4SQ*kt>r!5 z8#_%1gv;7<%MExd*iygKtlHGw(KMs%6oLgVb?ALONne0o~p=3C+>_j5^ z34R{DmC$z1WF$+tXw~+*<+q(q^%+1 zdO6{4i%lYq>vk5@*g9^Ho^5%w5ZKVLcu!ticsX~QX}w(I@&MrUwt8~5h$>;h)LWuU zcWn~ASccW8A^ftOzh*q-I7W&3N>-)#>)es`|sy$Mq(0qJ4=@M;o?Zu~U=+78CUDy5y@Py{YhC2`)SE^AoH2q{1Hgx`&C~{PjXysQZN}pp+=+K7NFk*_&40)fb9;{b zIv3IPI3hMiK`fz(u27DQe8xRbR6RPY4qaC84yJy%{Y_Waq_f*Gxx%!Jik|X~x3=+h zqpQwYJWOD-sdScsUPDN z!N#KbWFXbbU)NUM9BT}^%I;rCpTorNH9fNb`K5!H*`4jxdW^{iVi)xI$PRaJ8EIyHalniEd%xk-+ zaKQX;LLJJMe1W<-roHLj=k5g7)apvirWC*P<5P@l0a^ze&|Q$>l6-pRGmnrp$} zo6XNm*{zv&1jsXVeg&=ilWoV_ZQ>WW*sYa53CJSgq4z0?7V&W+kw$z+3~3;N#*Q^{ zPS`re6O=`RWu;z$R5Qn+Pu*MT&dtpW`Zp^%xtE)1qBmI!1oaQh1#Z|tdI}PU&{531 z+jq-Pg93(K!KR#^;_hfB&OI;jVCEHT&;{qcdM)G|S0otJXxwbOZ-(}bcU3xJbGcgvXqQ;eleHtlyl9^@ zMl-Yk3gV#;^D~<-6DhlW)bJnI;E~IfiENmyd&Oeb8J(}4C_#zCF>y=3^jrSGKew-@ z#d71QlR2NlzS6Si5AC~cQBz`5__NfanfC6V7k|4SB%J}&B~wFyflJ2`h@~7e1h1T|FMAS$`bVL!r914qLzkJLXWoO%+k>q}F>YIKj=Hz`Tcx{e?Rk^L<$uH_lBZa+2ZUZk6{}q1>F&|Cm*128 zXTl&-(pK0*Hqx0Kll}{N3;EJ14F?zy`ZEAt!X`ccJ6(Mi{*lZ`>4d;EDC)ZQnHOiwBn0^}U2qpR3V7p0>-2 zYnF$}(l1y^xn&P^?m*ovOll>v7ETl1DIO6#=?Io`6*R=OYR)nW-I-pXI=<1RFE@CO z-g7d8w0_HBGr8T&xk?yAooohI^vKeAccaTG8m%p@6^{}Qd>gkVcMM&JW}aR8qHnZ= z4iEW#9c!-2H%@=?eUbZ^wfYH8C7d1ge=e-Lzq!fc`Qsh9EyN>b@T8k0bq4U0!UK2+ z`Hb9sVzpihM>Hc6m{4O*%I*We<}q#izh+PruYRZ>Py)32PJ+#F)L-Mv=U zOX!j_RGMv;YK7z8na!6a+mvNde;cW_%}(V*uC^9tEmUaC`IRR&$t5c$K-Qo4U7)s< zYR(R%6^~ypqpFUSQD2w;iG}Z`9Uk(G*th$8yG1_0w^{TW272AF*%1`AUHfMxlWhoG zF{jX@!giBH&H(s|3K19$=q>%F*J$F;n}A2L37r|s(|c@HH>kb2tMGY}9dR2uiya2~ zUv$6~!I9F0pWCBH#xCjBO;fc? z*i4jmM#ZItEgt9xI}JvWEo8jtVuT~Mbt$S8>UZAaUIU1p&apFLR9M5a?rIf7QUOQT zefr2fdij&YZKoSAgiL(EdtWn5H3T&N#Jk*`_YFj;LqNshnAb<`7&}dcJ{${`m{~Y2 zd*>}}yg6ik-|Xp5c1r?uOq72Wlln}oIYJzjCCK4gWrJY?N+p$ik-5 z68r566U8F#(US>YmoPR~-=9V>kwRuJ+6k~h4-fbz{(Ha>@ZwK3rP^yX*66Z6k>}T) zz$t&;SKHA0@(Cz-WekQ$!*kD7e|=5dbl%Sgv|>|`NWPZegl}<=W9e-K`;1z}PLqL; z3CPNy+xh;1bY(G2XLUD?29nqzfQz?pd^0v8CZVN#mQ*(ND$%j;ta{C6NwB*j>gEVq z<%339f7>3!L|j3(kp)(>qBxxTfV>b8JDOoYCKS)oo@I@8dAoW(O{|S`qBJmGg-l0t zX&y|+58VWpv$t++mQOoZQFk)fGuhH%SlH$Ig(%9TXZ(Jx1lac1N#Ls2M^)*2BgMyQ zo~cj@WbcR#uIq4@@Z%A7a}N){if!OHoL40Or2RpbZ-y0CtP|_$v=*9N?!B#vSNHFv zmfLB3Hm`q*WP*oBD%z;XFWS{gLhpXk^XX0a*RM43 zE@xB7H;W3L_4Kf-G^esO++If3S1)IK{C>aZP{8D=N@=a6DQ3X~GygK{(>o zHW;EA@M8e|85t&cT~B@w^k)Kh#=B?~74g2p!iIIYFRELv;-@^8I28cW=*qWuGiW3H zvAu)@Zw+zts>?WBmF@+HX(K{0#Hm#QTuZ66|EdWTtI|yDc`!Q=EpxsfQ}|PPua<^4 zHm-d?D)9IR>>2)OUihimJ484oliFl*BllHtXy&8498T=Ubz~cPUg(R$ zX6T72Cd|{Hy(P*C@I8dC-j`^lH4q;=fvxfcdai#UK=aLNah?!m;u6CTXFBrJ+?HRHELI3;%272#bU&apmVC_kYhd) zAPKtE9*eMH^L4d7I&4sYobt83nw6Ntb8hC$_$xoh0CiltaQyd@h-44+HfnQ0XfvnZ z1E#ncu-!uxCtUy7*(UW`7SVQO(FDStq=A2v9K-CH@t;oexx(vj>c<&TQLk)jyY!Uv z9nd=PB_D6YRtp)vy#4=SK(?rK?O#Z5Gp3E!g3U~j7A%jx4Q;>fd@;Aa*a^OMn(Gk!pF9l3J>GDv%J*yo4KyU zH@krS3D6hYdBeQ^Q+L6K`Ot?Mnuxh|2j>a*F7#cF9LMy9FQ$+;O{_Afd4y8(JB`iF zVd{xq9=y>jfcdsy1BE-sU*CcU=4?Eb@zt?Y+R>%A(J?QfiBFvSs2!xdR^PzH;kD~S z4{ScnUg?HM(*cRY46{vsiY(>f-O;7YhV#Az-F#h$BIv@lZ-3)L2|-%e-!RZD%p{ne9erSTx0LX`t;>)qMpHs0%D>!DUxTD;19iTJv(3 zo+(kBz*`Kok}|(&N}yo$t0YvQf8t$3W?Ol$d@p|P#Q&)HfBn?r&dg}bN|SItC?ad` zNFixFypMf^`bN6;9!u6V0_qw#%98z#U`ld&gy-U70eQYDt2|{XI`j*BIr5hY%!%E; zCY4_H%a=6sEU4J!7AZ!iC%wg|g(DgR>qXvX@Syd(r${AHeq#owjp;eHcyDJrKLD)D zqAqW-Ys9+4Ly;r8tc^ZF>FU_19Aj_vkJ69GLl5&xzt`-czfM3_8CQt6i0njG=pIJTQefiUKSF6WOCDOF ze_lnkyc#QVeRb%N#dSBgqnKfEU%ClB=^L8$|1seWtEKT7IfMm&Z;~6E=r`Bb zCb;h#Xl^!p_O{aOY972AYsqP%ID+ixDz(``Lo7*TFqB#1>&KbkQlgqef?pLc$ThCk zxLQ_s+j#Q!Z(6u)j^)Do1%Dpv!oEYxO4ZFl_eyqc3Z*mVC>Imxn%eKrMxPIC9624#UOak9=;C-KD zkt3E@!HfWn*%7j(o!#e~;lt@k)4@ILr<=+3b8kU?PIv-RO_HB=)Znj(GicQ02M2S? zl+a5xHig9+kGr?KzJ`{B;!}^gdJbEr&x4ocw84+hO=Jyy68_^L9QKSJ_N)$}GF0|+ zI$xQ2uP+VN%l9Syy5Fz(6Y;l5Ov{Xf0mj>O%(sJIoL&;W6WPmhdyWIgD<(buCq=Zk z3DD*Qh9uKO_!Ery*YQfGI5WD25cOfUh7rzCnItkX+DG-MX=5nIB?72Q3s#-kiy~c6 z9CdQ6G@PQbmvM2k?o4o>W~Cs{$h%a(g<3+@KKc;SV83`wM;y@O4o4E^Uvs>}9d|r* zWq-M=g1KxQBM2Gmfi*ao8;7>%=#wG;<7eF?#|eC8o;I6~@h4)1PqkpSdp%~HwwHaC zCwT2*?oGddpV8V8b5O=o(5fmU>BMvR))7gMivlZSB7fHwQ?0y@fwi!=D3(K=?GoT~!2T=znc$B+ZD-Fc$X2BK}r>64Z z_3l@B8{L*t6xg*y-y%fS{lpm)a%DR(zZ&KxqAV&g!p~>@OZKh_HX7plrIz#ru<(~X ze2^c9zv_Xy16)KhT1&|nq&G;EUnCfo!K-rz`KEqsYXj=1-Q;56Pi4rbSY?)5s}3DU zAM8ngxfMG`+T}Xs=$Vy(Y~r@g7Am!u6Pwqg%ZDc3D zx}qP-@9*#WqemVy z_kCUGoacF-YdO~$O{$#6jd4syq2baPMtd`;jkH_I_>VTZ^Fw>}>37g;D}K(!*s)F# zaK-MJd`oB$#_z4JdXVNvfLpG9+b%m^|yMI1j7f{!jJT!Cl1xo^x`v^n<-mR zwi!P^183NrYx~=oSJ9UQmc(Ej@4w$eRG(6-KlBgje;dYhJ;yZCh3Nzu+~0<;+Qpo{ zL(XCa;Cvh%SQWt~8B5PB8#yqdI*dl?U~$6AK83GiVv83}D&z0T+PZ%bd6KhQD60`% z=UtWM9*5F|PwnYcF$Gc4jz8@R%y+JSoQU>La0)dfENq|5zJs>f*kUBLwu+eUT%FU* zqrfd6Pd-1Mej=6$Tu2bcXn6Q0hiCE^sg;>}&^~Z7;SJXO)u}mFOiRWAgTl?x&gRf5 zjZN>exB{ZkkwW6snnyB_08|zQA05O>2=FIYm2XRaJqNotsvmBH8K>@=%!#d&fBFRB zQ~FSnJM*DmQF|>$F_f%AgN<26kTy1Wli5qJMstiS* zcqX`ND&ojm@JC0?YumV#T*fw>lI)=*oDFj{!rCF({2 zh_SDkQ@GI9;KBWRx2Mz5tcJ7Z&ti^-jS5C%s8kQ?mfq&9yDhVmmA@qmq)A&Sy?EQ! zYagxGl7>9ISadZNrKeQz)|u6+TEA(@c-QXuUpfgUyCNvj#|*LK&%d0bvKtW5+>;W` zGy>~YrimxF6OZ$$cN=W7J<$<+I{6wfVAu&xa5(r`{QCzf2&r*$2B zf@NE@<=b|`LVS!&8D7~E$Y47MfV$xe)REy9!fYa50w>pZvRq-^uRMA zGcp~iqhiqX6SvV)`Xde2Z-DL>b+OF++IAI?x0x8$DM15u0M>i7_{|5PZ}erzQU!4{n6h+Vqdab}FMn%G;%arXGd@Bm#j%6=2WttZ6fIY~#7 zYGoioi{F<{N8Ae%UvbOQe24Q<=`Yt4&I%&}+b*~teR0*}`NrYH#059c^33u`@oeja z;7c`vD;F07E!PvQ!QR$gt7`djDE-ck=JeuE!Jn$`k343gK?;>iy7M&{(SY3Q^yzRN?m*a zza2zbSJYxA=X7774|R)Cfiqdc1=?@6hvz{C%VMcM6f0xwiMg-RFTM=b+ae zbTQwLRgTu432uAha_#oT(l^M%ZhN;=Z_|Ih|GkKi#+?D{`@kJ`{K4I*h#HG3!4i&Y zA(9uQqF}eLh@?}+nR%uH;`t(1epTEVU~y-=w>x%0A@`?4gkj>gE-%$MSMi8;s<|-? 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 ql7_wg(sA53Ghe>Lh)t~t%|?st&D5& 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 z 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�h+pE&EQmR$>3@U)ny}k5+cE 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 z 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