• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

CCPBioSim / CodeEntropy / 14972158685

12 May 2025 12:25PM UTC coverage: 40.957% (+0.4%) from 40.52%
14972158685

push

github

web-flow
Merge pull request #88 from CCPBioSim/85-oop-refactor

Object-oriented programming Refactor for CodeEntropy

168 of 563 new or added lines in 4 files covered. (29.84%)

274 of 669 relevant lines covered (40.96%)

1.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

34.98
/CodeEntropy/entropy.py
1
import logging
3✔
2
import math
3✔
3

4
import numpy as np
3✔
5
import pandas as pd
3✔
6
from numpy import linalg as la
3✔
7

8
logger = logging.getLogger(__name__)
3✔
9

10

11
class EntropyManager:
3✔
12
    """
13
    Manages entropy calculations at multiple molecular levels, based on a
14
    molecular dynamics trajectory.
15
    """
16

17
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
18
        """
19
        Initializes the EntropyManager with required components.
20

21
        Args:
22
            run_manager: Manager for universe and selection operations.
23
            args: Argument namespace containing user parameters.
24
            universe: MDAnalysis universe representing the simulation system.
25
            data_logger: Logger for storing and exporting entropy data.
26
            level_manager: Provides level-specific data such as matrices and dihedrals.
27
        """
28
        self._run_manager = run_manager
3✔
29
        self._args = args
3✔
30
        self._universe = universe
3✔
31
        self._data_logger = data_logger
3✔
32
        self._level_manager = level_manager
3✔
33
        self._GAS_CONST = 8.3144598484848
3✔
34

35
        self._results_df = pd.DataFrame(
3✔
36
            columns=["Molecule ID", "Level", "Type", "Result"]
37
        )
38
        self._residue_results_df = pd.DataFrame(
3✔
39
            columns=["Molecule ID", "Residue", "Type", "Result"]
40
        )
41

42
    @property
3✔
43
    def results_df(self):
3✔
44
        """Returns the dataframe containing entropy results at all levels."""
NEW
45
        return self._results_df
×
46

47
    @property
3✔
48
    def residue_results_df(self):
3✔
49
        """
50
        Returns the dataframe containing united-atom level results for each residue.
51
        """
NEW
52
        return self._residue_results_df
×
53

54
    def execute(self):
3✔
55
        """
56
        Executes the full entropy computation workflow over selected molecules and
57
        levels. This includes both vibrational and conformational entropy, recorded
58
        per molecule and residue.
59
        """
NEW
60
        start, end, step = self._get_trajectory_bounds()
×
NEW
61
        number_frames = self._get_number_frames(start, end, step)
×
NEW
62
        reduced_atom = self._get_reduced_universe()
×
NEW
63
        number_molecules, levels = self._level_manager.select_levels(reduced_atom)
×
64

NEW
65
        ve = VibrationalEntropy(
×
66
            self._run_manager,
67
            self._args,
68
            self._universe,
69
            self._data_logger,
70
            self._level_manager,
71
        )
NEW
72
        ce = ConformationalEntropy(
×
73
            self._run_manager,
74
            self._args,
75
            self._universe,
76
            self._data_logger,
77
            self._level_manager,
78
        )
79

NEW
80
        for molecule_id in range(number_molecules):
×
NEW
81
            mol_container = self._get_molecule_container(reduced_atom, molecule_id)
×
82

NEW
83
            for level in levels[molecule_id]:
×
NEW
84
                highest_level = level == levels[molecule_id][-1]
×
NEW
85
                if level == "united_atom":
×
NEW
86
                    self._process_united_atom_level(
×
87
                        molecule_id,
88
                        mol_container,
89
                        ve,
90
                        ce,
91
                        level,
92
                        start,
93
                        end,
94
                        step,
95
                        number_frames,
96
                        highest_level,
97
                    )
NEW
98
                elif level in ("polymer", "residue"):
×
NEW
99
                    self._process_vibrational_only_levels(
×
100
                        molecule_id,
101
                        mol_container,
102
                        ve,
103
                        level,
104
                        start,
105
                        end,
106
                        step,
107
                        number_frames,
108
                        highest_level,
109
                    )
NEW
110
                if level == "residue":
