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

CCPBioSim / CodeEntropy / 15818947188

18 Jun 2025 08:44AM UTC coverage: 51.507% (+2.5%) from 49.023%
15818947188

push

github

web-flow
Merge pull request #107 from CCPBioSim/104-integrating-waterentropy

Integration of `waterEntropy` into `CodeEntropy`

63 of 98 new or added lines in 2 files covered. (64.29%)

1 existing line in 1 file now uncovered.

376 of 730 relevant lines covered (51.51%)

1.55 hits per line

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

43.46
/CodeEntropy/entropy.py
1
import logging
3✔
2
import math
3✔
3
from collections import defaultdict
3✔
4

5
import numpy as np
3✔
6
import pandas as pd
3✔
7
import waterEntropy.recipes.interfacial_solvent as GetSolvent
3✔
8
from numpy import linalg as la
3✔
9

10
logger = logging.getLogger(__name__)
3✔
11

12

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

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

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

37
    def execute(self):
3✔
38
        """
39
        Executes the full entropy computation workflow over selected molecules and
40
        levels. This includes both vibrational and conformational entropy, recorded
41
        per molecule and residue.
42
        """
43
        start, end, step = self._get_trajectory_bounds()
×
44
        number_frames = self._get_number_frames(start, end, step)
×
45

NEW
46
        has_water = self._universe.select_atoms("water").n_atoms > 0
×
NEW
47
        if has_water and self._args.water_entropy:
×
NEW
48
            self._calculate_water_entropy(self._universe, start, end, step)
×
49

NEW
50
            if self._args.selection_string != "all":
×
NEW
51
                self._args.selection_string += " and not water"
×
52
            else:
NEW
53
                self._args.selection_string = "not water"
×
54

NEW
55
            logger.debug(
×
56
                "WaterEntropy: molecule_data: %s",
57
                self._data_logger.molecule_data,
58
            )
NEW
59
            logger.debug(
×
60
                "WaterEntropy: residue_data: %s",
61
                self._data_logger.residue_data,
62
            )
63

64
        reduced_atom = self._get_reduced_universe()
×
65
        number_molecules, levels = self._level_manager.select_levels(reduced_atom)
×
66

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

82
        for molecule_id in range(number_molecules):
×
83
            mol_container = self._get_molecule_container(reduced_atom, molecule_id)
×
84

85
            for level in levels[molecule_id]:
×
86
                highest_level = level == levels[molecule_id][-1]
×
87
                if level == "united_atom":
×
88
                    self._process_united_atom_level(
×
89
                        molecule_id,
90
                        mol_container,
91
                        ve,
92
                        ce,
93
                        level,
94
                        start,
95
                        end,
96
                        step,
97
                        number_frames,
98
                        highest_level,
99
                    )
100

NEW
101
                    logger.debug(
×
102
                        "%s level: molecule_data: %s",
103
                        level,
104
                        self._data_logger.molecule_data,
105
                    )
NEW
106
                    logger.debug(
×
107
                        "%s level: residue_data: %s",
108
                        level,
109
                        self._data_logger.residue_data,
110
                    )
111

112
                elif level in ("polymer", "residue"):
×
113
                    self._process_vibrational_only_levels(
×
114
                        molecule_id,
115
                        mol_container,
116
                        ve,
117
                        level,
118
                        start,
119
                        end,
120
                        step,
121
                        number_frames,
122
                        highest_level,
123
                    )
124

NEW
125
                    logger.debug(
×
126
                        "%s level: molecule_data: %s",
127
                        level,
128
                        self._data_logger.molecule_data,
129
                    )
NEW
130
                    logger.debug(
×
131
                        "%s level: residue_data: %s",
132
                        level,
133
                        self._data_logger.residue_data,
134
                    )
135

136
                if level == "residue":
×
137
                    self._process_conformational_residue_level(
×
138
                        molecule_id,
139
                        mol_container,
140
                        ce,
141
                        level,
142
                        start,
143
                        end,
144
                        step,
145
                        number_frames,
146
                    )
