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 +5 -0
- fsc/core.py +301 -0
- fscpy-1.0.0.dist-info/METADATA +708 -0
- fscpy-1.0.0.dist-info/RECORD +7 -0
- fscpy-1.0.0.dist-info/WHEEL +5 -0
- fscpy-1.0.0.dist-info/licenses/LICENSE +674 -0
- fscpy-1.0.0.dist-info/top_level.txt +1 -0
fsc/__init__.py
ADDED
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.")
|