fscpy 2.0.0__tar.gz → 2.0.2__tar.gz

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.
Files changed (28) hide show
  1. {fscpy-2.0.0 → fscpy-2.0.2}/PKG-INFO +78 -11
  2. fscpy-2.0.2/README.md +190 -0
  3. {fscpy-2.0.0 → fscpy-2.0.2}/pyproject.toml +1 -1
  4. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/parcellation/__init__.py +2 -1
  5. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/parcellation/connectivity.py +135 -83
  6. fscpy-2.0.2/src/fsc/parcellation/streamlines.py +121 -0
  7. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/solver.py +445 -441
  8. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/voxel/fod.py +11 -8
  9. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/voxel/roi.py +1 -1
  10. {fscpy-2.0.0 → fscpy-2.0.2}/src/fscpy.egg-info/PKG-INFO +78 -11
  11. {fscpy-2.0.0 → fscpy-2.0.2}/src/fscpy.egg-info/SOURCES.txt +1 -0
  12. fscpy-2.0.2/tests/test_parcellation.py +97 -0
  13. fscpy-2.0.2/tests/test_streamlines.py +79 -0
  14. fscpy-2.0.2/tests/test_voxel.py +171 -0
  15. fscpy-2.0.0/README.md +0 -123
  16. fscpy-2.0.0/src/fsc/parcellation/streamlines.py +0 -65
  17. fscpy-2.0.0/tests/test_parcellation.py +0 -51
  18. fscpy-2.0.0/tests/test_voxel.py +0 -93
  19. {fscpy-2.0.0 → fscpy-2.0.2}/LICENSE +0 -0
  20. {fscpy-2.0.0 → fscpy-2.0.2}/setup.cfg +0 -0
  21. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/__init__.py +0 -0
  22. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/utils.py +0 -0
  23. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/voxel/__init__.py +0 -0
  24. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/voxel/support.py +0 -0
  25. {fscpy-2.0.0 → fscpy-2.0.2}/src/fsc/weighted_graph.py +0 -0
  26. {fscpy-2.0.0 → fscpy-2.0.2}/src/fscpy.egg-info/dependency_links.txt +0 -0
  27. {fscpy-2.0.0 → fscpy-2.0.2}/src/fscpy.egg-info/requires.txt +0 -0
  28. {fscpy-2.0.0 → fscpy-2.0.2}/src/fscpy.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fscpy
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: Function-structure coupling on parcellation and FOD-derived voxel graphs
5
5
  Author: Viljami Sairanen
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -699,8 +699,8 @@ Dynamic: license-file
699
699
 
700
700
  The Python package is imported as `fsc` and provides two workflows:
701
701
 