147

NEW
148
                    logger.debug(
×
149
                        "%s level: molecule_data: %s",
150
                        level,
151
                        self._data_logger.molecule_data,
152
                    )
NEW
153
                    logger.debug(
×
154
                        "%s level: residue_data: %s",
155
                        level,
156
                        self._data_logger.residue_data,
157
                    )
158

NEW
159
        self._finalize_molecule_results()
×
160

161
        self._data_logger.log_tables()
×
162

163
    def _get_trajectory_bounds(self):
3✔
164
        """
165
        Returns the start, end, and step frame indices based on input arguments.
166

167
        Returns:
168
            Tuple of (start, end, step) frame indices.
169
        """
170
        start = self._args.start or 0
×
171
        end = self._args.end or -1
×
172
        step = self._args.step or 1
×
173

174
        return start, end, step
×
175

176
    def _get_number_frames(self, start, end, step):
3✔
177
        """
178
        Calculates the total number of trajectory frames used in the calculation.
179

180
        Args:
181
            start (int): Start frame index.
182
            end (int): End frame index. If -1, it refers to the end of the trajectory.
183
            step (int): Frame step size.
184

185
        Returns:
186
            int: Total number of frames considered.
187
        """
188
        trajectory_length = len(self._universe.trajectory)
×
189

190
        if start == 0 and end == -1 and step == 1:
×
191
            return trajectory_length
×
192

193
        if end == -1:
×
194
            end = trajectory_length
×
195
        else:
196
            end += 1
×
197

198
        return math.floor((end - start) / step)
×
199

200
    def _get_reduced_universe(self):
3✔
201
        """
202
        Applies atom selection based on the user's input.
203

204
        Returns:
205
            MDAnalysis.Universe: Selected subset of the system.
206
        """
207
        if self._args.selection_string == "all":
×
208
            return self._universe
×
209
        reduced = self._run_manager.new_U_select_atom(
×
210
            self._universe, self._args.selection_string
211
        )
212
        name = f"{len(reduced.trajectory)}_frame_dump_atom_selection"
×
213
        self._run_manager.write_universe(reduced, name)
×
214
        return reduced
×
215

216
    def _get_molecule_container(self, universe, molecule_id):
3✔
217
        """
218
        Extracts the atom group corresponding to a single molecule from the universe.
219

220
        Args:
221
            universe (MDAnalysis.Universe): The reduced universe.
222
            molecule_id (int): Index of the molecule to extract.
223

224
        Returns:
225
            MDAnalysis.Universe: Universe containing only the selected molecule.
226
        """
227
        frag = universe.atoms.fragments[molecule_id]
×
228
        selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}"
×
229
        return self._run_manager.new_U_select_atom(universe, selection_string)
×
230

231
    def _process_united_atom_level(
3✔
232
        self, mol_id, mol_container, ve, ce, level, start, end, step, n_frames, highest
233
    ):
234
        """
235
        Calculates translational, rotational, and conformational entropy at the
236
        united-atom level.
237

238
        Args:
239
            mol_id (int): ID of the molecule.
240
            mol_container (Universe): Universe for the selected molecule.
241
            ve: VibrationalEntropy object.
242
            ce: ConformationalEntropy object.
243
            level (str): Granularity level (should be 'united_atom').
244
            start, end, step (int): Trajectory frame parameters.
245
            n_frames (int): Number of trajectory frames.
246
            highest (bool): Whether this is the highest level of resolution for
247
            the molecule.
248
        """
249
        bin_width = self._args.bin_width
×
250
        S_trans, S_rot, S_conf = 0, 0, 0
×
251
        for residue_id, residue in enumerate(mol_container.residues):
×
252
            res_container = self._run_manager.new_U_select_atom(
×
253
                mol_container,
254
                f"index {residue.atoms.indices[0]}:{residue.atoms.indices[-1]}",
255
            )
