@@ -29,8 +29,8 @@ def _generate_q_vectors(
2929 qmax : float ,
3030 dq : float ,
3131 max_q_vectors : int | None = None ,
32- ) -> tuple [np .ndarray , np . ndarray , list [np .ndarray ]]:
33- """Generate q-vectors grouped into shells by magnitude.
32+ ) -> tuple [np .ndarray , list [np .ndarray ]]:
33+ """Generate q-vectors grouped into non-empty shells by magnitude.
3434
3535 All q-vectors compatible with the simulation box that fall within the
3636 requested q-range are generated deterministically from Miller indices.
@@ -55,25 +55,22 @@ def _generate_q_vectors(
5555
5656 Returns
5757 -------
58- q_bin_centers : numpy.ndarray
59- Center of each q-shell.
6058 q_bin_values : numpy.ndarray
61- Mean |q| of vectors actually used in each shell.
59+ Mean :math:` |q|` of the vectors actually used in each non-empty shell.
6260 q_vectors_per_shell : list of numpy.ndarray
63- List of arrays, each of shape ``(n_selected, 3)``, containing the
64- q-vectors for that shell.
61+ For each non-empty shell, an ``(n_selected, 3)`` array of q-vectors.
6562
6663 """
64+ # Under PBC, only q = 2π n / L for integer Miller indices n = (h, k, l)
65+ # are allowed. Enumerate every combination whose |q| can reach qmax.
6766 q_factor = 2 * np .pi / box_lengths # (3,)
6867 max_n = np .ceil (qmax / q_factor ).astype (int )
6968
70- # Generate all Miller index combinations (excluding (0,0,0))
7169 hh = np .arange (- max_n [0 ], max_n [0 ] + 1 )
7270 kk = np .arange (- max_n [1 ], max_n [1 ] + 1 )
7371 ll = np .arange (- max_n [2 ], max_n [2 ] + 1 )
7472 miller = np .array (np .meshgrid (hh , kk , ll , indexing = "ij" )).reshape (3 , - 1 ).T
75- nonzero = np .any (miller != 0 , axis = 1 )
76- miller = miller [nonzero ]
73+ miller = miller [np .any (miller != 0 , axis = 1 )] # drop (0, 0, 0)
7774
7875 q_vecs = miller * q_factor [np .newaxis , :] # (N, 3)
7976 q_mags = np .linalg .norm (q_vecs , axis = 1 )
@@ -84,31 +81,27 @@ def _generate_q_vectors(
8481
8582 n_shells = int (np .ceil ((qmax - qmin ) / dq ))
8683 q_bin_edges = np .linspace (qmin , qmin + n_shells * dq , n_shells + 1 )
87- q_bin_centers = 0.5 * (q_bin_edges [:- 1 ] + q_bin_edges [1 :])
8884 shell_indices = np .digitize (q_mags , q_bin_edges ) - 1
8985
90- q_vectors_per_shell = []
91- q_bin_values = np .zeros (n_shells )
92-
86+ q_vectors_per_shell : list [np .ndarray ] = []
87+ q_bin_values : list [float ] = []
9388 for i_shell in range (n_shells ):
9489 in_shell = shell_indices == i_shell
95- vecs_in_shell = q_vecs [in_shell ]
96- mags_in_shell = q_mags [in_shell ]
97-
98- if len (vecs_in_shell ) == 0 :
99- q_vectors_per_shell .append (np .empty ((0 , 3 )))
90+ if not in_shell .any ():
10091 continue
92+ vecs = q_vecs [in_shell ]
93+ mags = q_mags [in_shell ]
10194
102- if max_q_vectors is not None and len (vecs_in_shell ) > max_q_vectors :
103- stride = len (vecs_in_shell ) // max_q_vectors
104- selected = np .arange (0 , len (vecs_in_shell ), stride )[:max_q_vectors ]
105- vecs_in_shell = vecs_in_shell [ selected ]
106- mags_in_shell = mags_in_shell [ selected ]
95+ if max_q_vectors is not None and len (vecs ) > max_q_vectors :
96+ stride = len (vecs ) // max_q_vectors
97+ sel = np .arange (0 , len (vecs ), stride )[:max_q_vectors ]
98+ vecs = vecs [ sel ]
99+ mags = mags [ sel ]
107100
108- q_vectors_per_shell .append (vecs_in_shell )
109- q_bin_values [ i_shell ] = np .mean (mags_in_shell )
101+ q_vectors_per_shell .append (vecs )
102+ q_bin_values . append ( float ( np .mean (mags )) )
110103
111- return q_bin_centers , q_bin_values , q_vectors_per_shell
104+ return np . asarray ( q_bin_values ) , q_vectors_per_shell
112105
113106
114107@render_docs
@@ -131,7 +124,8 @@ class Qens(AnalysisBase):
131124 memory; lag times are logarithmically spaced.
132125
133126 For each q-shell, all q-vector orientations compatible with the simulation
134- box are used and averaged (powder averaging).
127+ box are used and averaged (powder averaging). Requires an orthorhombic
128+ simulation cell.
135129
136130 Parameters
137131 ----------
@@ -156,7 +150,7 @@ class Qens(AnalysisBase):
156150 results.lag_times : numpy.ndarray
157151 Lag times in the same time unit as the trajectory.
158152 results.q_values : numpy.ndarray
159- Scattering vector magnitudes for each q-shell.
153+ Scattering vector magnitudes for each non-empty q-shell.
160154 results.F_s : numpy.ndarray
161155 Incoherent intermediate scattering function, shape ``(n_q, n_tau)``.
162156
@@ -203,28 +197,36 @@ def _prepare(self) -> None:
203197 "Analysis of the incoherent intermediate scattering function F_s(q, t)."
204198 )
205199
206- box = np .diag (mda .lib .mdamath .triclinic_vectors (self ._universe .dimensions ))
207-
208- self ._q_bin_centers , self ._q_bin_values , self ._q_vectors_per_shell = (
209- _generate_q_vectors (
210- box_lengths = box ,
211- qmin = self .qmin ,
212- qmax = self .qmax ,
213- dq = self .dq ,
214- max_q_vectors = self .max_q_vectors ,
200+ # The Miller-index q-grid in `_generate_q_vectors` assumes axis-aligned
201+ # cell vectors; a triclinic cell would need the full reciprocal basis.
202+ cell = mda .lib .mdamath .triclinic_vectors (self ._universe .dimensions )
203+ if not np .allclose (cell - np .diag (np .diag (cell )), 0 ):
204+ raise NotImplementedError (
205+ "Qens currently supports only orthorhombic simulation cells."
215206 )
207+ box = np .diag (cell )
208+
209+ self ._q_bin_values , self ._q_vectors_per_shell = _generate_q_vectors (
210+ box_lengths = box ,
211+ qmin = self .qmin ,
212+ qmax = self .qmax ,
213+ dq = self .dq ,
214+ max_q_vectors = self .max_q_vectors ,
216215 )
217216
218- # Flatten q-vectors and remember which shell each came from.
219- all_q_vecs = [qv for qv in self ._q_vectors_per_shell if len (qv ) > 0 ]
220- if all_q_vecs :
221- self ._all_q_vecs = np .vstack (all_q_vecs ) # (total_q, 3)
217+ # `_all_q_vecs` is the flat (total_q, 3) view we feed into the
218+ # correlator; `_shell_of_q[i]` records which shell row i came from so
219+ # we can powder-average per-q correlations within each shell in
220+ # `_conclude`.
221+ if self ._q_vectors_per_shell :
222+ self ._all_q_vecs = np .vstack (self ._q_vectors_per_shell )
223+ self ._shell_of_q = np .concatenate ([
224+ np .full (len (qv ), i )
225+ for i , qv in enumerate (self ._q_vectors_per_shell )
226+ ])
222227 else :
223228 self ._all_q_vecs = np .empty ((0 , 3 ))
224-
225- self ._shell_of_q = np .concatenate (
226- [np .full (len (qv ), i ) for i , qv in enumerate (self ._q_vectors_per_shell )]
227- ).astype (int ) if all_q_vecs else np .empty (0 , dtype = int )
229+ self ._shell_of_q = np .empty (0 , dtype = int )
228230
229231 logging .info (
230232 f"Streaming { len (self ._all_q_vecs )} q-vectors across "
@@ -281,36 +283,39 @@ def _single_frame(self) -> float:
281283 return 0.0
282284
283285 positions = self .atomgroup .positions # (n_atoms, 3)
284- # phases: (n_atoms, total_q)
286+ # phases[j, k] = exp(i q_k . r_j(t)). The base class correlates this
287+ # element-wise, so every (atom, q) pair gets its own autocorrelation.
285288 self ._corr .phases = np .exp (1j * (positions @ self ._all_q_vecs .T ))
286289 return 0.0
287290
288291 def _conclude (self ) -> None :
289- if not self ._correlators or "phases" not in self .correlation :
292+ if (
293+ not self ._correlators
294+ or "phases" not in self .correlation
295+ or len (self .times ) < 2
296+ ):
290297 logging .warning ("No correlation data collected." )
291298 self .results .lag_times = np .array ([])
292299 self .results .q_values = np .array ([])
293300 self .results .F_s = np .array ([])
294301 return
295302
296- # correlation.phases: (n_lags, n_atoms, total_q), complex
297- # Average over atoms (axis=1), take real part: per-q F_s(t)
298- per_q = self .correlation .phases .mean (axis = 1 ).real # (n_lags, total_q)
299-
300- # Average per-q correlations within each shell (powder average).
303+ # correlation.phases has shape (n_lags, n_atoms, total_q), complex.
304+ # Mean over atoms then real part gives the per-q-vector F_s; we then
305+ # powder-average within each shell.
306+ per_q = self .correlation .phases .mean (axis = 1 ).real
301307 n_shells = len (self ._q_vectors_per_shell )
302- n_tau = per_q .shape [0 ]
303- F_s_full = np .zeros ((n_shells , n_tau ))
308+ F_s = np .zeros ((n_shells , per_q .shape [0 ]))
304309 for i_shell in range (n_shells ):
305- mask = self ._shell_of_q == i_shell
306- if mask .any ():
307- F_s_full [i_shell ] = per_q [:, mask ].mean (axis = 1 )
310+ F_s [i_shell ] = per_q [:, self ._shell_of_q == i_shell ].mean (axis = 1 )
308311
309- active = np .array ([len (qv ) > 0 for qv in self ._q_vectors_per_shell ])
312+ # self.lags arrives from MAiCoS in units of frame intervals; convert
313+ # to physical time via the actually-sampled dt (which already reflects
314+ # any user-supplied stride).
310315 dt = float (self .times [1 ] - self .times [0 ])
311316 self .results .lag_times = self .lags * dt
312- self .results .q_values = self ._q_bin_values [ active ]
313- self .results .F_s = F_s_full [ active ]
317+ self .results .q_values = self ._q_bin_values
318+ self .results .F_s = F_s
314319
315320 @render_docs
316321 def save (self ) -> None :
0 commit comments