mvpure-py 2.3.2__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.
mvpure_py/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ._version import __version__
2
+
3
+ __all__ = ["__version__"]
mvpure_py/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '2.3.2'
22
+ __version_tuple__ = version_tuple = (2, 3, 2)
23
+
24
+ __commit_id__ = commit_id = 'g8b490f09b'
@@ -0,0 +1,21 @@
1
+ """
2
+ References
3
+ ----------
4
+ .. [1] Jurkowska, J., Dreszer, J., Lewandowska, M., Tolpa, K., & Piotrowski, T. J. (2025).
5
+ Multi-Source Neural Activity Indices and Spatial Filters for EEG/MEG Inverse Problem: An Extension to MNE-Python. bioRxiv, 2025-09.
6
+ """
7
+ from .mvpure_localizer import (
8
+ localize,
9
+ Localized,
10
+ read_localized
11
+ )
12
+ from .localizer_utils import (
13
+ get_activity_index,
14
+ )
15
+
16
+ from ._utils import (
17
+ _check_localize_params,
18
+ _prepare_localize_params,
19
+ _check_vertices_and_indices,
20
+ _check_n_sources_param
21
+ )
@@ -0,0 +1,178 @@
1
+ from ..utils import _check_hemi_param
2
+
3
+ import mne
4
+ import numpy as np
5
+ from termcolor import colored
6
+ import logging
7
+ logging.basicConfig(
8
+ level=logging.WARNING,
9
+ format='[%(levelname)s] %(message)s'
10
+ )
11
+
12
+
13
+ def _check_localize_params(
14
+ forward: mne.Forward,
15
+ leadfield: np.array) -> None:
16
+ """
17
+ Checking user input for mvpure_py.localizer.localize() function.
18
+ """
19
+ if forward is None and leadfield is None:
20
+ raise ValueError("One of the parameters 'forward' and 'leadfield' should not be None")
21
+
22
+ if forward is not None and leadfield is not None:
23
+ if np.array_equal(forward['sol']['data'], leadfield):
24
+ raise ValueError("Both parameters 'forward' and 'leadfield' were specified. "
25
+ "They should be equal, but they are not.")
26
+
27
+
28
+ def _prepare_localize_params(
29
+ forward: mne.Forward,
30
+ leadfield: np.array) -> np.array:
31
+ """
32
+ Adjusting input parameters in mvpure_py.localizer.localize() function.
33
+ Handling forward/leadfield options.
34
+ """
35
+ if forward is not None:
36
+ leadfield = forward["sol"]["data"]
37
+
38
+ return leadfield
39
+
40
+
41
+ def _check_n_sources_param(n_sources: int) -> None:
42
+ """
43
+ Checks type of 'n_sources_to_localize' from mvpure_py.localizer.localize() function.
44
+ Should be an integer.
45
+ """
46
+ if not isinstance(n_sources, (int, np.integer)):
47
+ raise TypeError(f"Parameter 'n_sources_to_localize' should be an integer. Got type: {type(n_sources)} instead.")
48
+
49
+
50
+ def _check_rank(
51
+ r: int | str | float | list,
52
+ n_sources_to_localize: int):
53
+ """
54
+ Checking correctness of rank optimization ('r') parameter.
55
+ """
56
+ if not isinstance(r, (int, np.integer, str, float, np.floating, list)):
57
+ raise ValueError(f"Optimization parameter 'r' (set to: {r}) should be of type: int, float, string or list. "
58
+ f"Got {type(r)} instead.")
59
+
60
+ # if 'r' is a list, it should contain integers / floats
61
+ # and all elements should be equal or less to n_sources_to_localize,
62
+ if isinstance(r, list):
63
+ if not all(
64
+ isinstance(x, (int, np.integer, float, np.floating)) and
65
+ n_sources_to_localize >= x >= 1
66
+ for x in r
67
+ ):
68
+ raise ValueError("If `r` is a list, all elements should be int or float"
69
+ " and equal or less than number of sources to localize.")
70
+
71
+ # 'r' should be less or equal to number of sources to localize
72
+ if isinstance(r, (int, np.integer)) and r > n_sources_to_localize:
73
+ raise ValueError(f"Optimization parameter 'r' (set to {r}) should be equal or less than"
74
+ f" number of sources to localize ('n_sources_to_localize' set to {n_sources_to_localize}).")
75
+
76
+ # only option for 'r' as string is 'full'
77
+ if isinstance(r, str) and r not in ["full"]:
78
+ raise ValueError(f"If parameter 'r' is a string it should be set to 'full'. "
79
+ f"Got {r} instead.")
80
+
81
+ # if 'r' == 0, use full rank
82
+ if isinstance(r, (int, np.integer)) and r == 0:
83
+ return "full"
84
+
85
+ # if 'r' is smaller than zero, take n_sources_to_localize - abs(r)
86
+ if isinstance(r, (int, np.integer)) and r < 0 and (n_sources_to_localize - abs(r)) > 0:
87
+ adjusted_value = n_sources_to_localize - abs(r)
88
+ logging.warning(
89
+ f"Got rank lower than zero. Taking n_sources_to_localize - {abs(r)} = {adjusted_value} instead."
90
+ )
91
+ return adjusted_value
92
+
93
+ # if 'r' is smaller than zero and n_sources_to_localize - abs(r) == 0, use full rank
94
+ if isinstance(r, (int, np.integer)) and r < 0 and (n_sources_to_localize - abs(r)) == 0:
95
+ return "full"
96
+
97
+ # if 'r' is smaller than zero and n_sources_to_localize - abs(r) == 0, raise error
98
+ if isinstance(r, (int, np.integer)) and r < 0 and (n_sources_to_localize - abs(r)) < 0:
99
+ raise ValueError(f"Optimization parameter 'r' (set to {r}) should be equal or higher than 0.")
100
+
101
+ # if using float and float can not be converted to integer - raise error
102
+ if isinstance(r, (float, np.floating)) and int(r) != r:
103
+ raise TypeError(f"Parameter 'r' should be type an integer (if numeric). Got float instead.")
104
+
105
+
106
+ def _prepare_localizer_to_use(localizer_to_use: str | list[str]):
107
+ """
108
+ Checking and adjusting 'localizer_to_use' parameter from mvpure_py.localizer.localize() function.
109
+ """
110
+ # list of all localizers options
111
+ all_localizers = ['mai', 'mai_mvp']
112
+ # if localizer_to_use is a string
113
+ if isinstance(localizer_to_use, str):
114
+ # if localizer_to_use == 'all' use all localizers
115
+ if localizer_to_use == 'all':
116
+ param_localizer_to_use = all_localizers
117
+ # can be single name of localizers
118
+ elif localizer_to_use in all_localizers:
119
+ param_localizer_to_use = [localizer_to_use]
120
+ # otherwise raise ValueError
121
+ else:
122
+ raise ValueError(f"If 'localizer_to_use' is a string it should be either 'all' or "
123
+ f"name of one of the localizers to use. Possible options are: {all_localizers}. "
124
+ f"Got '{localizer_to_use}' instead.")
125
+ elif isinstance(localizer_to_use, list):
126
+ param_localizer_to_use = [loc for loc in localizer_to_use if loc in all_localizers]
127
+ if len(param_localizer_to_use) < len(localizer_to_use):
128
+ print(colored(
129
+ f"WARNING: One or more of localizers' names are not valid."
130
+ f" Possible options are: {all_localizers}, got {localizer_to_use}.",
131
+ "yellow"
132
+ ))
133
+ if len(param_localizer_to_use) == 0:
134
+ raise ValueError(f"None of the localizers' name are valid."
135
+ f" Possible options are: {all_localizers}, got {localizer_to_use}")
136
+ else:
137
+ raise ValueError(f"'localizer_to_use' should be either list of valid localizers' names "
138
+ f"or string with single localizer name")
139
+ return param_localizer_to_use
140
+
141
+
142
+ def _define_hemi_based_on_vertices(localized) -> str:
143
+ """
144
+ Define hemi (`both`, `rh`, `lh`) based on vertices stored in `localized` object.
145
+ """
146
+ if hasattr(localized['stc'], 'lh_vertno') and hasattr(localized['stc'], 'rh_vertno'):
147
+ hemi = "both"
148
+ elif hasattr(localized['stc'], 'lh_vertno'):
149
+ hemi = "lh"
150
+ elif hasattr(localized['stc'], 'rh_vertno'):
151
+ hemi = "rh"
152
+
153
+ return hemi
154
+
155
+
156
+ def _check_norm_param(norm):
157
+ if norm not in ["max", "sum"]:
158
+ raise ValueError(f"'norm' can be equal to one of the following: "
159
+ f" 'max' (max normalization), 'sum' (L1 normalization. "
160
+ f"Got {norm} instead.")
161
+
162
+
163
+ def _check_vertices_and_indices(lh_vertices: list, lh_indices: list, rh_vertices: list, rh_indices: list):
164
+ if (lh_indices is not None and lh_vertices is None) or (lh_indices is None and lh_vertices is not None):
165
+ raise ValueError(f"Both 'lh_vertices' and 'lh_indices' should be specified. "
166
+ f"Got lh_vertices={lh_vertices} and lh_indices={lh_indices}.")
167
+
168
+ if (rh_indices is not None and rh_vertices is None) or (rh_indices is None and rh_vertices is not None):
169
+ raise ValueError(f"Both 'rh_vertices' and 'rh_indices' should be specified. "
170
+ f"Got rh_vertices={rh_vertices} and rh_indices={rh_indices}.")
171
+
172
+ if lh_vertices is not None and lh_indices is not None and len(lh_vertices) != len(lh_indices):
173
+ raise ValueError(f"Can not add vertices to Localized object because length of vertices list ({len(lh_vertices)})"
174
+ f"does not match length of indices list ({len(lh_indices)}).")
175
+
176
+ if rh_vertices is not None and rh_indices is not None and len(rh_vertices) != len(rh_indices):
177
+ raise ValueError(f"Can not add vertices to Localized object because length of vertices list ({len(rh_vertices)})"
178
+ f"does not match length of indices list ({len(rh_indices)}).")
@@ -0,0 +1,345 @@
1
+ """ Functions used during sources localization. """
2
+
3
+ # Authors: Julia Jurkowska, Tomasz Piotrowski
4
+
5
+ import os
6
+ from typing import List, Optional, Tuple
7
+
8
+ import numpy as np
9
+ from joblib import Parallel, delayed
10
+ from scipy.linalg import cho_factor, cho_solve, pinv
11
+ from tqdm import tqdm
12
+
13
+ # Number of threads used by BLAS/LAPACK - ADJUSTABLE
14
+ os.environ["OMP_NUM_THREADS"] = "10"
15
+
16
+
17
+ # ============================================================
18
+ # -------- PSD low-rank pseudo-inverse (apply-only) ----------
19
+ # ============================================================
20
+
21
+ class PSDPinvApply:
22
+ """
23
+ Robust pseudo-inverse operator for PSD (possibly low-rank) matrix A:
24
+ A^+ ≈ U diag(1/lam) U^T (truncated eigenpairs)
25
+
26
+ Use: op.apply(X) = A^+ @ X
27
+ """
28
+ __slots__ = ("U", "lam", "cutoff")
29
+
30
+ def __init__(self, U: np.ndarray, lam: np.ndarray, cutoff: float):
31
+ self.U = U
32
+ self.lam = lam
33
+ self.cutoff = cutoff
34
+
35
+ def apply(self, X: np.ndarray) -> np.ndarray:
36
+ if self.U.size == 0:
37
+ return np.zeros_like(X)
38
+ UtX = self.U.T @ X
39
+ return self.U @ (UtX / self.lam[:, None])
40
+
41
+
42
+ def psd_pinv_operator(A: np.ndarray,
43
+ rmax: Optional[int] = None,
44
+ eps_rel: float = 1e-10) -> PSDPinvApply:
45
+ """
46
+ Build a robust pseudo-inverse *operator* for PSD A (low-rank supported).
47
+
48
+ Parameters
49
+ ----------
50
+ A : (m,m) PSD matrix (may be singular)
51
+ rmax : Optional[int]
52
+ Keep at most rmax eigenpairs (largest).
53
+ eps_rel : float
54
+ Keep eigenvalues > eps_rel * max_eig.
55
+
56
+ Returns
57
+ -------
58
+ PSDPinvApply
59
+ .apply(X) computes A^+ @ X
60
+ """
61
+ As = 0.5 * (A + A.T) # enforce symmetry numerically
62
+ lam_all, U_all = np.linalg.eigh(As) # ascending
63
+ lam_max = lam_all[-1] if lam_all.size else 0.0
64
+
65
+ if lam_max <= 0:
66
+ return PSDPinvApply(
67
+ U=np.zeros((A.shape[0], 0), dtype=A.dtype),
68
+ lam=np.zeros((0,), dtype=A.dtype),
69
+ cutoff=0.0
70
+ )
71
+
72
+ cutoff = eps_rel * float(lam_max)
73
+ keep = lam_all > cutoff
74
+
75
+ if rmax is not None and int(np.sum(keep)) > int(rmax):
76
+ idx = np.argsort(lam_all)[-int(rmax):]
77
+ keep2 = np.zeros_like(keep, dtype=bool)
78
+ keep2[idx] = True
79
+ keep = keep & keep2
80
+
81
+ lam = lam_all[keep].astype(A.dtype, copy=False)
82
+ U = U_all[:, keep].astype(A.dtype, copy=False)
83
+
84
+ return PSDPinvApply(U=U, lam=lam, cutoff=cutoff)
85
+
86
+
87
+ # ============================================================
88
+ # ---------------- Helper: build batch blocks ----------------
89
+ # ============================================================
90
+
91
+ def _build_blocks_batch(H_sel: np.ndarray,
92
+ M_sel: Optional[np.ndarray],
93
+ A_M_batch: np.ndarray,
94
+ H_batch: np.ndarray) -> np.ndarray:
95
+ """
96
+ Construct block matrices for a batch of candidate sources.
97
+ """
98
+ k = 0 if M_sel is None else M_sel.shape[0]
99
+ B = A_M_batch.shape[1]
100
+ m = k + 1
101
+ dtype = A_M_batch.dtype
102
+
103
+ if k == 0:
104
+ s_batch = np.sum(A_M_batch * H_batch, axis=0)
105
+ return s_batch.reshape(B, 1, 1).astype(dtype, copy=False)
106
+
107
+ a_batch = H_sel[:, :k].T @ A_M_batch
108
+ s_batch = np.sum(A_M_batch * H_batch, axis=0)
109
+
110
+ blocks = np.empty((B, m, m), dtype=dtype)
111
+ blocks[:, :k, :k] = M_sel[None, :, :]
112
+ blocks[:, :k, k:k+1] = a_batch.T.reshape(B, k, 1)
113
+ blocks[:, k:k+1, :k] = a_batch.T.reshape(B, 1, k)
114
+ blocks[:, k, k] = s_batch
115
+
116
+ return blocks
117
+
118
+
119
+ # ============================================================
120
+ # ---------------- Activity index: simplified ----------------
121
+ # ============================================================
122
+
123
+ def _simple_mai_batch(blocks_G: np.ndarray,
124
+ blocks_S: np.ndarray,
125
+ current_iter: int) -> np.ndarray:
126
+ """
127
+ MAI = trace(G S⁻¹) - current_iter
128
+ """
129
+ B, m, _ = blocks_S.shape
130
+
131
+ if m == 1:
132
+ return blocks_G[:, 0, 0] / blocks_S[:, 0, 0] - current_iter
133
+
134
+ I = np.eye(m)
135
+
136
+ def _process_block(i):
137
+ try:
138
+ invS = cho_solve(cho_factor(blocks_S[i], lower=False, check_finite=False),
139
+ I, check_finite=False)
140
+ except np.linalg.LinAlgError:
141
+ invS = pinv(blocks_S[i])
142
+
143
+ return np.trace(blocks_G[i] @ invS) - current_iter
144
+
145
+ results = Parallel(n_jobs=-1)(delayed(_process_block)(i) for i in range(B))
146
+ return np.array(results, dtype=float)
147
+
148
+ # ============================================================
149
+ # ---------------- Activity index: full versions --------------
150
+ # ============================================================
151
+
152
+
153
+ def _mai_mvp_batch(blocks_G: np.ndarray,
154
+ blocks_S: np.ndarray,
155
+ r: int,
156
+ current_iter: int) -> np.ndarray:
157
+ """
158
+ MAI-MVP = sum of top-r eigenvalues of (G S⁻¹) - r
159
+ """
160
+ B, m, _ = blocks_S.shape
161
+
162
+ if current_iter <= r:
163
+ return _simple_mai_batch(blocks_G, blocks_S, current_iter)
164
+
165
+ I = np.eye(m)
166
+
167
+ def _process_block(i):
168
+ if m == 1:
169
+ return blocks_G[i, 0, 0] / blocks_S[i, 0, 0] - r
170
+
171
+ try:
172
+ invS = cho_solve(cho_factor(blocks_S[i], lower=False, check_finite=False),
173
+ I, check_finite=False)
174
+ except np.linalg.LinAlgError:
175
+ invS = pinv(blocks_S[i])
176
+
177
+ M = blocks_G[i] @ invS
178
+ s, _ = np.linalg.eig(M)
179
+ s_sorted = np.sort(s)[::-1].real
180
+
181
+ return np.sum(s_sorted[:r]) - r
182
+
183
+ results = Parallel(n_jobs=-1)(delayed(_process_block)(i) for i in range(B))
184
+ return np.array(results, dtype=float)
185
+
186
+
187
+ # ============================================================
188
+ # ---------------- Dispatcher: choose function ---------------
189
+ # ============================================================
190
+
191
+ def _choose_activity_index_batch(localizer_to_use: str):
192
+ """
193
+ Return the appropriate activity index function depending on the localizer name.
194
+ """
195
+ if localizer_to_use == "mai":
196
+ return lambda Gs, Ss, Ts, current_iter, r: _simple_mai_batch(Gs, Ss, current_iter)
197
+ elif localizer_to_use == "mai_mvp":
198
+ return lambda Gs, Ss, Ts, current_iter, r: _mai_mvp_batch(Gs, Ss, r, current_iter)
199
+ else:
200
+ raise ValueError("Allowed: 'mai' and 'mai_mvp'")
201
+
202
+
203
+ # ============================================================
204
+ # ---------------- Main function -----------------------------
205
+ # ============================================================
206
+
207
+ def get_activity_index(localizer_to_use: str,
208
+ H: np.ndarray,
209
+ R: np.ndarray,
210
+ N: np.ndarray,
211
+ n_sources_to_localize: int,
212
+ r: int,
213
+ batch_size: int = 16384,
214
+ show_progress: bool = True
215
+ ) -> Tuple[List[int], np.ndarray, int, np.ndarray]:
216
+ """
217
+ Greedy source localization algorithm.
218
+ """
219
+
220
+ # --------------------------------------------------------
221
+ # Robust precompute for PSD low-rank R and N (NO Cholesky)
222
+ # --------------------------------------------------------
223
+ # A_R = R^+ H
224
+ # A_N = N^+ H
225
+ # A_TR = R^+ N R^+ H (same object as: pinv(R) @ N @ pinv(R) @ H)
226
+ # --------------------------------------------------------
227
+
228
+ R_pinv = psd_pinv_operator(R, rmax=None, eps_rel=1e-10)
229
+ N_pinv = psd_pinv_operator(N, rmax=None, eps_rel=1e-10)
230
+
231
+ A_R = R_pinv.apply(H)
232
+ A_N = N_pinv.apply(H)
233
+ A_TR = R_pinv.apply(N @ A_R)
234
+
235
+ # Initialization
236
+ n_channels, n_sources = H.shape
237
+ H_sel = np.zeros((n_channels, n_sources_to_localize), dtype=H.dtype)
238
+ S_sel = G_sel = T_sel = None
239
+ index_max: List[int] = []
240
+ act_values: List[float] = []
241
+
242
+ func = _choose_activity_index_batch(localizer_to_use)
243
+ selected_mask = np.zeros(n_sources, dtype=bool)
244
+ all_indices = np.arange(n_sources, dtype=int)
245
+
246
+ need_G = ("mai" in localizer_to_use) or (localizer_to_use == "mpz_mvp")
247
+ need_T = ("mpz" in localizer_to_use)
248
+
249
+ # Greedy selection loop
250
+ for outer_iter in range(n_sources_to_localize):
251
+ best_val = -np.inf
252
+ best_idx = None
253
+
254
+ if show_progress:
255
+ pbar = tqdm(total=n_sources, desc=f"iter {outer_iter+1}/{n_sources_to_localize}")
256
+
257
+ # Process candidates in batches
258
+ for start in range(0, n_sources, batch_size):
259
+ end = min(n_sources, start + batch_size)
260
+ batch_idx = all_indices[start:end]
261
+ valid = ~selected_mask[batch_idx]
262
+ if not np.any(valid):
263
+ if show_progress:
264
+ pbar.update(end - start)
265
+ continue
266
+ batch_idx = batch_idx[valid]
267
+
268
+ # Build block structures
269
+ A_R_batch = A_R[:, batch_idx]
270
+ H_batch = H[:, batch_idx]
271
+ blocks_S = _build_blocks_batch(H_sel[:, :len(index_max)], S_sel, A_R_batch, H_batch)
272
+
273
+ if need_G:
274
+ A_N_batch = A_N[:, batch_idx]
275
+ blocks_G = _build_blocks_batch(H_sel[:, :len(index_max)], G_sel, A_N_batch, H_batch)
276
+ else:
277
+ blocks_G = np.zeros_like(blocks_S)
278
+
279
+ if need_T:
280
+ A_TR_batch = A_TR[:, batch_idx]
281
+ blocks_T = _build_blocks_batch(H_sel[:, :len(index_max)], T_sel, A_TR_batch, H_batch)
282
+ else:
283
+ blocks_T = np.zeros_like(blocks_S)
284
+
285
+ # Compute activity index values for the batch
286
+ vals = func(blocks_G, blocks_S, blocks_T, current_iter=outer_iter + 1, r=r)
287
+ local_best_idx = int(np.argmax(vals))
288
+ local_best_val = float(vals[local_best_idx])
289
+
290
+ # Update global best
291
+ if local_best_val > best_val:
292
+ best_val = local_best_val
293
+ best_idx = int(batch_idx[local_best_idx])
294
+
295
+ if show_progress:
296
+ pbar.update(end - start)
297
+
298
+ if show_progress:
299
+ pbar.close()
300
+
301
+ if best_idx is None:
302
+ break
303
+
304
+ # Save chosen index and its value
305
+ index_max.append(best_idx)
306
+ act_values.append(float(best_val))
307
+ sel_pos = len(index_max) - 1
308
+ H_sel[:, sel_pos:sel_pos+1] = H[:, best_idx:best_idx+1]
309
+ selected_mask[best_idx] = True
310
+
311
+ # Incrementally update block matrices S_sel / G_sel / T_sel
312
+ aR_chosen = A_R[:, best_idx:best_idx+1]
313
+ S_sel = _build_blocks_batch(H_sel[:, :sel_pos+1], S_sel, aR_chosen,
314
+ H[:, best_idx:best_idx+1])[0]
315
+
316
+ if need_G:
317
+ aN_chosen = A_N[:, best_idx:best_idx+1]
318
+ G_sel = _build_blocks_batch(H_sel[:, :sel_pos+1], G_sel, aN_chosen,
319
+ H[:, best_idx:best_idx+1])[0]
320
+
321
+ if need_T:
322
+ aTR_chosen = A_TR[:, best_idx:best_idx+1]
323
+ T_sel = _build_blocks_batch(H_sel[:, :sel_pos+1], T_sel, aTR_chosen,
324
+ H[:, best_idx:best_idx+1])[0]
325
+
326
+ # Collect selected sources
327
+ # Sort and permute act_values to match sorted indices, so the returned tuple is internally self-consistent
328
+ if index_max:
329
+ sort_perm = np.argsort(index_max)
330
+ sorted_sources = [index_max[i] for i in sort_perm]
331
+ H_res = H[:, sorted_sources].copy()
332
+ act_values_sorted = np.array(act_values, dtype=float)[sort_perm]
333
+ else:
334
+ sorted_sources = []
335
+ H_res = np.zeros((n_channels, 0), dtype=H.dtype)
336
+ act_values_sorted = np.array([], dtype=float)
337
+
338
+ # Final summary
339
+ print("\n[Activity Index Result]")
340
+ print(f" Selected indices (greedy discovery order): {index_max}")
341
+ print(f" Selected indices (sorted, matches H_res columns): {sorted_sources}")
342
+ print(f" Index max values (sorted to match H_res/sorted_sources): {act_values_sorted}")
343
+ print(f" Rank parameter (r): {r}\n")
344
+
345
+ return sorted_sources, act_values_sorted, r, H_res