256
            heavy_res = self._run_manager.new_U_select_atom(
×
257
                res_container, "not name H*"
258
            )
259

260
            force_matrix, torque_matrix = self._level_manager.get_matrices(
×
261
                res_container, level, start, end, step, n_frames, highest
262
            )
263

264
            S_trans_res = ve.vibrational_entropy_calculation(
×
265
                force_matrix, "force", self._args.temperature, highest
266
            )
267
            S_rot_res = ve.vibrational_entropy_calculation(
×
268
                torque_matrix, "torque", self._args.temperature, highest
269
            )
270

271
            dihedrals = self._level_manager.get_dihedrals(heavy_res, level)
×
272
            S_conf_res = ce.conformational_entropy_calculation(
×
273
                heavy_res, dihedrals, bin_width, start, end, step, n_frames
274
            )
275

276
            S_trans += S_trans_res
×
277
            S_rot += S_rot_res
×
278
            S_conf += S_conf_res
×
279

NEW
280
            self._data_logger.add_residue_data(
×
281
                mol_id, residue.resname, level, "Transvibrational", S_trans_res
282
            )
NEW
283
            self._data_logger.add_residue_data(
×
284
                mol_id, residue.resname, level, "Rovibrational", S_rot_res
285
            )
NEW
286
            self._data_logger.add_residue_data(
×
287
                mol_id, residue.resname, level, "Conformational", S_conf_res
288
            )
289

NEW
290
        self._data_logger.add_results_data(
×
291
            residue.resname, level, "Transvibrational", S_trans
292
        )
NEW
293
        self._data_logger.add_results_data(
×
294
            residue.resname, level, "Rovibrational", S_rot
295
        )
NEW
296
        self._data_logger.add_results_data(
×
297
            residue.resname, level, "Conformational", S_conf
298
        )
299

300
    def _process_vibrational_only_levels(
3✔
301
        self, mol_id, mol_container, ve, level, start, end, step, n_frames, highest
302
    ):
303
        """
304
        Calculates vibrational entropy at levels where conformational entropy is
305
        not considered.
306

307
        Args:
308
            mol_id (int): Molecule ID.
309
            mol_container (Universe): Selected molecule's universe.
310
            ve: VibrationalEntropy object.
311
            level (str): Current granularity level ('polymer' or 'residue').
312
            start, end, step (int): Trajectory frame parameters.
313
            n_frames (int): Number of trajectory frames.
314
            highest (bool): Flag indicating if this is the highest granularity
315
            level.
316
        """
317
        force_matrix, torque_matrix = self._level_manager.get_matrices(
×
318
            mol_container, level, start, end, step, n_frames, highest
319
        )
320
        S_trans = ve.vibrational_entropy_calculation(
×
321
            force_matrix, "force", self._args.temperature, highest
322
        )
323
        S_rot = ve.vibrational_entropy_calculation(
×
324
            torque_matrix, "torque", self._args.temperature, highest
325
        )
NEW
326
        residue = mol_container.residues[mol_id]
×
NEW
327
        self._data_logger.add_results_data(
×
328
            residue.resname, level, "Transvibrational", S_trans
329
        )
NEW
330
        self._data_logger.add_results_data(
×
331
            residue.resname, level, "Rovibrational", S_rot
332
        )
333

334
    def _process_conformational_residue_level(
3✔
335
        self, mol_id, mol_container, ce, level, start, end, step, n_frames
336
    ):
337
        """
338
        Computes conformational entropy at the residue level (whole-molecule dihedral
339
        analysis).
340

341
        Args:
342
            mol_id (int): ID of the molecule.
343
            mol_container (Universe): Selected molecule's universe.
344
            ce: ConformationalEntropy object.
345
            level (str): Level name (should be 'residue').
346
            start, end, step (int): Frame bounds.
347
            n_frames (int): Number of frames used.
348
        """
349
        bin_width = self._args.bin_width
×
350
        dihedrals = self._level_manager.get_dihedrals(mol_container, level)