×
NEW
111
                    self._process_conformational_residue_level(
×
112
                        molecule_id,
113
                        mol_container,
114
                        ce,
115
                        level,
116
                        start,
117
                        end,
118
                        step,
119
                        number_frames,
120
                    )
121

NEW
122
            self._finalize_molecule_results(molecule_id, level)
×
123

NEW
124
        self._data_logger.log_tables()
×
125

126
    def _get_trajectory_bounds(self):
3✔
127
        """
128
        Returns the start, end, and step frame indices based on input arguments.
129

130
        Returns:
131
            Tuple of (start, end, step) frame indices.
132
        """
NEW
133
        start = self._args.start or 0
×
NEW
134
        end = self._args.end or -1
×
NEW
135
        step = self._args.step or 1
×
136

NEW
137
        return start, end, step
×
138

139
    def _get_number_frames(self, start, end, step):
3✔
140
        """
141
        Calculates the total number of trajectory frames used in the calculation.
142

143
        Args:
144
            start (int): Start frame index.
145
            end (int): End frame index. If -1, it refers to the end of the trajectory.
146
            step (int): Frame step size.
147

148
        Returns:
149
            int: Total number of frames considered.
150
        """
NEW
151
        trajectory_length = len(self._universe.trajectory)
×
152

NEW
153
        if start == 0 and end == -1 and step == 1:
×
NEW
154
            return trajectory_length
×
155

NEW
156
        if end == -1:
×
NEW
157
            end = trajectory_length
×
158
        else:
NEW
159
            end += 1
×
160

NEW
161
        return math.floor((end - start) / step)
×
162

163
    def _get_reduced_universe(self):
3✔
164
        """
165
        Applies atom selection based on the user's input.
166

167
        Returns:
168
            MDAnalysis.Universe: Selected subset of the system.
169
        """
NEW
170
        if self._args.selection_string == "all":
×
NEW
171
            return self._universe
×
NEW
172
        reduced = self._run_manager.new_U_select_atom(
×
173
            self._universe, self._args.selection_string
174
        )
NEW
175
        name = f"{len(reduced.trajectory)}_frame_dump_atom_selection"
×
NEW
176
        self._run_manager.write_universe(reduced, name)
×
NEW
177
        return reduced
×
178

179
    def _get_molecule_container(self, universe, molecule_id):
3✔
180
        """
181
        Extracts the atom group corresponding to a single molecule from the universe.
182

183
        Args:
184
            universe (MDAnalysis.Universe): The reduced universe.
185
            molecule_id (int): Index of the molecule to extract.
186

187
        Returns:
188
            MDAnalysis.Universe: Universe containing only the selected molecule.
189
        """
NEW
190
        frag = universe.atoms.fragments[molecule_id]
×
NEW
191
        selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}"
×
NEW
192
        return self._run_manager.new_U_select_atom(universe, selection_string)
×
193

194
    def _process_united_atom_level(
3✔
195
        self, mol_id, mol_container, ve, ce, level, start, end, step, n_frames, highest
196
    ):
197
        """
198
        Calculates translational, rotational, and conformational entropy at the
199
        united-atom level.
200

201
        Args:
202
            mol_id (int): ID of the molecule.
203
            mol_container (Universe): Universe for the selected molecule.
204
            ve: VibrationalEntropy object.
205
            ce: ConformationalEntropy object.
206
            level (str): Granularity level (should be 'united_atom').
207
            start, end, step (int): Trajectory frame parameters.
208
            n_frames (int): Number of trajectory frames.
209
            highest (bool): Whether this is the highest level of resolution for
210
            the molecule.
211
        """
NEW
212
        bin_width = self._args.bin_width
×
NEW
213
        S_trans, S_rot, S_conf = 0, 0, 0
×
NEW
214
        for residue_id, residue in enumerate(mol_container.residues):
×
NEW
215
            res_container = self._run_manager.new_U_select_atom(
×
216
                mol_container,
217
                f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}",
218
            )
NEW
219
            heavy_res = self._run_manager.new_U_select_atom(
×
220
                res_container, "not name H*"
221
            )
222

NEW
223
            force_matrix, torque_matrix = self._level_manager.get_matrices(
×
224
                res_container, level, start, end, step, n_frames, highest
225
            )
226

