ensemble-analyzer 1__tar.gz → 1a0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {ensemble_analyzer-1/src/ensemble_analyzer.egg-info → ensemble_analyzer-1a0}/PKG-INFO +1 -1
  2. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/pyproject.toml +2 -2
  3. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_clustering/cluster_config.py +2 -0
  4. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_clustering/cluster_manager.py +7 -3
  5. ensemble_analyzer-1a0/src/ensemble_analyzer/cli/clustering.py +407 -0
  6. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/ensemble_io.py +5 -2
  7. {ensemble_analyzer-1 → ensemble_analyzer-1a0/src/ensemble_analyzer.egg-info}/PKG-INFO +1 -1
  8. ensemble_analyzer-1/src/ensemble_analyzer/cli/clustering.py +0 -153
  9. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/LICENSE +0 -0
  10. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/README.md +0 -0
  11. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/setup.cfg +0 -0
  12. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/__init__.py +0 -0
  13. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/__main__.py +0 -0
  14. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_calculators/__init__.py +0 -0
  15. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_calculators/_gaussian.py +0 -0
  16. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_calculators/_orca.py +0 -0
  17. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_calculators/base.py +0 -0
  18. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_clustering/__init__.py +0 -0
  19. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_conformer/__init__.py +0 -0
  20. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_conformer/conformer.py +0 -0
  21. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_conformer/energy_data.py +0 -0
  22. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_conformer/spectral_data.py +0 -0
  23. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_logger/__init__.py +0 -0
  24. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_logger/create_log.py +0 -0
  25. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_logger/formatter.py +0 -0
  26. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_logger/logger.py +0 -0
  27. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/__init__.py +0 -0
  28. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/calculation_config.py +0 -0
  29. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/calculation_executor.py +0 -0
  30. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/calculator_orchestration.py +0 -0
  31. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/checkpoint_manager.py +0 -0
  32. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/protocol_executor.py +0 -0
  33. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/protocol_manager.py +0 -0
  34. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_managers/pruning_manager.py +0 -0
  35. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_parsers/__init__.py +0 -0
  36. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_parsers/_gaussian.py +0 -0
  37. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_parsers/_orca.py +0 -0
  38. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_parsers/base.py +0 -0
  39. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_protocol/__init__.py +0 -0
  40. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_protocol/protocol.py +0 -0
  41. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_protocol/solvent.py +0 -0
  42. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/__init__.py +0 -0
  43. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/base.py +0 -0
  44. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/comp_electronic.py +0 -0
  45. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/comp_vibronic.py +0 -0
  46. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/compare.py +0 -0
  47. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/experimental.py +0 -0
  48. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/_spectral/graph_default.py +0 -0
  49. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/__init__.py +0 -0
  50. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/check_setup.py +0 -0
  51. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/get_av_energy.py +0 -0
  52. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/pickle_editor/__init__.py +0 -0
  53. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/pickle_editor/cli.py +0 -0
  54. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/pickle_editor/core.py +0 -0
  55. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/pickle_editor/tui.py +0 -0
  56. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/protocol_wizard.py +0 -0
  57. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/cli/regraph.py +0 -0
  58. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/clustering.py +0 -0
  59. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/constants.py +0 -0
  60. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/graph.py +0 -0
  61. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/io_utils.py +0 -0
  62. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/launch.py +0 -0
  63. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/parameters_file/basis_sets +0 -0
  64. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/parameters_file/default_protocol.json +0 -0
  65. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/parameters_file/default_threshold.json +0 -0
  66. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/parameters_file/functionals +0 -0
  67. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/parser_arguments.py +0 -0
  68. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/parser_parameter.py +0 -0
  69. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/rrho.py +0 -0
  70. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer/title.py +0 -0
  71. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer.egg-info/SOURCES.txt +0 -0
  72. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer.egg-info/dependency_links.txt +0 -0
  73. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer.egg-info/entry_points.txt +0 -0
  74. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer.egg-info/requires.txt +0 -0
  75. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/src/ensemble_analyzer.egg-info/top_level.txt +0 -0
  76. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_calculation_executor.py +0 -0
  77. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_calculator.py +0 -0
  78. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_checkpoint_manager.py +0 -0
  79. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_clustering.py +0 -0
  80. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_config.py +0 -0
  81. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_conformer.py +0 -0
  82. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_data_structure.py +0 -0
  83. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_ensemble_io.py +0 -0
  84. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_experimental.py +0 -0
  85. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_high_level.py +0 -0
  86. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_integration.py +0 -0
  87. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_io_utils.py +0 -0
  88. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_logger.py +0 -0
  89. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_parser_parameters.py +0 -0
  90. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_parsers.py +0 -0
  91. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_protocol_executor.py +0 -0
  92. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_pruning_manager.py +0 -0
  93. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_rrho.py +0 -0
  94. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_spectral.py +0 -0
  95. {ensemble_analyzer-1 → ensemble_analyzer-1a0}/test/test_spectral_compare.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ensemble-analyzer