×
351
        S_conf = ce.conformational_entropy_calculation(
×
352
            mol_container, dihedrals, bin_width, start, end, step, n_frames
353
        )
NEW
354
        residue = mol_container.residues[mol_id]
×
NEW
355
        self._data_logger.add_results_data(
×
356
            residue.resname, level, "Conformational", S_conf
357
        )
358

359
    def _finalize_molecule_results(self):
3✔
360
        """
361
        Aggregates and logs total entropy per molecule using residue_data grouped by
362
        resid.
363
        """
NEW
364
        entropy_by_molecule = defaultdict(float)
×
365

NEW
366
        for mol_id, level, entropy_type, result in self._data_logger.molecule_data:
×
NEW
367
            if level != "Molecule Total":
×
NEW
368
                try:
×
NEW
369
                    entropy_by_molecule[mol_id] += float(result)
×
NEW
370
                except ValueError:
×
NEW
371
                    logger.warning(f"Skipping invalid entry: {mol_id}, {result}")
×
372

NEW
373
        for mol_id, total_entropy in entropy_by_molecule.items():
×
NEW
374
            self._data_logger.molecule_data.append(
×
375
                (mol_id, "Molecule Total", "Molecule Total Entropy", total_entropy)
376
            )
377

378
        # Save to file
UNCOV
379
        self._data_logger.save_dataframes_as_json(
×
380
            pd.DataFrame(
381
                self._data_logger.molecule_data,
382
                columns=["Molecule ID", "Level", "Type", "Result (J/mol/K)"],
383
            ),
384
            pd.DataFrame(
385
                self._data_logger.residue_data,
386
                columns=[
387
                    "Residue ID",
388
                    "Residue Name",
389
                    "Level",
390
                    "Type",
391
                    "Result (J/mol/K)",
392
                ],
393
            ),
394
            self._args.output_file,
395
        )
396

397
    def _calculate_water_entropy(self, universe, start, end, step):
3✔
398
        """
399
        Calculates orientational and vibrational entropy for water molecules.
400

401
        Args:
402
            universe: MDAnalysis Universe object.
403
            start (int): Start frame.
404
            end (int): End frame.
405
            step (int): Step size.
406
        """
407
        Sorient_dict, _, vibrations, _ = (
3✔
408
            GetSolvent.get_interfacial_water_orient_entropy(universe, start, end, step)
409
        )
410

411
        # Log per-residue entropy using helper functions
412
        self._calculate_water_orientational_entropy(Sorient_dict)
3✔
413
        self._calculate_water_vibrational_translational_entropy(vibrations)
3✔
414
        self._calculate_water_vibrational_rotational_entropy(vibrations)
3✔
415

416
        # Aggregate entropy components per molecule
417
        results = {}
3✔
418

419
        for row in self._data_logger.residue_data:
3✔
420
            mol_id = row[1]
3✔
421
            entropy_type = row[3].split()[0]
3✔
422
            value = float(row[4])
3✔
423

424
            if mol_id not in results:
3✔
425
                results[mol_id] = {
3✔
426
                    "Orientational": 0.0,
427
                    "Transvibrational": 0.0,
428
                    "Rovibrational": 0.0,
429
                }
430

431
            results[mol_id][entropy_type] += value
3✔
432

433
        # Log per-molecule entropy components and total
434
        for mol_id, components in results.items():
3✔
435
            total = 0.0
3✔
436
            for entropy_type in ["Orientational", "Transvibrational", "Rovibrational"]:
3✔
437
                S_component = components[entropy_type]
3✔
438
                self._data_logger.add_results_data(
3✔
439
                    mol_id, "water", entropy_type, S_component
440
                )
441
                total += S_component
3✔
442

443
    def _calculate_water_orientational_entropy(self, Sorient_dict):
3✔
444
        """
445
        Logs orientational entropy values directly from Sorient_dict.
446
        """
447
        for resid, resname_dict in Sorient_dict.items():
3✔
448
            for resname, values in resname_dict.items():
