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

CCPBioSim / CodeEntropy / 19035988435

03 Nov 2025 01:17PM UTC coverage: 99.433% (-0.6%) from 100.0%
19035988435

Pull #170

github

web-flow
Merge 328e159a2 into a6d704774
Pull Request #170: Add `Python v3.14` Support for `CodeEntropy`

1052 of 1058 relevant lines covered (99.43%)

10.94 hits per line

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

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

5
import numpy as np
11✔
6
import pandas as pd
11✔
7
import waterEntropy.recipes.interfacial_solvent as GetSolvent
11✔
8
from numpy import linalg as la
11✔
9
from rich.progress import (
11✔
10
    BarColumn,
11
    Progress,
12
    SpinnerColumn,
13
    TextColumn,
14
    TimeElapsedColumn,
15
)
16

17
from CodeEntropy.config.logging_config import LoggingConfig
11✔
18

19
logger = logging.getLogger(__name__)
11✔
20
console = LoggingConfig.get_console()
11✔
21

22

23
class EntropyManager:
11✔
24
    """
25
    Manages entropy calculations at multiple molecular levels, based on a
26
    molecular dynamics trajectory.
27
    """
28

29
    def __init__(
11✔
30
        self, run_manager, args, universe, data_logger, level_manager, group_molecules
31
    ):
32
        """
33
        Initializes the EntropyManager with required components.
34

35
        Args:
36
            run_manager: Manager for universe and selection operations.
37
            args: Argument namespace containing user parameters.
38
            universe: MDAnalysis universe representing the simulation system.
39
            data_logger: Logger for storing and exporting entropy data.
40
            level_manager: Provides level-specific data such as matrices and dihedrals.
41
            group_molecules: includes the grouping functions for averaging over
42
            molecules.
43
        """
44
        self._run_manager = run_manager
11✔
45
        self._args = args
11✔
46
        self._universe = universe
11✔
47
        self._data_logger = data_logger
11✔
48
        self._level_manager = level_manager
11✔
49
        self._group_molecules = group_molecules
11✔
50
        self._GAS_CONST = 8.3144598484848
11✔
51

52
    def execute(self):
11✔
53
        """
54
        Run the full entropy computation workflow.
55

56
        This method orchestrates the entire entropy analysis pipeline, including:
57
        - Handling water entropy if present.
58
        - Initializing molecular structures and levels.
59
        - Building force and torque covariance matrices.
60
        - Computing vibrational and conformational entropies.
61
        - Finalizing and logging results.
62
        """
63
        # Set up initial information
64
        start, end, step = self._get_trajectory_bounds()
11✔
65
        number_frames = self._get_number_frames(start, end, step)
11✔
66

67
        console.print(
11✔
68
            f"Analyzing a total of {number_frames} frames in this calculation."
69
        )
70

71
        ve = VibrationalEntropy(
11✔
72
            self._run_manager,
73
            self._args,
74
            self._universe,
75
            self._data_logger,
76
            self._level_manager,
77
            self._group_molecules,
78
        )
79
        ce = ConformationalEntropy(
11✔
80
            self._run_manager,
81
            self._args,
82
            self._universe,
83
            self._data_logger,
84
            self._level_manager,
85
            self._group_molecules,
86
        )
87

88
        reduced_atom, number_molecules, levels, groups = self._initialize_molecules()
11✔
89

90
        water_atoms = self._universe.select_atoms("water")
11✔
91
        water_resids = set(res.resid for res in water_atoms.residues)
11✔
92

93
        water_groups = {
11✔
94
            gid: g
95
            for gid, g in groups.items()
96
            if any(
97
                res.resid in water_resids
98
                for mol in [self._universe.atoms.fragments[i] for i in g]
99
                for res in mol.residues
100
            )
101
        }
102
        nonwater_groups = {
11✔
103
            gid: g for gid, g in groups.items() if gid not in water_groups
104
        }
105

106
        if self._args.water_entropy and water_groups:
11✔
107
            self._handle_water_entropy(start, end, step, water_groups)
11✔
108
        else:
109
            nonwater_groups.update(water_groups)
11✔
110

111
        force_matrices, torque_matrices, frame_counts = (
11✔
112
            self._level_manager.build_covariance_matrices(
113
                self,
114
                reduced_atom,
115
                levels,
116
                nonwater_groups,
117
                start,
118
                end,
119
                step,
120
                number_frames,
121
            )
122
        )
123

124
        # Identify the conformational states from dihedral angles for the
125
        # conformational entropy calculations
126
        states_ua, states_res = self._level_manager.build_conformational_states(
11✔
127
            self,
128
            reduced_atom,
129
            levels,
130
            nonwater_groups,
131
            start,
132
            end,
133
            step,
134
            number_frames,
135
            self._args.bin_width,
136
            ce,
137
        )
138

139
        # Complete the entropy calculations
140
        self._compute_entropies(
11✔
141
            reduced_atom,
142
            levels,
143
            nonwater_groups,
144
            force_matrices,
145
            torque_matrices,
146
            states_ua,
147
            states_res,
148
            frame_counts,
149
            number_frames,
150
            ve,
151
            ce,
152
        )
153

154
        # Print the results in a nicely formated way
155
        self._finalize_molecule_results()
11✔
156
        self._data_logger.log_tables()
11✔
157

158
    def _handle_water_entropy(self, start, end, step, water_groups):
11✔
159
        """
160
        Compute water entropy for each water group, log data, and update selection
161
        string to exclude water from further analysis.
162

163
        Args:
164
            start (int): Start frame index
165
            end (int): End frame index
166
            step (int): Step size
167
            water_groups (dict): {group_id: [atom indices]} for water
168
        """