NEW
227
            S_trans_res = ve.vibrational_entropy_calculation(
×
228
                force_matrix, "force", self._args.temperature, highest
229
            )
NEW
230
            S_rot_res = ve.vibrational_entropy_calculation(
×
231
                torque_matrix, "torque", self._args.temperature, highest
232
            )
233

NEW
234
            dihedrals = self._level_manager.get_dihedrals(heavy_res, level)
×
NEW
235
            S_conf_res = ce.conformational_entropy_calculation(
×
236
                heavy_res, dihedrals, bin_width, start, end, step, n_frames
237
            )
238

NEW
239
            S_trans += S_trans_res
×
NEW
240
            S_rot += S_rot_res
×
NEW
241
            S_conf += S_conf_res
×
242

NEW
243
            self._log_residue_data(mol_id, residue_id, "Transvibrational", S_trans_res)
×
NEW
244
            self._log_residue_data(mol_id, residue_id, "Rovibrational", S_rot_res)
×
NEW
245
            self._log_residue_data(mol_id, residue_id, "Conformational", S_conf_res)
×
246

NEW
247
        self._log_result(mol_id, level, "Transvibrational", S_trans)
×
NEW
248
        self._log_result(mol_id, level, "Rovibrational", S_rot)
×
NEW
249
        self._log_result(mol_id, level, "Conformational", S_conf)
×
250

251
    def _process_vibrational_only_levels(
3✔
252
        self, mol_id, mol_container, ve, level, start, end, step, n_frames, highest
253
    ):
254
        """
255
        Calculates vibrational entropy at levels where conformational entropy is
256
        not considered.
257

258
        Args:
259
            mol_id (int): Molecule ID.
260
            mol_container (Universe): Selected molecule's universe.
261
            ve: VibrationalEntropy object.
262
            level (str): Current granularity level ('polymer' or 'residue').
263
            start, end, step (int): Trajectory frame parameters.
264
            n_frames (int): Number of trajectory frames.
265
            highest (bool): Flag indicating if this is the highest granularity
266
            level.
267
        """
NEW
268
        force_matrix, torque_matrix = self._level_manager.get_matrices(
×
269
            mol_container, level, start, end, step, n_frames, highest
270
        )
NEW
271
        S_trans = ve.vibrational_entropy_calculation(
×
272
            force_matrix, "force", self._args.temperature, highest
273
        )
NEW
274
        S_rot = ve.vibrational_entropy_calculation(
×
275
            torque_matrix, "torque", self._args.temperature, highest
276
        )
277

NEW
278
        self._log_result(mol_id, level, "Transvibrational", S_trans)
×
NEW
279
        self._log_result(mol_id, level, "Rovibrational", S_rot)
×
280

281
    def _process_conformational_residue_level(
3✔
282
        self, mol_id, mol_container, ce, level, start, end, step, n_frames
283
    ):
284
        """
285
        Computes conformational entropy at the residue level (whole-molecule dihedral
286
        analysis).
287

288
        Args:
289
            mol_id (int): ID of the molecule.
290
            mol_container (Universe): Selected molecule's universe.
291
            ce: ConformationalEntropy object.
292
            level (str): Level name (should be 'residue').
293
            start, end, step (int): Frame bounds.
294
            n_frames (int): Number of frames used.
295
        """
NEW
296
        bin_width = self._args.bin_width
×
NEW
297
        dihedrals = self._level_manager.get_dihedrals(mol_container, level)
×
NEW
298
        S_conf = ce.conformational_entropy_calculation(
×
299
            mol_container, dihedrals, bin_width, start, end, step, n_frames
300
        )
NEW
301
        self._log_result(mol_id, level, "Conformational", S_conf)
×
302

303
    def _finalize_molecule_results(self, mol_id, level):
3✔
304
        """
305
        Summarizes entropy for a molecule and saves results to file.
306

307
        Args:
308
            mol_id (int): ID of the molecule.
309
            level (str): Current level name (used for tagging final results).
310
        """
NEW
311
        S_total = self._results_df[self._results_df["Molecule ID"] == mol_id][
×
312
            "Result"
313
        ].sum()
NEW
314
        self._log_result(mol_id, "Molecule Total", "Molecule Total Entropy", S_total)
×
NEW
315
        self._data_logger.save_dataframes_as_json(
×
316
            self._results_df, self._residue_results_df, self._args.output_file
317
        )