3✔
449
                if isinstance(values, list) and len(values) == 2:
3✔
450
                    Sor, count = values
3✔
451
                    self._data_logger.add_residue_data(
3✔
452
                        resid, resname, "Water", "Orientational", Sor
453
                    )
454

455
    def _calculate_water_vibrational_translational_entropy(self, vibrations):
3✔
456
        """
457
        Logs summed translational entropy values per residue-solvent pair.
458
        """
459
        for (solute_id, _), entropy in vibrations.translational_S.items():
3✔
460
            if isinstance(entropy, (list, np.ndarray)):
3✔
461
                entropy = float(np.sum(entropy))
3✔
462

463
            if "_" in solute_id:
3✔
464
                resname, resid_str = solute_id.rsplit("_", 1)
3✔
465
                try:
3✔
466
                    resid = int(resid_str)
3✔
467
                except ValueError:
3✔
468
                    resid = -1
3✔
469
            else:
470
                resname = solute_id
3✔
471
                resid = -1
3✔
472

473
            self._data_logger.add_residue_data(
3✔
474
                resid, resname, "Water", "Transvibrational", entropy
475
            )
476

477
    def _calculate_water_vibrational_rotational_entropy(self, vibrations):
3✔
478
        """
479
        Logs summed rotational entropy values per residue-solvent pair.
480
        """
481
        for (solute_id, _), entropy in vibrations.rotational_S.items():
3✔
482
            if isinstance(entropy, (list, np.ndarray)):
3✔
483
                entropy = float(np.sum(entropy))
3✔
484

485
            if "_" in solute_id:
3✔
486
                resname, resid_str = solute_id.rsplit("_", 1)
3✔
487
                try:
3✔
488
                    resid = int(resid_str)
3✔
489
                except ValueError:
3✔
490
                    resid = -1
3✔
491
            else:
492
                resname = solute_id
3✔
493
                resid = -1
3✔
494

495
            self._data_logger.add_residue_data(
3✔
496
                resid, resname, "Water", "Rovibrational", entropy
497
            )
498

499

500
class VibrationalEntropy(EntropyManager):
3✔
501
    """
502
    Performs vibrational entropy calculations using molecular trajectory data.
503
    Extends the base EntropyManager with constants and logic specific to
504
    vibrational modes and thermodynamic properties.
505
    """
506

507
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
508
        """
509
        Initializes the VibrationalEntropy manager with all required components and
510
        defines physical constants used in vibrational entropy calculations.
511
        """
512
        super().__init__(run_manager, args, universe, data_logger, level_manager)
3✔
513
        self._PLANCK_CONST = 6.62607004081818e-34
3✔
514

515
    def frequency_calculation(self, lambdas, temp):
3✔
516
        """
517
        Function to calculate an array of vibrational frequencies from the eigenvalues
518
        of the covariance matrix.
519

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

524
        frequency=sqrt(λ/kT)/2π
525

526
        Input
527
        -----
528
        lambdas : array of floats - eigenvalues of the covariance matrix
529
        temp: float - temperature
530

531
        Returns
532
        -------
533
        frequencies : array of floats - corresponding vibrational frequencies
534
        """
535
        pi = np.pi
3✔
536
        # get kT in Joules from given temperature
537
        kT = self._run_manager.get_KT2J(temp)
3✔
538
        logger.debug(f"Temperature: {temp}, kT: {kT}")
3✔
539

540
        lambdas = np.array(lambdas)  # Ensure input is a NumPy array
3✔
541
        logger.debug(f"Eigenvalues (lambdas): {lambdas}")
3✔
542

543
        # Check for negatives and raise an error if any are found
544
        if np.any(lambdas < 0):
3✔
545
            logger.error(f"Negative eigenvalues encountered: {lambdas[lambdas < 0]}")
×
546
            raise ValueError(
×
547
                f"Negative eigenvalues encountered: {lambdas[lambdas < 0]}"
548
            )
549

550
        # Compute frequencies safely
551
        frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT)