169
        if not water_groups or not self._args.water_entropy:
11✔
170
            return
11✔
171

172
        for group_id, atom_indices in water_groups.items():
11✔
173

174
            self._calculate_water_entropy(
11✔
175
                universe=self._universe,
176
                start=start,
177
                end=end,
178
                step=step,
179
                group_id=group_id,
180
            )
181

182
        self._args.selection_string = (
11✔
183
            self._args.selection_string + " and not water"
184
            if self._args.selection_string != "all"
185
            else "not water"
186
        )
187

188
        logger.debug(f"WaterEntropy: molecule_data: {self._data_logger.molecule_data}")
11✔
189
        logger.debug(f"WaterEntropy: residue_data: {self._data_logger.residue_data}")
11✔
190

191
    def _initialize_molecules(self):
11✔
192
        """
193
        Prepare the reduced universe and determine molecule-level configurations.
194

195
        Returns:
196
            tuple: A tuple containing:
197
                - reduced_atom (Universe): The reduced atom selection.
198
                - number_molecules (int): Number of molecules in the system.
199
                - levels (list): List of entropy levels per molecule.
200
                - groups (dict): Groups for averaging over molecules.
201
        """
202
        # Based on the selection string, create a new MDAnalysis universe
203
        reduced_atom = self._get_reduced_universe()
11✔
204

205
        # Count the molecules and identify the length scale levels for each one
206
        number_molecules, levels = self._level_manager.select_levels(reduced_atom)
11✔
207

208
        # Group the molecules for averaging
209
        grouping = self._args.grouping
11✔
210
        groups = self._group_molecules.grouping_molecules(reduced_atom, grouping)
11✔
211

212
        return reduced_atom, number_molecules, levels, groups
11✔
213

214
    def _compute_entropies(
11✔
215
        self,
216
        reduced_atom,
217
        levels,
218
        groups,
219
        force_matrices,
220
        torque_matrices,
221
        states_ua,
222
        states_res,
223
        frame_counts,
224
        number_frames,
225
        ve,
226
        ce,
227
    ):
228
        """
229
        Compute vibrational and conformational entropies for all molecules and levels.
230

231
        This method iterates over each molecule and its associated entropy levels
232
        (united_atom, residue, polymer), computing the corresponding entropy
233
        contributions using force/torque matrices and dihedral conformations.
234

235
        For each level:
236
        - "united_atom": Computes per-residue conformational states and entropy.
237
        - "residue": Computes molecule-level conformational and vibrational entropy.
238
        - "polymer": Computes only vibrational entropy.
239

240
        Parameters:
241
            reduced_atom (Universe): The reduced atom selection from the trajectory.
242
            levels (list): List of entropy levels per molecule.
243
            groups (dict): Groups for averaging over molecules.
244
            force_matrices (dict): Precomputed force covariance matrices.
245
            torque_matrices (dict): Precomputed torque covariance matrices.
246
            states_ua (dict): Dictionary to store united-atom conformational states.
247
            states_res (list): List to store residue-level conformational states.
248
            frames_count (dict): Dictionary to store the frame counts
249
            number_frames (int): Total number of trajectory frames to process.
250
            ve: Vibrational Entropy object
251
            ce: Conformational Entropy object
252
        """
253
        with Progress(
11✔
254
            SpinnerColumn(),
255
            TextColumn("[bold blue]{task.fields[title]}", justify="right"),
256
            BarColumn(),
257
            "[progress.percentage]{task.percentage:>3.1f}%",
258
            TimeElapsedColumn(),
259
        ) as progress:
260

261
            task = progress.add_task(
11✔
262
                "[green]Calculating Entropy...",
263
                total=len(groups),
264
                title="Starting...",
265
            )
266

267
            for group_id in groups.keys():
11✔
268
                mol = self._get_molecule_container(reduced_atom, groups[group_id][0])
11✔
269

270
                residue_group = "_".join(
11✔
271
                    sorted(set(res.resname for res in mol.residues))
272
                )
273
                group_residue_count = len(groups[group_id])
11✔
274
                group_atom_count = 0
11✔
275
                for mol_id in groups[group_id]:
11✔
276
                    each_mol = self._get_molecule_container(reduced_atom, mol_id)
11✔
277
                    group_atom_count += len(each_mol.atoms)
11✔
278
                self._data_logger.add_group_label(
11✔
279
                    group_id, residue_group, group_residue_count, group_atom_count
280
                )
281

282
                resname = mol.atoms[0].resname
11✔
283
                resid = mol.atoms[0].resid
11✔
284
                segid = mol.atoms[0].segid
11✔
285

286
                mol_label = f"{resname}_{resid} (segid {segid})"
11✔
287

288
                for level in levels[groups[group_id][0]]:
11✔
289
                    progress.update(
11✔
290
                        task,
291
                        title=f"Calculating entropy values | "
292
                        f"Molecule: {mol_label} | "
293
                        f"Level: {level}",
294
                    )
295
                    highest = level == levels[groups[group_id][0]][-1]
11✔
296

297
                    if level == "united_atom":
11✔
298
                        self._process_united_atom_entropy(
11✔
299
                            group_id,
300
                            mol,
301
                            ve,
302
                            ce,
303
                            level,
304
                            force_matrices["ua"],
305
                            torque_matrices["ua"],
306
                            states_ua,
307
                            frame_counts["ua"],
308
                            highest,
309
                            number_frames,
310
                        )