318

319
    def _log_result(self, mol_id, level, entropy_type, value):
3✔
320
        """
321
        Logs and stores a single entropy value in the global results dataframe.
322

323
        Args:
324
            mol_id (int): Molecule ID.
325
            level (str): Entropy level or type.
326
            entropy_type (str): Type of entropy (e.g., 'Transvibrational').
327
            value (float): Entropy value.
328
        """
NEW
329
        row = pd.DataFrame(
×
330
            {
331
                "Molecule ID": [mol_id],
332
                "Level": [level],
333
                "Type": [f"{entropy_type} (J/mol/K)"],
334
                "Result": [value],
335
            }
336
        )
NEW
337
        self._results_df = pd.concat([self._results_df, row], ignore_index=True)
×
NEW
338
        self._data_logger.add_results_data(mol_id, level, entropy_type, value)
×
339

340
    def _log_residue_data(self, mol_id, residue_id, entropy_type, value):
3✔
341
        """
342
        Logs and stores per-residue entropy data.
343

344
        Args:
345
            mol_id (int): Molecule ID.
346
            residue_id (int): Residue index within the molecule.
347
            entropy_type (str): Entropy category.
348
            value (float): Entropy value.
349
        """
NEW
350
        row = pd.DataFrame(
×
351
            {
352
                "Molecule ID": [mol_id],
353
                "Residue": [residue_id],
354
                "Type": [f"{entropy_type} (J/mol/K)"],
355
                "Result": [value],
356
            }
357
        )
NEW
358
        self._residue_results_df = pd.concat(
×
359
            [self._residue_results_df, row], ignore_index=True
360
        )
NEW
361
        self._data_logger.add_residue_data(mol_id, residue_id, entropy_type, value)
×
362

363

364
class VibrationalEntropy(EntropyManager):
3✔
365
    """
366
    Performs vibrational entropy calculations using molecular trajectory data.
367
    Extends the base EntropyManager with constants and logic specific to
368
    vibrational modes and thermodynamic properties.
369
    """
370

371
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
372
        """
373
        Initializes the VibrationalEntropy manager with all required components and
374
        defines physical constants used in vibrational entropy calculations.
375
        """
376
        super().__init__(run_manager, args, universe, data_logger, level_manager)
3✔
377
        self._PLANCK_CONST = 6.62607004081818e-34
3✔
378

379
    def frequency_calculation(self, lambdas, temp):
3✔
380
        """
381
        Function to calculate an array of vibrational frequencies from the eigenvalues
382
        of the covariance matrix.
383

384
        Calculated from eq. (3) in Higham, S.-Y. Chou, F. Gräter and  R. H. Henchman,
385
        Molecular Physics, 2018, 116, 1965–1976//eq. (3) in A. Chakravorty, J. Higham
386
        and R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551
387

388
        frequency=sqrt(λ/kT)/2π
389

390
        Input
391
        -----
392
        lambdas : array of floats - eigenvalues of the covariance matrix
393
        temp: float - temperature
394

395
        Returns
396
        -------
397
        frequencies : array of floats - corresponding vibrational frequencies
398
        """
399
        pi = np.pi
3✔
400
        # get kT in Joules from given temperature
401
        kT = self._run_manager.get_KT2J(temp)
3✔
402
        logger.debug(f"Temperature: {temp}, kT: {kT}")
3✔
403

404
        lambdas = np.array(lambdas)  # Ensure input is a NumPy array
3✔
405
        logger.debug(f"Eigenvalues (lambdas): {lambdas}")
3✔
406

407
        # Check for negatives and raise an error if any are found
408
        if np.any(lambdas < 0):
3✔
NEW
409
            logger.error(f"Negative eigenvalues encountered: {lambdas[lambdas < 0]}")
×
NEW
410
            raise ValueError(
×
411
                f"Negative eigenvalues encountered: {lambdas[lambdas < 0]}"
412
            )
413

414
        # Compute frequencies safely
415
        frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT)
3✔
416
        logger.debug(f"Calculated frequencies: {frequencies}")
3✔
417

418
        return frequencies
3✔
419

420
    def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_level):
