Coverage for model_workflow/analyses/lipid_order.py: 97%

90 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-23 10:54 +0000

1from model_workflow.tools.get_reduced_trajectory import calculate_frame_step 

2from model_workflow.utils.mda_spells import to_MDAnalysis_topology 

3from model_workflow.utils.auxiliar import save_json 

4from model_workflow.utils.constants import OUTPUT_LIPID_ORDER_FILENAME 

5from model_workflow.utils.type_hints import * 

6import numpy as np 

7import MDAnalysis 

8 

9 

10def lipid_order ( 

11 trajectory_file : 'File', 

12 standard_topology_file : 'File', 

13 output_directory : str, 

14 membrane_map: dict, 

15 lipid_map: dict, 

16 snapshots : int, 

17 frames_limit: int = 100): 

18 """ 

19 Calculate lipid order parameters for membranes. 

20 This function computes the order parameter (S) for lipid acyl chains, defined as: 

21 S = 0.5*(3*<cos²θ> - 1) 

22 where θ is the angle between the C-H bond and the membrane normal (z-axis). 

23 

24 Notes: 

25 - For non-cholesterol lipids, analyzes all acyl chains separately. 

26 - For cholesterol, uses carbon atoms bonded to hydrogen. 

27 - The order parameter is calculated for each carbon atom in the chain. 

28 """ 

29 if membrane_map is None or membrane_map['n_mems'] == 0: 

30 print('-> Skipping lipid order analysis') 

31 return 

32 

33 # Set the main output filepath 

34 output_analysis_filepath = f'{output_directory}/{OUTPUT_LIPID_ORDER_FILENAME}' 

35 

36 mda_top = to_MDAnalysis_topology(standard_topology_file) 

37 u = MDAnalysis.Universe(mda_top, trajectory_file.path) 

38 order_parameters_dict = {} 

39 frame_step, _ = calculate_frame_step(snapshots, frames_limit) 

40 for ref in lipid_map: 

41 # Take the first residue of the reference 

42 res = u.residues[ref["resindices"][0]] 

43 if ref['lipidmaps']['name'] != 'Cholesterol': 

44 carbon_groups = get_all_acyl_chains(res) 

45 else: 

46 carbon_groups = [res.atoms.select_atoms('element C and bonded element H').indices] 

47 

48 order_parameters_dict[ref["resname"]] = {} 

49 # For every 'tail' 

50 for chain_idx, group in enumerate(carbon_groups): 

51 atoms = res.universe.atoms[group] 

52 C_names = sorted([atom.name for atom in atoms],key=natural_sort_key) 

53 # Find all C-H bonds indices 

54 ch_pairs = find_CH_bonds(u, ref["resindices"], C_names) 

55 # Initialize the order parameters to sum over the trajectory 

56 order_parameters = [] 

57 costheta_sums = {C_name: np.zeros(len(ch_pairs[C_name]['C'])) for C_name in C_names} 

58 n = 0 

59 # Loop over the trajectory 

60 for ts in u.trajectory[0:snapshots:frame_step]: 

61 for C_name in C_names: 

62 d = u.atoms[ch_pairs[C_name]['C']].positions - u.atoms[ch_pairs[C_name]['H']].positions 

63 costheta_sums[C_name] += d[:,2]**2/np.linalg.norm(d, axis=1)**2 

64 n += 1 

65 order_parameters = 1.5 * np.array([costheta_sums[C_name].mean() for C_name in C_names])/n - 0.5 

66 serrors = 1.5 * np.array([costheta_sums[C_name].std() for C_name in C_names])/n 

67 order_parameters_dict[ref["resname"]][str(chain_idx)] = { 

68 'atoms': C_names, 

69 'avg': order_parameters.tolist(), 

70 'std': serrors.tolist()} 

71 # Save the data 

72 data = { 'data': order_parameters_dict} 

73 save_json(data, output_analysis_filepath) 

74 

75 

76def get_all_acyl_chains(residue: 'MDAnalysis.Residue') -> list: 