311

312
                    elif level == "residue":
11✔
313
                        self._process_vibrational_entropy(
11✔
314
                            group_id,
315
                            mol,
316
                            number_frames,
317
                            ve,
318
                            level,
319
                            force_matrices["res"][group_id],
320
                            torque_matrices["res"][group_id],
321
                            highest,
322
                        )
323

324
                        self._process_conformational_entropy(
11✔
325
                            group_id,
326
                            mol,
327
                            ce,
328
                            level,
329
                            states_res,
330
                            number_frames,
331
                        )
332

333
                    elif level == "polymer":
11✔
334
                        self._process_vibrational_entropy(
11✔
335
                            group_id,
336
                            mol,
337
                            number_frames,
338
                            ve,
339
                            level,
340
                            force_matrices["poly"][group_id],
341
                            torque_matrices["poly"][group_id],
342
                            highest,
343
                        )
344

345
                progress.advance(task)
11✔
346

347
    def _get_trajectory_bounds(self):
11✔
348
        """
349
        Returns the start, end, and step frame indices based on input arguments.
350

351
        Returns:
352
            Tuple of (start, end, step) frame indices.
353
        """
354
        start = self._args.start or 0
11✔
355
        end = len(self._universe.trajectory) if self._args.end == -1 else self._args.end
11✔
356
        step = self._args.step or 1
11✔
357

358
        return start, end, step
11✔
359

360
    def _get_number_frames(self, start, end, step):
11✔
361
        """
362
        Calculates the total number of trajectory frames used in the calculation.
363

364
        Args:
365
            start (int): Start frame index.
366
            end (int): End frame index. If -1, it refers to the end of the trajectory.
367
            step (int): Frame step size.
368

369
        Returns:
370
            int: Total number of frames considered.
371
        """
372
        return math.floor((end - start) / step)
11✔
373

374
    def _get_reduced_universe(self):
11✔
375
        """
376
        Applies atom selection based on the user's input.
377

378
        Returns:
379
            MDAnalysis.Universe: Selected subset of the system.
380
        """
381
        # If selection string is "all" the universe does not change
382
        if self._args.selection_string == "all":
11✔
383
            return self._universe
11✔
384

385
        # Otherwise create a new (smaller) universe based on the selection
386
        reduced = self._run_manager.new_U_select_atom(
11✔
387
            self._universe, self._args.selection_string
388
        )
389
        name = f"{len(reduced.trajectory)}_frame_dump_atom_selection"
11✔
390
        self._run_manager.write_universe(reduced, name)
11✔
391
        return reduced
11✔
392

393
    def _get_molecule_container(self, universe, molecule_id):
11✔
394
        """
395
        Extracts the atom group corresponding to a single molecule from the universe.
396

397
        Args:
398
            universe (MDAnalysis.Universe): The reduced universe.
399
            molecule_id (int): Index of the molecule to extract.
400

401
        Returns:
402
            MDAnalysis.Universe: Universe containing only the selected molecule.
403
        """
404
        # Identify the atoms in the molecule
405
        frag = universe.atoms.fragments[molecule_id]
11✔
406
        selection_string = f"index {frag.indices[0]}:{frag.indices[-1]}"
11✔
407

408
        # Build a new universe with only the one molecule
409
        return self._run_manager.new_U_select_atom(universe, selection_string)
11✔
410

411
    def _process_united_atom_entropy(
11✔
412
        self,
413
        group_id,
414
        mol_container,
415
        ve,
416
        ce,
417
        level,
418
        force_matrix,
419
        torque_matrix,
420
        states,
421
        frame_counts,
422
        highest,
423
        number_frames,
424
    ):
425
        """
426
        Calculates translational, rotational, and conformational entropy at the
427
        united-atom level.
428

429
        Args:
430
            group_id (int): ID of the group.
431
            mol_container (Universe): Universe for the selected molecule.
432
            ve: VibrationalEntropy object.
433
            ce: ConformationalEntropy object.
434
            level (str): Granularity level (should be 'united_atom').
435
            start, end, step (int): Trajectory frame parameters.
436
            n_frames (int): Number of trajectory frames.
437
            frame_counts: Number of frames counted
438
            highest (bool): Whether this is the highest level of resolution for
439
             the molecule.
440
            number_frames (int): The number of frames analysed.
441
        """
442
        S_trans, S_rot, S_conf = 0, 0, 0
11✔
443

444
        # The united atom entropy is calculated separately for each residue
445
        # This is to allow residue by residue information
446
        # and prevents the matrices from becoming too large
447
        for residue_id, residue in enumerate(mol_container.residues):
11✔
448

449
            key = (group_id, residue_id)
11✔
450

451
            # Find the relevant force and torque matrices and tidy them up
452
            # by removing rows and columns that are all zeros
453
            f_matrix = force_matrix[key]
11✔
454
            f_matrix = self._level_manager.filter_zero_rows_columns(f_matrix)
11✔
455

456
            t_matrix = torque_matrix[key]
11✔
457
            t_matrix = self._level_manager.filter_zero_rows_columns(t_matrix)
11✔
458

459
            # Calculate the vibrational entropy
460
            S_trans_res = ve.vibrational_entropy_calculation(
11✔
461
                f_matrix, "force", self._args.temperature, highest
462
            )
463
            S_rot_res = ve.vibrational_entropy_calculation(
11✔
464
                t_matrix, "torque", self._args.temperature, highest
465
            )
466

467
            # Get the relevant conformational states
468
            values = states[key]