3✔
552
        logger.debug(f"Calculated frequencies: {frequencies}")
3✔
553

554
        return frequencies
3✔
555

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

563
        Input
564
        -----
565
        matrix : matrix - force/torque covariance matrix
566
        matrix_type: string
567
        temp: float - temperature
568
        highest_level: bool - is this the highest level of the heirarchy
569

570
        Returns
571
        -------
572
        S_vib_total : float - transvibrational/rovibrational entropy
573
        """
574
        # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues
575
        # Get eigenvalues of the given matrix and change units to SI units
576
        lambdas = la.eigvals(matrix)
3✔
577
        logger.debug(f"Eigenvalues (lambdas) before unit change: {lambdas}")
3✔
578

579
        lambdas = self._run_manager.change_lambda_units(lambdas)
3✔
580
        logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}")
3✔
581

582
        # Calculate frequencies from the eigenvalues
583
        frequencies = self.frequency_calculation(lambdas, temp)
3✔
584
        logger.debug(f"Calculated frequencies: {frequencies}")
3✔
585

586
        # Sort frequencies lowest to highest
587
        frequencies = np.sort(frequencies)
3✔
588
        logger.debug(f"Sorted frequencies: {frequencies}")
3✔
589

590
        kT = self._run_manager.get_KT2J(temp)
3✔
591
        logger.debug(f"Temperature: {temp}, kT: {kT}")
3✔
592
        exponent = self._PLANCK_CONST * frequencies / kT
3✔
593
        logger.debug(f"Exponent values: {exponent}")
3✔
594
        power_positive = np.power(np.e, exponent)
3✔
595
        power_negative = np.power(np.e, -exponent)
3✔
596
        logger.debug(f"Power positive values: {power_positive}")
3✔
597
        logger.debug(f"Power negative values: {power_negative}")
3✔
598
        S_components = exponent / (power_positive - 1) - np.log(1 - power_negative)
3✔
599
        S_components = (
3✔
600
            S_components * self._GAS_CONST
601
        )  # multiply by R - get entropy in J mol^{-1} K^{-1}
602
        logger.debug(f"Entropy components: {S_components}")
3✔
603
        # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues
604
        if matrix_type == "force":  # force covariance matrix
3✔
605
            if (
3✔
606
                highest_level
607
            ):  # whole molecule level - we take all frequencies into account
608
                S_vib_total = sum(S_components)
3✔
609

610
            # discard the 6 lowest frequencies to discard translation and rotation of
611
            # the whole unit the overall translation and rotation of a unit is an
612
            # internal motion of the level above
613
            else:
614
                S_vib_total = sum(S_components[6:])
×
615

616
        else:  # torque covariance matrix - we always take all values into account
617
            S_vib_total = sum(S_components)
3✔
618

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

621
        return S_vib_total
3✔
622

623

624
class ConformationalEntropy(EntropyManager):
3✔
625
    """
626
    Performs conformational entropy calculations based on molecular dynamics data.
627
    Inherits from EntropyManager and includes constants specific to conformational
628
    analysis using statistical mechanics principles.
629
    """
630

631
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
632
        """
633
        Initializes the ConformationalEntropy manager with all required components and
634
        sets the gas constant used in conformational entropy calculations.
635
        """
636
        super().__init__(run_manager, args, universe, data_logger, level_manager)
×
637

638
    def assign_conformation(
3✔
639
        self, data_container, dihedral, number_frames, bin_width, start, end, step
640
    ):
641
        """
642
        Create a state vector, showing the state in which the input dihedral is
643
        as a function of time. The function creates a histogram from the timeseries of
644
        the dihedral angle values and identifies points of dominant occupancy
645
        (called CONVEX TURNING POINTS).
646
        Based on the identified TPs, states are assigned to each configuration of the
647
        dihedral.
648

649
        Input
650
        -----
651
        dihedral_atom_group : the group of 4 atoms defining the dihedral
652
        number_frames : number of frames in the trajectory