3
- Version: 1
3
+ Version: 1a0
4
4
  Summary: Automatic Refinement of Conformational Ensemble
5
5
  Author-email: Andrea Pellegrini <andrea.pellegrini15@unibo.it>, Paolo Righi <paolo.righi@unibo.it>, Andrea Mazzanti <andrea.mazzanti@unibo.it>, Michele Mancinelli <michele.mancinelli@unibo.it>
6
6
  Maintainer-email: Andrea Pellegrini <andrea.pellegrini15@unibo.it>, Paolo Righi <paolo.righi@unibo.it>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ensemble-analyzer"
7
- version = '1'
7
+ version = '1a'
8
8
  description = "Automatic Refinement of Conformational Ensemble"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -76,4 +76,4 @@ ensemble_analyzer = ["parameters_file/*"]
76
76
 
77
77
  [project.urls]
78
78
  Homepage = "https://github.com/andre-cloud/ensemble_analyzer"
79
- Documentation = "https://enan.readthedocs.io/en/latest/"
79
+ Documentation = "https://enan.readthedocs.io/en/latest/"
@@ -16,6 +16,8 @@ class PCAResult:
16
16
  energies: np.ndarray # Relative energies array
17
17
  explained_variance: np.ndarray # Variance ratio per component
18
18
  n_clusters: Optional[int] = None # Number of clusters found/used
19
+ original_features: Optional[np.ndarray] = None # Added for before/after plot
20
+ components: Optional[np.ndarray] = None # Added for loading plot
19
21
 
20
22
 
21
23
  @dataclass
@@ -142,7 +142,7 @@ class ClusteringManager:
142
142
  ensemble = sorted(conformers) if sort_by_energy else conformers[:]
143
143
 
144
144
  # Track which clusters we've seen
145
- seen_clusters = set()
145
+ seen_clusters = []
146
146
  deactivated_count = 0
147
147
 
148
148
  for conf in ensemble:
@@ -154,7 +154,7 @@ class ClusteringManager:
154
154
  conf.active = False
155
155
  deactivated_count += 1
156
156
  else:
157
- seen_clusters.add(conf.cluster)
157
+ seen_clusters.append(conf.cluster)
158
158
 
159
159
  active_count = len([c for c in ensemble if c.active])
160
160
 
@@ -229,6 +229,7 @@ class ClusteringManager:
229
229
  kmeans = KMeans(
230
230
  n_clusters=n_clusters,
231
231
  n_init='auto',
232
+ random_state=self.config.random_state
232
233
  )
233
234
  cluster_labels = kmeans.fit_predict(pca_scores)
234
235
 
@@ -244,7 +245,9 @@ class ClusteringManager:
244
245
  numbers=numbers,
245
246
  energies=energies,
246
247
  explained_variance=pca.explained_variance_ratio_,
247
- n_clusters=n_clusters
248
+ n_clusters=n_clusters,
249
+ original_features=eigenvalues,
250
+ components=pca.components_
248
251
  )
249
252
 
