fscpy 1.0.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.
fsc/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "1.0.0"
2
+
3
+ from .core import FSC
4
+
5
+ __all__ = ["FSC"]
fsc/core.py ADDED
@@ -0,0 +1,301 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """
5
+ Function-to-Structure Coupling (FSC)
6
+
7
+ This module implements the constrained-Laplacian / Modified Nodal Analysis
8
+ (MNA) formulation described in the accompanying paper.
9
+
10
+ Notation follows the paper:
11
+ - FC values define pairwise imposed potential-difference constraints
12
+ - SC is the structural weight matrix (weighted adjacency)
13
+ - L is the graph Laplacian built directly from SC
14
+ - phi are nodal potentials
15
+ - I are edge-level flows/currents, defined as:
16
+ I_ij = SC_ij * (phi_i - phi_j)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import numpy as np
22
+ from scipy.sparse.linalg import minres
23
+
24
+
25
+ class FSC:
26
+ """
27
+ Function-to-Structure Coupling via constrained Laplacians / MNA.
28
+
29
+ Parameters
30
+ ----------
31
+ FC : np.ndarray
32
+ Symmetric functional connectivity matrix. Nonzero upper-triangular
33
+ entries are treated as imposed pairwise potential differences.
34
+ SC : np.ndarray
35
+ Symmetric structural connectivity matrix interpreted as edge weights
36
+ (conductances).
37
+
38
+ Notes
39
+ -----
40
+ The model solves the block system
41
+
42
+ [L B] [phi] [ 0 ]
43
+ [C 0] [ i_s] = [s_fc ]
44
+
45
+ where:
46
+ - L is the graph Laplacian derived from SC
47
+ - B is the incidence matrix of imposed FC constraints
48
+ - C = B.T in the absence of dependent sources
49
+ - phi are nodal potentials
50
+ - i_s are auxiliary source currents enforcing the constraints
51
+ - s_fc imposed pairwise potential differences derived from FC
52
+ """
53
+
54
+ def __init__(self, FC: np.ndarray | None = None, SC: np.ndarray | None = None):
55
+ if FC is None:
56
+ raise ValueError("Give functional connectivity matrix FC.")
57
+ if SC is None:
58
+ raise ValueError("Give structural connectivity matrix SC.")
59
+
60
+ self._validate_dimension(FC, SC, "FC", "SC")
61
+ self._validate_square(FC, "FC")
62
+ self._validate_square(SC, "SC")
63
+ self._validate_symmetry(FC, "FC")
64
+ self._validate_symmetry(SC, "SC")
65
+
66
+ self._FC = np.array(FC, dtype=float, copy=True)
67
+ self._SC = np.array(SC, dtype=float, copy=True)
68
+
69
+ self._n_nodes: int = self._SC.shape[0]
70
+ self._constraint_indices: np.ndarray | None = None
71
+ self._n_constraints: int | None = None
72
+
73
+ self._L: np.ndarray | None = None
74
+ self._B: np.ndarray | None = None
75
+ self._C: np.ndarray | None = None
76
+
77
+ self._phi: np.ndarray | None = None
78
+ self._source_currents: np.ndarray | None = None
79
+ self._voltage_differences: np.ndarray | None = None
80
+ self._edge_currents: np.ndarray | None = None
81
+
82
+ self._solve()
83
+
84
+ @staticmethod
85
+ def _validate_square(matrix: np.ndarray, matrix_name: str) -> None:
86
+ if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]:
87
+ raise ValueError(f"{matrix_name} must be a square 2D matrix.")
88
+
89
+ @staticmethod
90
+ def _validate_symmetry(matrix: np.ndarray, matrix_name: str, atol: float = 1e-8) -> None:
91
+ if not np.allclose(matrix, matrix.T, atol=atol):
92
+ raise ValueError(f"{matrix_name} must be symmetric.")
93
+
94
+ @staticmethod
95
+ def _validate_dimension(A: np.ndarray, B: np.ndarray, A_name: str, B_name: str) -> None:
96
+ if A.shape != B.shape:
97
+ raise ValueError(f"{A_name} dimensions do not match {B_name}.")
98
+
99
+ def _get_constraint_indices(self) -> np.ndarray:
100
+ """
101
+ Return upper-triangular indices of nonzero FC constraints.
102
+
103
+ Only the upper triangle is used to avoid duplicating symmetric constraints,
104
+ but both positive and negative FC values are included.
105
+ """
106
+ fc_upper = np.triu(self._FC, k=1)
107
+ return np.argwhere(fc_upper != 0)
108
+
109
+ def _build_laplacian(self, sc_matrix: np.ndarray | None = None) -> np.ndarray:
110
+ """
111
+ Build graph Laplacian L = D - W directly from structural weights SC.
112
+ """
113
+ if sc_matrix is None:
114
+ sc_matrix = self._SC
115
+
116
+ degrees = np.sum(sc_matrix, axis=1)
117
+ laplacian = np.diag(degrees) - sc_matrix
118
+ self._L = laplacian
119
+ return laplacian
120
+
121
+ def _build_constraint_incidence_matrix(self, constraint_indices: np.ndarray) -> np.ndarray:
122
+ """
123
+ Build incidence matrix B for imposed FC constraints.
124
+
125
+ For each constrained pair (i, j), the column has:
126
+ - +1 at i and -1 at j if FC_ij > 0
127
+ - -1 at i and +1 at j if FC_ij < 0
128
+ """
129
+ n = self._n_nodes
130
+ m = len(constraint_indices)
131
+ B = np.zeros((n, m), dtype=float)
132
+
133
+ for col, (i, j) in enumerate(constraint_indices):
134
+ fc_value = self._FC[i, j]
135
+ sign = np.sign(fc_value)
136
+ if sign == 0:
137
+ continue
138
+ B[i, col] = sign
139
+ B[j, col] = -sign
140
+
141
+ self._B = B
142
+ self._C = B.T
143
+ return B
144
+
145
+ def _solve_mna_system(self, A: np.ndarray, z: np.ndarray) -> np.ndarray:
146
+ """
147
+ Solve the grounded MNA system.
148
+
149
+ The first node is grounded (phi_0 = 0) by removing the first row and
150
+ column of the full block system.
151
+ """
152
+ A_grounded = A[1:, 1:]
153
+ z_grounded = z[1:]
154
+
155
+ solution, info = minres(A_grounded, z_grounded)
156
+ if info != 0:
157
+ raise RuntimeError(f"MINRES did not converge successfully (info={info}).")
158
+
159
+ return solution
160
+
161
+ def _solve(self) -> None:
162
+ """
163
+ Assemble and solve the constrained-Laplacian / MNA system.
164
+ """
165
+ constraint_indices = self._get_constraint_indices()
166
+ self._constraint_indices = constraint_indices
167
+ self._n_constraints = len(constraint_indices)
168
+
169
+ L = self._build_laplacian()
170
+ B = self._build_constraint_incidence_matrix(constraint_indices)
171
+ C = self._C
172
+ D = np.zeros((self._n_constraints, self._n_constraints), dtype=float)
173
+
174
+ A = np.block([[L, B], [C, D]])
175
+
176
+ s_fc = np.array([self._FC[i, j] for i, j in constraint_indices], dtype=float)
177
+ z = np.zeros(self._n_nodes + self._n_constraints, dtype=float)
178
+ z[self._n_nodes :] = s_fc
179
+
180
+ solution = self._solve_mna_system(A, z)
181
+
182
+ phi = np.zeros(self._n_nodes, dtype=float)
183
+ phi[1:] = solution[: self._n_nodes - 1]
184
+
185
+ self._phi = phi
186
+ self._source_currents = solution[self._n_nodes - 1 :]
187
+
188
+ self._voltage_differences = phi[:, None] - phi[None, :]
189
+ self._edge_currents = self._SC * self._voltage_differences
190
+ np.fill_diagonal(self._edge_currents, 0.0)
191
+
192
+ def get_nodal_potentials(self) -> np.ndarray:
193
+ """
194
+ Return nodal potentials phi.
195
+ """
196
+ if self._phi is None:
197
+ raise RuntimeError("Model has not been solved yet.")
198
+ return self._phi.copy()
199
+
200
+ def get_voltage_difference_matrix(self) -> np.ndarray:
201
+ """
202
+ Return signed nodal voltage-difference matrix:
203
+ phi_i - phi_j
204
+ """
205
+ if self._voltage_differences is None:
206
+ raise RuntimeError("Model has not been solved yet.")
207
+ return self._voltage_differences.copy()
208
+
209
+ def get_edge_currents(self) -> np.ndarray:
210
+ """
211
+ Return edge-level current / flow matrix:
212
+ I_ij = SC_ij * (phi_i - phi_j)
213
+ """
214
+ if self._edge_currents is None:
215
+ raise RuntimeError("Model has not been solved yet.")
216
+ return self._edge_currents.copy()
217
+
218
+ def get_graph_laplacian(self) -> np.ndarray:
219
+ """
220
+ Return graph Laplacian L = D - SC.
221
+ """
222
+ if self._L is None:
223
+ raise RuntimeError("Model has not been solved yet.")
224
+ return self._L.copy()
225
+
226
+ def get_constraint_incidence_matrix(self) -> np.ndarray:
227
+ """
228
+ Return incidence matrix B for FC constraints.
229
+ """
230
+ if self._B is None:
231
+ raise RuntimeError("Model has not been solved yet.")
232
+ return self._B.copy()
233
+
234
+ def get_source_currents(self) -> np.ndarray:
235
+ """
236
+ Return auxiliary MNA source currents i_s.
237
+ """
238
+ if self._source_currents is None:
239
+ raise RuntimeError("Model has not been solved yet.")
240
+ return self._source_currents.copy()
241
+
242
+ def get_streamline_currents(
243
+ self,
244
+ streamline_assignments: np.ndarray | None = None,
245
+ streamline_weights: np.ndarray | None = None,
246
+ nodal_potentials: np.ndarray | None = None,
247
+ ) -> np.ndarray:
248
+ """
249
+ Compute streamline-wise currents from node assignments.
250
+
251
+ Parameters
252
+ ----------
253
+ streamline_assignments : np.ndarray
254
+ Array of shape (n_streamlines, 2), where each row contains the
255
+ 1-based node indices assigned by MRtrix.
256
+ streamline_weights : np.ndarray
257
+ Structural weights for each streamline. If streamlines inherit the
258
+ parent edge weight uniformly, pass that here.
259
+ nodal_potentials : np.ndarray, optional
260
+ Custom nodal potentials to use instead of the solved phi.
261
+
262
+ Returns
263
+ -------
264
+ np.ndarray
265
+ Signed streamline-wise currents.
266
+ """
267
+ if streamline_assignments is None:
268
+ raise ValueError("Give streamline_assignments.")
269
+ if streamline_weights is None:
270
+ raise ValueError("Give streamline_weights.")
271
+
272
+ if nodal_potentials is None:
273
+ if self._phi is None:
274
+ raise RuntimeError("Model has not been solved yet.")
275
+ phi = self._phi
276
+ else:
277
+ phi = np.asarray(nodal_potentials, dtype=float)
278
+
279
+ if len(streamline_assignments) != len(streamline_weights):
280
+ raise ValueError("streamline_assignments and streamline_weights must have the same length.")
281
+
282
+ streamline_currents = np.zeros(len(streamline_weights), dtype=float)
283
+
284
+ for idx, assignment in enumerate(streamline_assignments):
285
+ if np.any(assignment == 0):
286
+ continue
287
+
288
+ inode = int(assignment[0]) - 1
289
+ jnode = int(assignment[1]) - 1
290
+ weight = float(streamline_weights[idx])
291
+
292
+ if weight <= 0:
293
+ continue
294
+
295
+ streamline_currents[idx] = weight * (phi[inode] - phi[jnode])
296
+
297
+ return streamline_currents
298
+
299
+
300
+ if __name__ == "__main__":
301
+ print("No main functions implemented. Use as a class.")