11✔
469
            # Check if there is information in the states array
470
            contains_non_empty_states = (
11✔
471
                np.any(values) if isinstance(values, np.ndarray) else any(values)
472
            )
473

474
            # Calculate the conformational entropy
475
            # If there are no conformational states (i.e. no dihedrals)
476
            # then the conformational entropy is zero
477
            S_conf_res = (
11✔
478
                ce.conformational_entropy_calculation(values, number_frames)
479
                if contains_non_empty_states
480
                else 0
481
            )
482

483
            # Add the data to the united atom level entropy
484
            S_trans += S_trans_res
11✔
485
            S_rot += S_rot_res
11✔
486
            S_conf += S_conf_res
11✔
487

488
            # Print out the data for each residue
489
            self._data_logger.add_residue_data(
11✔
490
                group_id,
491
                residue.resname,
492
                level,
493
                "Transvibrational",
494
                frame_counts[key],
495
                S_trans_res,
496
            )
497
            self._data_logger.add_residue_data(
11✔
498
                group_id,
499
                residue.resname,
500
                level,
501
                "Rovibrational",
502
                frame_counts[key],
503
                S_rot_res,
504
            )
505
            self._data_logger.add_residue_data(
11✔
506
                group_id,
507
                residue.resname,
508
                level,
509
                "Conformational",
510
                frame_counts[key],
511
                S_conf_res,
512
            )
513

514
        # Print the total united atom level data for the molecule group
515
        self._data_logger.add_results_data(group_id, level, "Transvibrational", S_trans)
11✔
516
        self._data_logger.add_results_data(group_id, level, "Rovibrational", S_rot)
11✔
517
        self._data_logger.add_results_data(group_id, level, "Conformational", S_conf)
11✔
518

519
        residue_group = "_".join(
11✔
520
            sorted(set(res.resname for res in mol_container.residues))
521
        )
522

523
        logger.debug(f"residue_group {residue_group}")
11✔
524

525
    def _process_vibrational_entropy(
11✔
526
        self,
527
        group_id,
528
        mol_container,
529
        number_frames,
530
        ve,
531
        level,
532
        force_matrix,
533
        torque_matrix,
534
        highest,
535
    ):
536
        """
537
        Calculates vibrational entropy.
538

539
        Args:
540
            group_id (int): Group ID.
541
            ve: VibrationalEntropy object.
542
            level (str): Current granularity level.
543
            force_matrix : Force covariance matrix
544
            torque_matrix : Torque covariance matrix
545
            frame_count:
546
            highest (bool): Flag indicating if this is the highest granularity
547
            level.
548
        """
549
        # Find the relevant force and torque matrices and tidy them up
550
        # by removing rows and columns that are all zeros
551
        force_matrix = self._level_manager.filter_zero_rows_columns(force_matrix)
11✔
552

553
        torque_matrix = self._level_manager.filter_zero_rows_columns(torque_matrix)
11✔
554

555
        # Calculate the vibrational entropy
556
        S_trans = ve.vibrational_entropy_calculation(
11✔
557
            force_matrix, "force", self._args.temperature, highest
558
        )
559
        S_rot = ve.vibrational_entropy_calculation(
11✔
560
            torque_matrix, "torque", self._args.temperature, highest
561
        )
562

563
        # Print the vibrational entropy for the molecule group
564
        self._data_logger.add_results_data(group_id, level, "Transvibrational", S_trans)
11✔
565
        self._data_logger.add_results_data(group_id, level, "Rovibrational", S_rot)
11✔
566

567
        residue_group = "_".join(
11✔
568
            sorted(set(res.resname for res in mol_container.residues))
569
        )
570
        residue_count = len(mol_container.residues)
11✔
571
        atom_count = len(mol_container.atoms)
11✔
572
        self._data_logger.add_group_label(
11✔
573
            group_id, residue_group, residue_count, atom_count
574
        )
575

576
    def _process_conformational_entropy(
11✔
577
        self, group_id, mol_container, ce, level, states, number_frames
578
    ):
579
        """
580
        Computes conformational entropy at the residue level (whole-molecule dihedral
581
        analysis).
582

583
        Args:
584
            mol_id (int): ID of the molecule.
585
            mol_container (Universe): Selected molecule's universe.
586
            ce: ConformationalEntropy object.
587
            level (str): Level name (should be 'residue').
588
            states (array): The conformational states.
589
            number_frames (int): Number of frames used.
590
        """
591
        # Get the relevant conformational states
592
        # Check if there is information in the states array
593
        group_states = states[group_id] if group_id < len(states) else None
11✔
594

595
        if group_states is not None:
11✔
596
            contains_state_data = (
11✔
597
                group_states.any()
598
                if isinstance(group_states, np.ndarray)
599
                else any(group_states)
600
            )
601
        else:
602
            contains_state_data = False
11✔
603

604
        # Calculate the conformational entropy
605
        # If there are no conformational states (i.e. no dihedrals)
606
        # then the conformational entropy is zero
607
        S_conf = (
11✔
608
            ce.conformational_entropy_calculation(group_states, number_frames)
609
            if contains_state_data
610
            else 0
611
        )
612
        self._data_logger.add_results_data(group_id, level, "Conformational", S_conf)
11✔
613

614
        residue_group = "_".join(
11✔
615
            sorted(set(res.resname for res in mol_container.residues))
616
        )
617
        residue_count = len(mol_container.residues)
11✔
618
        atom_count = len(mol_container.atoms)
11✔
619
        self._data_logger.add_group_label(
11✔
620
            group_id, residue_group, residue_count, atom_count
621
        )