250
253
  def _calculate_distance_matrix_eigenvalues(
@@ -314,6 +317,7 @@ class ClusteringManager:
314
317
  kmeans = KMeans(
315
318
  n_clusters=k,
316
319
  n_init='auto',
320
+ random_state=self.config.random_state
317
321
  )
318
322
  labels = kmeans.fit_predict(features)
319
323
  score = silhouette_score(features, labels)
@@ -0,0 +1,407 @@
1
+ import argparse
2
+ import os
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from sklearn.cluster import KMeans
6
+ from sklearn.metrics import silhouette_score
7
+ from unittest.mock import Mock
8
+
9
+ from ensemble_analyzer.ensemble_io import read_ensemble, save_snapshot
10
+ from ensemble_analyzer.clustering import *
11
+ from ensemble_analyzer._clustering.cluster_config import ClusteringConfig
12
+ from ensemble_analyzer._clustering.cluster_manager import ClusteringManager
13
+
14
+ def parse_args():
15
+ parser = argparse.ArgumentParser(
16
+ description='Perform PCA analysis and clustering on conformer ensemble',
17
+ formatter_class=argparse.RawDescriptionHelpFormatter,
18
+ epilog="""
19
+ Examples:
20
+ # Basic PCA with 5 clusters
21
+ python clustering.py ensemble.xyz -nc 5
22
+ # Auto-detect optimal clusters, exclude hydrogen
23
+ python clustering.py ensemble.xyz --no-H
24
+ # Save reduced ensemble
25
+ python clustering.py ensemble.xyz -nc 10 --save-reduced
26
+ # Custom output and title
27
+ python clustering.py ensemble.xyz -o my_pca.png --title "Drug Conformers"
28
+ """
29
+ )
30
+
31
+ parser.add_argument('file', help='Input ensemble file (XYZ format)')
32
+ parser.add_argument('-nc', '--ncluster', type=int, default=None,
33
+ help='Number of clusters (default: auto-detect using silhouette score)')
34
+ parser.add_argument('--no-H', action='store_false', dest='include_H',
35
+ help='Exclude hydrogen atoms from distance matrix calculation')
36
+ parser.add_argument('--no-legend', action='store_false', dest='legend',
37
+ help='Exclude conformer legend from plot')
38
+ parser.add_argument('--title', default='PCA Cluster Analysis',
39
+ help='Plot title (default: "PCA Cluster Analysis")')
40
+ parser.add_argument('-o', '--output', default='cluster.png',
41
+ help='Output image filename (default: cluster.png)')
42
+ parser.add_argument('--write', action='store_true',
43
+ help='Save cluster-reduced ensemble to clustered.xyz')
44
+ parser.add_argument('--dpi', type=int, default=300,
45
+ help='Output image DPI (default: 300)')
46
+ parser.add_argument('-v', '--verbose', action='store_true',
47
+ help='Enable verbose output')
48
+
49
+ args = parser.parse_args()
50
+ return args
51
+
52
+ class CLILogger:
53
+ def __init__(self, verbose=False):
54
+ self.verbose = verbose
55
+
56
+ def info(self, msg):
57
+ print(f"ℹ️ {msg}")
58
+
59
+ def debug(self, msg):
60
+ if self.verbose:
61
+ print(f"🔍 {msg}")
62
+
63
+ def warning(self, msg):
64
+ print(f"⚠️ {msg}")
65
+
66
+ def error(self, msg):
67
+ print(f"❌ {msg}")
68
+
69
+ def plot_component_analysis(result, output_base, logger):
70
+ """
71
+ Generate loading plot showing feature contributions to principal components.
72
+ """
73
+ out_dir = os.path.dirname(output_base) or "."
74
+ base_name = os.path.splitext(os.path.basename(output_base))[0]
75
+
76
+ logger.info("Generating component loading analysis...")
77
+
78
+ if getattr(result, 'components', None) is None:
79
+ logger.warning("No component loadings available, skipping loading plot")
80
+ return
81
+
82
+ components = result.components[:2] # First 2 PCs
83
+ n_features = components.shape[1]
84
+
85
+ fig, axes = plt.subplots(1, 2, figsize=(14, 5))
86
+
87
+ for idx, (ax, pc) in enumerate(zip(axes, components)):
88
+ pc_variance = result.explained_variance[idx] * 100
89
+ feature_indices = np.arange(n_features)
90
+ colors = ['#2E86AB' if x >= 0 else '#A23B72' for x in pc]
91
+
92
+ ax.bar(feature_indices, pc, color=colors, alpha=0.7, edgecolor='black', linewidth=0.5)
93
+ ax.axhline(y=0, color='black', linestyle='-', linewidth=0.8)
94
+ ax.set_xlabel('Feature Index', fontsize=10)
95
+ ax.set_ylabel('Loading Value', fontsize=10)
96
+ ax.set_title(f'PC{idx+1} Loadings ({pc_variance:.1f}% variance)', fontsize=11, fontweight='bold')
97
+ ax.grid(True, alpha=0.3, axis='y')
98
+
99
+ # Highlight top contributors
100
+ top_indices = np.argsort(np.abs(pc))[-5:]
101
+ for top_idx in top_indices:
102
+ ax.text(top_idx, pc[top_idx], f'{top_idx}',
103
+ ha='center', va='bottom' if pc[top_idx] > 0 else 'top',
104
+ fontsize=8, fontweight='bold')
105
+
106
+ plt.tight_layout()
107
+ loading_file = os.path.join(out_dir, f"{base_name}_loadings.png")
108
+ plt.savefig(loading_file, dpi=300, bbox_inches='tight')
109
+ plt.close()
110
+ logger.info(f"✓ Component loadings saved: {loading_file}")
111
+
112
+ def plot_before_after_pca(result, output_base, logger):
113
+ """
114
+ Side-by-side comparison of data before and after PCA transformation,
115
+ with PCA axes drawn in the original space.
116
+ """
117
+ out_dir = os.path.dirname(output_base) or "."
118
+ base_name = os.path.splitext(os.path.basename(output_base))[0]
119
+
120
+ logger.info("Generating before/after PCA comparison...")
121
+
122
+ if getattr(result, 'original_features', None) is None:
123
+ logger.warning("Original features not available, skipping before/after plot")
124
+ return
125
+
126
+ original = result.original_features
127
+ transformed = result.scores
128
+ labels = result.clusters
129
+
130
+ fig, axes = plt.subplots(1, 2, figsize=(16, 6))
131
+
132
+ unique_labels = np.unique(labels)
133
+ colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
134
+
135
+ # Plot 1: Original space (first 2 features)
136
+ ax1 = axes[0]
137
+ for idx, label in enumerate(unique_labels):
138
+ mask = labels == label
139
+ ax1.scatter(original[mask, 0], original[mask, 1],
140
+ c=[colors[idx]],
141
+ alpha=0.6, s=50, edgecolors='black', linewidth=0.5)
142
+
143
+ # Draw PCA axes in the original space if components are available
144
+ if getattr(result, 'components', None) is not None:
145
+ # Calculate the center of the original data (since PCA is centered)
146
+ mean_0 = np.mean(original[:, 0])
147
+ mean_1 = np.mean(original[:, 1])
148
+
149
+ # Extract the projection of the first two PCs onto the first two original features
150
+ v1_0, v1_1 = result.components[0, 0], result.components[0, 1]
151
+ v2_0, v2_1 = result.components[1, 0], result.components[1, 1]
152
+
153
+ # Scale vectors based on the standard deviation of the scores
154
+ scale1 = np.std(transformed[:, 0]) * 2
155
+ scale2 = np.std(transformed[:, 1]) * 2
156
+
157
+ # Plot PC1 Axis (Red)
158
+ ax1.plot([mean_0 - v1_0*scale1, mean_0 + v1_0*scale1],
159
+ [mean_1 - v1_1*scale1, mean_1 + v1_1*scale1],
160
+ color='red', linewidth=2, linestyle='--')
161
+ ax1.text(mean_0 + v1_0*scale1, mean_1 + v1_1*scale1, 'PC1', color='red', fontsize=12, fontweight='bold')
162
+
163
+ # Plot PC2 Axis (Green)
164
+ ax1.plot([mean_0 - v2_0*scale2, mean_0 + v2_0*scale2],
165
+ [mean_1 - v2_1*scale2, mean_1 + v2_1*scale2],
166
+ color='green', linewidth=2, linestyle='--')
167
+ ax1.text(mean_0 + v2_0*scale2, mean_1 + v2_1*scale2, 'PC2', color='green', fontsize=12, fontweight='bold')
168
+
169
+ ax1.set_xlabel('Feature 1 (Original Space)', fontsize=11)
170
+ ax1.set_ylabel('Feature 2 (Original Space)', fontsize=11)
171
+ ax1.set_title('Original Feature Space with PC Axes', fontsize=12, fontweight='bold')
172
+ ax1.grid(True, alpha=0.3)
173
+
174
+ # Plot 2: PCA space (PC1 vs PC2)
175
+ ax2 = axes[1]
176
+ for idx, label in enumerate(unique_labels):
177
+ mask = labels == label
178
+ ax2.scatter(transformed[mask, 0], transformed[mask, 1],
179
+ c=[colors[idx]],
180
+ alpha=0.6, s=50, edgecolors='black', linewidth=0.5)
181
+
182
+ var1, var2 = result.explained_variance[0] * 100, result.explained_variance[1] * 100
183
+ ax2.set_xlabel(f'PC1 ({var1:.1f}% variance)', fontsize=11)
184
+ ax2.set_ylabel(f'PC2 ({var2:.1f}% variance)', fontsize=11)
185
+ ax2.set_title(f'PCA-Transformed Space', fontsize=12, fontweight='bold')
186
+ ax2.grid(True, alpha=0.3)
187
+
188
+ plt.tight_layout()
189
+ comparison_file = os.path.join(out_dir, f"{base_name}_before_after.png")
190
+ plt.savefig(comparison_file, dpi=300, bbox_inches='tight')
191
+ plt.close()
192
+ logger.info(f"✓ Before/After comparison saved: {comparison_file}")
193
+
194
+ def plot_clustering_metrics(result, output_base, logger):
195
+ """
196
+ Generate evaluation plots: Scree plot with cumulative variance and Silhouette scores.
197
+ """
198
+ out_dir = os.path.dirname(output_base) or "."
199
+ base_name = os.path.splitext(os.path.basename(output_base))[0]
200
+
201
+ logger.info("Generating clustering metric plots...")
202
+
203
+ variance = result.explained_variance * 100
204
+ cumulative_variance = np.cumsum(variance)
205
+ n_components = len(variance)
206
+
207
+ fig, ax1 = plt.subplots(figsize=(10, 6))
208
+
209
+ x_pos = np.arange(1, n_components + 1)
210
+ bars = ax1.bar(x_pos, variance, alpha=0.7, color='#3498db',
211
+ edgecolor='black', linewidth=0.8, label='Individual Variance')
212
+
213
+ ax1.set_xlabel('Principal Component', fontsize=12, fontweight='bold')
214
+ ax1.set_ylabel('Explained Variance (%)', fontsize=12, fontweight='bold', color='#3498db')
215
+ ax1.tick_params(axis='y', labelcolor='#3498db')
216
+ # ax1.set_xticks(x_pos)
217
+ ax1.grid(True, alpha=0.3, axis='y', linestyle='--')
218
+
219
+ ax2 = ax1.twinx()
220
+ line = ax2.plot(x_pos, cumulative_variance, color='#e74c3c', marker='o',
221
+ linewidth=2.5, markersize=8, label='Cumulative Variance')
222
+
223
+ ax2.set_ylabel('Cumulative Variance (%)', fontsize=12, fontweight='bold', color='#e74c3c')
224
+ ax2.tick_params(axis='y', labelcolor='#e74c3c')
225
+ ax2.set_ylim([0, 105])
226
+
227
+ ax2.axhline(y=90, color='gray', linestyle='--', linewidth=1.5, alpha=0.7, label='90% threshold')
228
+
229
+ for i, (bar, val) in enumerate(zip(bars, variance)):
230
+ if i < 5:
231
+ height = bar.get_height()
232
+ ax1.text(bar.get_x() + bar.get_width()/2., height,
233
+ f'{val:.1f}%', ha='center', va='bottom', fontsize=9, fontweight='bold')
234
+
235
+ lines1, labels1 = ax1.get_legend_handles_labels()
236
+ lines2, labels2 = ax2.get_legend_handles_labels()
237
+ ax1.legend(lines1 + lines2, labels1 + labels2, loc='lower right', fontsize=10)
238
+
239
+ plt.title('Scree Plot: Explained Variance per Component', fontsize=13, fontweight='bold', pad=15)
240
+ plt.tight_layout()
241
+
242
+ scree_file = os.path.join(out_dir, f"{base_name}_scree.png")
243
+ plt.savefig(scree_file, dpi=300, bbox_inches='tight')
244
+ plt.close()
245
+ logger.info(f"✓ Scree plot saved: {scree_file}")
246
+
247
+ features = result.scores
248
+ min_k = max(int(len(features) * 0.1), 2)
249
+ max_k = int(len(features) * 0.8)
250
+
251
+ if max_k > min_k:
252
+ k_range = range(min_k, max_k + 1)
253
+ scores = []
254
+ for k in k_range:
255
+ kmeans = KMeans(n_clusters=k, n_init='auto', random_state=500)
256
+ labels = kmeans.fit_predict(features)
257
+ scores.append(silhouette_score(features, labels))
258
+
259
+ plt.figure(figsize=(8, 5))
260
+ plt.plot(k_range, scores, marker='s', color='orange')
261
+ plt.axvline(
262
+ x=result.n_clusters, color='red', linestyle='--',
263
+ label=f'Selected k={result.n_clusters}'
264
+ )
265
+ plt.title('Silhouette Score vs Number of Clusters')
266
+ plt.xlabel('Number of Clusters (k)')
267
+ plt.ylabel('Silhouette Score')
268
+ plt.legend()
269
+ plt.grid(True, alpha=0.3)
270
+
271
+ sil_file = os.path.join(out_dir, f"{base_name}_silhouette.png")
272
+ plt.savefig(sil_file, dpi=300, bbox_inches='tight')
273
+ plt.close()
274
+ logger.info(f"✓ Silhouette plot saved: {sil_file}")
275
+ else:
276
+ logger.warning("Not enough data points to generate Silhouette plot")
277
+
278
+
279
+ def plot_3d_original_space(result, output_base, logger):
280
+ """
281
+ Generate a 3D scatter plot of the first 3 original features to illustrate
282
+ the complexity of the data before PCA transformation.
283
+ """
284
+ out_dir = os.path.dirname(output_base) or "."
285
+ base_name = os.path.splitext(os.path.basename(output_base))[0]
286
+
287
+ logger.info("Generating 3D original space plot...")
288
+
289
+ if getattr(result, 'original_features', None) is None or result.original_features.shape[1] < 3:
290
+ logger.warning("Not enough original features available for 3D plot, skipping")
291
+ return
292
+
293
+ original = result.original_features
294
+ labels = result.clusters
295
+
296
+ fig, ax = plt.subplots(figsize=(5, 6))
297
+
298
+
299
+ unique_labels = np.unique(labels)
300
+ colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
301
+
302
+ for idx, label in enumerate(unique_labels):
303
+ mask = labels == label
304
+ ax.scatter(
305
+ original[mask, 0],
306
+ original[mask, 1],
307
+ c=[colors[idx]],
308
+ # label=f'Cluster {label}',
309
+ alpha=0.7,
310
+ s=40,
311
+ edgecolors='black',
312
+ linewidth=0.5
313
+ )
314
+
315
+ ax.set_xlabel('Feature 1', fontsize=10)
316
+ ax.set_ylabel('Feature 2', fontsize=10)
317
+ # ax.set_zlabel('Feature 3', fontsize=10)
318
+ ax.set_title('Original Features (Pre-PCA)', fontsize=12, fontweight='bold')
319
+ ax.grid()
320
+ # Imposta un angolo di visualizzazione iniziale
321
+ # ax.view_init(elev=20, azim=45)
322
+
323
+ plt.tight_layout()
324
+ plot_file = os.path.join(out_dir, f"{base_name}_3d_original.png")
325
+ plt.savefig(plot_file, dpi=300, bbox_inches='tight')
326
+ plt.close()
327
+
328
+ logger.info(f"✓ 3D Original Space plot saved: {plot_file}")
329
+
330
+ def main():
331
+ args = parse_args()
332
+ logger = CLILogger(verbose=args.verbose)
333
+
334
+ print(f"\n{'='*60}")
335
+ print(f"PCA CLUSTERING ANALYSIS")
336
+ print(f"{'='*60}\n")
337
+
338
+ logger.info(f"Loading ensemble from {args.file}...")
339
+ try:
340
+ ensemble = read_ensemble(args.file, raw=True)
341
+ logger.info(f"✓ Loaded {len(ensemble)} conformers")
342
+ except TypeError:
343
+ try:
344
+ ensemble = read_ensemble(args.file, logger, raw=True)
345
+ logger.info(f"✓ Loaded {len(ensemble)} conformers")
346
+ except Exception as e:
347
+ logger.error(f"Failed to load ensemble: {e}")
348
+ exit(1)
349
+ except Exception as e:
350
+ logger.error(f"Failed to load ensemble: {e}")
351
+ exit(1)
352
+
353
+ config = ClusteringConfig(
354
+ n_clusters=args.ncluster,
355
+ include_H=args.include_H,
356
+ set_cluster_attribute=True
357
+ )
358
+
359
+ manager = ClusteringManager(logger=logger, config=config)
360
+
361
+ result = manager.perform_pca(
362
+ conformers=ensemble,
363
+ n_clusters=args.ncluster,
364
+ output_file=args.output,
365
+ title=args.title,
366
+ include_legend=args.legend
367
+ )
368
+
369
+ if result:
370
+ plot_clustering_metrics(result, args.output, logger)
371
+ plot_component_analysis(result, args.output, logger)
372
+ plot_before_after_pca(result, args.output, logger)
373
+
374
+ print(f"\n{'='*60}")
375
+ print(f"RESULTS")
376
+ print(f"{'='*60}")
377
+ print(f"✓ Clusters identified: {result.n_clusters}")
378
+ print(f"✓ Variance explained (PC1): {result.explained_variance[0]*100:.1f}%")
379
+ print(f"✓ Variance explained (PC2): {result.explained_variance[1]*100:.1f}%")
380
+ print(f"✓ Total variance (PC1+PC2): {result.explained_variance[:2].sum()*100:.1f}%")
381
+ print(f"✓ Output saved: {args.output}")
382
+
383
+ if getattr(result, 'components', None) is not None:
384
+ print(f"\n{'='*60}")
385
+ print(f"COMPONENT ANALYSIS")
386
+ print(f"{'='*60}")
387
+ for i in range(min(3, len(result.components))):
388
+ pc = result.components[i]
389
+ top_features = np.argsort(np.abs(pc))[-5:][::-1]
390
+ print(f"PC{i+1} top features: {list(top_features)} (variance: {result.explained_variance[i]*100:.1f}%)")
391
+ print(f"{'='*60}")
392
+
393
+ if args.write:
394
+ logger.info("\nReducing ensemble by clusters...")
395
+ print([i.cluster for i in ensemble])
396
+ reduced = manager.reduce_by_clusters(ensemble, True)
397
+ save_snapshot("clustered.xyz", reduced, logger)
398
+ active = len([c for c in reduced if c.active])
399
+ logger.info(f"✓ Reduced ensemble: {active} conformers → clustered.xyz")
400
+
401
+ print(f"{'='*60}\n")
402
+ else:
403
+ logger.error("PCA analysis failed or was skipped")
404
+ exit(1)
405
+
406
+ if __name__ == '__main__':
407
+ main()
@@ -4,6 +4,7 @@ import numpy as np
4
4
  from typing import List, Tuple, Optional
5
5
 
6
6
  from ensemble_analyzer._conformer.conformer import Conformer
7
+ from ensemble_analyzer._conformer.energy_data import EnergyRecord
7
8
  from ensemble_analyzer._logger.logger import Logger
8
9
 
9
10
 
@@ -20,7 +21,7 @@ def _parse_xyz_str(fl: List[str], raw: bool =False) -> Tuple[np.ndarray, np.ndar
20
21
  """
21
22
  e = None
22
23
  if raw:
23
- e = float(re.findall(r'([- ]\d*\.\d*)$', fl[1].strip())[0])
24
+ e = float(re.findall(r'([- ]\d*\.\d*)', fl[1].strip())[0])
24
25
  fl = fl[2:]
25
26
  atoms, geom = [], []
26
27
  for line in fl:
@@ -47,7 +48,7 @@ def read_ensemble(file: str, log:Logger, raw: bool=False) -> list:
47
48
  str: If the file does not end with .xyz.
48
49
  """
49
50
 
50
- confs = []
51
+ confs : List[Conformer] = []
51
52
 
52
53
  if not file.endswith(".xyz"):
53
54
  raise ValueError("Ensemble file must be an XYZ (multi)geometry file")
@@ -63,6 +64,8 @@ def read_ensemble(file: str, log:Logger, raw: bool=False) -> list:
63
64
  continue
64
65
  atoms, geom, e = _parse_xyz_str(fl[old_idx:i], raw=raw)
65
66
  confs.append(Conformer(counter, geom=geom, atoms=atoms))
67
+ if raw:
68
+ confs[-1].energies.add(protocol_number=0, record=EnergyRecord(E=e))
66
69
  old_idx = i
67
70
  counter += 1
68
71
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ensemble-analyzer
3
- Version: 1
3
+ Version: 1a0
4
4
  Summary: Automatic Refinement of Conformational Ensemble
5
5
  Author-email: Andrea Pellegrini <andrea.pellegrini15@unibo.it>, Paolo Righi <paolo.righi@unibo.it>, Andrea Mazzanti <andrea.mazzanti@unibo.it>, Michele Mancinelli <michele.mancinelli@unibo.it>
6
6
  Maintainer-email: Andrea Pellegrini <andrea.pellegrini15@unibo.it>, Paolo Righi <paolo.righi@unibo.it>
@@ -1,153 +0,0 @@
1
- import argparse
2
- from unittest.mock import Mock
3
- from ensemble_analyzer.ensemble_io import read_ensemble, save_snapshot
4
- from ensemble_analyzer.clustering import *
5
-
6
- def parse_args():
7
- parser = argparse.ArgumentParser(
8
- description='Perform PCA analysis and clustering on conformer ensemble',
9
- formatter_class=argparse.RawDescriptionHelpFormatter,
10
- epilog="""
11
- Examples:
12
- # Basic PCA with 5 clusters
13
- python clustering.py ensemble.xyz -nc 5
14
- # Auto-detect optimal clusters, exclude hydrogen
15
- python clustering.py ensemble.xyz --no-H
16
- # Save reduced ensemble
17
- python clustering.py ensemble.xyz -nc 10 --save-reduced
18
- # Custom output and title
19
- python clustering.py ensemble.xyz -o my_pca.png --title "Drug Conformers"
20
- """
21
- )
22
-
23
- parser.add_argument(
24
- 'file',
25
- help='Input ensemble file (XYZ format)'
26
- )
27
- parser.add_argument(
28
- '-nc', '--ncluster',
29
- type=int,
30
- default=None,
31
- help='Number of clusters (default: auto-detect using silhouette score)'
32
- )
33
- parser.add_argument(
34
- '--no-H',
35
- action='store_false',
36
- dest='include_H',
37
- help='Exclude hydrogen atoms from distance matrix calculation'
38
- )
39
- parser.add_argument(
40
- '--no-legend',
41
- action='store_false',
42
- dest='legend',
43
- help='Exclude conformer legend from plot'
44
- )
45
- parser.add_argument(
46
- '--title',
47
- default='PCA Cluster Analysis',
48
- help='Plot title (default: "PCA Cluster Analysis")'
49
- )
50
- parser.add_argument(
51
- '-o', '--output',
52
- default='cluster.png',
53
- help='Output image filename (default: cluster.png)'
54
- )
55
- parser.add_argument(
56
- '--save-reduced',
57
- action='store_true',
58
- help='Save cluster-reduced ensemble to clustered.xyz'
59
- )
60
- parser.add_argument(
61
- '--dpi',
62
- type=int,
63
- default=300,
64
- help='Output image DPI (default: 300)'
65
- )
66
- parser.add_argument(
67
- '-v', '--verbose',
68
- action='store_true',
69
- help='Enable verbose output'
70
- )
71
-
72
- args = parser.parse_args()
73
- return args
74
-
75
- class CLILogger:
76
- def __init__(self, verbose=False):
77
- self.verbose = verbose
78
-
79
- def info(self, msg):
80
- print(f"ℹ️ {msg}")
81
-
82
- def debug(self, msg):
83
- if self.verbose:
84
- print(f"🔍 {msg}")
85
-
86
- def warning(self, msg):
87
- print(f"⚠️ {msg}")
88
-
89
- def error(self, msg):
90
- print(f"❌ {msg}")
91
-
92
-
93
- def main():
94
- args = parse_args()
95
- logger = CLILogger(verbose=args.verbose)
96
-
97
- # Load ensemble
98
- print(f"\n{'='*60}")
99
- print(f"PCA CLUSTERING ANALYSIS")
100
- print(f"{'='*60}\n")
101
-
102
- logger.info(f"Loading ensemble from {args.file}...")
103
- try:
104
- ensemble = read_ensemble(args.file, logger, raw=True)
105
- logger.info(f"✓ Loaded {len(ensemble)} conformers")
106
- except Exception as e:
107
- logger.error(f"Failed to load ensemble: {e}")
108
- exit(1)
109
-
110
- # Perform PCA
111
- config = ClusteringConfig(
112
- n_clusters=args.ncluster,
113
- include_H=args.include_H,
114
- set_cluster_attribute=False
115
- )
116
-
117
- manager = ClusteringManager(logger=logger, config=config)
118
-
119
- result = manager.perform_pca(
120
- conformers=ensemble,
121
- n_clusters=args.ncluster,
122
- output_file=args.output,
123
- title=args.title,
124
- include_legend=args.legend
125
- )
126
-
127
- if result:
128
- print(f"\n{'='*60}")
129
- print(f"RESULTS")
130
- print(f"{'='*60}")
131
- print(f"✓ Clusters identified: {result.n_clusters}")
132
- print(f"✓ Variance explained (PC1): {result.explained_variance[0]*100:.1f}%")
133
- print(f"✓ Variance explained (PC2): {result.explained_variance[1]*100:.1f}%")
134
- print(f"✓ Total variance (PC1+PC2): {result.explained_variance[:2].sum()*100:.1f}%")
135
- print(f"✓ Output saved: {args.output}")
136
-
137
- # Save reduced ensemble if requested
138
- if args.save_reduced:
139
- logger.info("\nReducing ensemble by clusters...")
140
- reduced = manager.reduce_by_clusters(ensemble)
141
- save_snapshot("clustered.xyz", reduced, logger)
142
- active = len([c for c in reduced if c.active])
143
- logger.info(f"✓ Reduced ensemble: {active} conformers → clustered.xyz")
144
-
145
- print(f"{'='*60}\n")
146
-
147
- else:
148
- logger.error("PCA analysis failed or was skipped")
149
- exit(1)
150
-
151
-
152
- if __name__ == '__main__':
153
- main()
File without changes
File without changes
File without changes