From 5d65da5b35f6dc4e48bbfbd079bfd086ab772183 Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Tue, 27 Jan 2026 22:15:28 +0000 Subject: [PATCH 01/20] add modified UA-rovib, modified RES-rovib and dimensions in universe ops --- CodeEntropy/axes.py | 496 +++++++++++++++++++++++++ CodeEntropy/levels.py | 84 +++-- CodeEntropy/mda_universe_operations.py | 26 +- 3 files changed, 565 insertions(+), 41 deletions(-) create mode 100644 CodeEntropy/axes.py diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py new file mode 100644 index 0000000..ad0b563 --- /dev/null +++ b/CodeEntropy/axes.py @@ -0,0 +1,496 @@ +import logging + +import numpy as np + +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 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 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}") + # set center of rotation to center of mass of the residue + # we might want to change to center of bonding later + center = residue.atoms.center_of_mass() + + 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 + ) + 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 + trans_axes = data_container.atoms.principal_axes() + rot_axes = np.real(residue.principal_axes()) + eigenvalues, _ = np.linalg.eig(residue.moment_of_inertia()) + moment_of_inertia = sorted(eigenvalues, reverse=True) + center = residue.center_of_mass() + + 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) + + trans_axes = data_container.atoms.principal_axes() + # 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 + if len(heavy_bonded) == 2: + custom_axes = self.get_custom_axes( + atom.position, + [heavy_bonded[0].position], + heavy_bonded[1].position, + dimensions, + ) + # case5 + if len(heavy_bonded) > 2: + custom_axes = self.get_custom_axes( + atom.position, + heavy_bonded.positions, + heavy_bonded[1].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 + ) + 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 + ) + + return custom_axes, custom_moment_of_inertia + + def get_vanilla_axes(self, molecule): + """ + From a selection of atoms, get the ordered principal axes (3,3) and + the ordered moment of inertia axes (3,) for that selection of atoms + + Args: + molecule: mdanalysis instance of molecule + molecule_scale: the length scale of molecule + + Returns: + principal_axes: the principal axes, (3,3) array + moment_of_inertia: the moment of inertia, (3,) array + """ + # default moment of inertia + moment_of_inertia = molecule.moment_of_inertia() + principal_axes = molecule.principal_axes() + # diagonalise moment of inertia tensor here + # pylint: disable=unused-variable + eigenvalues, _eigenvectors = np.linalg.eig(moment_of_inertia) + # sort eigenvalues of moi tensor by largest to smallest magnitude + order = abs(eigenvalues).argsort()[::-1] # decending order + # principal_axes = principal_axes[order] # PI already ordered correctly + moment_of_inertia = eigenvalues[order] + + return principal_axes, 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_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 + """ + 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) + # scale vector with distance + ab_dist = np.sqrt((ab_vector**2).sum(axis=-1)) + scaled_vector = np.divide(ab_vector, ab_dist) + axis1 += scaled_vector # ab_vector + if len(b_list) > 2: + # use the first heavy bonded atom and atom c + ac_vector = self.get_vector(b_list[0], c, dimensions) + else: + ac_vector = self.get_vector(a, c, dimensions) + ac_dist = np.sqrt((ac_vector**2).sum(axis=-1)) + ac_vector_norm = np.divide(ac_vector, ac_dist) + + if len(b_list) > 2: + axis2 = np.cross(ac_vector_norm, axis1) + else: + axis2 = np.cross(axis1, ac_vector_norm) + axis3 = np.cross(axis1, axis2) + + custom_axes = np.array((axis1, axis2, axis3)) + + return custom_axes + + def get_custom_moment_of_inertia( + self, UA, custom_rotation_axes: np.ndarray, center_of_mass: 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 = UA.positions - center_of_mass + 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: (3,) array of atom cooordinates + b: (3,) array of atom cooordinates + dimensions: (3,) array of system box dimensions. + + Returns: + delta_wrapped: (3,) array of the vector between two points + """ + delta = b - a + delta_wrapped = [] + for delt, box in zip(delta, dimensions): + if delt < 0 and delt < 0.5 * box: + delt = delt + box + if delt > 0 and delt > 0.5 * box: + delt = delt - box + delta_wrapped.append(delt) + delta_wrapped = np.array(delta_wrapped) + + return delta_wrapped + + def get_moment_of_inertia_tensor( + self, center_of_mass: np.ndarray, positions: np.ndarray, masses: list + ) -> 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 = positions - center_of_mass + 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 + + logger.debug(f"principal_axes: {principal_axes}") + + 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 diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py index 9d43670..6d3652d 100644 --- a/CodeEntropy/levels.py +++ b/CodeEntropy/levels.py @@ -9,6 +9,8 @@ TimeElapsedColumn, ) +from CodeEntropy.axes import AxesManager + logger = logging.getLogger(__name__) @@ -115,11 +117,25 @@ 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 = data_container.atoms.principal_axes() - rot_axes = np.real(bead.principal_axes()) + axes_manager = AxesManager() + if level == "united_atom": + trans_axes, rot_axes, center, moment_of_inertia = ( + axes_manager.get_UA_axes(data_container, bead_index) + ) + elif level == "residue": + trans_axes, rot_axes, center, moment_of_inertia = ( + axes_manager.get_residue_axes(data_container, bead_index) + ) + else: + trans_axes = data_container.atoms.principal_axes() + rot_axes = np.real(bead.principal_axes()) + eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) + moment_of_inertia = sorted(eigenvalues, reverse=True) + center = bead.center_of_mass() # Sort out coordinates, forces, and torques for each atom in the bead weighted_forces[bead_index] = self.get_weighted_forces( @@ -130,7 +146,11 @@ def get_matrices( force_partitioning, ) weighted_torques[bead_index] = self.get_weighted_torques( - data_container, bead, rot_axes, force_partitioning + bead, + rot_axes, + center, + force_partitioning, + moment_of_inertia, ) # Create covariance submatrices @@ -167,6 +187,7 @@ def get_matrices( # Enforce consistent shape before accumulation if force_matrix is None: force_matrix = np.zeros_like(force_block) + force_matrix = force_block # add first set of forces elif force_matrix.shape != force_block.shape: raise ValueError( f"Inconsistent force matrix shape: existing " @@ -177,6 +198,7 @@ def get_matrices( if torque_matrix is None: torque_matrix = np.zeros_like(torque_block) + torque_matrix = torque_block # add first set of torques elif torque_matrix.shape != torque_block.shape: raise ValueError( f"Inconsistent torque matrix shape: existing " @@ -296,7 +318,9 @@ def get_weighted_forces( return weighted_force - def get_weighted_torques(self, data_container, bead, rot_axes, force_partitioning): + def get_weighted_torques( + self, bead, rot_axes, center, force_partitioning, moment_of_inertia + ): """ Compute moment-of-inertia weighted torques for a bead. @@ -311,9 +335,7 @@ def get_weighted_torques(self, data_container, bead, rot_axes, force_partitionin 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. + - Torque components that are effectively zero, zero or negative are skipped. Parameters ---------- @@ -326,55 +348,41 @@ def get_weighted_torques(self, data_container, bead, rot_axes, force_partitionin force_partitioning : float Scaling factor applied to forces to avoid over-counting correlated contributions (typically 0.5). + moment_of_inertia : np.ndarray + Moment of inertia (3,) 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) + # translate and rotate positions and forces + translated_coords = bead.positions - center + rotated_coords = np.tensordot(translated_coords, rot_axes.T, axes=1) + rotated_forces = np.tensordot(bead.forces, rot_axes.T, axes=1) + # scale forces + rotated_forces *= force_partitioning + # get torques here + torques = np.sum(np.cross(rotated_coords, rotated_forces), axis=0) + weighted_torque = np.zeros((3,)) for dimension in range(3): if np.isclose(torques[dimension], 0): weighted_torque[dimension] = 0 continue - if np.isclose(moments_of_inertia[dimension], 0): + if moment_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." - ) + if moment_of_inertia[dimension] < 0: + weighted_torque[dimension] = 0 + continue + # Compute weighted torque weighted_torque[dimension] = torques[dimension] / np.sqrt( - moments_of_inertia[dimension] + moment_of_inertia[dimension] ) logger.debug(f"Weighted Torque: {weighted_torque}") diff --git a/CodeEntropy/mda_universe_operations.py b/CodeEntropy/mda_universe_operations.py index d7f739e..306dfd3 100644 --- a/CodeEntropy/mda_universe_operations.py +++ b/CodeEntropy/mda_universe_operations.py @@ -54,8 +54,15 @@ def new_U_select_frame(self, u, start=None, end=None, step=1): .run() .results["timeseries"][start:end:step] ) + dimensions = ( + AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) + .run() + .results["timeseries"][start:end:step] + ) u2 = mda.Merge(select_atom) - u2.load_new(coordinates, format=MemoryReader, forces=forces) + u2.load_new( + coordinates, format=MemoryReader, forces=forces, dimensions=dimensions + ) logger.debug(f"MDAnalysis.Universe - reduced universe: {u2}") return u2 @@ -89,8 +96,15 @@ def new_U_select_atom(self, u, select_string="all"): .run() .results["timeseries"] ) + dimensions = ( + AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) + .run() + .results["timeseries"] + ) u2 = mda.Merge(select_atom) - u2.load_new(coordinates, format=MemoryReader, forces=forces) + u2.load_new( + coordinates, format=MemoryReader, forces=forces, dimensions=dimensions + ) logger.debug(f"MDAnalysis.Universe - reduced universe: {u2}") return u2 @@ -148,12 +162,18 @@ def merge_forces(self, tprfile, trrfile, forcefile, fileformat=None, kcal=False) .results["timeseries"] ) + dimensions = ( + AnalysisFromFunction(lambda ag: ag.dimensions.copy(), select_atom) + .run() + .results["timeseries"] + ) + if kcal: # Convert from kcal to kJ forces *= 4.184 logger.debug("Merging forces with coordinates universe.") new_universe = mda.Merge(select_atom) - new_universe.load_new(coordinates, forces=forces) + new_universe.load_new(coordinates, forces=forces, dimensions=dimensions) return new_universe From 993f8274bfe27d015f67e8a1a01aaf52269b6954 Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Fri, 30 Jan 2026 16:53:15 +0000 Subject: [PATCH 02/20] add combined FTmatrices, match UA bonded axes to Jon's code, comment out Sconf calcs for speedier testing --- CodeEntropy/axes.py | 84 ++++---- CodeEntropy/config/arg_config_manager.py | 6 + CodeEntropy/dihedral_tools.py | 247 ++++++++++++----------- CodeEntropy/entropy.py | 97 ++++++--- CodeEntropy/levels.py | 219 +++++++++++++++++--- 5 files changed, 440 insertions(+), 213 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index ad0b563..a3bff16 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -96,7 +96,18 @@ def get_UA_axes(self, data_container, index): index = int(index) - trans_axes = data_container.atoms.principal_axes() + # trans_axes = data_container.atoms.principal_axes() + # 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() + moment_of_inertia_tensor = self.get_moment_of_inertia_tensor( + center, UAs.positions, UA_masses + ) + 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 = [] @@ -174,12 +185,12 @@ def get_bonded_axes(self, system, atom, dimensions): # 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_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) + # case1, won't apply to UA level + # 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( @@ -193,16 +204,16 @@ def get_bonded_axes(self, system, atom, dimensions): light_bonded[0].position, dimensions, ) - # case4 - if len(heavy_bonded) == 2: - custom_axes = self.get_custom_axes( - atom.position, - [heavy_bonded[0].position], - heavy_bonded[1].position, - dimensions, - ) + # case4, not used in Jon's code, use case5 instead + # if len(heavy_bonded) == 2: + # custom_axes = self.get_custom_axes( + # atom.position, + # [heavy_bonded[0].position], + # heavy_bonded[1].position, + # dimensions, + # ) # case5 - if len(heavy_bonded) > 2: + if len(heavy_bonded) >= 2: custom_axes = self.get_custom_axes( atom.position, heavy_bonded.positions, @@ -210,17 +221,18 @@ def get_bonded_axes(self, system, atom, dimensions): 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 + ) + # 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 ) - 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 - ) return custom_axes, custom_moment_of_inertia @@ -304,31 +316,27 @@ def get_custom_axes( Returns: custom_axes: (3,3) array of the axes used to rotate forces """ - axis1 = np.zeros(3) + 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) - # scale vector with distance - ab_dist = np.sqrt((ab_vector**2).sum(axis=-1)) - scaled_vector = np.divide(ab_vector, ab_dist) - axis1 += scaled_vector # ab_vector - if len(b_list) > 2: - # use the first heavy bonded atom and atom c - ac_vector = self.get_vector(b_list[0], c, 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(a, c, dimensions) - ac_dist = np.sqrt((ac_vector**2).sum(axis=-1)) - ac_vector_norm = np.divide(ac_vector, ac_dist) + ac_vector = self.get_vector(c, a, dimensions) - if len(b_list) > 2: - axis2 = np.cross(ac_vector_norm, axis1) - else: - axis2 = np.cross(axis1, ac_vector_norm) - axis3 = np.cross(axis1, axis2) + unscaled_axis2 = np.cross(ac_vector, unscaled_axis1) + unscaled_axis3 = np.cross(unscaled_axis2, unscaled_axis1) - custom_axes = np.array((axis1, axis2, axis3)) + 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 custom_axes + return scaled_custom_axes def get_custom_moment_of_inertia( self, UA, custom_rotation_axes: np.ndarray, center_of_mass: np.ndarray @@ -466,8 +474,6 @@ def get_custom_principal_axes( if dot_z < 0: principal_axes[2] *= -1 - logger.debug(f"principal_axes: {principal_axes}") - return principal_axes, moment_of_inertia def get_UA_masses(self, molecule) -> list[float]: diff --git a/CodeEntropy/config/arg_config_manager.py b/CodeEntropy/config/arg_config_manager.py index e733952..fe51dc6 100644 --- a/CodeEntropy/config/arg_config_manager.py +++ b/CodeEntropy/config/arg_config_manager.py @@ -85,6 +85,12 @@ "help": "How to group molecules for averaging", "default": "molecules", }, + "combined_forcetorque": { + "type": bool, + "help": """Use cobined force-torque matrix for residue + level vibrational entropies""", + "default": True, + }, } diff --git a/CodeEntropy/dihedral_tools.py b/CodeEntropy/dihedral_tools.py index 1c5d24d..e8137ee 100644 --- a/CodeEntropy/dihedral_tools.py +++ b/CodeEntropy/dihedral_tools.py @@ -2,13 +2,14 @@ import numpy as np from MDAnalysis.analysis.dihedrals import Dihedral -from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, -) + +# from rich.progress import ( +# BarColumn, +# Progress, +# SpinnerColumn, +# TextColumn, +# TimeElapsedColumn, +# ) logger = logging.getLogger(__name__) @@ -46,122 +47,122 @@ def build_conformational_states( 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] - 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] = [] - 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: - # 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] = [] - 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: - # 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) + # SWITCH OFF SCONF + # 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] + # 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] = [] + # 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: + # # 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] = [] + # 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: + # # 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) return states_ua, states_res diff --git a/CodeEntropy/entropy.py b/CodeEntropy/entropy.py index c5e72c1..221b33a 100644 --- a/CodeEntropy/entropy.py +++ b/CodeEntropy/entropy.py @@ -122,7 +122,7 @@ def execute(self): else: nonwater_groups.update(water_groups) - force_matrices, torque_matrices, frame_counts = ( + force_matrices, torque_matrices, forcetorque_matrices, frame_counts = ( self._level_manager.build_covariance_matrices( self, reduced_atom, @@ -133,6 +133,7 @@ def execute(self): step, number_frames, self._args.force_partitioning, + self._args.combined_forcetorque, ) ) @@ -155,6 +156,7 @@ def execute(self): nonwater_groups, force_matrices, torque_matrices, + forcetorque_matrices, states_ua, states_res, frame_counts, @@ -230,6 +232,7 @@ def _compute_entropies( groups, force_matrices, torque_matrices, + forcetorque_matrices, states_ua, states_res, frame_counts, @@ -309,6 +312,7 @@ def _compute_entropies( f"Level: {level}", ) highest = level == levels[groups[group_id][0]][-1] + forcetorque_matrix = None if level == "united_atom": self._process_united_atom_entropy( @@ -326,6 +330,8 @@ def _compute_entropies( ) elif level == "residue": + if highest: + forcetorque_matrix = forcetorque_matrices["res"][group_id] self._process_vibrational_entropy( group_id, mol, @@ -334,6 +340,7 @@ def _compute_entropies( level, force_matrices["res"][group_id], torque_matrices["res"][group_id], + forcetorque_matrix, highest, ) @@ -347,6 +354,8 @@ def _compute_entropies( ) elif level == "polymer": + if highest: + forcetorque_matrix = forcetorque_matrices["poly"][group_id] self._process_vibrational_entropy( group_id, mol, @@ -355,6 +364,7 @@ def _compute_entropies( level, force_matrices["poly"][group_id], torque_matrices["poly"][group_id], + forcetorque_matrix, highest, ) @@ -463,21 +473,23 @@ def _process_united_atom_entropy( 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 - ) + # SWITCH OFF SCONF + # # 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 + # ) + S_conf_res = 0 # Add the data to the united atom level entropy S_trans += S_trans_res @@ -530,6 +542,7 @@ def _process_vibrational_entropy( level, force_matrix, torque_matrix, + forcetorque_matrix, highest, ): """ @@ -548,21 +561,43 @@ def _process_vibrational_entropy( # 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) + if forcetorque_matrix is not None: + forcetorque_matrix = self._level_manager.filter_zero_rows_columns( + forcetorque_matrix + ) - torque_matrix = self._level_manager.filter_zero_rows_columns(torque_matrix) + S_FTtrans = ve.vibrational_entropy_calculation( + forcetorque_matrix, "forcetorqueTRANS", self._args.temperature, highest + ) + S_FTrot = ve.vibrational_entropy_calculation( + forcetorque_matrix, "forcetorqueROT", self._args.temperature, highest + ) - # 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 - ) + self._data_logger.add_results_data( + group_id, level, "FTmat-Transvibrational", S_FTtrans + ) + self._data_logger.add_results_data( + group_id, level, "FTmat-Rovibrational", S_FTrot + ) - # 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) + else: + 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)) @@ -899,6 +934,7 @@ def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_lev """ # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues # Get eigenvalues of the given matrix and change units to SI units + logger.debug(f"matrix_type: {matrix_type}") lambdas = la.eigvals(matrix) logger.debug(f"Eigenvalues (lambdas) before unit change: {lambdas}") @@ -939,6 +975,11 @@ def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_lev else: S_vib_total = sum(S_components[6:]) + elif matrix_type == "forcetorqueTRANS": # three lowest are translations + S_vib_total = sum(S_components[:3]) + elif matrix_type == "forcetorqueROT": # three highest are rotations + S_vib_total = sum(S_components[3:]) + else: # torque covariance matrix - we always take all values into account S_vib_total = sum(S_components) diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py index 6d3652d..ae4a227 100644 --- a/CodeEntropy/levels.py +++ b/CodeEntropy/levels.py @@ -209,6 +209,112 @@ def get_matrices( return force_matrix, torque_matrix + def get_combined_forcetorque_matrices( + self, + data_container, + level, + highest_level, + forcetorque_matrix, + force_partitioning, + ): + """ + Compute and accumulate combined 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. + forcetorque_matrix (np.ndarray or None): Accumulated matrices to add + to. + force_partitioning (float): Factor to adjust force contributions, + default is 0.5. + + Returns: + forcetorque_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 + axes_manager = AxesManager() + if level == "residue": + trans_axes, rot_axes, center, moment_of_inertia = ( + axes_manager.get_residue_axes(data_container, bead_index) + ) + else: + trans_axes = data_container.atoms.principal_axes() + rot_axes = np.real(bead.principal_axes()) + eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) + moment_of_inertia = sorted(eigenvalues, reverse=True) + center = bead.center_of_mass() + + # 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( + bead, + rot_axes, + center, + force_partitioning, + moment_of_inertia, + ) + + # Create covariance submatrices + forcetorque_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): + ft_sub = self.create_FTsubmatrix( + np.concatenate((weighted_forces[i], weighted_torques[i])), + np.concatenate((weighted_forces[j], weighted_torques[j])), + ) + forcetorque_submatrix[i][j] = ft_sub + forcetorque_submatrix[j][i] = ft_sub.T + + # Convert block matrices to full matrix + forcetorque_block = np.block( + [ + [forcetorque_submatrix[i][j] for j in range(number_beads)] + for i in range(number_beads) + ] + ) + + # Enforce consistent shape before accumulation + if forcetorque_matrix is None: + forcetorque_matrix = np.zeros_like(forcetorque_block) + forcetorque_matrix = forcetorque_block # add first set of torques + elif forcetorque_matrix.shape != forcetorque_block.shape: + raise ValueError( + f"Inconsistent forcetorque matrix shape: existing " + f"{forcetorque_matrix.shape}, new {forcetorque_block.shape}" + ) + else: + forcetorque_matrix = forcetorque_block + + return forcetorque_matrix + def get_beads(self, data_container, level): """ Function to define beads depending on the level in the hierarchy. @@ -364,7 +470,8 @@ def get_weighted_torques( # scale forces rotated_forces *= force_partitioning # get torques here - torques = np.sum(np.cross(rotated_coords, rotated_forces), axis=0) + torques = np.cross(rotated_coords, rotated_forces) + torques = np.sum(torques, axis=0) weighted_torque = np.zeros((3,)) for dimension in range(3): @@ -415,6 +522,30 @@ def create_submatrix(self, data_i, data_j): return submatrix + def create_FTsubmatrix(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 : 6x6 matrix for the covariance between i and j + """ + + # Start with 6 by 6 matrix of zeros + submatrix = np.zeros((6, 6)) + + # 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) + + return submatrix + def build_covariance_matrices( self, entropy_manager, @@ -426,6 +557,7 @@ def build_covariance_matrices( step, number_frames, force_partitioning, + combined_forcetorque, ): """ Construct average force and torque covariance matrices for all molecules and @@ -451,7 +583,8 @@ def build_covariance_matrices( Total number of frames to process. force_partitioning : float Factor to adjust force contributions, default is 0.5. - + combined_forcetorque : bool + Whether to use combined forcetorque covariance matrix. Returns ------- @@ -474,6 +607,12 @@ def build_covariance_matrices( "poly": [None] * number_groups, } + forcetorque_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) @@ -532,13 +671,15 @@ def build_covariance_matrices( number_frames, force_avg, torque_avg, + forcetorque_avg, frame_counts, force_partitioning, + combined_forcetorque, ) progress.advance(task) - return force_avg, torque_avg, frame_counts + return force_avg, torque_avg, forcetorque_avg, frame_counts def update_force_torque_matrices( self, @@ -551,8 +692,10 @@ def update_force_torque_matrices( num_frames, force_avg, torque_avg, + forcetorque_avg, frame_counts, force_partitioning, + combined_forcetorque, ): """ Update the running averages of force and torque covariance matrices @@ -595,6 +738,8 @@ def update_force_torque_matrices( combination. force_partitioning : float Factor to adjust force contributions, default is 0.5. + combined_forcetorque : bool + Whether to use combined forcetorque covariance matrix. Returns ------- None @@ -649,28 +794,56 @@ def update_force_torque_matrices( # 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 highest and combined_forcetorque: + # use combined forcetorque covariance matrix for the highest level only + ft_mat = self.get_combined_forcetorque_matrices( + mol, + level, + highest, + ( + None + if forcetorque_avg[key][group_id] is None + else forcetorque_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 + if forcetorque_avg[key][group_id] is None: + forcetorque_avg[key][group_id] = ft_mat.copy() + frame_counts[key][group_id] = 1 + else: + frame_counts[key][group_id] += 1 + n = frame_counts[key][group_id] + forcetorque_avg[key][group_id] += ( + ft_mat - forcetorque_avg[key][group_id] + ) / n 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 + 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 From 316f8d47982a07e3f617f8844069b0a5dccd9bc7 Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Fri, 30 Jan 2026 20:40:29 +0000 Subject: [PATCH 03/20] add arg to choose between axes schemes --- CodeEntropy/config/arg_config_manager.py | 8 +++++++- CodeEntropy/entropy.py | 1 + CodeEntropy/levels.py | 20 +++++++++++++++----- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CodeEntropy/config/arg_config_manager.py b/CodeEntropy/config/arg_config_manager.py index fe51dc6..813c6a3 100644 --- a/CodeEntropy/config/arg_config_manager.py +++ b/CodeEntropy/config/arg_config_manager.py @@ -87,7 +87,13 @@ }, "combined_forcetorque": { "type": bool, - "help": """Use cobined force-torque matrix for residue + "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, }, diff --git a/CodeEntropy/entropy.py b/CodeEntropy/entropy.py index 221b33a..a4cbe0d 100644 --- a/CodeEntropy/entropy.py +++ b/CodeEntropy/entropy.py @@ -134,6 +134,7 @@ def execute(self): number_frames, self._args.force_partitioning, self._args.combined_forcetorque, + self._args.customised_axes, ) ) diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py index ae4a227..fe2ac15 100644 --- a/CodeEntropy/levels.py +++ b/CodeEntropy/levels.py @@ -86,6 +86,7 @@ def get_matrices( force_matrix, torque_matrix, force_partitioning, + customised_axes, ): """ Compute and accumulate force/torque covariance matrices for a given level. @@ -122,11 +123,11 @@ def get_matrices( # translation and rotation use different axes # how the axes are defined depends on the level axes_manager = AxesManager() - if level == "united_atom": + if level == "united_atom" and customised_axes: trans_axes, rot_axes, center, moment_of_inertia = ( axes_manager.get_UA_axes(data_container, bead_index) ) - elif level == "residue": + elif level == "residue" and customised_axes: trans_axes, rot_axes, center, moment_of_inertia = ( axes_manager.get_residue_axes(data_container, bead_index) ) @@ -216,6 +217,7 @@ def get_combined_forcetorque_matrices( highest_level, forcetorque_matrix, force_partitioning, + customised_axes, ): """ Compute and accumulate combined force/torque covariance matrices for @@ -252,7 +254,7 @@ def get_combined_forcetorque_matrices( # translation and rotation use different axes # how the axes are defined depends on the level axes_manager = AxesManager() - if level == "residue": + if level == "residue" and customised_axes: trans_axes, rot_axes, center, moment_of_inertia = ( axes_manager.get_residue_axes(data_container, bead_index) ) @@ -558,6 +560,7 @@ def build_covariance_matrices( number_frames, force_partitioning, combined_forcetorque, + customised_axes, ): """ Construct average force and torque covariance matrices for all molecules and @@ -675,6 +678,7 @@ def build_covariance_matrices( frame_counts, force_partitioning, combined_forcetorque, + customised_axes, ) progress.advance(task) @@ -696,6 +700,7 @@ def update_force_torque_matrices( frame_counts, force_partitioning, combined_forcetorque, + customised_axes, ): """ Update the running averages of force and torque covariance matrices @@ -737,9 +742,11 @@ def update_force_torque_matrices( Dictionary holding the count of frames processed for each molecule/level combination. force_partitioning : float - Factor to adjust force contributions, default is 0.5. + Factor to adjust force contributions, default is 0.5. combined_forcetorque : bool - Whether to use combined forcetorque covariance matrix. + Whether to use combined forcetorque covariance matrix. + customised_axes: bool + Whether to use bonded axes for UA rovib calculations Returns ------- None @@ -772,6 +779,7 @@ def update_force_torque_matrices( 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, + customised_axes, ) if key not in force_avg["ua"]: @@ -806,6 +814,7 @@ def update_force_torque_matrices( else forcetorque_avg[key][group_id] ), force_partitioning, + customised_axes, ) if forcetorque_avg[key][group_id] is None: @@ -833,6 +842,7 @@ def update_force_torque_matrices( else torque_avg[key][group_id] ), force_partitioning, + customised_axes, ) if force_avg[key][group_id] is None: From e4a41f46256cb7da5bf1106ed7cb259dc1a15a8a Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Mon, 2 Feb 2026 10:28:00 +0000 Subject: [PATCH 04/20] uncomment Sconf calcs --- CodeEntropy/dihedral_tools.py | 246 +++++++++++++++++----------------- CodeEntropy/entropy.py | 32 +++-- 2 files changed, 137 insertions(+), 141 deletions(-) diff --git a/CodeEntropy/dihedral_tools.py b/CodeEntropy/dihedral_tools.py index e8137ee..036fcf0 100644 --- a/CodeEntropy/dihedral_tools.py +++ b/CodeEntropy/dihedral_tools.py @@ -2,14 +2,13 @@ import numpy as np from MDAnalysis.analysis.dihedrals import Dihedral - -# from rich.progress import ( -# BarColumn, -# Progress, -# SpinnerColumn, -# TextColumn, -# TimeElapsedColumn, -# ) +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, +) logger = logging.getLogger(__name__) @@ -47,122 +46,121 @@ def build_conformational_states( states_ua = {} states_res = [None] * number_groups - # SWITCH OFF SCONF - # 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] - # 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] = [] - # 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: - # # 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] = [] - # 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: - # # 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) + 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] + 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] = [] + 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: + # 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] = [] + 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: + # 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) return states_ua, states_res diff --git a/CodeEntropy/entropy.py b/CodeEntropy/entropy.py index a4cbe0d..9b7016e 100644 --- a/CodeEntropy/entropy.py +++ b/CodeEntropy/entropy.py @@ -474,23 +474,21 @@ def _process_united_atom_entropy( t_matrix, "torque", self._args.temperature, highest ) - # SWITCH OFF SCONF - # # 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 - # ) - S_conf_res = 0 + # 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 From 3a8295b3df2cf9909ba988e83e1506578cd6a29a Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Mon, 2 Feb 2026 10:29:44 +0000 Subject: [PATCH 05/20] match main line space --- CodeEntropy/dihedral_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/CodeEntropy/dihedral_tools.py b/CodeEntropy/dihedral_tools.py index 036fcf0..1c5d24d 100644 --- a/CodeEntropy/dihedral_tools.py +++ b/CodeEntropy/dihedral_tools.py @@ -49,6 +49,7 @@ def build_conformational_states( 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"), From 8a6ecdd5f3829004eebec15901a0a7b38b9bf100 Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Mon, 2 Feb 2026 11:39:04 +0000 Subject: [PATCH 06/20] remove redundant axes methods --- CodeEntropy/axes.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index a3bff16..facd54b 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -185,12 +185,9 @@ def get_bonded_axes(self, system, atom, dimensions): # 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, won't apply to UA level - # 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( @@ -205,13 +202,6 @@ def get_bonded_axes(self, system, atom, dimensions): dimensions, ) # case4, not used in Jon's code, use case5 instead - # if len(heavy_bonded) == 2: - # custom_axes = self.get_custom_axes( - # atom.position, - # [heavy_bonded[0].position], - # heavy_bonded[1].position, - # dimensions, - # ) # case5 if len(heavy_bonded) >= 2: custom_axes = self.get_custom_axes( @@ -236,32 +226,6 @@ def get_bonded_axes(self, system, atom, dimensions): return custom_axes, custom_moment_of_inertia - def get_vanilla_axes(self, molecule): - """ - From a selection of atoms, get the ordered principal axes (3,3) and - the ordered moment of inertia axes (3,) for that selection of atoms - - Args: - molecule: mdanalysis instance of molecule - molecule_scale: the length scale of molecule - - Returns: - principal_axes: the principal axes, (3,3) array - moment_of_inertia: the moment of inertia, (3,) array - """ - # default moment of inertia - moment_of_inertia = molecule.moment_of_inertia() - principal_axes = molecule.principal_axes() - # diagonalise moment of inertia tensor here - # pylint: disable=unused-variable - eigenvalues, _eigenvectors = np.linalg.eig(moment_of_inertia) - # sort eigenvalues of moi tensor by largest to smallest magnitude - order = abs(eigenvalues).argsort()[::-1] # decending order - # principal_axes = principal_axes[order] # PI already ordered correctly - moment_of_inertia = eigenvalues[order] - - return principal_axes, moment_of_inertia - def find_bonded_atoms(self, atom_idx: int, system): """ for a given atom, find its bonded heavy and H atoms From 5d076f12f51713b5490e7c47e5997d3ea14ab26d Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 2 Feb 2026 16:19:56 +0000 Subject: [PATCH 07/20] fix unit tests within `test_entropy.py` in relation to changes within PR #267 --- tests/test_CodeEntropy/test_entropy.py | 33 ++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/test_CodeEntropy/test_entropy.py b/tests/test_CodeEntropy/test_entropy.py index 8fce98f..c8b43c0 100644 --- a/tests/test_CodeEntropy/test_entropy.py +++ b/tests/test_CodeEntropy/test_entropy.py @@ -83,7 +83,12 @@ def test_execute_full_workflow(self): return_value=(mock_reduced_atom, 3, mock_levels, mock_groups) ) entropy_manager._level_manager.build_covariance_matrices = MagicMock( - return_value=("force_matrices", "torque_matrices", "frame_counts") + return_value=( + "force_matrices", + "torque_matrices", + "forcetorque_avg", + "frame_counts", + ) ) entropy_manager._dihedral_analysis.build_conformational_states = MagicMock( return_value=(["state_ua"], ["state_res"]) @@ -128,6 +133,7 @@ def test_execute_full_workflow(self): mock_groups, "force_matrices", "torque_matrices", + "forcetorque_avg", ["state_ua"], ["state_res"], "frame_counts", @@ -173,7 +179,12 @@ def test_execute_triggers_handle_water_entropy_minimal(self): return_value=(MagicMock(), 3, {}, {0: [0]}) ) entropy_manager._level_manager.build_covariance_matrices = MagicMock( - return_value=("force_matrices", "torque_matrices", "frame_counts") + return_value=( + "force_matrices", + "torque_matrices", + "forcetorque_avg", + "frame_counts", + ) ) entropy_manager._dihedral_analysis.build_conformational_states = MagicMock( return_value=(["state_ua"], ["state_res"]) @@ -715,6 +726,8 @@ def test_process_vibrational_only_levels(self): 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, @@ -724,6 +737,7 @@ def test_process_vibrational_only_levels(self): level="Vibrational", force_matrix=force_matrix, torque_matrix=torque_matrix, + forcetorque_matrix=forcetorque_matrix, highest=True, ) @@ -731,7 +745,7 @@ def test_process_vibrational_only_levels(self): df = data_logger.molecule_data self.assertEqual(len(df), 2) # Transvibrational and Rovibrational - expected_types = {"Transvibrational", "Rovibrational"} + expected_types = {"FTmat-Transvibrational", "FTmat-Rovibrational"} actual_types = set(entry[2] for entry in df) self.assertSetEqual(actual_types, expected_types) @@ -789,6 +803,7 @@ def test_compute_entropies_polymer_branch(self): groups, force_matrices, torque_matrices, + force_matrices, states_ua, states_res, frame_counts, @@ -942,6 +957,8 @@ def test_compute_entropies_united_atom(self): universe_operations.get_molecule_container = MagicMock(return_value=mol_mock) manager._process_united_atom_entropy = MagicMock() + force_torque_matrices = MagicMock() + ve = MagicMock() ce = MagicMock() @@ -951,6 +968,7 @@ def test_compute_entropies_united_atom(self): groups, force_matrices, torque_matrices, + force_torque_matrices, states_ua, states_res, frame_counts, @@ -1017,6 +1035,8 @@ def test_compute_entropies_residue(self): manager._process_vibrational_entropy = MagicMock() manager._process_conformational_entropy = MagicMock() + force_torque_matrices = MagicMock() + # Mock entropy calculators ve = MagicMock() ce = MagicMock() @@ -1028,6 +1048,7 @@ def test_compute_entropies_residue(self): groups, force_matrices, torque_matrices, + force_torque_matrices, states_ua, states_res, frame_counts, @@ -1048,6 +1069,7 @@ def test_compute_entropies_polymer(self): data_logger = DataLogger() group_molecules = MagicMock() dihedral_analysis = MagicMock() + manager = EntropyManager( run_manager, args, @@ -1066,9 +1088,10 @@ def test_compute_entropies_polymer(self): 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() @@ -1085,6 +1108,7 @@ def test_compute_entropies_polymer(self): groups, force_matrices, torque_matrices, + force_torque_matrices, states_ua, states_res, frame_counts, @@ -1101,6 +1125,7 @@ def test_compute_entropies_polymer(self): "polymer", force_matrices["poly"][0], torque_matrices["poly"][0], + force_torque_matrices["poly"][0], True, ) From 59c93993fad5a8998a031d3cf2d235b9a5e7b7ce Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 2 Feb 2026 16:54:14 +0000 Subject: [PATCH 08/20] fix unit tests within `test_levels.py` in relation to changes within PR #267 --- tests/test_CodeEntropy/test_levels.py | 412 +++++++++++++++----------- 1 file changed, 241 insertions(+), 171 deletions(-) diff --git a/tests/test_CodeEntropy/test_levels.py b/tests/test_CodeEntropy/test_levels.py index 98a2684..9801f38 100644 --- a/tests/test_CodeEntropy/test_levels.py +++ b/tests/test_CodeEntropy/test_levels.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import numpy as np @@ -60,12 +60,15 @@ def test_select_levels(self): def test_get_matrices(self): """ - Test `get_matrices` with mocked internal methods and a simple setup. - Ensures that the method returns correctly shaped force and torque matrices. + 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) @@ -73,39 +76,62 @@ def test_get_matrices(self): 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)) + + # 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) - 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, - ) + 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, + ) - self.assertIsInstance(force_matrix, np.ndarray) - self.assertIsInstance(torque_matrix, np.ndarray) + 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, + ) - self.assertEqual(force_matrix.shape, (6, 6)) - self.assertEqual(torque_matrix.shape, (6, 6)) + # Shape: 2 beads × 3 dof => 6×6 + assert force_matrix.shape == (6, 6) + assert torque_matrix.shape == (6, 6) - level_manager.get_beads.assert_called_once_with(data_container, "residue") + # 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) - self.assertEqual(level_manager.get_weighted_forces.call_count, 2) - self.assertEqual(level_manager.get_weighted_torques.call_count, 2) + # Lightweight behavioral assertions + level_manager.get_beads.assert_called_once_with(data_container, "residue") + assert axes.get_residue_axes.call_count == 2 - self.assertEqual(level_manager.create_submatrix.call_count, 6) + # 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): """ @@ -115,6 +141,7 @@ def test_get_matrices_force_shape_mismatch(self): 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) @@ -129,6 +156,7 @@ def test_get_matrices_force_shape_mismatch(self): 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() @@ -138,17 +166,32 @@ def test_get_matrices_force_shape_mismatch(self): bad_force_matrix = np.zeros((3, 3)) correct_torque_matrix = np.zeros((6, 6)) - 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, + 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, ) - self.assertIn("Inconsistent force matrix shape", str(context.exception)) + 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): """ @@ -179,19 +222,35 @@ def test_get_matrices_torque_shape_mismatch(self): 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 - - 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, + 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, ) - self.assertIn("Inconsistent torque matrix shape", str(context.exception)) + 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): """ @@ -224,27 +283,47 @@ def test_get_matrices_torque_consistency(self): initial_force_matrix = np.zeros((6, 6)) initial_torque_matrix = np.zeros((6, 6)) - 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, - ) + # 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_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, - ) + 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_beads_polymer_level(self): """ Test `get_beads` for 'polymer' level. @@ -456,67 +535,58 @@ def test_get_weighted_torques_weighted_torque_basic(self): Test basic torque calculation with non-zero moment of inertia and torques. """ universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - # Mock atom - atom = MagicMock() - atom.index = 0 - - # Mock bead + # Bead with one "atom" bead = MagicMock() - bead.atoms = [atom] - bead.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) - bead.moment_of_inertia.return_value = np.identity(3) - - # Mock data_container - data_container = MagicMock() - data_container.atoms.__getitem__.return_value.position = np.array( - [1.0, 0.0, 0.0] - ) - data_container.atoms.__getitem__.return_value.force = np.array([0.0, 1.0, 0.0]) + bead.positions = np.array([[1.0, 0.0, 0.0]]) # r + bead.forces = np.array([[0.0, 1.0, 0.0]]) # F - # Rotation axes (identity matrix) 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]) result = level_manager.get_weighted_torques( - data_container, bead, rot_axes, force_partitioning + bead=bead, + rot_axes=rot_axes, + center=center, + force_partitioning=force_partitioning, + moment_of_inertia=moment_of_inertia, ) - np.testing.assert_array_almost_equal(result, np.array([0.0, 0.0, 0.5])) + expected = np.array([0.0, 0.0, 0.5]) + np.testing.assert_allclose(result, expected, rtol=0, atol=1e-12) 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) - atom = MagicMock() - atom.index = 0 - bead = MagicMock() - bead.atoms = [atom] - bead.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) - bead.moment_of_inertia.return_value = np.identity(3) - - data_container = MagicMock() - data_container.atoms.__getitem__.return_value.position = np.array( - [0.0, 0.0, 0.0] - ) - data_container.atoms.__getitem__.return_value.force = np.array([0.0, 0.0, 0.0]) + # All zeros => r x F = 0 + bead.positions = np.array([[0.0, 0.0, 0.0]]) + bead.forces = np.array([[0.0, 0.0, 0.0]]) rot_axes = np.identity(3) - + center = np.array([0.0, 0.0, 0.0]) force_partitioning = 0.5 + # Use non-zero MOI so that "skip division" is only due to zero torque + moment_of_inertia = np.array([1.0, 2.0, 3.0]) + result = level_manager.get_weighted_torques( - data_container, bead, rot_axes, force_partitioning + bead=bead, + rot_axes=rot_axes, + center=center, + force_partitioning=force_partitioning, + moment_of_inertia=moment_of_inertia, ) - np.testing.assert_array_almost_equal(result, np.zeros(3)) + + expected = np.zeros(3) + np.testing.assert_array_equal(result, expected) def test_get_weighted_torques_zero_moi(self): """ @@ -524,79 +594,65 @@ def test_get_weighted_torques_zero_moi(self): and torque in that dimension is non-zero. """ universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - atom = MagicMock() - atom.index = 0 - bead = MagicMock() - bead.atoms = [atom] - bead.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) - - # Set moment of inertia with zero in dimension 2 - moi = np.zeros((3, 3)) - moi[2, 2] = 0.0 - bead.moment_of_inertia.return_value = moi - - data_container = MagicMock() - # Position and force that will produce a non-zero torque in z (dimension 2) - data_container.atoms.__getitem__.return_value.position = np.array( - [1.0, 0.0, 0.0] - ) - data_container.atoms.__getitem__.return_value.force = np.array([0.0, 1.0, 0.0]) + # r = (1,0,0), F = (0,1,0) => torque = (0,0,1) + bead.positions = np.array([[1.0, 0.0, 0.0]]) + bead.forces = np.array([[0.0, 1.0, 0.0]]) rot_axes = np.identity(3) - + center = np.array([0.0, 0.0, 0.0]) force_partitioning = 0.5 + # MOI is zero in z dimension (index 2) + moment_of_inertia = np.array([1.0, 1.0, 0.0]) + torque = level_manager.get_weighted_torques( - data_container, bead, rot_axes, force_partitioning + bead=bead, + rot_axes=rot_axes, + center=center, + force_partitioning=force_partitioning, + moment_of_inertia=moment_of_inertia, ) - self.assertEqual(torque[2], 0) + # x and y torques are zero; z torque is non-zero + # but MOI_z==0 => weighted z should be 0 + expected = np.array([0.0, 0.0, 0.0]) + np.testing.assert_array_equal(torque, expected) - def test_get_weighted_torques_negative_moi_raises(self): + def test_get_weighted_torques_negative_moi_sets_zero(self): """ - Should raise ValueError when moment of inertia is negative in a dimension - and torque in that dimension is non-zero. + 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) - atom = MagicMock() - atom.index = 0 - bead = MagicMock() - bead.atoms = [atom] - bead.center_of_mass.return_value = np.array([0.0, 0.0, 0.0]) - - # Set moment of inertia with negative value in dimension 2 - moi = np.identity(3) - moi *= -1.0 - bead.moment_of_inertia.return_value = moi - - data_container = MagicMock() - # Position and force that will produce a non-zero torque in z (dimension 2) - data_container.atoms.__getitem__.return_value.position = np.array( - [1.0, 0.0, 0.0] - ) - data_container.atoms.__getitem__.return_value.force = np.array([0.0, 1.0, 0.0]) + # r=(1,0,0), F=(0,1,0) => raw torque in z is non-zero + bead.positions = np.array([[1.0, 0.0, 0.0]]) + bead.forces = np.array([[0.0, 1.0, 0.0]]) rot_axes = np.identity(3) + center = np.array([0.0, 0.0, 0.0]) + force_partitioning = 0.5 - foce_partitioning = 0.5 - - with self.assertRaises(ValueError) as context: - level_manager.get_weighted_torques( - data_container, bead, rot_axes, foce_partitioning - ) + # Negative MOI in z dimension + moment_of_inertia = np.array([1.0, 1.0, -1.0]) - self.assertIn( - "Negative value encountered for moment of inertia", str(context.exception) + result = level_manager.get_weighted_torques( + bead=bead, + rot_axes=rot_axes, + center=center, + force_partitioning=force_partitioning, + moment_of_inertia=moment_of_inertia, ) + # z torque would be non-zero, but negative MOI => z component forced to 0 + expected = np.array([0.0, 0.0, 0.0]) + np.testing.assert_array_equal(result, expected) + def test_create_submatrix_basic_outer_product(self): """ Test with known vectors to verify correct outer product. @@ -660,18 +716,10 @@ def test_build_covariance_matrices_atomic(self): """ Test `build_covariance_matrices` to ensure it correctly orchestrates calls and returns dictionaries with the expected structure. - - This test mocks dependencies including the entropy_manager, reduced_atom - trajectory, levels, groups, and internal method - `update_force_torque_matrices`. """ - - # Instantiate your class (replace YourClass with actual class name) universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) - # Mock entropy_manager and get_molecule_container entropy_manager = MagicMock() # Fake atom with minimal attributes @@ -680,30 +728,25 @@ def test_build_covariance_matrices_atomic(self): atom.resid = 1 atom.segid = "A" - # Fake molecule with atoms list fake_mol = MagicMock() fake_mol.atoms = [atom] - # Always return fake_mol from get_molecule_container universe_operations.get_molecule_container = MagicMock(return_value=fake_mol) - # Mock reduced_atom with trajectory yielding two timesteps timestep1 = MagicMock() timestep1.frame = 0 timestep2 = MagicMock() timestep2.frame = 1 + reduced_atom = MagicMock() reduced_atom.trajectory.__getitem__.return_value = [timestep1, timestep2] - # Setup groups and levels dictionaries groups = {"ua": ["mol1", "mol2"]} levels = {"mol1": ["level1", "level2"], "mol2": ["level1"]} - # Mock update_force_torque_matrices to just track calls level_manager.update_force_torque_matrices = MagicMock() - # Call the method under test - force_matrices, torque_matrices, _ = level_manager.build_covariance_matrices( + force_matrices, torque_matrices, *_ = level_manager.build_covariance_matrices( entropy_manager=entropy_manager, reduced_atom=reduced_atom, levels=levels, @@ -713,24 +756,21 @@ def test_build_covariance_matrices_atomic(self): step=1, number_frames=2, force_partitioning=0.5, + combined_forcetorque=False, + customised_axes=True, ) - # Assert returned matrices are dictionaries with correct keys 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"}) - # Assert 'res' and 'poly' entries are lists of correct length 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)) - # Check get_molecule_container call count: 2 timesteps * 2 molecules = 4 calls self.assertEqual(universe_operations.get_molecule_container.call_count, 4) - - # Check update_force_torque_matrices call count: self.assertEqual(level_manager.update_force_torque_matrices.call_count, 6) def test_update_force_torque_matrices_united_atom(self): @@ -771,6 +811,7 @@ def test_update_force_torque_matrices_united_atom(self): 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( @@ -783,8 +824,11 @@ def test_update_force_torque_matrices_united_atom(self): 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"] @@ -800,6 +844,8 @@ def test_update_force_torque_matrices_united_atom(self): 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) @@ -824,7 +870,6 @@ def test_update_force_torque_matrices_united_atom_increment(self): 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]]) @@ -833,10 +878,13 @@ def test_update_force_torque_matrices_united_atom_increment(self): f_mat_2 = np.array([[3.0]]) t_mat_2 = np.array([[4.0]]) - level_manager.get_matrices = MagicMock(return_value=(f_mat_1, t_mat_1)) + 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( @@ -849,12 +897,14 @@ def test_update_force_torque_matrices_united_atom_increment(self): 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, ) - level_manager.get_matrices = MagicMock(return_value=(f_mat_2, t_mat_2)) - + # Second update level_manager.update_force_torque_matrices( entropy_manager=entropy_manager, mol=mol, @@ -865,8 +915,11 @@ def test_update_force_torque_matrices_united_atom_increment(self): 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 @@ -874,7 +927,9 @@ def test_update_force_torque_matrices_united_atom_increment(self): 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) - self.assertEqual(frame_counts["ua"][(0, 0)], 2) + assert frame_counts["ua"][(0, 0)] == 2 + + assert forcetorque_avg["ua"] == {} def test_update_force_torque_matrices_residue(self): """ @@ -883,8 +938,8 @@ def test_update_force_torque_matrices_residue(self): incrementing frame counts. """ universe_operations = UniverseOperations() - level_manager = LevelManager(universe_operations) + entropy_manager = MagicMock() mol = MagicMock() mol.trajectory.__getitem__.return_value = None @@ -895,6 +950,7 @@ def test_update_force_torque_matrices_residue(self): 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( @@ -907,13 +963,18 @@ def test_update_force_torque_matrices_residue(self): 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) - self.assertEqual(frame_counts["res"][0], 1) + assert frame_counts["res"][0] == 1 + + assert forcetorque_avg["res"][0] is None def test_update_force_torque_matrices_incremental_average(self): """ @@ -923,8 +984,8 @@ def test_update_force_torque_matrices_incremental_average(self): 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 @@ -941,6 +1002,7 @@ def test_update_force_torque_matrices_incremental_average(self): 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 @@ -954,8 +1016,11 @@ def test_update_force_torque_matrices_incremental_average(self): 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 @@ -969,8 +1034,11 @@ def test_update_force_torque_matrices_incremental_average(self): 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 @@ -978,7 +1046,9 @@ def test_update_force_torque_matrices_incremental_average(self): np.testing.assert_array_almost_equal(force_avg["res"][0], expected_force) np.testing.assert_array_almost_equal(torque_avg["res"][0], expected_torque) - self.assertEqual(frame_counts["res"][0], 2) + + assert frame_counts["res"][0] == 2 + assert forcetorque_avg["res"][0] is None def test_filter_zero_rows_columns_no_zeros(self): """ From 9bcaa37e4c584278516872cdf2045a50848b1131 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Mon, 2 Feb 2026 17:03:19 +0000 Subject: [PATCH 09/20] fix unit tests within `test_mda_universe_operations.py` in relation to changes within PR #267 --- .../test_mda_universe_operations.py | 149 ++++++++---------- 1 file changed, 69 insertions(+), 80 deletions(-) diff --git a/tests/test_CodeEntropy/test_mda_universe_operations.py b/tests/test_CodeEntropy/test_mda_universe_operations.py index fb4c891..46e68a7 100644 --- a/tests/test_CodeEntropy/test_mda_universe_operations.py +++ b/tests/test_CodeEntropy/test_mda_universe_operations.py @@ -28,13 +28,6 @@ def setUp(self): def test_new_U_select_frame(self, MockMerge, MockAnalysisFromFunction): """ Unit test for UniverseOperations.new_U_select_frame(). - - Verifies that: - - The Universe is queried with select_atoms("all", updating=True) - - AnalysisFromFunction is called to obtain coordinates and forces - - mda.Merge is called with the selected AtomGroup - - The new universe returned by Merge.load_new receives the correct arrays - - The method returns the merged universe """ # Mock Universe and its components mock_universe = MagicMock() @@ -48,6 +41,7 @@ def test_new_U_select_frame(self, MockMerge, MockAnalysisFromFunction): # 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} @@ -55,35 +49,35 @@ def test_new_U_select_frame(self, MockMerge, MockAnalysisFromFunction): mock_forces_analysis = MagicMock() mock_forces_analysis.run.return_value.results = {"timeseries": forces} - # Set the side effects for the three AnalysisFromFunction calls + 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 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) - MockMerge.assert_called_once_with(mock_select_atoms) - # Ensure the 'load_new' method was called with the correct arguments - mock_merged_universe.load_new.assert_called_once() - args, kwargs = mock_merged_universe.load_new.call_args + # 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() - # Assert that the arrays are passed correctly - np.testing.assert_array_equal(args[0], coords) - np.testing.assert_array_equal(kwargs["forces"], forces) - - # Check if format was included in the kwargs - self.assertIn("format", kwargs) + # Merge called with selected AtomGroup + MockMerge.assert_called_once_with(mock_select_atoms) - # Ensure the result is the mock merged universe - self.assertEqual(result, mock_merged_universe) + assert result == mock_merged_universe @patch("CodeEntropy.mda_universe_operations.AnalysisFromFunction") @patch("CodeEntropy.mda_universe_operations.mda.Merge") @@ -93,7 +87,7 @@ def test_new_U_select_atom(self, MockMerge, MockAnalysisFromFunction): Ensures that: - The Universe is queried with the correct selection string - - Coordinates and forces are extracted via AnalysisFromFunction + - 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 @@ -106,6 +100,7 @@ def test_new_U_select_atom(self, MockMerge, MockAnalysisFromFunction): # 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} @@ -113,10 +108,13 @@ def test_new_U_select_atom(self, MockMerge, MockAnalysisFromFunction): mock_forces_analysis = MagicMock() mock_forces_analysis.run.return_value.results = {"timeseries": forces} - # Set the side effects for the three AnalysisFromFunction calls + 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 @@ -126,23 +124,19 @@ def test_new_U_select_atom(self, MockMerge, MockAnalysisFromFunction): 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) - MockMerge.assert_called_once_with(mock_select_atoms) - - # Ensure the 'load_new' method was called with the correct arguments - mock_merged_universe.load_new.assert_called_once() - args, kwargs = mock_merged_universe.load_new.call_args - # Assert that the arrays are passed correctly - np.testing.assert_array_equal(args[0], coords) - np.testing.assert_array_equal(kwargs["forces"], forces) + # 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() - # Check if format was included in the kwargs - self.assertIn("format", kwargs) + # Merge called with the selected AtomGroup + MockMerge.assert_called_once_with(mock_select_atoms) - # Ensure the result is the mock merged universe - self.assertEqual(result, mock_merged_universe) + # Returned universe should be the merged universe + assert result == mock_merged_universe def test_get_molecule_container(self): """ @@ -179,25 +173,13 @@ def test_get_molecule_container(self): def test_merge_forces(self, MockUniverse, MockMerge, MockAnalysisFromFunction): """ Unit test for UniverseOperations.merge_forces(). - - This test ensures that: - - Two MDAnalysis Universes are created: one for coordinates - (tprfile + trrfile) and one for forces (tprfile + forcefile). - - Both Universes correctly return AtomGroups via select_atoms("all"). - - Coordinates and forces are extracted using AnalysisFromFunction. - - mda.Merge is called with the coordinate AtomGroup. - - The merged Universe receives the correct coordinate and force arrays - through load_new(). - - When kcal=False, force values are passed through unchanged - (no kcal→kJ conversion). - - The returned universe is the same object returned by mda.Merge(). """ - + # 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 a mock AtomGroup from select_atoms("all") + # 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 @@ -205,6 +187,7 @@ def test_merge_forces(self, MockUniverse, MockMerge, MockAnalysisFromFunction): 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} @@ -212,10 +195,13 @@ def test_merge_forces(self, MockUniverse, MockMerge, MockAnalysisFromFunction): mock_forces_analysis = MagicMock() mock_forces_analysis.run.return_value.results = {"timeseries": forces} - # Two calls: first for coordinates, second for 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() @@ -230,28 +216,24 @@ def test_merge_forces(self, MockUniverse, MockMerge, MockAnalysisFromFunction): kcal=False, ) - self.assertEqual(MockUniverse.call_count, 2) - MockUniverse.assert_any_call("topol.tpr", "traj.trr", format=None) - MockUniverse.assert_any_call("topol.tpr", "forces.trr", format=None) + # 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") - self.assertEqual(MockAnalysisFromFunction.call_count, 2) + # 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) - mock_merged.load_new.assert_called_once() - args, kwargs = mock_merged.load_new.call_args - - # Coordinates passed positionally - np.testing.assert_array_equal(args[0], coords) - - # Forces passed via kwargs - np.testing.assert_array_equal(kwargs["forces"], forces) - - # Finally the function returns the merged universe - self.assertEqual(result, mock_merged) + # Returned object is merged universe + assert result == mock_merged @patch("CodeEntropy.mda_universe_operations.AnalysisFromFunction") @patch("CodeEntropy.mda_universe_operations.mda.Merge") @@ -262,15 +244,6 @@ def test_merge_forces_kcal_conversion( """ Unit test for UniverseOperations.merge_forces() covering the kcal→kJ conversion branch. - - Verifies that: - - Two Universe objects are constructed for coords and forces. - - Each Universe returns an AtomGroup via select_atoms("all"). - - AnalysisFromFunction is called twice. - - Forces are multiplied EXACTLY once by 4.184 when kcal=True. - - Merge() is called with the coordinate AtomGroup. - - load_new() receives the correct coordinates and converted forces. - - The returned Universe is the Merge() result. """ mock_u_coords = MagicMock() mock_u_force = MagicMock() @@ -286,6 +259,8 @@ def test_merge_forces_kcal_conversion( 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} @@ -295,9 +270,13 @@ def test_merge_forces_kcal_conversion( "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() @@ -306,10 +285,20 @@ def test_merge_forces_kcal_conversion( ops = UniverseOperations() result = ops.merge_forces("t.tpr", "c.trr", "f.trr", kcal=True) - _, kwargs = mock_merged.load_new.call_args + # 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 - expected_forces = original_forces * 4.184 - np.testing.assert_array_equal(kwargs["forces"], expected_forces) - np.testing.assert_array_equal(mock_merged.load_new.call_args[0][0], coords) + # 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) - self.assertEqual(result, mock_merged) + # Returned universe is the merged universe + assert result == mock_merged From f618cd413d423b4b6a387b409256d0dc61abe450 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 3 Feb 2026 09:06:05 +0000 Subject: [PATCH 10/20] include additional unit tests within `test_entropy.py` in relation to changes within PR #267 --- tests/test_CodeEntropy/test_entropy.py | 174 +++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/tests/test_CodeEntropy/test_entropy.py b/tests/test_CodeEntropy/test_entropy.py index c8b43c0..aee8405 100644 --- a/tests/test_CodeEntropy/test_entropy.py +++ b/tests/test_CodeEntropy/test_entropy.py @@ -8,6 +8,7 @@ import MDAnalysis as mda import numpy as np +import numpy.linalg as la import pytest import tests.data as data @@ -753,6 +754,77 @@ def test_process_vibrational_only_levels(self): 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' @@ -1546,6 +1618,108 @@ def test_vibrational_entropy_polymer_torque(self): 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=12) + + 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=12) + + finally: + la.eigvals = orig_eigvals + def test_calculate_water_orientational_entropy(self): """ Test that orientational entropy values are correctly extracted from Sorient_dict From c3824c486b58347a90c1a639c864c16f5ffb0085 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 3 Feb 2026 09:55:42 +0000 Subject: [PATCH 11/20] include additional unit tests within `test_levels.py` in relation to changes within PR #267 --- tests/test_CodeEntropy/test_levels.py | 589 ++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) diff --git a/tests/test_CodeEntropy/test_levels.py b/tests/test_CodeEntropy/test_levels.py index 9801f38..41c353d 100644 --- a/tests/test_CodeEntropy/test_levels.py +++ b/tests/test_CodeEntropy/test_levels.py @@ -324,6 +324,398 @@ def test_get_matrices_torque_consistency(self): 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(self): + """ + Test that: customised_axes=False triggers the else axes path. + Covers: + trans_axes = data_container.atoms.principal_axes() + rot_axes = real(bead.principal_axes()) + eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) + moment_of_inertia sorted(...) + center = bead.center_of_mass() + """ + universe_operations = UniverseOperations() + level_manager = LevelManager(universe_operations) + + bead1 = MagicMock() + bead2 = MagicMock() + + bead1.principal_axes.return_value = np.eye(3) + bead2.principal_axes.return_value = np.eye(3) + + 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.identity(3)) + + data_container = MagicMock() + data_container.atoms = MagicMock() + data_container.atoms.principal_axes.return_value = np.eye(3) + + with patch("CodeEntropy.levels.np.linalg.eig") as eig_mock: + eig_mock.return_value = (np.array([3.0, 2.0, 1.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, + ) + + assert force_matrix.shape == (6, 6) + assert torque_matrix.shape == (6, 6) + + data_container.atoms.principal_axes.assert_called() + assert bead1.principal_axes.called and bead2.principal_axes.called + assert bead1.center_of_mass.called and bead2.center_of_mass.called + assert bead1.moment_of_inertia.called and bead2.moment_of_inertia.called + assert eig_mock.call_count == 2 + + 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: + trans_axes = data_container.atoms.principal_axes() + rot_axes = real(bead.principal_axes()) + eig(bead.moment_of_inertia()) called + center_of_mass called + """ + universe_operations = UniverseOperations() + level_manager = LevelManager(universe_operations) + + bead1 = MagicMock() + bead2 = MagicMock() + + bead1.principal_axes.return_value = np.eye(3) + bead2.principal_axes.return_value = np.eye(3) + + bead1.moment_of_inertia.return_value = np.eye(3) + bead2.moment_of_inertia.return_value = np.eye(3) + + bead1.center_of_mass.return_value = np.zeros(3) + bead2.center_of_mass.return_value = np.zeros(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([4.0, 5.0, 6.0]) + ) + + level_manager.create_FTsubmatrix = MagicMock(return_value=np.identity(6)) + + data_container = MagicMock() + data_container.atoms = MagicMock() + data_container.atoms.principal_axes.return_value = np.eye(3) + + with patch("CodeEntropy.levels.np.linalg.eig") as eig_mock: + eig_mock.return_value = (np.array([3.0, 2.0, 1.0]), None) + + 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, + ) + + assert ft_matrix.shape == (12, 12) + + data_container.atoms.principal_axes.assert_called() + assert bead1.principal_axes.called and bead2.principal_axes.called + assert bead1.moment_of_inertia.called and bead2.moment_of_inertia.called + assert bead1.center_of_mass.called and bead2.center_of_mass.called + assert eig_mock.call_count == 2 + + 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. @@ -712,6 +1104,72 @@ def test_create_submatrix_symmetric_result_when_data_equal(self): 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 @@ -1050,6 +1508,137 @@ def test_update_force_torque_matrices_incremental_average(self): 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. From f22f3015db8cf26999449b94597310990c291fa1 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 3 Feb 2026 15:05:28 +0000 Subject: [PATCH 12/20] introduce new unit tests within `test_axes.py` in relation to changes within PR #267 --- tests/test_CodeEntropy/test_axes.py | 493 ++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 tests/test_CodeEntropy/test_axes.py diff --git a/tests/test_CodeEntropy/test_axes.py b/tests/test_CodeEntropy/test_axes.py new file mode 100644 index 0000000..da213b2 --- /dev/null +++ b/tests/test_CodeEntropy/test_axes.py @@ -0,0 +1,493 @@ +from unittest.mock import MagicMock, patch + +import numpy as np + +from CodeEntropy.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.center_of_mass.return_value = center_expected + + with patch("CodeEntropy.axes.np.linalg.eig") as eig_mock: + eig_mock.return_value = (np.array([1.0, 3.0, 2.0]), None) + + 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" + + data_container.atoms.principal_axes.assert_called_once() + residue.principal_axes.assert_called_once() + residue.moment_of_inertia.assert_called_once() + residue.center_of_mass.assert_called_once() + + eig_mock.assert_called_once() + eig_args, eig_kwargs = eig_mock.call_args + np.testing.assert_array_equal(eig_args[0], moi_tensor) + assert eig_kwargs == {} + + assert ( + not hasattr(axes_manager.get_UA_masses, "call_count") + or getattr(axes_manager.get_UA_masses, "call_count", 0) == 0 + ) + + np.testing.assert_array_equal(trans_axes_out, trans_axes_expected) + np.testing.assert_array_equal(rot_axes_out, np.real(rot_axes_expected)) + np.testing.assert_array_equal(center_out, center_expected) + assert moi_out == [3.0, 2.0, 1.0] + + def test_get_UA_axes_returns_expected_outputs(self): + """ + 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]) + + uas = MagicMock() + uas.positions = np.zeros((2, 3)) + + a0 = MagicMock() + a0.index = 7 + a1 = MagicMock() + a1.index = 9 + heavy_atoms = [a0, a1] + + heavy_ag = MagicMock() + heavy_ag.positions = np.array([[9.9, 8.8, 7.7]]) + heavy_ag.__getitem__.return_value = MagicMock() + + dc.select_atoms.side_effect = [uas, heavy_atoms, heavy_ag] + + 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_ag.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] == "mass 2 to 999" + assert calls[1] == "prop mass > 1.1" + assert calls[2] == "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: + # - twice for axis1 (a → b0, a → b1) + # - once for ac_vector using (c → b_list[0]) + 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 + + moi = axes.get_custom_moment_of_inertia(UA, np.eye(3), np.zeros(3)) + + 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]) + + tensor = axes.get_moment_of_inertia_tensor(center, pos, masses) + + 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] From e56f7e9049dc9554fa439d1f6913cf223c3456e0 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Tue, 3 Feb 2026 15:12:56 +0000 Subject: [PATCH 13/20] relax tolerance for `forcetorqueTRANS` and `forcetorqueROT` vibrational entropy test --- tests/test_CodeEntropy/test_entropy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_CodeEntropy/test_entropy.py b/tests/test_CodeEntropy/test_entropy.py index aee8405..540d779 100644 --- a/tests/test_CodeEntropy/test_entropy.py +++ b/tests/test_CodeEntropy/test_entropy.py @@ -1665,7 +1665,7 @@ def test_vibrational_entropy_calculation_forcetorqueTRANS(self): S_components *= ve._GAS_CONST expected = float(np.sum(S_components[:3])) - self.assertAlmostEqual(result, expected, places=12) + self.assertAlmostEqual(result, expected, places=6) finally: la.eigvals = orig_eigvals @@ -1715,7 +1715,7 @@ def test_vibrational_entropy_calculation_forcetorqueROT(self): S_components *= ve._GAS_CONST expected = float(np.sum(S_components[3:])) - self.assertAlmostEqual(result, expected, places=12) + self.assertAlmostEqual(result, expected, places=6) finally: la.eigvals = orig_eigvals From 534048442d2411627b8391cf1a801e096b3ec8dd Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Wed, 4 Feb 2026 11:31:42 +0000 Subject: [PATCH 14/20] unwrap/make whole molecules for MOI, COM and PA calcs --- CodeEntropy/axes.py | 89 +++++++++++++++++++++++++++++-------------- CodeEntropy/levels.py | 42 +++++++++++++++----- 2 files changed, 92 insertions(+), 39 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index facd54b..0ef1bf4 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -1,6 +1,7 @@ import logging import numpy as np +from MDAnalysis.lib.mdamath import make_whole logger = logging.getLogger(__name__) @@ -49,9 +50,7 @@ def get_residue_axes(self, data_container, index): f"and bonded resid {index}" ) residue = data_container.select_atoms(f"resindex {index}") - # set center of rotation to center of mass of the residue - # we might want to change to center of bonding later - center = residue.atoms.center_of_mass() + center = residue.atoms.center_of_mass(unwrap=True) if len(atom_set) == 0: # No bonds to other residues @@ -61,7 +60,7 @@ def get_residue_axes(self, data_container, index): 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 + center, UAs.positions, UA_masses, data_container.dimensions[:3] ) rot_axes, moment_of_inertia = self.get_custom_principal_axes( moment_of_inertia_tensor @@ -71,11 +70,13 @@ def get_residue_axes(self, data_container, index): ) 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 = np.real(residue.principal_axes()) - eigenvalues, _ = np.linalg.eig(residue.moment_of_inertia()) - moment_of_inertia = sorted(eigenvalues, reverse=True) - center = residue.center_of_mass() + rot_axes, moment_of_inertia = self.get_vanilla_axes(residue) + # rot_axes = np.real(residue.principal_axes()) + # eigenvalues, _ = np.linalg.eig(residue.moment_of_inertia(unwrap=True)) + # moment_of_inertia = sorted(eigenvalues, reverse=True) + center = residue.center_of_mass(unwrap=True) return trans_axes, rot_axes, center, moment_of_inertia @@ -96,13 +97,12 @@ def get_UA_axes(self, data_container, index): index = int(index) - # trans_axes = data_container.atoms.principal_axes() # 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() + center = data_container.atoms.center_of_mass(unwrap=True) moment_of_inertia_tensor = self.get_moment_of_inertia_tensor( - center, UAs.positions, UA_masses + center, UAs.positions, UA_masses, data_container.dimensions[:3] ) trans_axes, _moment_of_inertia = self.get_custom_principal_axes( moment_of_inertia_tensor @@ -185,9 +185,12 @@ def get_bonded_axes(self, system, atom, dimensions): # 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, won't apply to UA level + # 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( @@ -201,7 +204,7 @@ def get_bonded_axes(self, system, atom, dimensions): light_bonded[0].position, dimensions, ) - # case4, not used in Jon's code, use case5 instead + # case4, not used in Jon's 2019 paper code, use case5 instead # case5 if len(heavy_bonded) >= 2: custom_axes = self.get_custom_axes( @@ -214,7 +217,7 @@ def get_bonded_axes(self, system, atom, 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 + UA, custom_axes, atom.position, dimensions ) # get the moment of inertia from the custom axes @@ -243,6 +246,33 @@ def find_bonded_atoms(self, atom_idx: int, system): 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): + """ + From a selection of atoms, get the ordered principal axes (3,3) and + the ordered moment of inertia axes (3,) for that selection of atoms + + Args: + molecule: mdanalysis instance of molecule + molecule_scale: the length scale of molecule + + Returns: + principal_axes: the principal axes, (3,3) array + moment_of_inertia: the moment of inertia, (3,) array + """ + # default moment of inertia + moment_of_inertia = molecule.moment_of_inertia(unwrap=True) + make_whole(molecule.atoms) + principal_axes = molecule.principal_axes() + # diagonalise moment of inertia tensor here + # pylint: disable=unused-variable + eigenvalues, _eigenvectors = np.linalg.eig(moment_of_inertia) + # sort eigenvalues of moi tensor by largest to smallest magnitude + order = sorted(eigenvalues, reverse=True) # decending order + # principal_axes = principal_axes[order] # PI already ordered correctly + 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 ): @@ -303,7 +333,11 @@ def get_custom_axes( return scaled_custom_axes def get_custom_moment_of_inertia( - self, UA, custom_rotation_axes: np.ndarray, center_of_mass: np.ndarray + 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) @@ -318,7 +352,7 @@ def get_custom_moment_of_inertia( Returns: custom_moment_of_inertia: (3,) array for moment of inertia """ - translated_coords = UA.positions - center_of_mass + 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( @@ -366,27 +400,24 @@ 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: (3,) array of atom cooordinates + a: (N,3) array of atom cooordinates b: (3,) array of atom cooordinates dimensions: (3,) array of system box dimensions. Returns: - delta_wrapped: (3,) array of the vector between two points + delta_wrapped: (N,3) array of the vector """ delta = b - a - delta_wrapped = [] - for delt, box in zip(delta, dimensions): - if delt < 0 and delt < 0.5 * box: - delt = delt + box - if delt > 0 and delt > 0.5 * box: - delt = delt - box - delta_wrapped.append(delt) - delta_wrapped = np.array(delta_wrapped) + delta -= dimensions * np.round(delta / dimensions) - return delta_wrapped + return delta def get_moment_of_inertia_tensor( - self, center_of_mass: np.ndarray, positions: np.ndarray, masses: list + self, + center_of_mass: np.ndarray, + positions: np.ndarray, + masses: list, + dimensions: np.array, ) -> np.ndarray: """ Calculate a custom moment of inertia tensor. @@ -402,7 +433,7 @@ def get_moment_of_inertia_tensor( Returns: moment_of_inertia_tensor: a (3,3) moment of inertia tensor """ - r = positions - center_of_mass + 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) diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py index fe2ac15..f3aa3df 100644 --- a/CodeEntropy/levels.py +++ b/CodeEntropy/levels.py @@ -1,6 +1,7 @@ import logging import numpy as np +from MDAnalysis.lib.mdamath import make_whole from rich.progress import ( BarColumn, Progress, @@ -132,11 +133,13 @@ def get_matrices( axes_manager.get_residue_axes(data_container, bead_index) ) else: + make_whole(data_container.atoms) + make_whole(bead) trans_axes = data_container.atoms.principal_axes() rot_axes = np.real(bead.principal_axes()) - eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) + eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) moment_of_inertia = sorted(eigenvalues, reverse=True) - center = bead.center_of_mass() + center = bead.center_of_mass(unwrap=True) # Sort out coordinates, forces, and torques for each atom in the bead weighted_forces[bead_index] = self.get_weighted_forces( @@ -152,6 +155,7 @@ def get_matrices( center, force_partitioning, moment_of_inertia, + axes_manager, ) # Create covariance submatrices @@ -259,11 +263,15 @@ def get_combined_forcetorque_matrices( axes_manager.get_residue_axes(data_container, bead_index) ) else: + # ensure molecule is whole for PA calcs + make_whole(data_container.atoms) + make_whole(bead) trans_axes = data_container.atoms.principal_axes() - rot_axes = np.real(bead.principal_axes()) - eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) - moment_of_inertia = sorted(eigenvalues, reverse=True) - center = bead.center_of_mass() + rot_axes, moment_of_inertia = axes_manager.get_vanilla_axes(bead) + # rot_axes = np.real(bead.principal_axes()) + # eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) + # moment_of_inertia = sorted(eigenvalues, reverse=True) + center = bead.center_of_mass(unwrap=True) # Sort out coordinates, forces, and torques for each atom in the bead weighted_forces[bead_index] = self.get_weighted_forces( @@ -279,6 +287,7 @@ def get_combined_forcetorque_matrices( center, force_partitioning, moment_of_inertia, + axes_manager, ) # Create covariance submatrices @@ -427,7 +436,13 @@ def get_weighted_forces( return weighted_force def get_weighted_torques( - self, bead, rot_axes, center, force_partitioning, moment_of_inertia + self, + bead, + rot_axes, + center, + force_partitioning, + moment_of_inertia, + axes_manager, ): """ Compute moment-of-inertia weighted torques for a bead. @@ -466,7 +481,10 @@ def get_weighted_torques( """ # translate and rotate positions and forces - translated_coords = bead.positions - center + # translated_coords = bead.positions - center + translated_coords = axes_manager.get_vector( + center, bead.positions, bead.dimensions[:3] + ) rotated_coords = np.tensordot(translated_coords, rot_axes.T, axes=1) rotated_forces = np.tensordot(bead.forces, rot_axes.T, axes=1) # scale forces @@ -481,12 +499,16 @@ def get_weighted_torques( weighted_torque[dimension] = 0 continue - if moment_of_inertia[dimension] == 0: + if np.iscomplex(moment_of_inertia[dimension]): weighted_torque[dimension] = 0 continue + if moment_of_inertia[dimension] == 0: + moment_of_inertia[dimension] = 0 + continue + if moment_of_inertia[dimension] < 0: - weighted_torque[dimension] = 0 + moment_of_inertia[dimension] = 0 continue # Compute weighted torque From 0f4d218d79a57f00ed94e0b094552ea7cb08e164 Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Wed, 4 Feb 2026 11:44:51 +0000 Subject: [PATCH 15/20] remove unused calcs --- CodeEntropy/axes.py | 3 --- CodeEntropy/levels.py | 3 --- 2 files changed, 6 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index 0ef1bf4..c52e499 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -73,9 +73,6 @@ def get_residue_axes(self, data_container, index): make_whole(data_container.atoms) trans_axes = data_container.atoms.principal_axes() rot_axes, moment_of_inertia = self.get_vanilla_axes(residue) - # rot_axes = np.real(residue.principal_axes()) - # eigenvalues, _ = np.linalg.eig(residue.moment_of_inertia(unwrap=True)) - # moment_of_inertia = sorted(eigenvalues, reverse=True) center = residue.center_of_mass(unwrap=True) return trans_axes, rot_axes, center, moment_of_inertia diff --git a/CodeEntropy/levels.py b/CodeEntropy/levels.py index f3aa3df..02a2edc 100644 --- a/CodeEntropy/levels.py +++ b/CodeEntropy/levels.py @@ -268,9 +268,6 @@ def get_combined_forcetorque_matrices( make_whole(bead) trans_axes = data_container.atoms.principal_axes() rot_axes, moment_of_inertia = axes_manager.get_vanilla_axes(bead) - # rot_axes = np.real(bead.principal_axes()) - # eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia(unwrap=True)) - # moment_of_inertia = sorted(eigenvalues, reverse=True) center = bead.center_of_mass(unwrap=True) # Sort out coordinates, forces, and torques for each atom in the bead From 63183fd042c7dc85313c751e8a991e8dba50f542 Mon Sep 17 00:00:00 2001 From: Jas Kalayan Date: Wed, 4 Feb 2026 12:26:16 +0000 Subject: [PATCH 16/20] update docs --- CodeEntropy/axes.py | 2 +- CodeEntropy/levels.py | 11 +++++++++-- docs/getting_started.rst | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index c52e499..f04876f 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -18,7 +18,7 @@ class AxesManager: def __init__(self): """ - Initializes the LevelManager with placeholders for level-related data, + Initializes the AxesManager 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.py b/CodeEntropy/levels.py index 02a2edc..1426245 100644 --- a/CodeEntropy/levels.py +++ b/CodeEntropy/levels.py @@ -100,6 +100,8 @@ def get_matrices( to. force_partitioning (float): Factor to adjust force contributions, default is 0.5. + customised_axes (bool): Whether to use customised axes for rotating + forces. Returns: force_matrix (np.ndarray): Accumulated force covariance matrix. @@ -235,6 +237,8 @@ def get_combined_forcetorque_matrices( to. force_partitioning (float): Factor to adjust force contributions, default is 0.5. + customised_axes (bool): Whether to use customised axes for rotating + forces. Returns: forcetorque_matrix (np.ndarray): Accumulated torque covariance matrix. @@ -250,14 +254,15 @@ def get_combined_forcetorque_matrices( weighted_forces = [None for _ in range(number_beads)] weighted_torques = [None for _ in range(number_beads)] + # Create axes manager for custom axes + axes_manager = AxesManager() + # 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 - axes_manager = AxesManager() if level == "residue" and customised_axes: trans_axes, rot_axes, center, moment_of_inertia = ( axes_manager.get_residue_axes(data_container, bead_index) @@ -470,6 +475,8 @@ def get_weighted_torques( contributions (typically 0.5). moment_of_inertia : np.ndarray Moment of inertia (3,) + customised_axes: bool + Whether to use customised axes for rotating forces. Returns ------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 7fe9071..b7c8018 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -137,6 +137,18 @@ The top_traj_file argument is necessary to identify your simulation data, the ot - How to group molecules for averaging - ``molecules`` - ``str`` + * - ``--kcal_force_units`` + - Set input units as kcal/mol + - ``bool`` + - ``False`` + * - ``--combined_forcetorque`` + - Use the combined force-torque covariance matrix for the highest level to match the 2019 paper + - ``bool`` + - ``True`` + * - ``--customised_axes`` + - Use custom bonded axes to get COM, MOI and PA that match the 2019 paper + - ``bool`` + - ``True`` Averaging ^^^^^^^^^ From 7209969aead57d0c7540b54e7926ca0de45c110c Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 4 Feb 2026 14:54:33 +0000 Subject: [PATCH 17/20] fix unit tests within `test_axes.py` in relation to PR #267 --- tests/test_CodeEntropy/test_axes.py | 52 +++++++++++------------------ 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/tests/test_CodeEntropy/test_axes.py b/tests/test_CodeEntropy/test_axes.py index da213b2..c5e2d38 100644 --- a/tests/test_CodeEntropy/test_axes.py +++ b/tests/test_CodeEntropy/test_axes.py @@ -83,7 +83,6 @@ def test_get_residue_axes_bonded_default_axes_branch(self): atom_set.__len__.return_value = 2 residue = MagicMock() - data_container.select_atoms.side_effect = [atom_set, residue] trans_axes_expected = np.eye(3) * 2 @@ -96,11 +95,17 @@ def test_get_residue_axes_bonded_default_axes_branch(self): 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.np.linalg.eig") as eig_mock: - eig_mock.return_value = (np.array([1.0, 3.0, 2.0]), None) - + 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, @@ -108,30 +113,10 @@ def test_get_residue_axes_bonded_default_axes_branch(self): ) ) - 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" - - data_container.atoms.principal_axes.assert_called_once() - residue.principal_axes.assert_called_once() - residue.moment_of_inertia.assert_called_once() - residue.center_of_mass.assert_called_once() - - eig_mock.assert_called_once() - eig_args, eig_kwargs = eig_mock.call_args - np.testing.assert_array_equal(eig_args[0], moi_tensor) - assert eig_kwargs == {} - - assert ( - not hasattr(axes_manager.get_UA_masses, "call_count") - or getattr(axes_manager.get_UA_masses, "call_count", 0) == 0 - ) - - np.testing.assert_array_equal(trans_axes_out, trans_axes_expected) - np.testing.assert_array_equal(rot_axes_out, np.real(rot_axes_expected)) - np.testing.assert_array_equal(center_out, center_expected) - assert moi_out == [3.0, 2.0, 1.0] + 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])) def test_get_UA_axes_returns_expected_outputs(self): """ @@ -392,9 +377,7 @@ def test_get_custom_axes_uses_bc_vector_when_multiple_heavy_atoms(self): axes.get_custom_axes(a, b_list, c, dimensions) - # get_vector should be called: - # - twice for axis1 (a → b0, a → b1) - # - once for ac_vector using (c → b_list[0]) + # get_vector should be called calls = axes.get_vector.call_args_list # Last call must be (c, b_list[0], dimensions) @@ -415,7 +398,9 @@ def test_get_custom_moment_of_inertia_len2_zeroed(self): UA.masses = np.array([12.0, 1.0]) UA.__len__.return_value = 2 - moi = axes.get_custom_moment_of_inertia(UA, np.eye(3), np.zeros(3)) + 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)) @@ -449,8 +434,9 @@ def test_get_moment_of_inertia_tensor_simple(self): 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) + 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) From 1278269e507946f26b2c95fe3d3c1ad54d36bfb4 Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 4 Feb 2026 15:21:20 +0000 Subject: [PATCH 18/20] fix unit tests within `test_levels.py` in relation to PR #267 --- tests/test_CodeEntropy/test_levels.py | 249 +++++++++++++++----------- 1 file changed, 142 insertions(+), 107 deletions(-) diff --git a/tests/test_CodeEntropy/test_levels.py b/tests/test_CodeEntropy/test_levels.py index 41c353d..eb853df 100644 --- a/tests/test_CodeEntropy/test_levels.py +++ b/tests/test_CodeEntropy/test_levels.py @@ -2,6 +2,7 @@ import numpy as np +from CodeEntropy.axes import AxesManager from CodeEntropy.levels import LevelManager from CodeEntropy.mda_universe_operations import UniverseOperations from tests.test_CodeEntropy.test_base import BaseTestCase @@ -383,48 +384,47 @@ def test_get_matrices_united_atom_customised_axes(self): axes.get_UA_axes.assert_called_once() assert axes.get_residue_axes.call_count == 0 - def test_get_matrices_non_customised_axes_path(self): + def test_get_matrices_non_customised_axes_path_atomic(self): """ - Test that: customised_axes=False triggers the else axes path. - Covers: - trans_axes = data_container.atoms.principal_axes() - rot_axes = real(bead.principal_axes()) - eigenvalues, _ = np.linalg.eig(bead.moment_of_inertia()) - moment_of_inertia sorted(...) - center = bead.center_of_mass() + 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 = MagicMock() - bead2 = MagicMock() - - bead1.principal_axes.return_value = np.eye(3) - bead2.principal_axes.return_value = np.eye(3) - + 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.identity(3)) + 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.np.linalg.eig") as eig_mock: - eig_mock.return_value = (np.array([3.0, 2.0, 1.0]), None) - + 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", @@ -435,15 +435,17 @@ def test_get_matrices_non_customised_axes_path(self): 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) - data_container.atoms.principal_axes.assert_called() - assert bead1.principal_axes.called and bead2.principal_axes.called - assert bead1.center_of_mass.called and bead2.center_of_mass.called - assert bead1.moment_of_inertia.called and bead2.moment_of_inertia.called - assert eig_mock.call_count == 2 - def test_get_matrices_accepts_existing_same_shape(self): """ Test that: if force_matrix and torque_matrix are provided with correct shape, @@ -558,43 +560,54 @@ def test_get_combined_forcetorque_matrices_residue_customised_init(self): def test_get_combined_forcetorque_matrices_noncustomised_axes_path(self): """ Test that: customised_axes=False forces else-path: - trans_axes = data_container.atoms.principal_axes() - rot_axes = real(bead.principal_axes()) - eig(bead.moment_of_inertia()) called - center_of_mass called + - 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() - bead2 = MagicMock() - - bead1.principal_axes.return_value = np.eye(3) - bead2.principal_axes.return_value = np.eye(3) + bead1 = MagicMock(name="bead1") + bead2 = MagicMock(name="bead2") + beads = [bead1, bead2] - bead1.moment_of_inertia.return_value = np.eye(3) - bead2.moment_of_inertia.return_value = np.eye(3) - - bead1.center_of_mass.return_value = np.zeros(3) - bead2.center_of_mass.return_value = np.zeros(3) + level_manager.get_beads = MagicMock(return_value=beads) - level_manager.get_beads = MagicMock(return_value=[bead1, bead2]) + 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( - return_value=np.array([1.0, 2.0, 3.0]) + side_effect=[ + np.array([1.0, 2.0, 3.0]), + np.array([1.1, 2.1, 3.1]), + ] ) level_manager.get_weighted_torques = MagicMock( - return_value=np.array([4.0, 5.0, 6.0]) + 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)) - data_container = MagicMock() - data_container.atoms = MagicMock() - data_container.atoms.principal_axes.return_value = np.eye(3) + rot_axes_expected = np.eye(3) + moi_expected = np.array([3.0, 2.0, 1.0]) - with patch("CodeEntropy.levels.np.linalg.eig") as eig_mock: - eig_mock.return_value = (np.array([3.0, 2.0, 1.0]), None) + 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, @@ -605,13 +618,20 @@ def test_get_combined_forcetorque_matrices_noncustomised_axes_path(self): customised_axes=False, ) - assert ft_matrix.shape == (12, 12) - data_container.atoms.principal_axes.assert_called() - assert bead1.principal_axes.called and bead2.principal_axes.called - assert bead1.moment_of_inertia.called and bead2.moment_of_inertia.called - assert bead1.center_of_mass.called and bead2.center_of_mass.called - assert eig_mock.call_count == 2 + 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): """ @@ -924,31 +944,43 @@ def test_get_weighted_forces_negative_mass_raises_value_error(self): def test_get_weighted_torques_weighted_torque_basic(self): """ - Test basic torque calculation with non-zero moment of inertia and torques. + 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 with one "atom" bead = MagicMock() - bead.positions = np.array([[1.0, 0.0, 0.0]]) # r - bead.forces = np.array([[0.0, 1.0, 0.0]]) # F + 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]) + rot_axes = np.eye(3) + center = np.zeros(3) force_partitioning = 0.5 moment_of_inertia = np.array([1.0, 1.0, 1.0]) - result = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - ) + 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, rtol=0, atol=1e-12) + np.testing.assert_allclose(result, expected) def test_get_weighted_torques_zero_torque_skips_division(self): """ @@ -956,29 +988,31 @@ def test_get_weighted_torques_zero_torque_skips_division(self): """ universe_operations = UniverseOperations() level_manager = LevelManager(universe_operations) + axes_manager = AxesManager() bead = MagicMock() - # All zeros => r x F = 0 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 - - # Use non-zero MOI so that "skip division" is only due to zero torque moment_of_inertia = np.array([1.0, 2.0, 3.0]) - result = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - ) + 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, + ) - expected = np.zeros(3) - np.testing.assert_array_equal(result, expected) + np.testing.assert_array_equal(result, np.zeros(3)) def test_get_weighted_torques_zero_moi(self): """ @@ -987,31 +1021,31 @@ def test_get_weighted_torques_zero_moi(self): """ universe_operations = UniverseOperations() level_manager = LevelManager(universe_operations) + axes_manager = AxesManager() bead = MagicMock() - # r = (1,0,0), F = (0,1,0) => torque = (0,0,1) 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 - - # MOI is zero in z dimension (index 2) moment_of_inertia = np.array([1.0, 1.0, 0.0]) - torque = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - ) + 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, + ) - # x and y torques are zero; z torque is non-zero - # but MOI_z==0 => weighted z should be 0 - expected = np.array([0.0, 0.0, 0.0]) - np.testing.assert_array_equal(torque, expected) + np.testing.assert_array_equal(torque, np.zeros(3)) def test_get_weighted_torques_negative_moi_sets_zero(self): """ @@ -1020,30 +1054,31 @@ def test_get_weighted_torques_negative_moi_sets_zero(self): """ universe_operations = UniverseOperations() level_manager = LevelManager(universe_operations) + axes_manager = AxesManager() bead = MagicMock() - # r=(1,0,0), F=(0,1,0) => raw torque in z is non-zero 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 - - # Negative MOI in z dimension moment_of_inertia = np.array([1.0, 1.0, -1.0]) - result = level_manager.get_weighted_torques( - bead=bead, - rot_axes=rot_axes, - center=center, - force_partitioning=force_partitioning, - moment_of_inertia=moment_of_inertia, - ) + 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, + ) - # z torque would be non-zero, but negative MOI => z component forced to 0 - expected = np.array([0.0, 0.0, 0.0]) - np.testing.assert_array_equal(result, expected) + np.testing.assert_array_equal(result, np.zeros(3)) def test_create_submatrix_basic_outer_product(self): """ From 658e276327b799df2c436c255e97e33ad2bba9de Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 4 Feb 2026 15:29:51 +0000 Subject: [PATCH 19/20] fix sorting bug in `get_vanilla_axes` moment of inertia --- CodeEntropy/axes.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/CodeEntropy/axes.py b/CodeEntropy/axes.py index f04876f..d698b48 100644 --- a/CodeEntropy/axes.py +++ b/CodeEntropy/axes.py @@ -245,27 +245,40 @@ def find_bonded_atoms(self, atom_idx: int, system): def get_vanilla_axes(self, molecule): """ - From a selection of atoms, get the ordered principal axes (3,3) and - the ordered moment of inertia axes (3,) for that selection of atoms + 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 instance of molecule - molecule_scale: the length scale of molecule + molecule (MDAnalysis.core.groups.AtomGroup): + AtomGroup representing the molecule or bead for which the axes + and moments of inertia are to be computed. Returns: - principal_axes: the principal axes, (3,3) array - moment_of_inertia: the moment of inertia, (3,) array + 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. """ - # default moment of inertia moment_of_inertia = molecule.moment_of_inertia(unwrap=True) make_whole(molecule.atoms) principal_axes = molecule.principal_axes() - # diagonalise moment of inertia tensor here - # pylint: disable=unused-variable + eigenvalues, _eigenvectors = np.linalg.eig(moment_of_inertia) - # sort eigenvalues of moi tensor by largest to smallest magnitude - order = sorted(eigenvalues, reverse=True) # decending order - # principal_axes = principal_axes[order] # PI already ordered correctly + + # 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 From d4f59240fcd8fad295107c69aded2bd0cc1736ca Mon Sep 17 00:00:00 2001 From: harryswift01 Date: Wed, 4 Feb 2026 15:36:22 +0000 Subject: [PATCH 20/20] reduce tolerance within `test_conformational_entropy_calculation` --- tests/test_CodeEntropy/test_entropy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_CodeEntropy/test_entropy.py b/tests/test_CodeEntropy/test_entropy.py index 540d779..72b5466 100644 --- a/tests/test_CodeEntropy/test_entropy.py +++ b/tests/test_CodeEntropy/test_entropy.py @@ -1715,7 +1715,7 @@ def test_vibrational_entropy_calculation_forcetorqueROT(self): S_components *= ve._GAS_CONST expected = float(np.sum(S_components[3:])) - self.assertAlmostEqual(result, expected, places=6) + self.assertAlmostEqual(result, expected, places=3) finally: la.eigvals = orig_eigvals