622

623
    def _finalize_molecule_results(self):
11✔
624
        """
625
        Aggregates and logs total entropy and frame counts per molecule.
626
        """
627
        entropy_by_molecule = defaultdict(float)
11✔
628
        for (
11✔
629
            mol_id,
630
            level,
631
            entropy_type,
632
            result,
633
        ) in self._data_logger.molecule_data:
634
            if level != "Group Total":
11✔
635
                try:
11✔
636
                    entropy_by_molecule[mol_id] += float(result)
11✔
637
                except ValueError:
11✔
638
                    logger.warning(f"Skipping invalid entry: {mol_id}, {result}")
11✔
639

640
        for mol_id in entropy_by_molecule.keys():
11✔
641
            total_entropy = entropy_by_molecule[mol_id]
11✔
642

643
            self._data_logger.molecule_data.append(
11✔
644
                (
645
                    mol_id,
646
                    "Group Total",
647
                    "Group Total Entropy",
648
                    total_entropy,
649
                )
650
            )
651

652
        self._data_logger.save_dataframes_as_json(
11✔
653
            pd.DataFrame(
654
                self._data_logger.molecule_data,
655
                columns=[
656
                    "Group ID",
657
                    "Level",
658
                    "Type",
659
                    "Result (J/mol/K)",
660
                ],
661
            ),
662
            pd.DataFrame(
663
                self._data_logger.residue_data,
664
                columns=[
665
                    "Group ID",
666
                    "Residue Name",
667
                    "Level",
668
                    "Type",
669
                    "Frame Count",
670
                    "Result (J/mol/K)",
671
                ],
672
            ),
673
            self._args.output_file,
674
        )
675

676
    def _calculate_water_entropy(self, universe, start, end, step, group_id=None):
11✔
677
        """
678
        Calculate and aggregate the entropy of water molecules in a simulation.
679

680
        This function computes orientational, translational, and rotational
681
        entropy components for all water molecules, aggregates them per residue,
682
        and maps all waters to a single group ID. It also logs the total results
683
        and labels the water group in the data logger.
684

685
        Parameters
686
        ----------
687
        universe : MDAnalysis.Universe
688
            The simulation universe containing water molecules.
689
        start : int
690
            The starting frame for analysis.
691
        end : int
692
            The ending frame for analysis.
693
        step : int
694
            Frame interval for analysis.
695
        group_id : int or str, optional
696
            The group ID to which all water molecules will be assigned.
697
        """
698
        Sorient_dict, covariances, vibrations, _, water_count = (
11✔
699
            GetSolvent.get_interfacial_water_orient_entropy(
700
                universe, start, end, step, self._args.temperature, parallel=True
701
            )
702
        )
703

704
        self._calculate_water_orientational_entropy(Sorient_dict, group_id)
11✔
705
        self._calculate_water_vibrational_translational_entropy(
11✔
706
            vibrations, group_id, covariances
707
        )
708
        self._calculate_water_vibrational_rotational_entropy(
11✔
709
            vibrations, group_id, covariances
710
        )
711

712
        water_selection = universe.select_atoms("resname WAT")
11✔
713
        actual_water_residues = len(water_selection.residues)
11✔
714
        residue_names = {
11✔
715
            resname
716
            for res_dict in Sorient_dict.values()
717
            for resname in res_dict.keys()
718
            if resname.upper() in water_selection.residues.resnames
719
        }
720

721
        residue_group = "_".join(sorted(residue_names)) if residue_names else "WAT"
11✔
722
        self._data_logger.add_group_label(
11✔
723
            group_id, residue_group, actual_water_residues, len(water_selection.atoms)
724
        )
725

726
    def _calculate_water_orientational_entropy(self, Sorient_dict, group_id):
11✔
727
        """
728
        Aggregate orientational entropy for all water molecules into a single group.
729

730
        Parameters
731
        ----------
732
        Sorient_dict : dict
733
            Dictionary containing orientational entropy values per residue.
734
        group_id : int or str
735
            The group ID to which the water residues belong.
736
        covariances : object
737
            Covariance object.
738
        """
739
        for resid, resname_dict in Sorient_dict.items():
11✔
740
            for resname, values in resname_dict.items():
11✔
741
                if isinstance(values, list) and len(values) == 2:
11✔
742
                    Sor, count = values
11✔
743
                    self._data_logger.add_residue_data(
11✔
744
                        group_id, resname, "Water", "Orientational", count, Sor
745
                    )
746

747
    def _calculate_water_vibrational_translational_entropy(
11✔
748
        self, vibrations, group_id, covariances
749
    ):
750
        """
751
        Aggregate translational vibrational entropy for all water molecules.
752

753
        Parameters
754
        ----------
755
        vibrations : object
756
            Object containing translational entropy data (vibrations.translational_S).
757
        group_id : int or str
758
            The group ID for the water residues.
759
        covariances : object
760
            Covariance object.
761
        """
762

763
        for (solute_id, _), entropy in vibrations.translational_S.items():
11✔
764
            if isinstance(entropy, (list, np.ndarray)):
11✔
765
                entropy = float(np.sum(entropy))
11✔
766

767
            count = covariances.counts.get((solute_id, "WAT"), 1)
11✔
768
            resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id
11✔
769
            self._data_logger.add_residue_data(
11✔
770
                group_id, resname, "Water", "Transvibrational", count, entropy
771
            )
772

773
    def _calculate_water_vibrational_rotational_entropy(
11✔
774
        self, vibrations, group_id, covariances
775
    ):
