Skip to content

Commit 3ed2698

Browse files
committed
Rewrite some stuff
1 parent 4bb4f65 commit 3ed2698

1 file changed

Lines changed: 66 additions & 61 deletions

File tree

src/scatterkit/qens.py

Lines changed: 66 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)