torchspin 0.3.0__py3-none-any.whl

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 (116) hide show
  1. torchspin/__init__.py +363 -0
  2. torchspin/_cardamom_istos.py +327 -0
  3. torchspin/_cardamom_propagatedm.py +413 -0
  4. torchspin/_cardamom_utils.py +113 -0
  5. torchspin/_compile.py +100 -0
  6. torchspin/_linalg.py +140 -0
  7. torchspin/angmom.py +742 -0
  8. torchspin/autograd.py +557 -0
  9. torchspin/autoguess.py +342 -0
  10. torchspin/batch.py +225 -0
  11. torchspin/blochsteady.py +274 -0
  12. torchspin/cardamom.py +552 -0
  13. torchspin/chili.py +932 -0
  14. torchspin/chili_sle.py +1233 -0
  15. torchspin/constants.py +88 -0
  16. torchspin/convspec.py +148 -0
  17. torchspin/ctafft.py +91 -0
  18. torchspin/curry.py +241 -0
  19. torchspin/data/FourierSeriesCoefficients.txt +89 -0
  20. torchspin/data/GaussianCascadeCoefficients.txt +56 -0
  21. torchspin/data/isotopedata.txt +416 -0
  22. torchspin/data/spacegroups.txt +637 -0
  23. torchspin/dataproc.py +361 -0
  24. torchspin/dipbackground.py +69 -0
  25. torchspin/dipkernel.py +102 -0
  26. torchspin/diptensor.py +138 -0
  27. torchspin/endorfrq.py +198 -0
  28. torchspin/endorfrq_perturb.py +302 -0
  29. torchspin/eprload.py +1538 -0
  30. torchspin/eprsave.py +165 -0
  31. torchspin/esfit.py +2427 -0
  32. torchspin/evolve.py +274 -0
  33. torchspin/ewrls.py +124 -0
  34. torchspin/excitation.py +107 -0
  35. torchspin/exciteprofile.py +132 -0
  36. torchspin/experiment.py +323 -0
  37. torchspin/exponfit.py +177 -0
  38. torchspin/fastmotion.py +182 -0
  39. torchspin/fdaxis.py +57 -0
  40. torchspin/fitgui.py +350 -0
  41. torchspin/garlic.py +760 -0
  42. torchspin/ham.py +145 -0
  43. torchspin/ham_cf.py +115 -0
  44. torchspin/ham_ee.py +124 -0
  45. torchspin/ham_ez.py +119 -0
  46. torchspin/ham_ezho.py +403 -0
  47. torchspin/ham_hf.py +122 -0
  48. torchspin/ham_nn.py +108 -0
  49. torchspin/ham_nq.py +100 -0
  50. torchspin/ham_nz.py +136 -0
  51. torchspin/ham_oz.py +115 -0
  52. torchspin/ham_so.py +118 -0
  53. torchspin/ham_zf.py +254 -0
  54. torchspin/hamsymm.py +723 -0
  55. torchspin/initstate.py +158 -0
  56. torchspin/isotopologues.py +296 -0
  57. torchspin/levels.py +189 -0
  58. torchspin/levelsplot.py +363 -0
  59. torchspin/lineshape.py +715 -0
  60. torchspin/lpsvd.py +219 -0
  61. torchspin/makespec.py +87 -0
  62. torchspin/mdhmm.py +573 -0
  63. torchspin/mdload.py +788 -0
  64. torchspin/mdtraj2oripot.py +90 -0
  65. torchspin/ml.py +377 -0
  66. torchspin/mlpsvd.py +297 -0
  67. torchspin/nucdata.py +310 -0
  68. torchspin/nucfrq2d.py +228 -0
  69. torchspin/orca2torchspin.py +1198 -0
  70. torchspin/ordering.py +78 -0
  71. torchspin/oripotentialplot.py +246 -0
  72. torchspin/orisel.py +176 -0
  73. torchspin/pepper.py +2019 -0
  74. torchspin/pepper_autograd.py +613 -0
  75. torchspin/photoselect.py +174 -0
  76. torchspin/plegendre.py +131 -0
  77. torchspin/propint.py +127 -0
  78. torchspin/pulse.py +652 -0
  79. torchspin/py.typed +0 -0
  80. torchspin/rapidscan2spc.py +101 -0
  81. torchspin/resfields.py +337 -0
  82. torchspin/resfields_batch.py +446 -0
  83. torchspin/resfields_eig.py +250 -0
  84. torchspin/resfields_perturb.py +706 -0
  85. torchspin/resfreqs_matrix.py +344 -0
  86. torchspin/resfreqs_perturb.py +299 -0
  87. torchspin/resonator.py +310 -0
  88. torchspin/resonatorprofile.py +85 -0
  89. torchspin/rfmixer.py +147 -0
  90. torchspin/rotations.py +87 -0
  91. torchspin/rotutils.py +880 -0
  92. torchspin/saffron.py +1523 -0
  93. torchspin/saffron_pathways.py +92 -0
  94. torchspin/saffron_peaks.py +580 -0
  95. torchspin/saffron_thyme.py +472 -0
  96. torchspin/salt.py +680 -0
  97. torchspin/sigeq.py +71 -0
  98. torchspin/signalprocessing.py +226 -0
  99. torchspin/sitetransforms.py +254 -0
  100. torchspin/sphgrid.py +470 -0
  101. torchspin/spidyan.py +1508 -0
  102. torchspin/spinladder.py +161 -0
  103. torchspin/spinops.py +265 -0
  104. torchspin/spinsystem.py +1427 -0
  105. torchspin/stackplot.py +167 -0
  106. torchspin/stev.py +283 -0
  107. torchspin/stochtraj_diffusion.py +494 -0
  108. torchspin/stochtraj_jump.py +195 -0
  109. torchspin/strainwidth.py +727 -0
  110. torchspin/transmitter.py +89 -0
  111. torchspin/utils.py +605 -0
  112. torchspin-0.3.0.dist-info/METADATA +267 -0
  113. torchspin-0.3.0.dist-info/RECORD +116 -0
  114. torchspin-0.3.0.dist-info/WHEEL +5 -0
  115. torchspin-0.3.0.dist-info/licenses/LICENSE.md +22 -0
  116. torchspin-0.3.0.dist-info/top_level.txt +1 -0