776
        """
777
        Aggregate rotational vibrational entropy for all water molecules.
778

779
        Parameters
780
        ----------
781
        vibrations : object
782
            Object containing rotational entropy data (vibrations.rotational_S).
783
        group_id : int or str
784
            The group ID for the water residues.
785
        covariances : object
786
            Covariance object.
787
        """
788
        for (solute_id, _), entropy in vibrations.rotational_S.items():
11✔
789
            if isinstance(entropy, (list, np.ndarray)):
11✔
790
                entropy = float(np.sum(entropy))
11✔
791

792
            count = covariances.counts.get((solute_id, "WAT"), 1)
11✔
793

794
            resname = solute_id.rsplit("_", 1)[0] if "_" in solute_id else solute_id
11✔
795
            self._data_logger.add_residue_data(
11✔
796
                group_id, resname, "Water", "Rovibrational", count, entropy
797
            )
798

799

800
class VibrationalEntropy(EntropyManager):
11✔
801
    """
802
    Performs vibrational entropy calculations using molecular trajectory data.
803
    Extends the base EntropyManager with constants and logic specific to
804
    vibrational modes and thermodynamic properties.
805
    """
806

807
    def __init__(
11✔
808
        self, run_manager, args, universe, data_logger, level_manager, group_molecules
809
    ):
810
        """
811
        Initializes the VibrationalEntropy manager with all required components and
812
        defines physical constants used in vibrational entropy calculations.
813
        """
814
        super().__init__(
11✔
815
            run_manager, args, universe, data_logger, level_manager, group_molecules
816
        )
817
        self._PLANCK_CONST = 6.62607004081818e-34
11✔
818

819
    def frequency_calculation(self, lambdas, temp):
11✔
820
        """
821
        Function to calculate an array of vibrational frequencies from the eigenvalues
822
        of the covariance matrix.
823

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

828
        frequency=sqrt(λ/kT)/2π
829

830
        Args:
831
            lambdas : array of floats - eigenvalues of the covariance matrix
832
            temp: float - temperature
833

834
        Returns:
835
            frequencies : array of floats - corresponding vibrational frequencies
836
        """
837
        pi = np.pi
11✔
838
        # get kT in Joules from given temperature
839
        kT = self._run_manager.get_KT2J(temp)
11✔
840
        logger.debug(f"Temperature: {temp}, kT: {kT}")
11✔
841

842
        lambdas = np.array(lambdas)  # Ensure input is a NumPy array
11✔
843
        logger.debug(f"Eigenvalues (lambdas): {lambdas}")
11✔
844

845
        # Filter out lambda values that are negative or imaginary numbers
846
        # As these will produce supurious entropy results that can crash
847
        # the calculation
848
        lambdas = np.real_if_close(lambdas, tol=1000)
11✔
849
        valid_mask = (
11✔
850
            np.isreal(lambdas) & (lambdas > 0) & (~np.isclose(lambdas, 0, atol=1e-07))
851
        )
852

853
        # If any lambdas were removed by the filter, warn the user
854
        # as this will suggest insufficient sampling in the simulation data
855
        if len(lambdas) > np.count_nonzero(valid_mask):
11✔
856
            logger.warning(
11✔
857
                f"{len(lambdas) - np.count_nonzero(valid_mask)} "
858
                f"invalid eigenvalues excluded (complex, non-positive, or near-zero)."
859
            )
860

861
        lambdas = lambdas[valid_mask].real
11✔
862

863
        # Compute frequencies safely
864
        frequencies = 1 / (2 * pi) * np.sqrt(lambdas / kT)
11✔
865
        logger.debug(f"Calculated frequencies: {frequencies}")
11✔
866

867
        return frequencies
11✔
868

869
    def vibrational_entropy_calculation(self, matrix, matrix_type, temp, highest_level):
11✔
870
        """
871
        Function to calculate the vibrational entropy for each level calculated from
872
        eq. (4) in J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular
873
        Physics, 2018, 116, 1965–1976 / eq. (2) in A. Chakravorty, J. Higham and
874
        R. H. Henchman, J. Chem. Inf. Model., 2020, 60, 5540–5551.
875

876
        Args:
877
            matrix : matrix - force/torque covariance matrix
878
            matrix_type: string
879
            temp: float - temperature
880
            highest_level: bool - is this the highest level of the heirarchy
881

882
        Returns:
883
            S_vib_total : float - transvibrational/rovibrational entropy
884
        """
885
        # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues
886
        # Get eigenvalues of the given matrix and change units to SI units
887
        lambdas = la.eigvals(matrix)
11✔
888
        logger.debug(f"Eigenvalues (lambdas) before unit change: {lambdas}")
11✔
889

890
        lambdas = self._run_manager.change_lambda_units(lambdas)
11✔
891
        logger.debug(f"Eigenvalues (lambdas) after unit change: {lambdas}")
11✔
892

893
        # Calculate frequencies from the eigenvalues
894
        frequencies = self.frequency_calculation(lambdas, temp)
11✔
895
        logger.debug(f"Calculated frequencies: {frequencies}")
11✔
896

897
        # Sort frequencies lowest to highest
898
        frequencies = np.sort(frequencies)
11✔
899
        logger.debug(f"Sorted frequencies: {frequencies}")
11✔
900

901
        kT = self._run_manager.get_KT2J(temp)
11✔
902
        logger.debug(f"Temperature: {temp}, kT: {kT}")
11✔
903
        exponent = self._PLANCK_CONST * frequencies / kT