3✔
421
        """
422
        Function to calculate the vibrational entropy for each level calculated from
423
        eq. (4) in J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular
424
        Physics, 2018, 116, 1965–1976 / eq. (2) in A. Chakravorty, J. Higham and
425
        R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551.
426

427
        Input
428
        -----
429
        matrix : matrix - force/torque covariance matrix
430
        matrix_type: string
431
        temp: float - temperature
432
        highest_level: bool - is this the highest level of the heirarchy
433

434
        Returns
435
        -------
436
        S_vib_total : float - transvibrational/rovibrational entropy
437
        """
438
        # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues
439
        # Get eigenvalues of the given matrix and change units to SI units
440
        lambdas = la.eigvals(matrix)
3✔
441
        logger.debug(f"Eigenvalues (lambdas) before unit change: {lambdas}")
3✔
442

443
        lambdas = self._run_manager.change_lambda_units(lambdas)
3✔
444
        logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}")
3✔
445

446
        # Calculate frequencies from the eigenvalues
447
        frequencies = self.frequency_calculation(lambdas, temp)
3✔
448
        logger.debug(f"Calculated frequencies: {frequencies}")
3✔
449

450
        # Sort frequencies lowest to highest
451
        frequencies = np.sort(frequencies)
3✔
452
        logger.debug(f"Sorted frequencies: {frequencies}")
3✔
453

454
        kT = self._run_manager.get_KT2J(temp)
3✔
455
        logger.debug(f"Temperature: {temp}, kT: {kT}")
3✔
456
        exponent = self._PLANCK_CONST * frequencies / kT
3✔
457
        logger.debug(f"Exponent values: {exponent}")
3✔
458
        power_positive = np.power(np.e, exponent)
3✔
459
        power_negative = np.power(np.e, -exponent)
3✔
460
        logger.debug(f"Power positive values: {power_positive}")
3✔
461
        logger.debug(f"Power negative values: {power_negative}")
3✔
462
        S_components = exponent / (power_positive - 1) - np.log(1 - power_negative)
3✔
463
        S_components = (
3✔
464
            S_components * self._GAS_CONST
465
        )  # multiply by R - get entropy in J mol^{-1} K^{-1}
466
        logger.debug(f"Entropy components: {S_components}")
3✔
467
        # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues
468
        if matrix_type == "force":  # force covariance matrix
3✔
469
            if (
3✔
470
                highest_level
471
            ):  # whole molecule level - we take all frequencies into account
472
                S_vib_total = sum(S_components)
3✔
473

474
            # discard the 6 lowest frequencies to discard translation and rotation of
475
            # the whole unit the overall translation and rotation of a unit is an
476
            # internal motion of the level above
477
            else:
NEW
478
                S_vib_total = sum(S_components[6:])
×
479

480
        else:  # torque covariance matrix - we always take all values into account
481
            S_vib_total = sum(S_components)
3✔
482

483
        logger.debug(f"Total vibrational entropy: {S_vib_total}")
3✔
484

485
        return S_vib_total
3✔
486

487

488
class ConformationalEntropy(EntropyManager):
3✔
489
    """
490
    Performs conformational entropy calculations based on molecular dynamics data.
491
    Inherits from EntropyManager and includes constants specific to conformational
492
    analysis using statistical mechanics principles.
493
    """
494

495
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
496
        """
497
        Initializes the ConformationalEntropy manager with all required components and
498
        sets the gas constant used in conformational entropy calculations.
499
        """
NEW
500
        super().__init__(run_manager, args, universe, data_logger, level_manager)
×
501

502
    def assign_conformation(
3✔
503
        self, data_container, dihedral, number_frames, bin_width, start, end, step
504
    ):
505
        """
506
        Create a state vector, showing the state in which the input dihedral is
507
        as a function of time. The function creates a histogram from the timeseries of
508
        the dihedral angle values and identifies points of dominant occupancy
509
        (called CONVEX TURNING POINTS).
510
        Based on the identified TPs, states are assigned to each configuration of the
511
        dihedral.
512

513
        Input
514
        -----
515
        dihedral_atom_group : the group of 4 atoms defining the dihedral
516
        number_frames : number of frames in the trajectory
517
        bin_width : the width of the histogram bit, default 30 degrees
518
        start : int, starting frame, will default to 0
519
        end : int, ending frame, will default to -1 (last frame in trajectory)
520
        step : int, spacing between frames, will default to 1
521

522
        Return
523
        ------
524
        A timeseries with integer labels describing the state at each point in time.
525

526
        """