702
- - **Parcellation FSC:** a structural connectivity (SC) matrix and a functional connectivity (FC) matrix produce a symmetric matrix of structural-edge support. Relevant publication for this version is the Network Neuroscience paper 10.1162/NETN.a.589.
703
- - **Voxelwise FSC:** a spherical-harmonic fibre orientation distribution (FOD) defines a weighted white-matter voxel graph. One supernode per functional parcel connects to neighbouring white-matter voxels through FOD-supported interfaces. The output is a voxelwise map of white-matter edge support. This version is explained in the Aperture Neuro paper 10.52294/001c.172225.
702
+ - **Parcellation FSC:** a structural connectivity (SC) matrix and a functional connectivity (FC) matrix produce a symmetric matrix of structural-edge support. Related publication: the Network Neuroscience paper [doi:10.1162/NETN.a.589](https://doi.org/10.1162/NETN.a.589). The paper's joint formulation is available as `method="joint"` for FC without cycles, and a pairwise variant for dense FC is the default; see [Relation to the publications](#relation-to-the-publications).
703
+ - **Voxelwise FSC:** a spherical-harmonic fibre orientation distribution (FOD) defines a weighted white-matter voxel graph. One supernode per functional parcel connects to neighbouring white-matter voxels through FOD-supported interfaces. The output is a voxelwise map of white-matter edge support. This version is explained in the Aperture Neuro paper [doi:10.52294/001c.172225](https://doi.org/10.52294/001c.172225).
704
704
 
705
705
  These workflows use different structural graphs, so their numerical outputs are **not interchangeable**.
706
706
 
@@ -719,7 +719,6 @@ On Windows PowerShell, use double quotes around extras, for example `python -m p
719
719
 
720
720
  The ENIGMA example also requires the external [ENIGMA Toolbox](https://github.com/MICA-MNI/ENIGMA), which is not a dependency of FSC. Please, refer to their installation instructions if needed.
721
721
 
722
-
723
722
  ## Examples
724
723
 
725
724
  The scripts run from the repository root after installation:
@@ -730,7 +729,7 @@ The scripts run from the repository root after installation:
730
729
  | `python examples/enigma.py` | ENIGMA Toolbox's included HCP SC and FC matrices | Side-by-side SC, FC and FSC heatmaps |
731
730
  | `python examples/voxel.py` | Generated synthetic SH FOD and two parcel voxels | A small 3D white-matter support map |
732
731
 
733
- The synthetic voxel example requires `.[voxel]` but **no downloaded MRI data**. It constructs an x-oriented FOD bundle, attaches two functional ROI supernodes and evaluates the resulting support. The ENIGMA example follows the bundled cortical connectivity matrices returned by `load_sc()` and `load_fc()`.
732
+ The synthetic voxel example requires `.[voxel]` but **no downloaded MRI data**. It constructs an x-oriented FOD bundle in the MRtrix3 SH convention, attaches two functional ROI supernodes and evaluates the resulting support. The ENIGMA example follows the bundled cortical connectivity matrices returned by `load_sc()` and `load_fc()`.
734
733
 
735
734
  ### Parcellation workflow
736
735
 
@@ -753,7 +752,50 @@ support = fsc.solve_from_parcellations(sc, fc)
753
752
  print(support)
754
753
  ```
755
754
 
756
- `solve_from_parcellations()` returns the **symmetric support matrix**, not an `FSCResult` object. In this example, the only structural path connecting parcels 1 and 4 is 1–0–2–4. Its three unit-conductance edges each carry support `1/3`; the side branch 2–3 carries zero current.
755
+ `solve_from_parcellations()` returns the **symmetric support matrix**, not an `FSCResult` object. In this example, the only structural path connecting parcels 1 and 4 is 1–0–2–4. Its three unit-conductance edges each carry support `1/3` and the side branch 2–3 carries zero current.
756
+
757
+ FC must be square and symmetric. Only finite, positive values above the diagonal are used. Negative, zero and non-finite values are ignored. The papers used Fisher *z*-transformed FC. The fscpy uses whatever values it is given. The output scales linearly with the FC magnitudes.
758
+
759
+ `solve_from_parcellations()` has two methods:
760
+
761
+ - `method="pairwise"` (default) solves each FC pair separately and sums the absolute edge currents. It works for any FC matrix.
762
+ - `method="joint"` solves all FC constraints in one modified-nodal-analysis system as in the Network Neuroscience paper (Eq. 11). Returns absolute edge currents. Requires the FC pairs to contain no cycles, e.g. a set of disjoint pairs like the FiberCup example. Otherwise, error is raised. Three regions with positive FC between all of them already form a cycle. So, a dense FC matrix has likely no exact joint solution.
763
+
764
+ ```python
765
+ fc = np.zeros((5, 5))
766
+ fc[1, 4] = fc[4, 1] = 1
767
+ fc[3, 4] = fc[4, 3] = 1 # two pairs sharing node 4: no cycle
768
+
769
+ joint = fsc.solve_from_parcellations(sc, fc, method="joint")
770
+ pairwise = fsc.solve_from_parcellations(sc, fc) # different for several pairs
771
+ ```
772
+
773
+ The two methods agree for a single FC pair and generally differ for several, because the joint solve combines the constraints in one potential field while the pairwise method sums the magnitudes of separate solutions.
774
+
775
+ #### Mapping support to streamlines
776
+
777
+ The support matrix can be mapped back onto a tractogram so that each streamline gets a weight for visualisation, filtering, or analyses. The SC matrix does not have to come from fscpy. Any tool that writes a per-streamline parcel assignment, such as MRtrix3 `tck2connectome -out_assignments`, works.
778
+
779
+ ```python
780
+ from fsc.parcellation import streamline_support
781
+
782
+ assignments = np.loadtxt("assignments.txt", dtype=int) # two parcel labels per streamline
783
+ weights = np.loadtxt("sift2_weights.txt") # optional, see below
784
+
785
+ values = streamline_support(support, assignments, weights)
786
+ np.savetxt("fsc_streamline_weights.txt", values) # one value per streamline, input order
787
+ ```
788
+
789
+ The support of each parcel pair is split among the streamlines connecting that pair in proportion to their weights. So the values of each pair's streamlines sum to `support[a, b]`. This holds for both methods and for SC from any source or normalisation. Only the relative weights within a pair matter.
790
+
791
+ Requirements:
792
+
793
+ - **Assignments:** exactly two labels per streamline, numbered from 1 in SC matrix order, with 0 for an unassigned endpoint. This is the default `tck2connectome -out_assignments` format. Options that write more labels per streamline (e.g. `-assignment_all_voxels`), or that add a row for node 0 to the matrix (`-keep_unassigned`) break this correspondence!
794
+ - **Weights:** use the per-streamline weights the SC was built from. Omit them for streamline counts, pass the SIFT2 weights file if the SC used `-tck_weights_in`, or `1 / length` for length-scaled SC. Non-finite or non-positive weights mark a streamline as invalid.
795
+ - **Output:** one non-negative value per input streamline, in input order. Unassigned, invalid and self-connecting streamlines get 0. If the SC has an edge with no streamline in `assignments`, that edge's support is not mapped.
796
+
797
+ For signed streamline currents from a potential vector computed with `fsc.solve_fsc()`, use `streamline_currents()`. If SC is built inside fscpy from streamline resistances, use `sc_from_streamlines()`.
798
+
757
799
 
758
800
  ### Voxel workflow
759
801
 
@@ -783,15 +825,40 @@ support = sum_pairwise_support(
783
825
  nib.save(nib.Nifti1Image(support, fod_image.affine), 'fsc_support.nii.gz')
784
826
  ```
785
827
 
786
- `labels_in_fod_space()` uses nearest-neighbour resampling and assumes the images are **already spatially registered**. It does not register images or reorient SH coefficients. By default, the FOD coefficient convention is DIPY's `tournier07` basis with `legacy=True`; the supplied image must use the matching convention. An optional `wm_mask` can further restrict graph voxels; the FOD-norm threshold is still applied. ROI voxels must have positive labels, lie outside the selected WM voxel graph, and have FOD-supported WM contacts.
828
+ #### Input conventions
787
829
 
788
- The voxel workflow applies each finite, nonzero FC pair **independently in the mathematical model**. For efficiency, the implementation solves reusable grounded-Laplacian basis fields relative to one ROI per connected component, then reconstructs pair solutions. It accumulates **absolute** structural currents. Voxel support sums currents on **WM–WM edges only**, excluding ROI-supernode interface edges. Pairs without a structural path contribute zero. Specify `pairs=[(0, 1)]` to select one pair by zero-based FC matrix indices.
830
+ - **SH basis.** The FOD image must contain SH coefficients in the MRtrix3 convention, as written by `dwi2fod`. This is DIPY's `tournier07` basis with `legacy=False`, which is the default. FODs in the older MRtrix 0.2 convention need `legacy=True`.
831
+ Since version 2.0.2 the default is `legacy=False`; earlier versions defaulted to `legacy=True`. Pass `legacy=True` explicitly to reproduce results from those versions.
832
+ - **Same settings in both calls.** Pass identical `sphere`, `basis`, `legacy`, `kappa`, `kernel_power` and `orientation_floor` values to `graph_from_fod()` and `add_roi_nodes()`. They are not cross-checked.
833
+ - **Frame.** Neighbour directions are computed in world coordinates from the image affine, so the SH coefficients must be defined in that same frame, as MRtrix3 does. The image must have a correct affine.
834
+ - **Registration.** `labels_in_fod_space()` uses nearest-neighbour resampling and assumes the images are **already spatially registered**. It does not register images or reorient SH coefficients.
835
+ - **Masks.** An optional `wm_mask` restricts graph voxels and an optional `exclusion_mask` removes voxels. The FOD-norm threshold is always applied in addition to `wm_mask`.
836
+ - **ROIs.** ROI voxels must have positive labels, lie outside the selected WM voxel graph, and every ROI must have at least one FOD-supported WM contact, otherwise `add_roi_nodes()` raises an error. An optional `tissue_mask` excludes non-tissue ROI voxels.
837
+
838
+ #### How the graph is built
839
+
840
+ - **Nodes.** Voxels whose SH coefficient vector has a Euclidean norm above `fod_norm_threshold` (default 0.25), inside `wm_mask` if given. This threshold depends on the FOD intensity scale, so check it against your FOD normalisation.
841
+ - **Edges.** Each voxel is connected to its 26 neighbours (13 unique directions). The conductance of an interface is `sqrt(s_i · s_j) / ℓ`, where `ℓ` is the physical distance between voxel centres and `s` is the directional support of each endpoint. Interfaces below `min_conductance` (default 1e-12) are dropped.
842
+ - **Directional support.** The FOD amplitude along the interface direction is evaluated with an angular averaging kernel, with weights proportional to `exp(κ·|cos θ|^p)` over the sampling sphere (default `repulsion724`, `kappa=8`, `kernel_power=2`). It is clipped at zero after averaging, reduced by `orientation_floor` times the mean positive FOD amplitude of the voxel, and clipped at zero again. This kernel averaging is an implementation detail: the papers evaluate the FOD directly along the interface direction.
843
+ - **ROI supernodes.** Each ROI is a graph node. Labelled voxels outside the WM graph are ROI tissue. Each one touches the WM graph through its six face neighbours. The conductance of a contact is `interface_scale` times the *WM voxel's* directional support along the face normal, divided by the voxel size. Diagonal contacts are not used, and support is taken from the WM side only. All ROI supernodes are present in the graph when any pair is solved.
844
+ - **Support map.** Voxel support is the sum of absolute currents on the WM–WM edges incident to a voxel, accumulated over all FC pairs. ROI–WM interface edges are excluded.
845
+
846
+ #### Solving
847
+
848
+ The voxel workflow applies each finite, positive FC pair **independently in the mathematical model**. For efficiency, the implementation solves reusable grounded-Laplacian basis fields relative to one ROI per connected component, then reconstructs pair solutions. It accumulates **absolute** structural currents. Pairs without a structural path contribute zero. Specify `pairs=[(0, 1)]` to select one pair by zero-based FC matrix indices.
849
+
850
+ `solver` can be `'auto'`, `'cg'` or `'direct'`; the default `rtol` is `1e-4`. Each solution is checked for current conservation, and the check is relative to the injected current, so it does not depend on the scale of the conductances. One potential field is stored per active ROI, and run time grows with the number of FC pairs.
789
851
 
790
852
  ## Numerical interpretation
791
853
 
792
- In both workflows, nonzero FC magnitudes define imposed potential differences and negative FC values are treated using `abs(FC)`. SC values and FOD interface weights act as conductances, **not lengths or resistances**. FSC reports how the weighted structural graph supports the imposed correlations; it is not a direct measurement of axonal traffic or a uniquely determined anatomical tract.
854
+ In both workflows, only positive FC values define imposed potential differences. Negative values are ignored. SC values and FOD interface weights act as conductances, **not lengths or resistances**. FSC reports how the weighted structural graph supports the imposed correlations. It is not a direct measurement of axonal traffic or a uniquely determined anatomical tract.
855
+
856
+ The low-level `fsc.solve_fsc()` interface solves **simultaneous** functional constraints through modified nodal analysis and returns an `FSCResult` (potentials, currents and residual diagnostics). The constraints must be mutually compatible. The imposed potential differences must sum to zero around every cycle of constrained pairs. Constraints without cycles, e.g. the FiberCup experiment, always satisfy this. Whereas a dense FC matrix likely does not because it imposes far more potential differences than the graph has free potentials. `solve_from_parcellations(..., method="joint")` wraps this interface and checks for cycles before solving. For dense FC, use `solve_from_parcellations()` with the default `method="pairwise"`, or `solve_fsc_voxel()`, which evaluate FC pairs separately and sum their absolute structural-edge currents.
857
+
858
+ ## Relation to the publications
793
859
 
794
- The low-level `fsc.solve_fsc()` interface solves **simultaneous** functional constraints through modified nodal analysis and returns an `FSCResult` (potentials, currents and residual diagnostics). This is distinct from `solve_from_parcellations()` and `solve_fsc_voxel()`, which evaluate FC pairs separately and sum their absolute structural-edge currents.
860
+ - **Network Neuroscience (parcellation workflow).** The paper formulates FSC as one joint modified-nodal-analysis system (Eq. 11), with all constraints solved together. `solve_from_parcellations(..., method="joint")` implements it for FC pairs without cycles like in the FiberCup specificity test. When FC pairs form cycles, as many dense FC matrices, the constraints cannot all hold at once andthe joint system has no exact solution. For such inputs use the default `method="pairwise"`, which solves each pair separately and sums absolute currents. The two methods agree for a single constraint.
861
+ - **Aperture Neuro (voxel workflow).** The Methods describe imposing each selected FC value separately and accumulating the resulting current magnitudes, which is what `sum_pairwise_support()` does. The directional-support kernel, the six-face ROI contacts and the 0.25 norm threshold are implementation choices not given in the paper.
795
862
 
796
863
  ## Tests
797
864
 
@@ -801,7 +868,7 @@ Run the small deterministic tests without downloading neuroimaging datasets:
801
868
  python -m pytest -q
802
869
  ```
803
870
 
804
- The tests cover the five-node analytical solution, general MNA, negative FC, empty FC, ROI-basis accumulation, disconnected ROIs, and WM-only voxel support. An optional synthetic-FOD end-to-end test runs when NiBabel and DIPY are installed.
871
+ The tests cover the five-node analytical solution, general MNA, the joint parcellation method and its rejection of FC with cycles, mapping support to streamlines, negative FC, empty FC, ROI-basis accumulation, disconnected ROIs, WM-only voxel support, and the scale-invariance of the solver's convergence check. Optional tests run when NiBabel and DIPY are installed: a synthetic-FOD end-to-end example, and a check that the default SH basis reproduces amplitudes computed by MRtrix3 (`sh2amp`) from a reference FiberCup FOD stored in `tests/data/`.
805
872
 
806
873
  ## Research
807
874
 
fscpy-2.0.2/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # fscpy
2
+
3
+ **Function–structure coupling (FSC)** maps functional correlations onto the structural connections that can support them. FSC represents structural connectivity as weighted graph conductances, imposes functional connectivity as potential differences, and measures the resulting currents on structural edges.
4
+
5
+ The Python package is imported as `fsc` and provides two workflows:
6
+
7
+ - **Parcellation FSC:** a structural connectivity (SC) matrix and a functional connectivity (FC) matrix produce a symmetric matrix of structural-edge support. Related publication: the Network Neuroscience paper [doi:10.1162/NETN.a.589](https://doi.org/10.1162/NETN.a.589). The paper's joint formulation is available as `method="joint"` for FC without cycles, and a pairwise variant for dense FC is the default; see [Relation to the publications](#relation-to-the-publications).
8
+ - **Voxelwise FSC:** a spherical-harmonic fibre orientation distribution (FOD) defines a weighted white-matter voxel graph. One supernode per functional parcel connects to neighbouring white-matter voxels through FOD-supported interfaces. The output is a voxelwise map of white-matter edge support. This version is explained in the Aperture Neuro paper [doi:10.52294/001c.172225](https://doi.org/10.52294/001c.172225).
9
+
10
+ These workflows use different structural graphs, so their numerical outputs are **not interchangeable**.
11
+
12
+ ## Installation
13
+
14
+ From the repository directory, preferably in a virtual environment:
15
+
16
+ ```bash
17
+ python -m pip install -e . # Parcellation FSC (NumPy, SciPy)
18
+ python -m pip install -e '.[voxel]' # Also NiBabel and DIPY
19
+ python -m pip install -e '.[test]' # Run tests with pytest
20
+ python -m pip install -e '.[examples]' # Matplotlib for the ENIGMA plot
21
+ ```
22
+
23
+ On Windows PowerShell, use double quotes around extras, for example `python -m pip install -e ".[voxel,test,examples]"`.
24
+
25
+ The ENIGMA example also requires the external [ENIGMA Toolbox](https://github.com/MICA-MNI/ENIGMA), which is not a dependency of FSC. Please, refer to their installation instructions if needed.
26
+
27
+ ## Examples
28
+
29
+ The scripts run from the repository root after installation:
30
+
31
+ | Script | Data | Output |
32
+ | --- | --- | --- |
33
+ | `python examples/parcellation.py` | Explicit 5×5 SC and FC matrices | A 5×5 symmetric FSC matrix with a known three-edge path |
34
+ | `python examples/enigma.py` | ENIGMA Toolbox's included HCP SC and FC matrices | Side-by-side SC, FC and FSC heatmaps |
35
+ | `python examples/voxel.py` | Generated synthetic SH FOD and two parcel voxels | A small 3D white-matter support map |
36
+
37
+ The synthetic voxel example requires `.[voxel]` but **no downloaded MRI data**. It constructs an x-oriented FOD bundle in the MRtrix3 SH convention, attaches two functional ROI supernodes and evaluates the resulting support. The ENIGMA example follows the bundled cortical connectivity matrices returned by `load_sc()` and `load_fc()`.
38
+
39
+ ### Parcellation workflow
40
+
41
+ ```python
42
+ import numpy as np
43
+ import fsc
44
+
45
+ sc = np.array([
46
+ [0, 1, 1, 0, 0],
47
+ [1, 0, 0, 0, 0],
48
+ [1, 0, 0, 1, 1],
49
+ [0, 0, 1, 0, 0],
50
+ [0, 0, 1, 0, 0],
51
+ ], dtype=float)
52
+
53
+ fc = np.zeros((5, 5))
54
+ fc[1, 4] = fc[4, 1] = 1
55
+
56
+ support = fsc.solve_from_parcellations(sc, fc)
57
+ print(support)
58
+ ```
59
+
60
+ `solve_from_parcellations()` returns the **symmetric support matrix**, not an `FSCResult` object. In this example, the only structural path connecting parcels 1 and 4 is 1–0–2–4. Its three unit-conductance edges each carry support `1/3` and the side branch 2–3 carries zero current.
61
+
62
+ FC must be square and symmetric. Only finite, positive values above the diagonal are used. Negative, zero and non-finite values are ignored. The papers used Fisher *z*-transformed FC. The fscpy uses whatever values it is given. The output scales linearly with the FC magnitudes.
63
+
64
+ `solve_from_parcellations()` has two methods:
65
+
66
+ - `method="pairwise"` (default) solves each FC pair separately and sums the absolute edge currents. It works for any FC matrix.
67
+ - `method="joint"` solves all FC constraints in one modified-nodal-analysis system as in the Network Neuroscience paper (Eq. 11). Returns absolute edge currents. Requires the FC pairs to contain no cycles, e.g. a set of disjoint pairs like the FiberCup example. Otherwise, error is raised. Three regions with positive FC between all of them already form a cycle. So, a dense FC matrix has likely no exact joint solution.
68
+
69
+ ```python
70
+ fc = np.zeros((5, 5))
71
+ fc[1, 4] = fc[4, 1] = 1
72
+ fc[3, 4] = fc[4, 3] = 1 # two pairs sharing node 4: no cycle
73
+
74
+ joint = fsc.solve_from_parcellations(sc, fc, method="joint")
75
+ pairwise = fsc.solve_from_parcellations(sc, fc) # different for several pairs
76
+ ```
77
+
78
+ The two methods agree for a single FC pair and generally differ for several, because the joint solve combines the constraints in one potential field while the pairwise method sums the magnitudes of separate solutions.
79
+
80
+ #### Mapping support to streamlines
81
+
82
+ The support matrix can be mapped back onto a tractogram so that each streamline gets a weight for visualisation, filtering, or analyses. The SC matrix does not have to come from fscpy. Any tool that writes a per-streamline parcel assignment, such as MRtrix3 `tck2connectome -out_assignments`, works.
83
+
84
+ ```python
85
+ from fsc.parcellation import streamline_support
86
+
87
+ assignments = np.loadtxt("assignments.txt", dtype=int) # two parcel labels per streamline
88
+ weights = np.loadtxt("sift2_weights.txt") # optional, see below
89
+
90
+ values = streamline_support(support, assignments, weights)
91
+ np.savetxt("fsc_streamline_weights.txt", values) # one value per streamline, input order
92
+ ```
93
+
94
+ The support of each parcel pair is split among the streamlines connecting that pair in proportion to their weights. So the values of each pair's streamlines sum to `support[a, b]`. This holds for both methods and for SC from any source or normalisation. Only the relative weights within a pair matter.
95
+
96
+ Requirements:
97
+
98
+ - **Assignments:** exactly two labels per streamline, numbered from 1 in SC matrix order, with 0 for an unassigned endpoint. This is the default `tck2connectome -out_assignments` format. Options that write more labels per streamline (e.g. `-assignment_all_voxels`), or that add a row for node 0 to the matrix (`-keep_unassigned`) break this correspondence!
99
+ - **Weights:** use the per-streamline weights the SC was built from. Omit them for streamline counts, pass the SIFT2 weights file if the SC used `-tck_weights_in`, or `1 / length` for length-scaled SC. Non-finite or non-positive weights mark a streamline as invalid.
100
+ - **Output:** one non-negative value per input streamline, in input order. Unassigned, invalid and self-connecting streamlines get 0. If the SC has an edge with no streamline in `assignments`, that edge's support is not mapped.
101
+
102
+ For signed streamline currents from a potential vector computed with `fsc.solve_fsc()`, use `streamline_currents()`. If SC is built inside fscpy from streamline resistances, use `sc_from_streamlines()`.
103
+
104
+
105
+ ### Voxel workflow
106
+
107
+ The runnable demonstration is [`examples/voxel.py`](examples/voxel.py). For actual FOD images and registered parcel labels, the workflow is:
108
+
109
+ ```python
110
+ import nibabel as nib
111
+ import numpy as np
112
+ import fsc
113
+ from fsc.voxel import graph_from_fod, labels_in_fod_space, add_roi_nodes, sum_pairwise_support
114
+
115
+ fod_image = nib.load('fod_sh.nii.gz')
116
+ parcel_image = nib.load('parcellation_registered.nii.gz')
117
+ labels = labels_in_fod_space(parcel_image, fod_image)
118
+
119
+ fc = np.load('fc.npy')
120
+ roi_labels = np.load('roi_labels.npy') # must match the FC matrix order
121
+
122
+ wm_graph, voxel_mapping = graph_from_fod(fod_image, fod_norm_threshold=0.25)
123
+ graph, parcellation = add_roi_nodes(
124
+ wm_graph, voxel_mapping, fod_image, labels, roi_labels=roi_labels
125
+ )
126
+
127
+ support = sum_pairwise_support(
128
+ graph, voxel_mapping, parcellation, fc, solver='cg', rtol=1e-6
129
+ )
130
+ nib.save(nib.Nifti1Image(support, fod_image.affine), 'fsc_support.nii.gz')
131
+ ```
132
+
133
+ #### Input conventions
134
+
135
+ - **SH basis.** The FOD image must contain SH coefficients in the MRtrix3 convention, as written by `dwi2fod`. This is DIPY's `tournier07` basis with `legacy=False`, which is the default. FODs in the older MRtrix 0.2 convention need `legacy=True`.
136
+ Since version 2.0.2 the default is `legacy=False`; earlier versions defaulted to `legacy=True`. Pass `legacy=True` explicitly to reproduce results from those versions.
137
+ - **Same settings in both calls.** Pass identical `sphere`, `basis`, `legacy`, `kappa`, `kernel_power` and `orientation_floor` values to `graph_from_fod()` and `add_roi_nodes()`. They are not cross-checked.
138
+ - **Frame.** Neighbour directions are computed in world coordinates from the image affine, so the SH coefficients must be defined in that same frame, as MRtrix3 does. The image must have a correct affine.
139
+ - **Registration.** `labels_in_fod_space()` uses nearest-neighbour resampling and assumes the images are **already spatially registered**. It does not register images or reorient SH coefficients.
140
+ - **Masks.** An optional `wm_mask` restricts graph voxels and an optional `exclusion_mask` removes voxels. The FOD-norm threshold is always applied in addition to `wm_mask`.
141
+ - **ROIs.** ROI voxels must have positive labels, lie outside the selected WM voxel graph, and every ROI must have at least one FOD-supported WM contact, otherwise `add_roi_nodes()` raises an error. An optional `tissue_mask` excludes non-tissue ROI voxels.
142
+
143
+ #### How the graph is built
144
+
145
+ - **Nodes.** Voxels whose SH coefficient vector has a Euclidean norm above `fod_norm_threshold` (default 0.25), inside `wm_mask` if given. This threshold depends on the FOD intensity scale, so check it against your FOD normalisation.
146
+ - **Edges.** Each voxel is connected to its 26 neighbours (13 unique directions). The conductance of an interface is `sqrt(s_i · s_j) / ℓ`, where `ℓ` is the physical distance between voxel centres and `s` is the directional support of each endpoint. Interfaces below `min_conductance` (default 1e-12) are dropped.
147
+ - **Directional support.** The FOD amplitude along the interface direction is evaluated with an angular averaging kernel, with weights proportional to `exp(κ·|cos θ|^p)` over the sampling sphere (default `repulsion724`, `kappa=8`, `kernel_power=2`). It is clipped at zero after averaging, reduced by `orientation_floor` times the mean positive FOD amplitude of the voxel, and clipped at zero again. This kernel averaging is an implementation detail: the papers evaluate the FOD directly along the interface direction.
148
+ - **ROI supernodes.** Each ROI is a graph node. Labelled voxels outside the WM graph are ROI tissue. Each one touches the WM graph through its six face neighbours. The conductance of a contact is `interface_scale` times the *WM voxel's* directional support along the face normal, divided by the voxel size. Diagonal contacts are not used, and support is taken from the WM side only. All ROI supernodes are present in the graph when any pair is solved.
149
+ - **Support map.** Voxel support is the sum of absolute currents on the WM–WM edges incident to a voxel, accumulated over all FC pairs. ROI–WM interface edges are excluded.
150
+
151
+ #### Solving
152
+
153
+ The voxel workflow applies each finite, positive FC pair **independently in the mathematical model**. For efficiency, the implementation solves reusable grounded-Laplacian basis fields relative to one ROI per connected component, then reconstructs pair solutions. It accumulates **absolute** structural currents. Pairs without a structural path contribute zero. Specify `pairs=[(0, 1)]` to select one pair by zero-based FC matrix indices.
154
+
155
+ `solver` can be `'auto'`, `'cg'` or `'direct'`; the default `rtol` is `1e-4`. Each solution is checked for current conservation, and the check is relative to the injected current, so it does not depend on the scale of the conductances. One potential field is stored per active ROI, and run time grows with the number of FC pairs.
156
+
157
+ ## Numerical interpretation
158
+
159
+ In both workflows, only positive FC values define imposed potential differences. Negative values are ignored. SC values and FOD interface weights act as conductances, **not lengths or resistances**. FSC reports how the weighted structural graph supports the imposed correlations. It is not a direct measurement of axonal traffic or a uniquely determined anatomical tract.
160
+
161
+ The low-level `fsc.solve_fsc()` interface solves **simultaneous** functional constraints through modified nodal analysis and returns an `FSCResult` (potentials, currents and residual diagnostics). The constraints must be mutually compatible. The imposed potential differences must sum to zero around every cycle of constrained pairs. Constraints without cycles, e.g. the FiberCup experiment, always satisfy this. Whereas a dense FC matrix likely does not because it imposes far more potential differences than the graph has free potentials. `solve_from_parcellations(..., method="joint")` wraps this interface and checks for cycles before solving. For dense FC, use `solve_from_parcellations()` with the default `method="pairwise"`, or `solve_fsc_voxel()`, which evaluate FC pairs separately and sum their absolute structural-edge currents.
162
+
163
+ ## Relation to the publications
164
+
165
+ - **Network Neuroscience (parcellation workflow).** The paper formulates FSC as one joint modified-nodal-analysis system (Eq. 11), with all constraints solved together. `solve_from_parcellations(..., method="joint")` implements it for FC pairs without cycles like in the FiberCup specificity test. When FC pairs form cycles, as many dense FC matrices, the constraints cannot all hold at once andthe joint system has no exact solution. For such inputs use the default `method="pairwise"`, which solves each pair separately and sums absolute currents. The two methods agree for a single constraint.
166
+ - **Aperture Neuro (voxel workflow).** The Methods describe imposing each selected FC value separately and accumulating the resulting current magnitudes, which is what `sum_pairwise_support()` does. The directional-support kernel, the six-face ROI contacts and the 0.25 norm threshold are implementation choices not given in the paper.
167
+
168
+ ## Tests
169
+
170
+ Run the small deterministic tests without downloading neuroimaging datasets:
171
+
172
+ ```bash
173
+ python -m pytest -q
174
+ ```
175
+
176
+ The tests cover the five-node analytical solution, general MNA, the joint parcellation method and its rejection of FC with cycles, mapping support to streamlines, negative FC, empty FC, ROI-basis accumulation, disconnected ROIs, WM-only voxel support, and the scale-invariance of the solver's convergence check. Optional tests run when NiBabel and DIPY are installed: a synthetic-FOD end-to-end example, and a check that the default SH basis reproduces amplitudes computed by MRtrix3 (`sh2amp`) from a reference FiberCup FOD stored in `tests/data/`.
177
+
178
+ ## Research
179
+
180
+ The two FSC workflows are described in the following papers:
181
+
182
+ - **Parcellation workflow:** Sairanen, V. *From nodes to pathways: an edge-centric model of brain function–structure coupling via constrained Laplacians*. **Network Neuroscience** (2026). [doi:10.1162/NETN.a.589](https://doi.org/10.1162/NETN.a.589).
183
+
184
+ - **Voxel workflow:** Sairanen, V. *Voxel-wise function-structure coupling with diffusion-weighted and functional MRI*. **Aperture Neuro** (2026). [doi:10.52294/001c.172225](https://doi.org/10.52294/001c.172225).
185
+
186
+ Please, cite the publication corresponding to the workflow used.
187
+
188
+ ## License
189
+
190
+ GNU General Public License v3.0; see [LICENSE](LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fscpy"
7
- version = "2.0.0"
7
+ version = "2.0.2"
8
8
  description = "Function-structure coupling on parcellation and FOD-derived voxel graphs"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,5 +1,6 @@
1
1
  """FSC on regional parcellation graphs and tractography streamlines."""
2
2
 
3
3
  from .connectivity import graph_from_sc, solve_from_parcellations, edge_currents_to_matrix
4
+ from .streamlines import sc_from_streamlines, streamline_currents, streamline_support
4
5
 
5
- __all__ = ["graph_from_sc", "solve_from_parcellations", "edge_currents_to_matrix"]
6
+ __all__ = ["graph_from_sc", "solve_from_parcellations", "edge_currents_to_matrix", "sc_from_streamlines", "streamline_currents", "streamline_support"]
@@ -1,83 +1,135 @@
1
- """Parcellation FSC from structural and functional connectivity matrices."""
2
-
3
- import numpy as np
4
- from scipy.sparse.csgraph import connected_components
5
- from scipy.sparse.linalg import factorized
6
- from warnings import warn
7
- import fsc
8
-
9
-
10
- def graph_from_sc(sc):
11
- """Use the upper triangle of a symmetric SC matrix as graph conductances."""
12
- sc = fsc.validate_symmetric_matrix(sc, "SC")
13
- source, target = np.nonzero(np.triu(sc, k=1))
14
- return fsc.WeightedGraph(len(sc), source, target, sc[source, target])
15
-
16
- def solve_from_parcellations(sc, fc):
17
- """Calculate FSC support from parcellation SC and FC matrices."""
18
- graph = graph_from_sc(sc)
19
-
20
- if np.shape(fc) != (graph.n_nodes, graph.n_nodes):
21
- raise ValueError("SC and FC dimensions differ.")
22
-
23
- pairs, values = fsc.fc_pairs(fc)
24
- edge_support = np.zeros(len(graph.edge_i))
25
-
26
- if len(pairs) == 0:
27
- return edge_currents_to_matrix(graph, edge_support, absolute=True)
28
-
29
- laplacian = graph.laplacian()
30
- _, component_labels = connected_components(laplacian, directed=False)
31
-
32
- # Only FC pairs with a structural path can carry current.
33
- connected = (
34
- component_labels[pairs[:, 0]] == component_labels[pairs[:, 1]]
35
- )
36
-
37
- pairs = pairs[connected]
38
- values = values[connected]
39
-
40
- if len(pairs) == 0:
41
- warn("No structural support for any FC pair.", stacklevel=2)
42
- return edge_currents_to_matrix(graph, edge_support, absolute=True)
43
-
44
- # Ground one node in each connected component.
45
- _, ground_nodes = np.unique(component_labels, return_index=True)
46
- mask = np.ones(graph.n_nodes, dtype=bool)
47
- mask[ground_nodes] = False
48
-
49
- solve = factorized(laplacian[mask][:, mask].tocsc())
50
-
51
- rhs = np.zeros(graph.n_nodes)
52
- potentials = np.zeros(graph.n_nodes)
53
-
54
- for (source, target), value in zip(pairs, values):
55
- rhs.fill(0)
56
- rhs[source] = 1.0
57
- rhs[target] = -1.0
58
-
59
- potentials.fill(0)
60
- potentials[mask] = solve(rhs[mask])
61
-
62
- resistance = potentials[source] - potentials[target]
63
- potentials *= abs(value) / resistance
64
-
65
- currents = graph.conductance * (
66
- potentials[graph.edge_i] - potentials[graph.edge_j]
67
- )
68
- edge_support += np.abs(currents)
69
-
70
- return edge_currents_to_matrix(graph, edge_support, absolute=True)
71
-
72
-
73
- def edge_currents_to_matrix(graph, edge_currents, absolute=False):
74
- """Convert graph edge currents to an N-by-N connectivity matrix."""
75
- matrix = np.zeros((graph.n_nodes, graph.n_nodes))
76
- for node_i, node_j, current in zip(
77
- graph.edge_i, graph.edge_j, edge_currents
78
- ):
79
- if absolute:
80
- current = abs(current)
81
- matrix[node_i, node_j] += current
82
- matrix[node_j, node_i] += current if absolute else -current
83
- return matrix
1
+ """Parcellation FSC from structural and functional connectivity matrices."""
2
+
3
+ import numpy as np
4
+ from scipy import sparse
5
+ from scipy.sparse.csgraph import connected_components
6
+ from scipy.sparse.linalg import factorized
7
+ from warnings import warn
8
+ import fsc
9
+
10
+
11
+ def graph_from_sc(sc):
12
+ """Use the upper triangle of a symmetric SC matrix as graph conductances."""
13
+ sc = fsc.validate_symmetric_matrix(sc, "SC")
14
+ source, target = np.nonzero(np.triu(sc, k=1))
15
+ return fsc.WeightedGraph(len(sc), source, target, sc[source, target])
16
+
17
+ def solve_from_parcellations(sc, fc, *, method="pairwise", solver="auto"):
18
+ """Calculate FSC support from parcellation SC and FC matrices.
19
+
20
+ method="pairwise" solves each positive FC pair separately and sums the
21
+ absolute edge currents. It is defined for any FC matrix.
22
+
23
+ method="joint" solves all FC constraints in one modified-nodal-analysis
24
+ system (Network Neuroscience paper, Eq. 11) and returns absolute edge
25
+ currents. It requires the FC pairs to contain no cycles. Therefore, a dense FC likely has no exact joint solution.
26
+
27
+ solver is passed to fsc.solve_fsc() and is used only by method="joint".
28
+ """
29
+ if method == "pairwise":
30
+ return _solve_pairwise(sc, fc)
31
+ if method == "joint":
32
+ return _solve_joint(sc, fc, solver=solver)
33
+ raise ValueError(f"Unknown method: {method}")
34
+
35
+ def _solve_pairwise(sc, fc):
36
+ """Solve each FC pair separately and sum absolute edge currents."""
37
+ graph = graph_from_sc(sc)
38
+
39
+ if np.shape(fc) != (graph.n_nodes, graph.n_nodes):
40
+ raise ValueError("SC and FC dimensions differ.")
41
+
42
+ pairs, values = fsc.fc_pairs(fc)
43
+ edge_support = np.zeros(len(graph.edge_i))
44
+
45
+ if len(pairs) == 0:
46
+ return edge_currents_to_matrix(graph, edge_support, absolute=True)
47
+
48
+ laplacian = graph.laplacian()
49
+ _, component_labels = connected_components(laplacian, directed=False)
50
+
51
+ # Only FC pairs with a structural path can carry current.
52
+ connected = (
53
+ component_labels[pairs[:, 0]] == component_labels[pairs[:, 1]]
54
+ )
55
+
56
+ pairs = pairs[connected]
57
+ values = values[connected]
58
+
59
+ if len(pairs) == 0:
60
+ warn("No structural support for any FC pair.", stacklevel=3)
61
+ return edge_currents_to_matrix(graph, edge_support, absolute=True)
62
+
63
+ # Ground one node in each connected component.
64
+ _, ground_nodes = np.unique(component_labels, return_index=True)
65
+ mask = np.ones(graph.n_nodes, dtype=bool)
66
+ mask[ground_nodes] = False
67
+
68
+ solve = factorized(laplacian[mask][:, mask].tocsc())
69
+
70
+ rhs = np.zeros(graph.n_nodes)
71
+ potentials = np.zeros(graph.n_nodes)
72
+
73
+ for (source, target), value in zip(pairs, values):
74
+ rhs.fill(0)
75
+ rhs[source] = 1.0
76
+ rhs[target] = -1.0
77
+
78
+ potentials.fill(0)
79
+ potentials[mask] = solve(rhs[mask])
80
+
81
+ resistance = potentials[source] - potentials[target]
82
+ potentials *= value / resistance
83
+
84
+ currents = graph.conductance * (
85
+ potentials[graph.edge_i] - potentials[graph.edge_j]
86
+ )
87
+ edge_support += np.abs(currents)
88
+
89
+ return edge_currents_to_matrix(graph, edge_support, absolute=True)
90
+
91
+ def _independent_cycles(pairs, n_nodes):
92
+ """Number of independent cycles in the graph whose edges are FC pairs."""
93
+ if len(pairs) == 0:
94
+ return 0
95
+ adjacency = sparse.coo_matrix(
96
+ (np.ones(len(pairs)), (pairs[:, 0], pairs[:, 1])),
97
+ shape=(n_nodes, n_nodes),
98
+ )
99
+ n_components, _ = connected_components(adjacency, directed=False)
100
+ return len(pairs) - (n_nodes - n_components)
101
+
102
+ def _solve_joint(sc, fc, *, solver="auto"):
103
+ """Solve all FC constraints jointly (NetN Eq. 11) for cycle-free FC pairs only. Subnetworks with cycles are not solved!"""
104
+ graph = graph_from_sc(sc)
105
+
106
+ if np.shape(fc) != (graph.n_nodes, graph.n_nodes):
107
+ raise ValueError("SC and FC dimensions differ.")
108
+
109
+ pairs, values = fsc.fc_pairs(fc)
110
+ cycles = _independent_cycles(pairs, graph.n_nodes)
111
+ if cycles > 0:
112
+ raise ValueError(
113
+ f"The FC pairs form {cycles} independent cycle(s). The joint "
114
+ "system has an exact solution only if FC values sum to zero "
115
+ "around every cycle, which correlation values do not. Use "
116
+ "method='pairwise' for dense FC."
117
+ )
118
+
119
+ constraints = fsc.functional_connectivity_from_fc_pairs(
120
+ pairs, values, graph.n_nodes
121
+ )
122
+ result = fsc.solve_fsc(graph, constraints, solver=solver)
123
+ return edge_currents_to_matrix(graph, result.edge_currents, absolute=True)
124
+
125
+ def edge_currents_to_matrix(graph, edge_currents, absolute=False):
126
+ """Convert graph edge currents to an N-by-N connectivity matrix."""
127
+ matrix = np.zeros((graph.n_nodes, graph.n_nodes))
128
+ for node_i, node_j, current in zip(
129
+ graph.edge_i, graph.edge_j, edge_currents
130
+ ):
131
+ if absolute:
132
+ current = abs(current)
133
+ matrix[node_i, node_j] += current
134
+ matrix[node_j, node_i] += current if absolute else -current
135
+ return matrix