torchspin/mdhmm.py ADDED
@@ -0,0 +1,573 @@
1
+ """
2
+ mdhmm — Hidden Markov Model for MD dihedral trajectories.
3
+
4
+ Builds an HMM from dihedral angle trajectories using:
5
+ 1. Circular K-means clustering for initialization
6
+ 2. Baum-Welch (EM) algorithm for parameter estimation
7
+ 3. Viterbi decoding for state assignment
8
+
9
+ Based on EasySpin's mdhmm.m, mdhmm_em.m, mdhmm_kmeans.m.
10
+
11
+ All angles are in **radians**.
12
+ """
13
+
14
+ import numpy as np
15
+ from dataclasses import dataclass, field
16
+ from typing import Optional, Tuple
17
+ from scipy.optimize import minimize
18
+
19
+
20
+ @dataclass
21
+ class HMMResult:
22
+ """Hidden Markov Model result.
23
+
24
+ Attributes
25
+ ----------
26
+ TransProb : ndarray, shape (nStates, nStates)
27
+ Transition probability matrix.
28
+ eqDistr : ndarray, shape (nStates,)
29
+ Equilibrium distribution.
30
+ mu : ndarray, shape (nDihedrals, nStates)
31
+ State center vectors (dihedral angles).
32
+ Sigma : ndarray, shape (nDihedrals, nDihedrals, nStates)
33
+ Covariance matrices per state.
34
+ viterbiTraj : ndarray, shape (nSteps,) or (nTraj, nSteps)
35
+ Most-likely state sequence.
36
+ tauRelax : ndarray
37
+ Relaxation times from eigenvalues of TransProb.
38
+ tLag : float
39
+ Lag time (dt * nLag).
40
+ nStates : int
41
+ dt : float
42
+ nLag : int
43
+ """
44
+ TransProb: np.ndarray = None
45
+ eqDistr: np.ndarray = None
46
+ mu: np.ndarray = None
47
+ Sigma: np.ndarray = None
48
+ viterbiTraj: np.ndarray = None
49
+ tauRelax: np.ndarray = None
50
+ tLag: float = 0.0
51
+ nStates: int = 0
52
+ dt: float = 0.0
53
+ nLag: int = 0
54
+
55
+
56
+ @dataclass
57
+ class HMMOptions:
58
+ """Options for mdhmm."""
59
+ Verbosity: int = 0
60
+ isSeeded: bool = False
61
+ nTrials: int = 5
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Circular distance helpers
66
+ # ---------------------------------------------------------------------------
67
+
68
+ def _dist_pbc(x1, x2, W=2 * np.pi):
69
+ """Circular distance between angles."""
70
+ d = np.abs(x1 - x2)
71
+ return np.minimum(d, W - d)
72
+
73
+
74
+ def _mean_pbc(angles, axis=None, W=2 * np.pi):
75
+ """Circular mean of angles using unwrapping."""
76
+ if len(angles) == 0:
77
+ return 0.0
78
+ ref = angles.flat[0] if axis is None else np.take(angles, 0, axis=axis)
79
+ # Unwrap relative to reference
80
+ diff = angles - ref
81
+ diff = (diff + np.pi) % (2 * np.pi) - np.pi
82
+ mean_diff = np.mean(diff, axis=axis)
83
+ return (ref + mean_diff + np.pi) % (2 * np.pi) - np.pi
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Circular K-means (mdhmm_kmeans.m)
88
+ # ---------------------------------------------------------------------------
89
+
90
+ def _circular_kmeans(data, n_clusters, n_repeats=5, centroids0=None, seed=None):
91
+ """K-means clustering with circular distance metric.
92
+
93
+ Parameters
94
+ ----------
95
+ data : ndarray, shape (nDihedrals, nPoints)
96
+ Dihedral angle data.
97
+ n_clusters : int
98
+ n_repeats : int
99
+ centroids0 : ndarray, shape (nDihedrals, n_clusters), optional
100
+ seed : int, optional
101
+
102
+ Returns
103
+ -------
104
+ idx : ndarray, shape (nPoints,)
105
+ Cluster assignments.
106
+ centroids : ndarray, shape (nDihedrals, n_clusters)
107
+ """
108
+ n_dim, n_points = data.shape
109
+ rng = np.random.default_rng(seed)
110
+
111
+ best_cost = np.inf
112
+ best_idx = None
113
+ best_centroids = None
114
+
115
+ for trial in range(n_repeats):
116
+ # Initialize centroids
117
+ if centroids0 is not None and trial == 0:
118
+ centroids = centroids0.copy()
119
+ else:
120
+ # K-means++ initialization with circular distance
121
+ centroids = np.zeros((n_dim, n_clusters))
122
+ # First centroid: random
123
+ idx0 = rng.integers(0, n_points)
124
+ centroids[:, 0] = data[:, idx0]
125
+
126
+ for k in range(1, n_clusters):
127
+ # Distance to nearest centroid
128
+ dists = np.full(n_points, np.inf)
129
+ for j in range(k):
130
+ d = np.sum(_dist_pbc(data, centroids[:, j:j + 1]) ** 2, axis=0)
131
+ dists = np.minimum(dists, d)
132
+ # Probability proportional to distance²
133
+ probs = dists / dists.sum()
134
+ idx_k = rng.choice(n_points, p=probs)
135
+ centroids[:, k] = data[:, idx_k]
136
+
137
+ # Iterate
138
+ max_iter = 500
139
+ for iteration in range(max_iter):
140
+ # Assignment step
141
+ dists = np.zeros((n_clusters, n_points))
142
+ for k in range(n_clusters):
143
+ dists[k] = np.sum(_dist_pbc(data, centroids[:, k:k + 1]) ** 2, axis=0)
144
+ idx = np.argmin(dists, axis=0)
145
+
146
+ # Update step
147
+ new_centroids = np.zeros_like(centroids)
148
+ for k in range(n_clusters):
149
+ members = data[:, idx == k]
150
+ if members.shape[1] == 0:
151
+ new_centroids[:, k] = data[:, rng.integers(0, n_points)]
152
+ else:
153
+ for d in range(n_dim):
154
+ new_centroids[d, k] = _mean_pbc(members[d])
155
+
156
+ # Check convergence
157
+ centroid_change = np.max(_dist_pbc(new_centroids, centroids))
158
+ centroids = new_centroids
159
+ if centroid_change < 1e-4:
160
+ break
161
+
162
+ # Cost
163
+ cost = 0
164
+ for k in range(n_clusters):
165
+ members = data[:, idx == k]
166
+ cost += np.sum(_dist_pbc(members, centroids[:, k:k + 1]) ** 2)
167
+
168
+ if cost < best_cost:
169
+ best_cost = cost
170
+ best_idx = idx
171
+ best_centroids = centroids
172
+
173
+ return best_idx, best_centroids
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Gaussian probability (circular)
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _gaussian_prob_circular(data, mu, sigma):
181
+ """Multivariate Gaussian probability for circular data.
182
+
183
+ Parameters
184
+ ----------
185
+ data : ndarray, shape (nDim, nPoints)
186
+ mu : ndarray, shape (nDim,)
187
+ sigma : ndarray, shape (nDim, nDim)
188
+
189
+ Returns
190
+ -------
191
+ prob : ndarray, shape (nPoints,)
192
+ """
193
+ n_dim = data.shape[0]
194
+ diff = _dist_pbc(data, mu[:, np.newaxis])
195
+ # Use signed difference for proper Gaussian
196
+ diff_signed = data - mu[:, np.newaxis]
197
+ diff_signed = (diff_signed + np.pi) % (2 * np.pi) - np.pi
198
+
199
+ try:
200
+ sigma_inv = np.linalg.inv(sigma)
201
+ det_sigma = np.linalg.det(sigma)
202
+ except np.linalg.LinAlgError:
203
+ sigma_reg = sigma + 1e-6 * np.eye(n_dim)
204
+ sigma_inv = np.linalg.inv(sigma_reg)
205
+ det_sigma = np.linalg.det(sigma_reg)
206
+
207
+ det_sigma = max(det_sigma, 1e-300)
208
+
209
+ exponent = -0.5 * np.sum(diff_signed * (sigma_inv @ diff_signed), axis=0)
210
+ norm = 1.0 / ((2 * np.pi) ** (n_dim / 2) * np.sqrt(det_sigma))
211
+ prob = norm * np.exp(exponent)
212
+
213
+ return np.maximum(prob, 1e-300)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Forward-backward algorithm
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def _forward_backward(obs_prob, trans_prob, init_distr):
221
+ """Forward-backward algorithm for HMM.
222
+
223
+ Parameters
224
+ ----------
225
+ obs_prob : ndarray, shape (nStates, nSteps)
226
+ Observation probabilities B(state, time).
227
+ trans_prob : ndarray, shape (nStates, nStates)
228
+ Transition matrix A(i,j) = P(state_j at t+1 | state_i at t).
229
+ init_distr : ndarray, shape (nStates,)
230
+
231
+ Returns
232
+ -------
233
+ gamma : ndarray, shape (nStates, nSteps)
234
+ State occupancy probabilities.
235
+ xi : ndarray, shape (nStates, nStates, nSteps-1)
236
+ Transition counts.
237
+ log_lik : float
238
+ Log-likelihood.
239
+ """
240
+ n_states, n_steps = obs_prob.shape
241
+
242
+ # Forward pass with scaling
243
+ alpha = np.zeros((n_states, n_steps))
244
+ scale = np.zeros(n_steps)
245
+
246
+ alpha[:, 0] = init_distr * obs_prob[:, 0]
247
+ scale[0] = np.sum(alpha[:, 0])
248
+ if scale[0] > 0:
249
+ alpha[:, 0] /= scale[0]
250
+
251
+ for t in range(1, n_steps):
252
+ alpha[:, t] = (trans_prob.T @ alpha[:, t - 1]) * obs_prob[:, t]
253
+ scale[t] = np.sum(alpha[:, t])
254
+ if scale[t] > 0:
255
+ alpha[:, t] /= scale[t]
256
+
257
+ # Log-likelihood
258
+ log_lik = np.sum(np.log(np.maximum(scale, 1e-300)))
259
+
260
+ # Backward pass
261
+ beta = np.zeros((n_states, n_steps))
262
+ beta[:, -1] = 1.0
263
+
264
+ for t in range(n_steps - 2, -1, -1):
265
+ beta[:, t] = trans_prob @ (obs_prob[:, t + 1] * beta[:, t + 1])
266
+ if scale[t + 1] > 0:
267
+ beta[:, t] /= scale[t + 1]
268
+
269
+ # Gamma and Xi
270
+ gamma = alpha * beta
271
+ gamma_sum = np.sum(gamma, axis=0, keepdims=True)
272
+ gamma = gamma / np.maximum(gamma_sum, 1e-300)
273
+
274
+ xi = np.zeros((n_states, n_states, n_steps - 1))
275
+ for t in range(n_steps - 1):
276
+ xi_t = (alpha[:, t:t + 1] * trans_prob) * (obs_prob[:, t + 1] * beta[:, t + 1])
277
+ xi_sum = np.sum(xi_t)
278
+ if xi_sum > 0:
279
+ xi[:, :, t] = xi_t / xi_sum
280
+
281
+ return gamma, xi, log_lik
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Viterbi algorithm
286
+ # ---------------------------------------------------------------------------
287
+
288
+ def _viterbi(obs_prob, trans_prob, init_distr):
289
+ """Viterbi algorithm for most-likely state sequence.
290
+
291
+ Parameters
292
+ ----------
293
+ obs_prob : ndarray, shape (nStates, nSteps)
294
+ trans_prob : ndarray, shape (nStates, nStates)
295
+ init_distr : ndarray, shape (nStates,)
296
+
297
+ Returns
298
+ -------
299
+ path : ndarray, shape (nSteps,)
300
+ Most-likely state indices.
301
+ """
302
+ n_states, n_steps = obs_prob.shape
303
+
304
+ log_trans = np.log(np.maximum(trans_prob, 1e-300))
305
+ log_obs = np.log(np.maximum(obs_prob, 1e-300))
306
+ log_init = np.log(np.maximum(init_distr, 1e-300))
307
+
308
+ # Viterbi forward
309
+ V = np.zeros((n_states, n_steps))
310
+ backptr = np.zeros((n_states, n_steps), dtype=int)
311
+
312
+ V[:, 0] = log_init + log_obs[:, 0]
313
+
314
+ for t in range(1, n_steps):
315
+ for j in range(n_states):
316
+ scores = V[:, t - 1] + log_trans[:, j]
317
+ backptr[j, t] = np.argmax(scores)
318
+ V[j, t] = scores[backptr[j, t]] + log_obs[j, t]
319
+
320
+ # Backtrack
321
+ path = np.zeros(n_steps, dtype=int)
322
+ path[-1] = np.argmax(V[:, -1])
323
+ for t in range(n_steps - 2, -1, -1):
324
+ path[t] = backptr[path[t + 1], t + 1]
325
+
326
+ return path
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Transition matrix estimation via L-BFGS-B
331
+ # ---------------------------------------------------------------------------
332
+
333
+ def _estimate_transition_matrix(xi, gamma):
334
+ """Estimate transition matrix from EM sufficient statistics.
335
+
336
+ Parameters
337
+ ----------
338
+ xi : ndarray, shape (nStates, nStates)
339
+ Sum of transition counts.
340
+ gamma : ndarray, shape (nStates,)
341
+ Sum of state occupancies.
342
+
343
+ Returns
344
+ -------
345
+ T : ndarray, shape (nStates, nStates)
346
+ Row-stochastic transition matrix.
347
+ """
348
+ n = xi.shape[0]
349
+ T = xi.copy()
350
+
351
+ # Normalize rows
352
+ row_sums = T.sum(axis=1, keepdims=True)
353
+ row_sums = np.maximum(row_sums, 1e-300)
354
+ T = T / row_sums
355
+
356
+ return T
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Baum-Welch (EM) algorithm (mdhmm_em.m)
361
+ # ---------------------------------------------------------------------------
362
+
363
+ def _baum_welch(data, n_states, init_distr, trans_prob, mu, sigma,
364
+ max_iter=200, tol=1e-4):
365
+ """Baum-Welch algorithm for HMM parameter estimation.
366
+
367
+ Parameters
368
+ ----------
369
+ data : ndarray, shape (nDim, nSteps)
370
+ n_states : int
371
+ init_distr : ndarray, shape (nStates,)
372
+ trans_prob : ndarray, shape (nStates, nStates)
373
+ mu : ndarray, shape (nDim, nStates)
374
+ sigma : ndarray, shape (nDim, nDim, nStates)
375
+ max_iter : int
376
+ tol : float
377
+
378
+ Returns
379
+ -------
380
+ log_lik_history : list
381
+ eq_distr : ndarray
382
+ trans_prob : ndarray
383
+ mu : ndarray
384
+ sigma : ndarray
385
+ """
386
+ n_dim, n_steps = data.shape
387
+ log_lik_history = []
388
+ prev_log_lik = -np.inf
389
+
390
+ for iteration in range(max_iter):
391
+ # E-step: compute observation probabilities
392
+ obs_prob = np.zeros((n_states, n_steps))
393
+ for k in range(n_states):
394
+ obs_prob[k] = _gaussian_prob_circular(data, mu[:, k], sigma[:, :, k])
395
+
396
+ # Forward-backward
397
+ gamma, xi, log_lik = _forward_backward(obs_prob, trans_prob, init_distr)
398
+ log_lik_history.append(log_lik)
399
+
400
+ # Check convergence
401
+ if iteration > 0:
402
+ rel_change = abs(log_lik - prev_log_lik) / max(abs(prev_log_lik), 1)
403
+ if rel_change < tol:
404
+ break
405
+ prev_log_lik = log_lik
406
+
407
+ # M-step: update parameters
408
+ # Transition matrix
409
+ xi_sum = np.sum(xi, axis=2)
410
+ trans_prob = _estimate_transition_matrix(xi_sum, np.sum(gamma[:, :-1], axis=1))
411
+
412
+ # State means (circular)
413
+ for k in range(n_states):
414
+ weights = gamma[k]
415
+ total_weight = np.sum(weights)
416
+ if total_weight > 1e-10:
417
+ for d in range(n_dim):
418
+ # Weighted circular mean
419
+ diff = data[d] - mu[d, k]
420
+ diff = (diff + np.pi) % (2 * np.pi) - np.pi
421
+ mu[d, k] = mu[d, k] + np.sum(weights * diff) / total_weight
422
+ mu[d, k] = (mu[d, k] + np.pi) % (2 * np.pi) - np.pi
423
+
424
+ # Covariance matrices
425
+ for k in range(n_states):
426
+ weights = gamma[k]
427
+ total_weight = np.sum(weights)
428
+ if total_weight > 1e-10:
429
+ diff = data - mu[:, k:k + 1]
430
+ diff = (diff + np.pi) % (2 * np.pi) - np.pi
431
+ sigma[:, :, k] = (diff * weights) @ diff.T / total_weight
432
+ # Regularization
433
+ sigma[:, :, k] += 1e-4 * np.eye(n_dim)
434
+
435
+ # Update initial distribution
436
+ init_distr = gamma[:, 0]
437
+ init_distr = init_distr / np.sum(init_distr)
438
+
439
+ # Equilibrium distribution from eigenvector of TransProb
440
+ eigenvalues, eigenvectors = np.linalg.eig(trans_prob.T)
441
+ idx = np.argmin(np.abs(eigenvalues - 1.0))
442
+ eq_distr = np.real(eigenvectors[:, idx])
443
+ eq_distr = np.abs(eq_distr)
444
+ eq_distr = eq_distr / np.sum(eq_distr)
445
+
446
+ return log_lik_history, eq_distr, trans_prob, mu, sigma
447
+
448
+
449
+ # ---------------------------------------------------------------------------
450
+ # Main entry point
451
+ # ---------------------------------------------------------------------------
452
+
453
+ def mdhmm(
454
+ dihedrals: np.ndarray,
455
+ dt: float,
456
+ n_states: int,
457
+ n_lag: int,
458
+ opt: Optional[HMMOptions] = None,
459
+ ) -> HMMResult:
460
+ """Build Hidden Markov Model from dihedral angle trajectories.
461
+
462
+ Parameters
463
+ ----------
464
+ dihedrals : ndarray, shape (nDihedrals, nSteps) or (nDihedrals, nTraj, nSteps)
465
+ Spin-label dihedral angles in radians.
466
+ dt : float
467
+ Dihedral trajectory time step.
468
+ n_states : int
469
+ Number of HMM states.
470
+ n_lag : int
471
+ Lag step (number of time steps for downsampling).
472
+ opt : HMMOptions, optional
473
+
474
+ Returns
475
+ -------
476
+ hmm : HMMResult
477
+ """
478
+ if opt is None:
479
+ opt = HMMOptions()
480
+
481
+ # Handle multi-trajectory input
482
+ if dihedrals.ndim == 3:
483
+ n_dim, n_traj, n_steps_per_traj = dihedrals.shape
484
+ # Concatenate trajectories for clustering and EM
485
+ data = dihedrals.reshape(n_dim, -1)
486
+ else:
487
+ n_dim, total_steps = dihedrals.shape
488
+ n_traj = 1
489
+ data = dihedrals
490
+
491
+ # Downsample by lag
492
+ if n_lag > 1:
493
+ data = data[:, ::n_lag]
494
+
495
+ n_steps = data.shape[1]
496
+
497
+ # Step 1: K-means clustering
498
+ idx, centroids = _circular_kmeans(
499
+ data, n_states, n_repeats=opt.nTrials, seed=42
500
+ )
501
+
502
+ # Initialize HMM parameters from k-means
503
+ mu = centroids.copy() # (nDim, nStates)
504
+ sigma = np.zeros((n_dim, n_dim, n_states))
505
+ init_distr = np.zeros(n_states)
506
+
507
+ for k in range(n_states):
508
+ members = data[:, idx == k]
509
+ n_members = members.shape[1]
510
+ init_distr[k] = n_members / n_steps
511
+
512
+ if n_members > 1:
513
+ diff = members - mu[:, k:k + 1]
514
+ diff = (diff + np.pi) % (2 * np.pi) - np.pi
515
+ sigma[:, :, k] = (diff @ diff.T) / n_members + 1e-4 * np.eye(n_dim)
516
+ else:
517
+ sigma[:, :, k] = 0.1 * np.eye(n_dim)
518
+
519
+ # Initialize transition matrix
520
+ trans_prob = np.zeros((n_states, n_states))
521
+ for t in range(n_steps - 1):
522
+ trans_prob[idx[t], idx[t + 1]] += 1
523
+ row_sums = trans_prob.sum(axis=1, keepdims=True)
524
+ trans_prob = trans_prob / np.maximum(row_sums, 1)
525
+
526
+ # Step 2: Baum-Welch
527
+ log_lik_history, eq_distr, trans_prob, mu, sigma = _baum_welch(
528
+ data, n_states, init_distr, trans_prob, mu, sigma
529
+ )
530
+
531
+ # Step 3: Viterbi decoding
532
+ obs_prob = np.zeros((n_states, n_steps))
533
+ for k in range(n_states):
534
+ obs_prob[k] = _gaussian_prob_circular(data, mu[:, k], sigma[:, :, k])
535
+
536
+ viterbi_traj = _viterbi(obs_prob, trans_prob, eq_distr)
537
+
538
+ # Eliminate unvisited states
539
+ visited = np.unique(viterbi_traj)
540
+ if len(visited) < n_states:
541
+ # Remap
542
+ state_map = {old: new for new, old in enumerate(visited)}
543
+ viterbi_traj = np.array([state_map[s] for s in viterbi_traj])
544
+ n_states_new = len(visited)
545
+ mu = mu[:, visited]
546
+ sigma = sigma[:, :, visited]
547
+ trans_prob = trans_prob[np.ix_(visited, visited)]
548
+ eq_distr = eq_distr[visited]
549
+ eq_distr /= eq_distr.sum()
550
+ n_states = n_states_new
551
+
552
+ # Relaxation times
553
+ eigenvalues = np.sort(np.real(np.linalg.eigvals(trans_prob)))[::-1]
554
+ # Skip the eigenvalue = 1 (equilibrium)
555
+ tau_relax = np.zeros(len(eigenvalues) - 1)
556
+ for i in range(1, len(eigenvalues)):
557
+ if eigenvalues[i] > 0 and eigenvalues[i] < 1:
558
+ tau_relax[i - 1] = -n_lag * dt / np.log(eigenvalues[i])
559
+
560
+ # Build result
561
+ result = HMMResult()
562
+ result.TransProb = trans_prob
563
+ result.eqDistr = eq_distr
564
+ result.mu = mu
565
+ result.Sigma = sigma
566
+ result.viterbiTraj = viterbi_traj
567
+ result.tauRelax = tau_relax
568
+ result.tLag = n_lag * dt
569
+ result.nStates = n_states
570
+ result.dt = dt
571
+ result.nLag = n_lag
572
+
573
+ return result