653
        bin_width : the width of the histogram bit, default 30 degrees
654
        start : int, starting frame, will default to 0
655
        end : int, ending frame, will default to -1 (last frame in trajectory)
656
        step : int, spacing between frames, will default to 1
657

658
        Return
659
        ------
660
        A timeseries with integer labels describing the state at each point in time.
661

662
        """
663
        conformations = np.zeros(number_frames)
×
664
        phi = np.zeros(number_frames)
×
665

666
        # get the values of the angle for the dihedral
667
        # dihedral angle values have a range from -180 to 180
668
        for timestep in data_container.trajectory[start:end:step]:
×
669
            timestep_index = timestep.frame - start
×
670
            value = dihedral.value()
×
671
            # we want postive values in range 0 to 360 to make the peak assignment
672
            # work using the fact that dihedrals have circular symetry
673
            # (i.e. -15 degrees = +345 degrees)
674
            if value < 0:
×
675
                value += 360
×
676
            phi[timestep_index] = value
×
677

678
        # create a histogram using numpy
679
        number_bins = int(360 / bin_width)
×
680
        popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360))
×
681
        bin_value = [
×
682
            0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul))
683
        ]
684

685
        # identify "convex turning-points" and populate a list of peaks
686
        # peak : a bin whose neighboring bins have smaller population
687
        # NOTE might have problems if the peak is wide with a flat or sawtooth top
688
        peak_values = []
×
689

690
        for bin_index in range(number_bins):
×
691
            # if there is no dihedrals in a bin then it cannot be a peak
692
            if popul[bin_index] == 0:
×
693
                pass
×
694
            # being careful of the last bin
695
            # (dihedrals have circular symmetry, the histogram does not)
696
            elif (
×
697
                bin_index == number_bins - 1
698
            ):  # the -1 is because the index starts with 0 not 1
699
                if (
×
700
                    popul[bin_index] >= popul[bin_index - 1]
701
                    and popul[bin_index] >= popul[0]
702
                ):
703
                    peak_values.append(bin_value[bin_index])
×
704
            else:
705
                if (
×
706
                    popul[bin_index] >= popul[bin_index - 1]
707
                    and popul[bin_index] >= popul[bin_index + 1]
708
                ):
709
                    peak_values.append(bin_value[bin_index])
×
710

711
        # go through each frame again and assign conformation state
712
        for frame in range(number_frames):
×
713
            # find the TP that the snapshot is least distant from
714
            distances = [abs(phi[frame] - peak) for peak in peak_values]
×
715
            conformations[frame] = np.argmin(distances)
×
716

717
        logger.debug(f"Final conformations: {conformations}")
×
718

719
        return conformations
×
720

721
    def conformational_entropy_calculation(
3✔
722
        self, data_container, dihedrals, bin_width, start, end, step, number_frames
723
    ):
724
        """
725
        Function to calculate conformational entropies using eq. (7) in Higham,
726
        S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116,
727
        1965–1976 / eq. (4) in A. Chakravorty, J. Higham and R. H. Henchman,
728
        J. Chem. Inf. Model., 2020, 60, 5540–5551.
729

730
        Uses the adaptive enumeration method (AEM).
731

732
        Input
733
        -----
734
        dihedrals : array - array of dihedrals in the molecule
735
        Returns
736
        -------
737
        S_conf_total : float - conformational entropy