NEW
527
        conformations = np.zeros(number_frames)
×
NEW
528
        phi = np.zeros(number_frames)
×
529

530
        # get the values of the angle for the dihedral
531
        # dihedral angle values have a range from -180 to 180
NEW
532
        for timestep in data_container.trajectory[start:end:step]:
×
NEW
533
            timestep_index = timestep.frame - start
×
NEW
534
            value = dihedral.value()
×
535
            # we want postive values in range 0 to 360 to make the peak assignment
536
            # work using the fact that dihedrals have circular symetry
537
            # (i.e. -15 degrees = +345 degrees)
NEW
538
            if value < 0:
×
NEW
539
                value += 360
×
NEW
540
            phi[timestep_index] = value
×
541

542
        # create a histogram using numpy
NEW
543
        number_bins = int(360 / bin_width)
×
NEW
544
        popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360))
×
NEW
545
        bin_value = [
×
546
            0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul))
547
        ]
548

549
        # identify "convex turning-points" and populate a list of peaks
550
        # peak : a bin whose neighboring bins have smaller population
551
        # NOTE might have problems if the peak is wide with a flat or sawtooth top
NEW
552
        peak_values = []
×
553

NEW
554
        for bin_index in range(number_bins):
×
555
            # if there is no dihedrals in a bin then it cannot be a peak
NEW
556
            if popul[bin_index] == 0:
×
NEW
557
                pass
×
558
            # being careful of the last bin
559
            # (dihedrals have circular symmetry, the histogram does not)
NEW
560
            elif (
×
561
                bin_index == number_bins - 1
562
            ):  # the -1 is because the index starts with 0 not 1
NEW
563
                if (
×
564
                    popul[bin_index] >= popul[bin_index - 1]
565
                    and popul[bin_index] >= popul[0]
566
                ):
NEW
567
                    peak_values.append(bin_value[bin_index])
×
568
            else:
NEW
569
                if (
×
570
                    popul[bin_index] >= popul[bin_index - 1]
571
                    and popul[bin_index] >= popul[bin_index + 1]
572
                ):
NEW
573
                    peak_values.append(bin_value[bin_index])
×
574

575
        # go through each frame again and assign conformation state
NEW
576
        for frame in range(number_frames):
×
577
            # find the TP that the snapshot is least distant from
NEW
578
            distances = [abs(phi[frame] - peak) for peak in peak_values]
×
NEW
579
            conformations[frame] = np.argmin(distances)
×
580

NEW
581
        logger.debug(f"Final conformations: {conformations}")
×
582

NEW
583
        return conformations
×
584

585
    def conformational_entropy_calculation(
3✔
586
        self, data_container, dihedrals, bin_width, start, end, step, number_frames
587
    ):
588
        """
589
        Function to calculate conformational entropies using eq. (7) in Higham,
590
        S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116,
591
        1965–1976 / eq. (4) in A. Chakravorty, J. Higham and R. H. Henchman,
592
        J. Chem. Inf. Model., 2020, 60, 5540–5551.
593

594
        Uses the adaptive enumeration method (AEM).
595

596
        Input
597
        -----
598
        dihedrals : array - array of dihedrals in the molecule
599
        Returns
600
        -------
601
        S_conf_total : float - conformational entropy
602
        """
603

NEW
604
        S_conf_total = 0
×
605

606
        # For each dihedral, identify the conformation in each frame
NEW
607
        num_dihedrals = len(dihedrals)
×
NEW
608
        conformation = np.zeros((num_dihedrals, number_frames))
×
NEW
609
        index = 0
×
NEW
610
        for dihedral in dihedrals:
×
NEW
611
            conformation[index] = self.assign_conformation(
×
612
                data_container, dihedral, number_frames, bin_width, start, end, step
613
            )
NEW
614
            index += 1
×
615

NEW
616
        logger.debug(f"Conformation matrix: {conformation}")
×
617

618
        # For each frame, convert the conformation of all dihedrals into a
619
        # state string
NEW
620
        states = ["" for x in range(number_frames)]
×
NEW
621
        for frame_index in range(number_frames):