11✔
904
        logger.debug(f"Exponent values: {exponent}")
11✔
905
        power_positive = np.power(np.e, exponent)
11✔
906
        power_negative = np.power(np.e, -exponent)
11✔
907
        logger.debug(f"Power positive values: {power_positive}")
11✔
908
        logger.debug(f"Power negative values: {power_negative}")
11✔
909
        S_components = exponent / (power_positive - 1) - np.log(1 - power_negative)
11✔
910
        S_components = (
11✔
911
            S_components * self._GAS_CONST
912
        )  # multiply by R - get entropy in J mol^{-1} K^{-1}
913
        logger.debug(f"Entropy components: {S_components}")
11✔
914
        # N beads at a level => 3N x 3N covariance matrix => 3N eigenvalues
915
        if matrix_type == "force":  # force covariance matrix
11✔
916
            if (
11✔
917
                highest_level
918
            ):  # whole molecule level - we take all frequencies into account
919
                S_vib_total = sum(S_components)
11✔
920

921
            # discard the 6 lowest frequencies to discard translation and rotation of
922
            # the whole unit the overall translation and rotation of a unit is an
923
            # internal motion of the level above
924
            else:
925
                S_vib_total = sum(S_components[6:])
11✔
926

927
        else:  # torque covariance matrix - we always take all values into account
928
            S_vib_total = sum(S_components)
11✔
929

930
        logger.debug(f"Total vibrational entropy: {S_vib_total}")
11✔
931

932
        return S_vib_total
11✔
933

934

935
class ConformationalEntropy(EntropyManager):
11✔
936
    """
937
    Performs conformational entropy calculations based on molecular dynamics data.
938
    Inherits from EntropyManager and includes constants specific to conformational
939
    analysis using statistical mechanics principles.
940
    """
941

942
    def __init__(
11✔
943
        self, run_manager, args, universe, data_logger, level_manager, group_molecules
944
    ):
945
        """
946
        Initializes the ConformationalEntropy manager with all required components and
947
        sets the gas constant used in conformational entropy calculations.
948
        """
949
        super().__init__(
11✔
950
            run_manager, args, universe, data_logger, level_manager, group_molecules
951
        )
952

953
    def assign_conformation(
11✔
954
        self, data_container, dihedral, number_frames, bin_width, start, end, step
955
    ):
956
        """
957
        Create a state vector, showing the state in which the input dihedral is
958
        as a function of time. The function creates a histogram from the timeseries of
959
        the dihedral angle values and identifies points of dominant occupancy
960
        (called CONVEX TURNING POINTS).
961
        Based on the identified TPs, states are assigned to each configuration of the
962
        dihedral.
963

964
        Args:
965
            data_container (MDAnalysis Universe): data for the molecule/residue unit
966
            dihedral (array): The dihedral angles in the unit
967
            number_frames (int): number of frames in the trajectory
968
            bin_width (int): the width of the histogram bit, default 30 degrees
969
            start (int): starting frame, will default to 0
970
            end (int): ending frame, will default to -1 (last frame in trajectory)
971
            step (int): spacing between frames, will default to 1
972

973
        Returns:
974
            conformations (array): A timeseries with integer labels describing the
975
            state at each point in time.
976

977
        """
978
        conformations = np.zeros(number_frames)
11✔
979
        phi = np.zeros(number_frames)
11✔
980

981
        # get the values of the angle for the dihedral
982
        # dihedral angle values have a range from -180 to 180
983
        indices = list(range(number_frames))
11✔
984
        for timestep_index, _ in zip(
11✔
985
            indices, data_container.trajectory[start:end:step]
986
        ):
987
            timestep_index = timestep_index
11✔
988
            value = dihedral.value()
11✔
989
            # we want postive values in range 0 to 360 to make the peak assignment
990
            # works using the fact that dihedrals have circular symetry
991
            # (i.e. -15 degrees = +345 degrees)
992
            if value < 0:
11✔
993
                value += 360
11✔
994
            phi[timestep_index] = value
11✔
995

996
        # create a histogram using numpy
997
        number_bins = int(360 / bin_width)
11✔
998
        popul, bin_edges = np.histogram(a=phi, bins=number_bins, range=(0, 360))
11✔
999
        bin_value = [
11✔
1000
            0.5 * (bin_edges[i] + bin_edges[i + 1]) for i in range(0, len(popul))
1001
        ]
1002

1003
        # identify "convex turning-points" and populate a list of peaks
1004
        # peak : a bin whose neighboring bins have smaller population
1005
        # NOTE might have problems if the peak is wide with a flat or sawtooth
1006
        # top in which case check you have a sensible bin width
1007
        peak_values = []
11✔
1008

1009
        for bin_index in range(number_bins):
11✔
1010
            # if there is no dihedrals in a bin then it cannot be a peak
1011
            if popul[bin_index] == 0:
11✔
1012
                pass
11✔
1013
            # being careful of the last bin
1014
            # (dihedrals have circular symmetry, the histogram does not)
1015
            elif (
11✔
1016
                bin_index == number_bins - 1
1017
            ):  # the -1 is because the index starts with 0 not 1
1018
                if (
11✔
1019
                    popul[bin_index] >= popul[bin_index - 1]
1020
                    and popul[bin_index] >= popul[0]
1021
                ):
1022
                    peak_values.append(bin_value[bin_index])
11✔
1023
            else:
1024
                if (
11✔
1025
                    popul[bin_index] >= popul[bin_index - 1]
1026
                    and popul[bin_index] >= popul[bin_index + 1]
1027
                ):
1028
                    peak_values.append(bin_value[bin_index])
×
1029

1030
        # go through each frame again and assign conformation state
1031
        for frame in range(number_frames):
11✔
1032
            # find the TP that the snapshot is least distant from
1033
            distances = [abs(phi[frame] - peak) for peak in peak_values]
11✔
1034
            conformations[frame] = np.argmin(distances)
11✔
1035

1036
        logger.debug(f"Final conformations: {conformations}")
11✔
1037

1038
        return conformations
11✔
1039

1040
    def conformational_entropy_calculation(self, states, number_frames):
11✔
1041
        """
1042
        Function to calculate conformational entropies using eq. (7) in Higham,
1043
        S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116,
1044
        1965–1976 / eq. (4) in A. Chakravorty, J. Higham and R. H. Henchman,
1045
        J. Chem. Inf. Model., 2020, 60, 5540–5551.
1046

1047
        Uses the adaptive enumeration method (AEM).
1048

1049
        Args:
1050
           states (array): Conformational states in the molecule
1051
           number_frames (int): The number of frames analysed
1052

1053
        Returns:
1054
            S_conf_total (float) : conformational entropy
1055
        """
1056

1057
        S_conf_total = 0
11✔
1058

1059
        # Count how many times each state occurs, then use the probability
1060
        # to get the entropy
1061
        # entropy = sum over states p*ln(p)
1062
        values, counts = np.unique(states, return_counts=True)
11✔
1063
        for state in range(len(values)):
11✔
1064
            logger.debug(f"Unique states: {values}")
11✔
1065
            logger.debug(f"Counts: {counts}")
11✔
1066
            count = counts[state]
11✔
1067
            probability = count / number_frames
11✔
1068
            entropy = probability * np.log(probability)
11✔
1069
            S_conf_total += entropy
11✔
1070

1071
        # multiply by gas constant to get the units J/mol/K
1072
        S_conf_total *= -1 * self._GAS_CONST
11✔
1073

1074
        logger.debug(f"Total conformational entropy: {S_conf_total}")
11✔
1075

1076
        return S_conf_total
11✔
1077

1078

1079
class OrientationalEntropy(EntropyManager):
11✔
1080
    """
1081
    Performs orientational entropy calculations using molecular dynamics data.
1082
    Inherits from EntropyManager and includes constants relevant to rotational
1083
    and orientational degrees of freedom.
1084
    """
1085

1086
    def __init__(
11✔
1087
        self, run_manager, args, universe, data_logger, level_manager, group_molecules
1088
    ):
1089
        """
1090
        Initializes the OrientationalEntropy manager with all required components and
1091
        sets the gas constant used in orientational entropy calculations.
1092
        """
1093
        super().__init__(
11✔
1094
            run_manager, args, universe, data_logger, level_manager, group_molecules
1095
        )
1096

1097
    def orientational_entropy_calculation(self, neighbours_dict):
11✔
1098
        """
1099
        Function to calculate orientational entropies from eq. (10) in J. Higham,
1100
        S.-Y. Chou, F. Gräter and R. H. Henchman, Molecular Physics, 2018, 116,
1101
        3 1965–1976. Number of orientations, Ω, is calculated using eq. (8) in
1102
        J. Higham, S.-Y. Chou, F. Gräter and R. H. Henchman,  Molecular Physics,
1103
        2018, 116, 3 1965–1976.
1104

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

1108
        TODO future release - function for determing symmetry and symmetry numbers
1109
        maybe?
1110

1111
        Input
1112
        -----
1113
        neighbours_dict :  dictionary - dictionary of neighbours for the molecule -
1114
            should contain the type of neighbour molecule and the number of neighbour
1115
            molecules of that species
1116

1117
        Returns
1118
        -------
1119
        S_or_total : float - orientational entropy
1120
        """
1121

1122
        # Replaced molecule with neighbour as this is what the for loop uses
1123
        S_or_total = 0
11✔
1124
        for neighbour in neighbours_dict:  # we are going through neighbours
11✔
1125
            if neighbour in ["H2O"]:  # water molecules - call POSEIDON functions
11✔
1126
                pass  # TODO temporary until function is written
11✔
1127
            else:
1128
                # the bound ligand is always going to be a neighbour
1129
                omega = np.sqrt((neighbours_dict[neighbour] ** 3) * math.pi)
11✔
1130
                logger.debug(f"Omega for neighbour {neighbour}: {omega}")
11✔
1131
                # orientational entropy arising from each neighbouring species
1132
                # - we know the species is going to be a neighbour
1133
                S_or_component = math.log(omega)
11✔
1134
                logger.debug(
11✔
1135
                    f"S_or_component (log(omega)) for neighbour {neighbour}: "
1136
                    f"{S_or_component}"
1137
                )
1138
                S_or_component *= self._GAS_CONST
11✔
1139
                logger.debug(
11✔
1140
                    f"S_or_component after multiplying by GAS_CONST for neighbour "
1141
                    f"{neighbour}: {S_or_component}"
1142
                )
1143
                S_or_total += S_or_component
11✔
1144
                logger.debug(
11✔
1145
                    f"S_or_total after adding component for neighbour {neighbour}: "
1146
                    f"{S_or_total}"
1147
                )
1148
        # TODO for future releases
1149
        # implement a case for molecules with hydrogen bonds but to a lesser
1150
        # extent than water
1151

1152
        logger.debug(f"Final total orientational entropy: {S_or_total}")
11✔
1153

1154
        return S_or_total
11✔
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