738
        """
739

740
        S_conf_total = 0
×
741

742
        # For each dihedral, identify the conformation in each frame
743
        num_dihedrals = len(dihedrals)
×
744
        conformation = np.zeros((num_dihedrals, number_frames))
×
745
        index = 0
×
746
        for dihedral in dihedrals:
×
747
            conformation[index] = self.assign_conformation(
×
748
                data_container, dihedral, number_frames, bin_width, start, end, step
749
            )
750
            index += 1
×
751

752
        logger.debug(f"Conformation matrix: {conformation}")
×
753

754
        # For each frame, convert the conformation of all dihedrals into a
755
        # state string
756
        states = ["" for x in range(number_frames)]
×
757
        for frame_index in range(number_frames):
×
758
            for index in range(num_dihedrals):
×
759
                states[frame_index] += str(conformation[index][frame_index])
×
760

761
        logger.debug(f"States: {states}")
×
762

763
        # Count how many times each state occurs, then use the probability
764
        # to get the entropy
765
        # entropy = sum over states p*ln(p)
766
        values, counts = np.unique(states, return_counts=True)
×
767
        for state in range(len(values)):
×
768
            logger.debug(f"Unique states: {values}")
×
769
            logger.debug(f"Counts: {counts}")
×
770
            count = counts[state]
×
771
            probability = count / number_frames
×
772
            entropy = probability * np.log(probability)
×
773
            S_conf_total += entropy
×
774

775
        # multiply by gas constant to get the units J/mol/K
776
        S_conf_total *= -1 * self._GAS_CONST
×
777

778
        logger.debug(f"Total conformational entropy: {S_conf_total}")
×
779

780
        return S_conf_total
×
781

782

783
class OrientationalEntropy(EntropyManager):
3✔
784
    """
785
    Performs orientational entropy calculations using molecular dynamics data.
786
    Inherits from EntropyManager and includes constants relevant to rotational
787
    and orientational degrees of freedom.
788
    """
789

790
    def __init__(self, run_manager, args, universe, data_logger, level_manager):
3✔
791
        """
792
        Initializes the OrientationalEntropy manager with all required components and
793
        sets the gas constant used in orientational entropy calculations.
794
        """
795
        super().__init__(run_manager, args, universe, data_logger, level_manager)
×
796

797
    def orientational_entropy_calculation(self, neighbours_dict):
3✔
798
        """
799
        Function to calculate orientational entropies from eq. (10) in J. Higham,
800
        S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116,
801
        3 1965–1976. Number of orientations, Ω, is calculated using eq. (8) in
802
        J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman,  Molecular Physics,
803
        2018, 116, 3 1965–1976.
804

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

808
        TODO future release - function for determing symmetry and symmetry numbers
809
        maybe?
810

811
        Input
812
        -----
813
        neighbours_dict :  dictionary - dictionary of neighbours for the molecule -
814
            should contain the type of neighbour molecule and the number of neighbour
815
            molecules of that species
816

817
        Returns
818
        -------
819
        S_or_total : float - orientational entropy
820
        """
821

822
        # Replaced molecule with neighbour as this is what the for loop uses
823
        S_or_total = 0
×
824
        for neighbour in neighbours_dict:  # we are going through neighbours
×
825
            if neighbour in []:  # water molecules - call POSEIDON functions
×
826
                pass  # TODO temporary until function is written
×
827
            else:
828
                # the bound ligand is always going to be a neighbour
829
                omega = np.sqrt((neighbours_dict[neighbour] ** 3) * math.pi)
×
830
                logger.debug(f"Omega for neighbour {neighbour}: {omega}")
×
831
                # orientational entropy arising from each neighbouring species
832
                # - we know the species is going to be a neighbour
833
                S_or_component = math.log(omega)
×
834
                logger.debug(
×
835
                    f"S_or_component (log(omega)) for neighbour {neighbour}: "
836
                    f"{S_or_component}"
837
                )
838
                S_or_component *= self.GAS_CONST
×
839
                logger.debug(
×
840
                    f"S_or_component after multiplying by GAS_CONST for neighbour "
841
                    f"{neighbour}: {S_or_component}"
842
                )
843
            S_or_total += S_or_component
×
844
            logger.debug(
×
845
                f"S_or_total after adding component for neighbour {neighbour}: "
846
                f"{S_or_total}"
847
            )
848
        # TODO for future releases
849
        # implement a case for molecules with hydrogen bonds but to a lesser
850
        # extent than water
851

852
        logger.debug(f"Final total orientational entropy: {S_or_total}")
×
853

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

© 2026 Coveralls, Inc