×
NEW
622
            for index in range(num_dihedrals):
×
NEW
623
                states[frame_index] += str(conformation[index][frame_index])
×
624

NEW
625
        logger.debug(f"States: {states}")
×
626

627
        # Count how many times each state occurs, then use the probability
628
        # to get the entropy
629
        # entropy = sum over states p*ln(p)
NEW
630
        values, counts = np.unique(states, return_counts=True)
×
NEW
631
        for state in range(len(values)):
×
NEW
632
            logger.debug(f"Unique states: {values}")
×
NEW
633
            logger.debug(f"Counts: {counts}")
×
NEW
634
            count = counts[state]
×
NEW
635
            probability = count / number_frames
×
NEW
636
            entropy = probability * np.log(probability)
×
NEW
637
            S_conf_total += entropy
×
638

639
        # multiply by gas constant to get the units J/mol/K
NEW
640
        S_conf_total *= -1 * self._GAS_CONST
×
641

NEW
642
        logger.debug(f"Total conformational entropy: {S_conf_total}")
×
643

NEW
644
        return S_conf_total
×
645

646

647
class OrientationalEntropy(EntropyManager):
3✔
648
    """
649
    Performs orientational entropy calculations using molecular dynamics data.
650
    Inherits from EntropyManager and includes constants relevant to rotational
651
    and orientational degrees of freedom.
652
    """
653

654
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
655
        """
656
        Initializes the OrientationalEntropy manager with all required components and
657
        sets the gas constant used in orientational entropy calculations.
658
        """
NEW
659
        super().__init__(run_manager, args, universe, data_logger, level_manager)
×
660

661
    def orientational_entropy_calculation(self, neighbours_dict):
3✔
662
        """
663
        Function to calculate orientational entropies from eq. (10) in J. Higham,
664
        S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116,
665
        3 1965–1976. Number of orientations, Ω, is calculated using eq. (8) in
666
        J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman,  Molecular Physics,
667
        2018, 116, 3 1965–1976.
668

669
        σ is assumed to be 1 for the molecules we're concerned with and hence,
670
        max {1, (Nc^3*π)^(1/2)} will always be (Nc^3*π)^(1/2).
671

672
        TODO future release - function for determing symmetry and symmetry numbers
673
        maybe?
674

675
        Input
676
        -----
677
        neighbours_dict :  dictionary - dictionary of neighbours for the molecule -
678
            should contain the type of neighbour molecule and the number of neighbour
679
            molecules of that species
680

681
        Returns
682
        -------
683
        S_or_total : float - orientational entropy
684
        """
685

686
        # Replaced molecule with neighbour as this is what the for loop uses
NEW
687
        S_or_total = 0
×
NEW
688
        for neighbour in neighbours_dict:  # we are going through neighbours
×
NEW
689
            if neighbour in []:  # water molecules - call POSEIDON functions
×
NEW
690
                pass  # TODO temporary until function is written
×
691
            else:
692
                # the bound ligand is always going to be a neighbour
NEW
693
                omega = np.sqrt((neighbours_dict[neighbour] ** 3) * math.pi)
×
NEW
694
                logger.debug(f"Omega for neighbour {neighbour}: {omega}")
×
695
                # orientational entropy arising from each neighbouring species
696
                # - we know the species is going to be a neighbour
NEW
697
                S_or_component = math.log(omega)
×
NEW
698
                logger.debug(
×
699
                    f"S_or_component (log(omega)) for neighbour {neighbour}: "
700
                    f"{S_or_component}"
701
                )
NEW
702
                S_or_component *= self.GAS_CONST
×
NEW
703
                logger.debug(
×
704
                    f"S_or_component after multiplying by GAS_CONST for neighbour "
705
                    f"{neighbour}: {S_or_component}"
706
                )
NEW
707
            S_or_total += S_or_component
×
NEW
708
            logger.debug(
×
709
                f"S_or_total after adding component for neighbour {neighbour}: "
710
                f"{S_or_total}"
711
            )
712
        # TODO for future releases
713
        # implement a case for molecules with hydrogen bonds but to a lesser
714
        # extent than water
715

NEW
716
        logger.debug(f"Final total orientational entropy: {S_or_total}")
×
717

NEW
718
        return S_or_total
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc