diff --git a/CodeEntropy/levels/dihedrals.py b/CodeEntropy/levels/dihedrals.py index ee5f9c44..b54eb275 100644 --- a/CodeEntropy/levels/dihedrals.py +++ b/CodeEntropy/levels/dihedrals.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +from dataclasses import dataclass from typing import Any import numpy as np @@ -28,6 +29,59 @@ logger = logging.getLogger(__name__) UAKey = tuple[int, int] +PhiValues = dict[int, list[float]] +PhiContainer = dict[int, PhiValues | list[Any]] + + +@dataclass +class DihedralAngleData: + """Selected-frame dihedral angle data used to identify peaks. + + Attributes: + num_residues: Number of residues in the representative molecule. + num_dihedrals_ua: Number of united-atom dihedrals by residue index. + num_dihedrals_res: Number of residue-level dihedrals. + phi_ua: United-atom angle values by residue and dihedral index. + phi_res: Residue-level angle values by dihedral index, or an empty list + when no residue-level dihedrals are present. + """ + + num_residues: int + num_dihedrals_ua: list[int] + num_dihedrals_res: int + phi_ua: PhiContainer + phi_res: PhiValues | list[Any] + + +@dataclass +class DihedralPeakData: + """Histogram peak definitions used for conformational state assignment. + + Attributes: + peaks_ua: United-atom peak values by residue and dihedral index. + peaks_res: Residue-level peak values by dihedral index. + """ + + peaks_ua: list[list[Any]] + peaks_res: list[Any] + + +@dataclass +class ConformationStateData: + """Serial conformational state data calculated for one molecule group. + + Attributes: + state_res: Residue-level state labels for the group. + flex_res: Number of flexible residue-level dihedrals for the group. + states_ua_updates: United-atom state-label updates by ``(group, residue)``. + flexible_ua_updates: United-atom flexible-dihedral updates by + ``(group, residue)``. + """ + + state_res: list[str] + flex_res: int + states_ua_updates: dict[UAKey, list[str]] + flexible_ua_updates: dict[UAKey, int] class ConformationStateBuilder: @@ -211,6 +265,38 @@ def _identify_peaks( Returns: Tuple of ``(peaks_ua, peaks_res)``. """ + angle_data = self._collect_dihedral_angle_data( + data_container=data_container, + molecules=molecules, + level_list=level_list, + frame_selection=frame_selection, + ) + peak_data = self._build_peak_data( + angle_data=angle_data, + level_list=level_list, + bin_width=bin_width, + ) + return peak_data.peaks_ua, peak_data.peaks_res + + def _collect_dihedral_angle_data( + self, + data_container: Any, + molecules: list[Any], + level_list: list[Any], + frame_selection: FrameSelection, + ) -> DihedralAngleData: + """Collect selected-frame dihedral angle values for peak detection. + + Args: + data_container: MDAnalysis universe. + molecules: Molecule ids in the group. + level_list: Enabled hierarchy levels for the representative molecule. + frame_selection: Selected frames in the active analysis-universe index + space. + + Returns: + Dihedral angle values and dihedral counts for the group. + """ rep_mol = self._universe_operations.extract_fragment( data_container, molecules[0] ) @@ -218,10 +304,8 @@ def _identify_peaks( num_residues = len(rep_mol.residues) num_dihedrals_ua: list[int] = [0 for _ in range(num_residues)] - phi_ua: dict[int, Any] = {} - phi_res: dict[int, list[float]] | list[Any] = {} - peaks_ua: list[list[Any]] = [[] for _ in range(num_residues)] - peaks_res: list[Any] = [] + phi_ua: PhiContainer = {} + phi_res: PhiValues | list[Any] = {} num_dihedrals_res = 0 for molecule in molecules: @@ -277,38 +361,65 @@ def _identify_peaks( logger.debug("phi_ua %s", phi_ua) logger.debug("phi_res %s", phi_res) + return DihedralAngleData( + num_residues=num_residues, + num_dihedrals_ua=num_dihedrals_ua, + num_dihedrals_res=num_dihedrals_res, + phi_ua=phi_ua, + phi_res=phi_res, + ) + + def _build_peak_data( + self, + angle_data: DihedralAngleData, + level_list: list[Any], + bin_width: float, + ) -> DihedralPeakData: + """Build histogram peak definitions from collected angle values. + + Args: + angle_data: Selected-frame angle values and dihedral counts. + level_list: Enabled hierarchy levels for the representative molecule. + bin_width: Histogram bin width in degrees. + + Returns: + Peak definitions for united-atom and residue-level states. + """ + peaks_ua: list[list[Any]] = [[] for _ in range(angle_data.num_residues)] + peaks_res: list[Any] = [] + for level in level_list: if level == "united_atom": - for res_id in range(num_residues): - phi_values = phi_ua.get(res_id) + for res_id in range(angle_data.num_residues): + phi_values = angle_data.phi_ua.get(res_id) if not phi_values: peaks_ua[res_id] = [] else: peaks_ua[res_id] = self._process_histogram( - num_dihedrals=num_dihedrals_ua[res_id], + num_dihedrals=angle_data.num_dihedrals_ua[res_id], phi_values=phi_values, bin_width=bin_width, ) elif level == "residue": - if not phi_res: + if not angle_data.phi_res: peaks_res = [] else: peaks_res = self._process_histogram( - num_dihedrals=num_dihedrals_res, - phi_values=phi_res, + num_dihedrals=angle_data.num_dihedrals_res, + phi_values=angle_data.phi_res, bin_width=bin_width, ) - return peaks_ua, peaks_res + return DihedralPeakData(peaks_ua=peaks_ua, peaks_res=peaks_res) def _process_dihedral_phi( self, dihedral_results: Any, num_dihedrals: int, number_frames: int, - phi_values: dict[int, list[float]], - ) -> dict[int, list[float]]: + phi_values: PhiValues, + ) -> PhiValues: """Collect positive-angle dihedral values from a local result array. Args: @@ -342,7 +453,7 @@ def _process_dihedral_phi( def _process_histogram( self, num_dihedrals: int, - phi_values: dict[int, list[float]], + phi_values: PhiValues, bin_width: float, ) -> list[Any]: """Find histogram peaks from dihedral angle values. @@ -410,10 +521,10 @@ def _assign_states( level_list: list[Any], peaks_ua: list[list[Any]], peaks_res: list[Any], - states_ua: Any, - states_res: Any, - flexible_ua: Any, - flexible_res: Any, + states_ua: dict[UAKey, list[str]], + states_res: list[list[str]], + flexible_ua: dict[UAKey, int], + flexible_res: list[int], frame_selection: FrameSelection, ) -> None: """Assign discrete state labels for selected-frame dihedrals. @@ -435,14 +546,58 @@ def _assign_states( Returns: None. Mutates the provided state/flexible accumulators. """ + state_data = self._calculate_group_state_data( + data_container=data_container, + group_id=group_id, + molecules=molecules, + level_list=level_list, + peaks_ua=peaks_ua, + peaks_res=peaks_res, + frame_selection=frame_selection, + ) + self._merge_group_state_data( + state_data=state_data, + states_ua=states_ua, + states_res=states_res, + flexible_ua=flexible_ua, + flexible_res=flexible_res, + ) + + def _calculate_group_state_data( + self, + data_container: Any, + group_id: int, + molecules: list[Any], + level_list: list[Any], + peaks_ua: list[list[Any]], + peaks_res: list[Any], + frame_selection: FrameSelection, + ) -> ConformationStateData: + """Calculate conformational states for one group without final merging. + + Args: + data_container: MDAnalysis universe. + group_id: Molecule group id. + molecules: Molecule ids in the group. + level_list: Enabled hierarchy levels. + peaks_ua: UA-level peaks by residue. + peaks_res: Residue-level peaks. + frame_selection: Selected frames in the active analysis-universe index + space. + + Returns: + Serial conformational state data for the group. + """ rep_mol = self._universe_operations.extract_fragment( data_container, molecules[0] ) number_frames = self._analysis_frame_count(frame_selection) num_residues = len(rep_mol.residues) - state_res = [] + state_res: list[str] = [] flex_res = 0 + states_ua_updates: dict[UAKey, list[str]] = {} + flexible_ua_updates: dict[UAKey, int] = {} for molecule in molecules: mol = self._universe_operations.extract_fragment(data_container, molecule) @@ -456,8 +611,8 @@ def _assign_states( num_dihedrals = len(dihedrals) if num_dihedrals == 0: - states_ua[key] = [] - flexible_ua[key] = 0 + states_ua_updates[key] = [] + flexible_ua_updates[key] = 0 continue dihedral_results = self._run_dihedrals( @@ -471,12 +626,14 @@ def _assign_states( number_frames=number_frames, ) - if key not in states_ua: - states_ua[key] = states - flexible_ua[key] = flexible + if key not in states_ua_updates: + states_ua_updates[key] = states + flexible_ua_updates[key] = flexible else: - states_ua[key].extend(states) - flexible_ua[key] = max(flexible_ua[key], flexible) + states_ua_updates[key].extend(states) + flexible_ua_updates[key] = max( + flexible_ua_updates[key], flexible + ) if level == "residue": dihedrals = self._get_dihedrals(mol, level) @@ -499,8 +656,46 @@ def _assign_states( state_res.extend(states) flex_res = max(flex_res, flexible) - states_res.append(state_res) - flexible_res.append(flex_res) + return ConformationStateData( + state_res=state_res, + flex_res=flex_res, + states_ua_updates=states_ua_updates, + flexible_ua_updates=flexible_ua_updates, + ) + + @staticmethod + def _merge_group_state_data( + state_data: ConformationStateData, + states_ua: dict[UAKey, list[str]], + states_res: list[list[str]], + flexible_ua: dict[UAKey, int], + flexible_res: list[int], + ) -> None: + """Merge one group's state data into final output accumulators. + + Args: + state_data: Serial conformational state data for one group. + states_ua: UA state accumulator to mutate. + states_res: Residue state accumulator to mutate. + flexible_ua: UA flexible-dihedral accumulator to mutate. + flexible_res: Residue flexible-dihedral accumulator to mutate. + + Returns: + None. Mutates the provided accumulators. + """ + for key, states in state_data.states_ua_updates.items(): + if key not in states_ua: + states_ua[key] = states + flexible_ua[key] = state_data.flexible_ua_updates[key] + else: + states_ua[key].extend(states) + flexible_ua[key] = max( + flexible_ua[key], + state_data.flexible_ua_updates[key], + ) + + states_res.append(state_data.state_res) + flexible_res.append(state_data.flex_res) def _process_conformations( self, @@ -557,19 +752,22 @@ def _process_conformations( return states, num_flexible - def _run_dihedrals(self, dihedrals: list[Any], frame_selection: FrameSelection): - """Run MDAnalysis dihedral analysis over selected absolute frames. + def _run_dihedrals( + self, dihedrals: list[Any], frame_selection: FrameSelection + ) -> Any: + """Run MDAnalysis dihedral analysis over selected analysis frames. Args: dihedrals: Dihedral AtomGroups. - frame_selection: Absolute trajectory frame selection. + frame_selection: Selected trajectory frame selection. Returns: MDAnalysis Dihedral analysis result. Notes: - ``Dihedral.run(start, stop, step)`` uses absolute trajectory bounds. - The returned ``results.angles`` array is indexed locally from zero. + ``Dihedral.run(start, stop, step)`` uses frame bounds in the active + analysis-universe index space. The returned ``results.angles`` array + is indexed locally from zero. """ if not dihedrals: raise ValueError("Cannot run Dihedral analysis with no dihedrals.") @@ -579,18 +777,26 @@ def _run_dihedrals(self, dihedrals: list[Any], frame_selection: FrameSelection): @staticmethod def _analysis_frame_count(frame_selection: FrameSelection) -> int: - """Return the number of selected frames.""" + """Return the number of selected frames. + + Args: + frame_selection: Selected trajectory frame selection. + + Returns: + Number of selected frames. + """ return frame_selection.n_frames @staticmethod def _analysis_run_bounds(frame_selection: FrameSelection) -> tuple[int, int, int]: - """Return MDAnalysis run bounds for selected absolute frames. + """Return MDAnalysis run bounds for selected analysis frames. Args: - frame_selection: Absolute trajectory frame selection. + frame_selection: Selected trajectory frame selection. Returns: - Tuple of ``(start, stop, step)`` in source-trajectory index space. + Tuple of ``(start, stop, step)`` in active analysis-universe index + space. Raises: ValueError: If the selection is empty. diff --git a/tests/unit/CodeEntropy/levels/test_dihedrals.py b/tests/unit/CodeEntropy/levels/test_dihedrals.py index f477ab8d..ded662d9 100644 --- a/tests/unit/CodeEntropy/levels/test_dihedrals.py +++ b/tests/unit/CodeEntropy/levels/test_dihedrals.py @@ -1,39 +1,41 @@ -import contextlib +from __future__ import annotations + from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch import numpy as np import pytest -from CodeEntropy.levels.dihedrals import ConformationStateBuilder +from CodeEntropy.levels.dihedrals import ( + ConformationStateBuilder, + ConformationStateData, + DihedralAngleData, + DihedralPeakData, +) from CodeEntropy.trajectory.frames import FrameSelection class _AddableAG: - def __init__(self, name: str): - self.name = name - - def __add__(self, other: "_AddableAG") -> "_AddableAG": - return _AddableAG(f"({self.name}+{other.name})") - - -class _FakeProgress: - def __enter__(self): - return self + """Minimal addable AtomGroup test double.""" - def __exit__(self, exc_type, exc, tb): - return False + def __init__(self, name: str): + """Initialize the fake AtomGroup. - def add_task(self, *args, **kwargs): - return 1 + Args: + name: Human-readable identifier used in composed names. + """ + self.name = name - def advance(self, *args, **kwargs): - return None + def __add__(self, other: _AddableAG) -> _AddableAG: + """Return a composed fake AtomGroup. + Args: + other: Fake AtomGroup to combine with this object. -@contextlib.contextmanager -def _fake_progress_bar(*_args, **_kwargs): - yield _FakeProgress() + Returns: + New fake AtomGroup containing a composed name. + """ + return _AddableAG(f"({self.name}+{other.name})") def _make_frame_selection( @@ -41,7 +43,16 @@ def _make_frame_selection( stop: int = 2, step: int = 1, ) -> FrameSelection: - """Build a FrameSelection for dihedral unit tests.""" + """Build a FrameSelection for dihedral unit tests. + + Args: + start: Inclusive source-frame start. + stop: Exclusive source-frame stop. + step: Source-frame step. + + Returns: + FrameSelection covering the requested bounds. + """ return FrameSelection.from_bounds(start=start, stop=stop, step=step) @@ -109,7 +120,7 @@ def test_get_dihedrals_residue_builds_one_dihedral_when_4_residues(): assert mol.select_atoms.call_count == 4 -def test_identify_peaks_sets_empty_outputs_when_no_dihedrals(): +def test_collect_dihedral_angle_data_sets_empty_outputs_when_no_dihedrals(): uops = MagicMock() dt = ConformationStateBuilder(universe_operations=uops) @@ -123,16 +134,149 @@ def test_identify_peaks_sets_empty_outputs_when_no_dihedrals(): frame_selection = _make_frame_selection(start=0, stop=2, step=1) - peaks_ua, peaks_res = dt._identify_peaks( + angle_data = dt._collect_dihedral_angle_data( data_container=MagicMock(), molecules=[0], + level_list=["united_atom", "residue"], + frame_selection=frame_selection, + ) + + assert angle_data.num_residues == 1 + assert angle_data.num_dihedrals_ua == [0] + assert angle_data.num_dihedrals_res == 0 + assert angle_data.phi_ua == {0: []} + assert angle_data.phi_res == [] + + +def test_collect_dihedral_angle_data_wraps_negative_angles(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.residues = [MagicMock()] + mol.residues[0].atoms.indices = np.array([0, 1, 2, 3], dtype=int) + uops.extract_fragment.return_value = mol + + dihedrals = ["D0"] + angles = np.array([[-10.0], [10.0]], dtype=float) + + dt._select_heavy_residue = MagicMock(return_value=mol) + dt._get_dihedrals = MagicMock(return_value=dihedrals) + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self, *args, **kwargs): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + frame_selection = _make_frame_selection(start=0, stop=2, step=1) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + angle_data = dt._collect_dihedral_angle_data( + data_container=MagicMock(), + molecules=[0], + level_list=["united_atom", "residue"], + frame_selection=frame_selection, + ) + + assert angle_data.phi_ua[0][0] == [350.0, 10.0] + assert angle_data.phi_res[0] == [350.0, 10.0] + + +def test_build_peak_data_returns_empty_outputs_when_no_angles(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + angle_data = DihedralAngleData( + num_residues=1, + num_dihedrals_ua=[0], + num_dihedrals_res=0, + phi_ua={0: []}, + phi_res=[], + ) + + peak_data = dt._build_peak_data( + angle_data=angle_data, + level_list=["united_atom", "residue"], + bin_width=30.0, + ) + + assert peak_data == DihedralPeakData(peaks_ua=[[]], peaks_res=[]) + + +def test_build_peak_data_calls_process_histogram_for_ua_and_residue(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + angle_data = DihedralAngleData( + num_residues=1, + num_dihedrals_ua=[1], + num_dihedrals_res=1, + phi_ua={0: {0: [10.0, 20.0]}}, + phi_res={0: [30.0, 40.0]}, + ) + + dt._process_histogram = MagicMock(side_effect=[["ua_peak"], ["res_peak"]]) + + peak_data = dt._build_peak_data( + angle_data=angle_data, + level_list=["united_atom", "residue"], + bin_width=30.0, + ) + + assert peak_data.peaks_ua == [["ua_peak"]] + assert peak_data.peaks_res == ["res_peak"] + assert dt._process_histogram.call_args_list == [ + call( + num_dihedrals=1, + phi_values={0: [10.0, 20.0]}, + bin_width=30.0, + ), + call( + num_dihedrals=1, + phi_values={0: [30.0, 40.0]}, + bin_width=30.0, + ), + ] + + +def test_identify_peaks_delegates_to_angle_collection_and_peak_building(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + angle_data = DihedralAngleData( + num_residues=1, + num_dihedrals_ua=[1], + num_dihedrals_res=1, + phi_ua={0: {0: [10.0]}}, + phi_res={0: [10.0]}, + ) + peak_data = DihedralPeakData(peaks_ua=[[[10.0]]], peaks_res=[[10.0]]) + + dt._collect_dihedral_angle_data = MagicMock(return_value=angle_data) + dt._build_peak_data = MagicMock(return_value=peak_data) + + frame_selection = _make_frame_selection(start=0, stop=2, step=1) + + peaks_ua, peaks_res = dt._identify_peaks( + data_container="universe", + molecules=[0], bin_width=30.0, level_list=["united_atom", "residue"], frame_selection=frame_selection, ) - assert peaks_ua == [[]] - assert peaks_res == [] + assert peaks_ua == peak_data.peaks_ua + assert peaks_res == peak_data.peaks_res + dt._collect_dihedral_angle_data.assert_called_once_with( + data_container="universe", + molecules=[0], + level_list=["united_atom", "residue"], + frame_selection=frame_selection, + ) + dt._build_peak_data.assert_called_once_with( + angle_data=angle_data, + level_list=["united_atom", "residue"], + bin_width=30.0, + ) def test_identify_peaks_wraps_negative_angles_and_calls_process_histogram(): @@ -185,6 +329,154 @@ def test_find_histogram_peaks_hits_interior_and_wraparound_last_bin(): ] +def test_calculate_group_state_data_initialises_then_extends_for_multiple_molecules(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.residues = [MagicMock()] + mol.residues[0].atoms.indices = np.array([0, 1, 2, 3], dtype=int) + uops.extract_fragment.return_value = mol + + dihedrals = ["D0"] + angles = np.array([[5.0], [15.0]], dtype=float) + peaks = [[5.0, 15.0]] + + dt._select_heavy_residue = MagicMock(return_value=mol) + dt._get_dihedrals = MagicMock(return_value=dihedrals) + + class _FakeDihedral: + def __init__(self, _dihedrals): + pass + + def run(self, *args, **kwargs): + return SimpleNamespace(results=SimpleNamespace(angles=angles)) + + frame_selection = _make_frame_selection(start=0, stop=2, step=1) + + with patch("CodeEntropy.levels.dihedrals.Dihedral", _FakeDihedral): + state_data = dt._calculate_group_state_data( + data_container=MagicMock(), + group_id=0, + molecules=[0, 1], + level_list=["united_atom", "residue"], + peaks_ua=[peaks], + peaks_res=peaks, + frame_selection=frame_selection, + ) + + assert state_data.states_ua_updates[(0, 0)] == ["0", "1", "0", "1"] + assert state_data.flexible_ua_updates[(0, 0)] == 1 + assert state_data.state_res == ["0", "1", "0", "1"] + assert state_data.flex_res == 1 + + +def test_merge_group_state_data_initialises_final_accumulators(): + states_ua = {} + states_res = [] + flexible_ua = {} + flexible_res = [] + + state_data = ConformationStateData( + state_res=["0", "1"], + flex_res=1, + states_ua_updates={(0, 0): ["0", "1"]}, + flexible_ua_updates={(0, 0): 1}, + ) + + ConformationStateBuilder._merge_group_state_data( + state_data=state_data, + states_ua=states_ua, + states_res=states_res, + flexible_ua=flexible_ua, + flexible_res=flexible_res, + ) + + assert states_ua == {(0, 0): ["0", "1"]} + assert states_res == [["0", "1"]] + assert flexible_ua == {(0, 0): 1} + assert flexible_res == [1] + + +def test_merge_group_state_data_extends_existing_ua_states(): + states_ua = {(0, 0): ["0"]} + states_res = [] + flexible_ua = {(0, 0): 1} + flexible_res = [] + + state_data = ConformationStateData( + state_res=["1"], + flex_res=0, + states_ua_updates={(0, 0): ["1"], (0, 1): ["2"]}, + flexible_ua_updates={(0, 0): 2, (0, 1): 0}, + ) + + ConformationStateBuilder._merge_group_state_data( + state_data=state_data, + states_ua=states_ua, + states_res=states_res, + flexible_ua=flexible_ua, + flexible_res=flexible_res, + ) + + assert states_ua[(0, 0)] == ["0", "1"] + assert states_ua[(0, 1)] == ["2"] + assert flexible_ua[(0, 0)] == 2 + assert flexible_ua[(0, 1)] == 0 + assert states_res == [["1"]] + assert flexible_res == [0] + + +def test_assign_states_delegates_to_calculation_and_merge(): + dt = ConformationStateBuilder(universe_operations=MagicMock()) + + state_data = ConformationStateData( + state_res=["0"], + flex_res=1, + states_ua_updates={(0, 0): ["0"]}, + flexible_ua_updates={(0, 0): 1}, + ) + dt._calculate_group_state_data = MagicMock(return_value=state_data) + dt._merge_group_state_data = MagicMock() + + frame_selection = _make_frame_selection(start=0, stop=2, step=1) + states_ua = {} + states_res = [] + flexible_ua = {} + flexible_res = [] + + dt._assign_states( + data_container="universe", + group_id=0, + molecules=[0], + level_list=["united_atom"], + peaks_ua=[[[10.0]]], + peaks_res=[], + states_ua=states_ua, + states_res=states_res, + flexible_ua=flexible_ua, + flexible_res=flexible_res, + frame_selection=frame_selection, + ) + + dt._calculate_group_state_data.assert_called_once_with( + data_container="universe", + group_id=0, + molecules=[0], + level_list=["united_atom"], + peaks_ua=[[[10.0]]], + peaks_res=[], + frame_selection=frame_selection, + ) + dt._merge_group_state_data.assert_called_once_with( + state_data=state_data, + states_ua=states_ua, + states_res=states_res, + flexible_ua=flexible_ua, + flexible_res=flexible_res, + ) + + def test_assign_states_initialises_then_extends_for_multiple_molecules(): uops = MagicMock() dt = ConformationStateBuilder(universe_operations=uops) @@ -312,6 +604,43 @@ def run(self, *args, **kwargs): assert dt._process_histogram.call_count == 2 +def test_collect_dihedral_angle_data_initialises_phi_res_dict_before_processing(): + uops = MagicMock() + dt = ConformationStateBuilder(universe_operations=uops) + + mol = MagicMock() + mol.residues = [MagicMock(), MagicMock(), MagicMock(), MagicMock()] + uops.extract_fragment.return_value = mol + + frame_selection = _make_frame_selection(start=0, stop=2, step=1) + + dihedrals = ["D0"] + dihedral_results = MagicMock() + processed_phi = {0: [10.0, 20.0]} + + dt._get_dihedrals = MagicMock(return_value=dihedrals) + dt._run_dihedrals = MagicMock(return_value=dihedral_results) + dt._process_dihedral_phi = MagicMock(return_value=processed_phi) + + angle_data = dt._collect_dihedral_angle_data( + data_container=MagicMock(), + molecules=[0], + level_list=["residue"], + frame_selection=frame_selection, + ) + + assert angle_data.num_residues == 4 + assert angle_data.phi_res == processed_phi + assert angle_data.num_dihedrals_res == 1 + + dt._process_dihedral_phi.assert_called_once_with( + dihedral_results=dihedral_results, + num_dihedrals=1, + number_frames=2, + phi_values={}, + ) + + def test_identify_peaks_initialises_phi_res_dict_before_processing_residue_dihedrals(): uops = MagicMock() dt = ConformationStateBuilder(universe_operations=uops)