77 """ 

78 Finds all groups of connected Carbon atoms within a residue, including cyclic structures. 

79  

80 Parameters 

81 ---------- 

82 residue : MDAnalysis.core.groups.Residue 

83 The residue to analyze 

84  

85 Returns 

86 ------- 

87 list 

88 A list of lists, where each inner list contains atom indices of  

89 connected Carbon atoms forming a distinct group 

90 """ 

91 def explore_carbon_group(start_atom, visited): 

92 """Helper function to explore a connected group of carbons""" 

93 to_visit = [start_atom] 

94 group = set() 

95 while to_visit: 

96 current_atom = to_visit.pop(0) 

97 if current_atom.index in visited: 

98 continue 

99 visited.add(current_atom.index) 

100 if current_atom.element == 'C': 

101 group.add(current_atom.index) 

102 # Add all bonded carbon atoms to the visit queue 

103 for bond in current_atom.bonds: 

104 for bonded_atom in bond.atoms: 

105 if (bonded_atom.element == 'C' and 

106 bonded_atom.index not in visited): 

107 to_visit.append(bonded_atom) 

108 return list(group) 

109 

110 # Get all Carbon atoms in the residue 

111 carbon_atoms = residue.atoms.select_atoms('element C') 

112 visited = set() 

113 # Remove ester carbons (without hydrogens) 

114 for at in carbon_atoms: 

115 if 'H' not in at.bonded_atoms.elements: 

116 visited.add(at.index) 

117 carbon_groups = [] 

118 # Find all distinct groups of connected carbons 

119 for carbon in carbon_atoms: 

120 if carbon.index not in visited: 

121 # Get all carbons connected to this one 

122 connected_carbons = explore_carbon_group(carbon, visited) 

123 if len(connected_carbons)>6: # Only add non-empty groups 

124 carbon_groups.append(sorted(connected_carbons)) 

125 return carbon_groups 

126 

127 

128def find_CH_bonds(universe, lipid_resindices, atom_names): 

129 """ 

130 Find all carbon-hydrogen bonds in a lipid molecule using topology information. 

131  

132 Args: 

133 universe: MDAnalysis Universe object with topology information 

134 lipid_resname: Residue name of the lipid (default: 'DPPC') 

135  

136 Returns: 

137 dict: Dict for each carbon, of tuples containing (carbon_index, hydrogen_index) for each C-H bond 

138 """ 

139 # Get all carbons in the lipid 

140 carbons = universe.residues[lipid_resindices].atoms.select_atoms('name ' + ' '.join(atom_names)) 

141 # Get how many carbons with different name that we will average over later 

142 ch_pairs = {} 

143 for name in atom_names: 

144 ch_pairs[name] = { 'C':[], 'H':[] } 

145 

146 # Iterate through each carbon 

147 for carbon in carbons: 

148 # Get all bonds for this carbon 

149 for bond in carbon.bonds: 

150 # Check if the bond is between C and H 

151 atoms = bond.atoms 

152 at1_nm = atoms[0].name 

153 at2_nm = atoms[1].name 

154 if ('C' in at1_nm and 'H' in at2_nm): 

155 # Store the indices making sure carbon is first 

156 ch_pairs[carbon.name]['C'].append(atoms[0].index) 

157 ch_pairs[carbon.name]['H'].append(atoms[1].index) 

158 elif 'H' in at1_nm and 'C' in at2_nm: 

159 ch_pairs[carbon.name]['C'].append(atoms[1].index) 

160 ch_pairs[carbon.name]['H'].append(atoms[0].index) 

161 # Convert lists to numpy arrays 

162 for name in ch_pairs: 

163 ch_pairs[name]['C'] = np.array(ch_pairs[name]['C']) 

164 ch_pairs[name]['H'] = np.array(ch_pairs[name]['H']) 

165 return ch_pairs 

166 

167def natural_sort_key(s): 

168 """Sort strings that contain numbers in human order (C1, C2, C3 instead of C1, C11, C2)""" 

169 import re 

170 return [int(text) if text.isdigit() else text.lower() 

171 for text in re.split('([0-9]+)', s)]