rfick commited on
Commit
aa66c01
·
verified ·
1 Parent(s): 4d73176

disco-space 8c9afd1593ec

Browse files
README.md CHANGED
@@ -11,12 +11,16 @@ license: mit
11
  short_description: DiSCo replay to connectome on the shared GPU pool
12
  ---
13
 
14
- # The DiSCo Space on ZeroGPU
15
 
16
- The same application as `rfick/disco` (dmrai-lab/disco-space) on Hugging Face's shared GPU pool: the compute backend
17
- is PyTorch (`DISCO_BACKEND=torch`: dmipy-sim's shape-moment image, dmipy-fit's `csd_tournier07_torch`, dmipy-tract's
18
- torch tracker), the GPU exists for the duration of one run (`spaces.GPU`), and the layout's tiles are rebuilt on the
19
- device inside every call from the layout downloaded from the Hub when the container started. Deterministic algorithms are on and TF32 off inside the
 
 
 
 
20
  call. Source and pins: https://github.com/dmrai-lab/disco-space (`requirements-zero.txt`, `app.py`).
21
 
22
  ## GPU quota per visitor
 
11
  short_description: DiSCo replay to connectome on the shared GPU pool
12
  ---
13
 
14
+ # The DiSCo replay Space (`rfick/disco-replay`)
15
 
16
+ The DiSCo page (dmrai-lab/disco-space) on Hugging Face's shared GPU pool, serving the v2 DiSCo walk through its
17
+ time-segment layout (`SubstrateCommons/disco-time-segments`): the compute backend is PyTorch (`DISCO_BACKEND=torch`:
18
+ dmipy-sim's time-segment image, dmipy-fit's `csd_tournier07_torch`, dmipy-tract's torch tracker), the GPU exists for
19
+ the duration of one run (`spaces.GPU`), and the layout's packed words are streamed to the device a chunk at a time
20
+ inside every call (decoded in the kernel) from the layout downloaded from the Hub when the container started. Its
21
+ acquisitions are the paper's DiSCo flagship set: DiSCo's own 364 measurements, clinical, research, Connectome 2.0 and
22
+ a cosine OGSE at 50 Hz, each one stored waveform of the layout. (Formerly `rfick/disco-zero`; the old address
23
+ redirects.) Deterministic algorithms are on and TF32 off inside the
24
  call. Source and pins: https://github.com/dmrai-lab/disco-space (`requirements-zero.txt`, `app.py`).
25
 
26
  ## GPU quota per visitor
app.py CHANGED
@@ -4,14 +4,15 @@ the source's share a run needs that the page has not cached, a brain's pack resp
4
  as the request arrives); the cache lookup before it and the page's drawing and files after it stay in this process
5
  (disco-space#7). The configuration is ``DISCO_CONFIG`` (``space/config.toml``, the DiSCo Space, unless the
6
  Space sets another: ``brain.toml`` for the brain Space). The compute backend is torch (``DISCO_BACKEND=torch``), the
7
- layout's tiles are rebuilt on the device inside every call (``resident = false``: nothing survives between calls),
 
8
  deterministic algorithms are on and TF32 off inside the call. The source's data come from the Hub at the config's
9
  revision, downloaded once when the container starts (the parent process loads it before the page serves; the forked
10
  GPU worker inherits it). ``spaces`` must be imported before torch."""
11
  import os
12
 
13
  os.environ.setdefault("DISCO_BACKEND", "torch")
14
- os.environ.setdefault("DISCO_RESIDENT", "0") # the pool drops the device between calls: tiles rebuilt per call
15
  os.environ.setdefault("JAX_PLATFORMS", "cpu") # jax is a library dependency here; it never sees the GPU
16
 
17
  from space.limits import cap_threads, cap_affinity # noqa: E402 (no numpy behind it)
@@ -47,7 +48,7 @@ def probe():
47
  for the deployment check, `/probe`)."""
48
  import glob, json, time
49
  import numpy as np
50
- st = A._load(); src = st["source"]; where = getattr(getattr(src, "moments", None), "path", "")
51
  out = dict(torch=torch.__version__, cuda=torch.cuda.is_available(), device=torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
52
  gpu_memory_gb=round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1) if torch.cuda.is_available() else None,
53
  layout=where, data=sorted(os.path.basename(p) for p in glob.glob(os.path.join(where, "*")))[:12], backend=os.environ.get("DISCO_BACKEND"),
@@ -59,24 +60,21 @@ def probe():
59
  out["host_ram_total_gb"] = round(int(mem["MemTotal"].split()[0]) / 1e6, 1); out["host_ram_available_gb"] = round(int(mem["MemAvailable"].split()[0]) / 1e6, 1)
60
  except Exception as e:
61
  out["meminfo"] = repr(e)[:100]
62
- files = sorted(glob.glob(os.path.join(where, "m_*.npy")))
63
  if files:
64
  t0 = time.perf_counter(); m = np.load(files[0], mmap_mode="r"); chunk = np.array(m[:100000]); dt = time.perf_counter() - t0
65
  out["read_mb"] = round(chunk.nbytes / 1e6, 1); out["read_mb_per_s"] = round(chunk.nbytes / 1e6 / dt, 1)
66
- t0 = time.perf_counter(); torch.as_tensor(chunk, device="cuda"); torch.cuda.synchronize(); out["upload_mb_per_s"] = round(chunk.nbytes / 1e6 / (time.perf_counter() - t0), 1)
67
- # the moment image inside this worker: the first compiled call, a second one (the kernel alone), the eager kernel
68
  try:
69
- from dmipy_sim.replay import shape_moments as SM
70
- kernel = dict(SM._TORCH_KERNEL)
71
- b = np.full(184, 1e9); u = np.tile([0.6, 0.0, 0.8], (184, 1)); name = "d10.2-D16.7"
72
- try:
73
- t0 = time.perf_counter(); src.moments.image(name, b, u, backend="torch", resident=True); torch.cuda.synchronize(); out["image_first_compiled_s"] = round(time.perf_counter() - t0, 2)
74
- t0 = time.perf_counter(); src.moments.image(name, b, u, backend="torch", resident=True); torch.cuda.synchronize(); out["image_second_compiled_s"] = round(time.perf_counter() - t0, 2)
75
- SM._TORCH_KERNEL["fn"] = SM._tile_sums_torch
76
- t0 = time.perf_counter(); src.moments.image(name, b, u, backend="torch", resident=True); torch.cuda.synchronize(); out["image_eager_s"] = round(time.perf_counter() - t0, 2)
77
- t0 = time.perf_counter(); src.moments.image(name, b, u, backend="torch", resident=False); torch.cuda.synchronize(); out["image_eager_nonresident_s"] = round(time.perf_counter() - t0, 2)
78
- finally:
79
- SM._TORCH_KERNEL.update(kernel) # the probe leaves the kernel as it found it
80
  except Exception as e:
81
  out["image_probe_error"] = repr(e)[:300]
82
  return json.dumps(out)
 
4
  as the request arrives); the cache lookup before it and the page's drawing and files after it stay in this process
5
  (disco-space#7). The configuration is ``DISCO_CONFIG`` (``space/config.toml``, the DiSCo Space, unless the
6
  Space sets another: ``brain.toml`` for the brain Space). The compute backend is torch (``DISCO_BACKEND=torch``), the
7
+ layout's packed words are streamed to the device a chunk at a time inside every call (``resident = false``: nothing
8
+ survives between calls),
9
  deterministic algorithms are on and TF32 off inside the call. The source's data come from the Hub at the config's
10
  revision, downloaded once when the container starts (the parent process loads it before the page serves; the forked
11
  GPU worker inherits it). ``spaces`` must be imported before torch."""
12
  import os
13
 
14
  os.environ.setdefault("DISCO_BACKEND", "torch")
15
+ os.environ.setdefault("DISCO_RESIDENT", "0") # the pool drops the device between calls: words streamed per call
16
  os.environ.setdefault("JAX_PLATFORMS", "cpu") # jax is a library dependency here; it never sees the GPU
17
 
18
  from space.limits import cap_threads, cap_affinity # noqa: E402 (no numpy behind it)
 
48
  for the deployment check, `/probe`)."""
49
  import glob, json, time
50
  import numpy as np
51
+ st = A._load(); src = st["source"]; where = getattr(getattr(src, "layout", None), "path", "")
52
  out = dict(torch=torch.__version__, cuda=torch.cuda.is_available(), device=torch.cuda.get_device_name(0) if torch.cuda.is_available() else None,
53
  gpu_memory_gb=round(torch.cuda.get_device_properties(0).total_memory / 1e9, 1) if torch.cuda.is_available() else None,
54
  layout=where, data=sorted(os.path.basename(p) for p in glob.glob(os.path.join(where, "*")))[:12], backend=os.environ.get("DISCO_BACKEND"),
 
60
  out["host_ram_total_gb"] = round(int(mem["MemTotal"].split()[0]) / 1e6, 1); out["host_ram_available_gb"] = round(int(mem["MemAvailable"].split()[0]) / 1e6, 1)
61
  except Exception as e:
62
  out["meminfo"] = repr(e)[:100]
63
+ files = sorted(glob.glob(os.path.join(where, "p_*.npy")))
64
  if files:
65
  t0 = time.perf_counter(); m = np.load(files[0], mmap_mode="r"); chunk = np.array(m[:100000]); dt = time.perf_counter() - t0
66
  out["read_mb"] = round(chunk.nbytes / 1e6, 1); out["read_mb_per_s"] = round(chunk.nbytes / 1e6 / dt, 1)
67
+ t0 = time.perf_counter(); torch.as_tensor(chunk.view(np.int32), device="cuda"); torch.cuda.synchronize(); out["upload_mb_per_s"] = round(chunk.nbytes / 1e6 / (time.perf_counter() - t0), 1)
68
+ # the time-segment image inside this worker, streamed and (when it fits) resident: first and second call
69
  try:
70
+ name = "d10.2-D16.7"
71
+ seq = src.sequence(name, np.full(184, 1000.0), np.tile([0.6, 0.0, 0.8], (184, 1)))
72
+ for resident in (False, True):
73
+ for k in ("first", "second"):
74
+ t0 = time.perf_counter(); src.layout.image(seq, partition=name, backend="torch", resident=resident); torch.cuda.synchronize()
75
+ out[f"image_{'resident' if resident else 'streamed'}_{k}_s"] = round(time.perf_counter() - t0, 2)
76
+ out["gpu_peak_gb"] = round(torch.cuda.max_memory_allocated() / 1e9, 2)
77
+ src.layout.release()
 
 
 
78
  except Exception as e:
79
  out["image_probe_error"] = repr(e)[:300]
80
  return json.dumps(out)
data/SOURCE.md CHANGED
@@ -11,6 +11,6 @@ https://doi.org/10.1016/j.dib.2021.107429; data at https://doi.org/10.5281/zenod
11
  | `DiSCo_gradients.bvals`, `DiSCo_gradients_dipy.bvecs` | the dataset's 364-measurement gradient table (b in s/mm²), the "DiSCo 364" acquisition |
12
  | `DiSCo_Connectivity_Matrix_Strands_Count.txt` | ground truth: strands per region pair |
13
  | `DiSCo_Connectivity_Matrix_Cross-Sectional_Area.txt` | ground truth: total cross-sectional area per region pair |
14
- | `DiSCo_Strands_Trajectories.tck`, `DiSCo_Strands_Diameters.txt` | the 12,196 strands' centerlines (in units of the 25 µm voxel, i.e. the image grid) and inner diameters (mm): the ground-truth view |
15
 
16
  Unmodified copies of the dataset's DiSCo1 files, redistributed here under CC BY 4.0 so the Space is self-contained.
 
11
  | `DiSCo_gradients.bvals`, `DiSCo_gradients_dipy.bvecs` | the dataset's 364-measurement gradient table (b in s/mm²), the "DiSCo 364" acquisition |
12
  | `DiSCo_Connectivity_Matrix_Strands_Count.txt` | ground truth: strands per region pair |
13
  | `DiSCo_Connectivity_Matrix_Cross-Sectional_Area.txt` | ground truth: total cross-sectional area per region pair |
14
+ | `DiSCo_Strands_Diameters.txt` (the centrelines, `DiSCo_Strands_Trajectories.tck`, are read from `SubstrateCommons/disco-replay` `substrate/` at the pinned revision) | the 12,196 strands' centerlines (in units of the 25 µm voxel, i.e. the image grid) and inner diameters (mm): the ground-truth view |
15
 
16
  Unmodified copies of the dataset's DiSCo1 files, redistributed here under CC BY 4.0 so the Space is self-contained.
data/classes/qte-now-80.npy ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6310e918172eb0086c9c31158307d8e4cfeca8c25b1a9fbab0a25e6838c71706
3
+ size 24128
data/protocols/c2.bvals ADDED
@@ -0,0 +1 @@
 
 
1
+ 0.0 0.0 0.0 0.0 0.0 0.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0 6000.0
data/protocols/c2.bvecs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 -0.32949755 0.33414940 -0.60771156 -0.82127342 0.01160086 -0.12962089 0.26498506 0.56912603 -0.40095140 0.11311349 -0.25030891 0.77356999 -0.52375456 0.94083501 -0.63920498 -0.67014535 -0.45412061 0.61922918 0.79604789 -0.78743880 -0.18826079 -0.94768325 -0.58407055 -0.02913436 -0.62545224 -0.33733820 -0.21403085 0.00421902 0.58345560 -0.35220267 0.45295742 0.91782422 0.94162595 0.28168108 0.79855761 0.99971654 0.48125673 0.36140462 0.24089400 0.26950471 -0.82478877 0.13564362 0.29332779 0.56938601 -0.08884351 -0.33494071 -0.85970235 0.73020584 0.92961722 -0.03034955 -0.60692305 0.80574615 0.08571867 -0.94986286 -0.03689412 -0.79313489 0.57162376 0.36836640 -0.49539615 0.74900912
2
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.62521613 0.94159591 -0.79319112 -0.55746095 0.64167929 -0.22505973 0.77269471 -0.07315939 0.80871935 0.37214629 -0.52159459 -0.63362406 0.30920269 -0.33860613 -0.01697085 0.68218268 -0.25517415 0.28358495 -0.51642751 0.22226796 0.96109710 0.09345700 -0.46706432 -0.91920621 0.52925384 -0.89663480 0.37609875 0.07555217 0.60185062 -0.73383382 -0.38312209 -0.21553952 0.10663176 -0.64667073 0.02326119 -0.01496704 0.81874023 0.15874085 -0.16398178 -0.95125392 -0.13803286 0.94259977 -0.84100598 -0.78380323 0.84364697 0.05931043 0.41440579 -0.31387397 0.35178020 -0.74332437 -0.70211555 0.37244222 -0.45885689 -0.22994878 -0.99750236 -0.42974786 -0.61279834 0.51158361 0.86507917 0.62230435
3
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.70748580 0.04172908 0.03917277 0.12143827 0.76688533 0.96568450 0.57682390 0.81898917 0.43036146 0.92125592 0.81564976 0.01048864 0.79377255 0.01324307 0.76884913 0.29245853 0.85361621 0.73221227 0.31561113 0.57492355 0.20211443 0.30522492 0.66386182 0.39269726 0.57332353 0.28679084 0.90152123 0.99713292 0.54530302 0.58089690 0.80501369 0.33337877 0.31932779 0.70885311 0.60146876 0.01851587 0.31313957 0.91879706 0.95659817 0.14994393 0.54833422 0.30513354 0.45460724 0.24789529 0.52949658 0.94037067 0.29862973 0.60686289 0.10982952 0.66824231 0.37239518 0.46049966 0.88436569 0.21185870 0.06023173 0.43157134 0.54564134 0.77626568 0.07887130 0.22742611
data/protocols/clinical.bvals ADDED
@@ -0,0 +1 @@
 
 
1
+ 0.0 0.0 0.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0
data/protocols/clinical.bvecs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ 0.00000000 0.00000000 0.00000000 -0.21204524 -0.95684375 0.79698041 -0.41182571 -0.81011573 -0.11514776 -0.92695104 0.43532954 0.92492393 -0.42978830 -0.64150046 -0.39834740 -0.23078110 -0.72213785 -0.55205845 0.72558771 0.38087223 -0.63961904 0.96273433 0.35854872 0.01680837 0.04499735 0.64600654 0.69874109 -0.28489889 0.47941432 0.02769143 0.18831204 0.17906768 0.77676570
2
+ 0.00000000 0.00000000 0.00000000 -0.75054853 0.24143549 0.60309286 -0.90060083 0.12191004 0.46881409 -0.24577886 -0.12018932 -0.25417356 0.08442250 -0.30479278 0.91680538 -0.37028342 0.63418578 0.48863467 -0.66199141 0.35783255 -0.64027306 0.19605979 -0.79351726 -0.96157594 0.97637349 -0.44154424 0.48464294 0.81674577 0.81785461 0.02205862 -0.52460313 0.73817677 0.05109827
3
+ 0.00000000 0.00000000 0.00000000 0.62587036 0.16173726 0.03318481 0.13898823 0.57345483 0.87575929 0.28346871 0.89221226 0.28268628 0.89897434 0.70397338 0.02805805 0.89979457 0.27627039 0.67562388 0.18785595 0.85257974 0.42536795 0.18628785 0.49170436 0.27402405 0.21135290 0.62266703 0.52619589 0.50175589 0.31823852 0.99937310 0.83025908 0.65040743 0.62771332
data/protocols/ogse.bvals ADDED
@@ -0,0 +1 @@
 
 
1
+ 0.0 0.0 0.0 0.0 0.0 0.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0
data/protocols/ogse.bvecs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 -0.32949755 0.33414940 -0.60771156 -0.82127342 0.01160086 -0.12962089 0.26498506 0.56912603 -0.40095140 0.11311349 -0.25030891 0.77356999 -0.52375456 0.94083501 -0.63920498 -0.67014535 -0.45412061 0.61922918 0.79604789 -0.78743880 -0.18826079 -0.94768325 -0.58407055 -0.02913436 -0.62545224 -0.33733820 -0.21403085 0.00421902 0.58345560 -0.35220267 0.45295742 0.91782422 0.94162595 0.28168108 0.79855761 0.99971654 0.48125673 0.36140462 0.24089400 0.26950471 -0.82478877 0.13564362 0.29332779 0.56938601 -0.08884351 -0.33494071 -0.85970235 0.73020584 0.92961722 -0.03034955 -0.60692305 0.80574615 0.08571867 -0.94986286 -0.03689412 -0.79313489 0.57162376 0.36836640 -0.49539615 0.74900912
2
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.62521613 0.94159591 -0.79319112 -0.55746095 0.64167929 -0.22505973 0.77269471 -0.07315939 0.80871935 0.37214629 -0.52159459 -0.63362406 0.30920269 -0.33860613 -0.01697085 0.68218268 -0.25517415 0.28358495 -0.51642751 0.22226796 0.96109710 0.09345700 -0.46706432 -0.91920621 0.52925384 -0.89663480 0.37609875 0.07555217 0.60185062 -0.73383382 -0.38312209 -0.21553952 0.10663176 -0.64667073 0.02326119 -0.01496704 0.81874023 0.15874085 -0.16398178 -0.95125392 -0.13803286 0.94259977 -0.84100598 -0.78380323 0.84364697 0.05931043 0.41440579 -0.31387397 0.35178020 -0.74332437 -0.70211555 0.37244222 -0.45885689 -0.22994878 -0.99750236 -0.42974786 -0.61279834 0.51158361 0.86507917 0.62230435
3
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.70748580 0.04172908 0.03917277 0.12143827 0.76688533 0.96568450 0.57682390 0.81898917 0.43036146 0.92125592 0.81564976 0.01048864 0.79377255 0.01324307 0.76884913 0.29245853 0.85361621 0.73221227 0.31561113 0.57492355 0.20211443 0.30522492 0.66386182 0.39269726 0.57332353 0.28679084 0.90152123 0.99713292 0.54530302 0.58089690 0.80501369 0.33337877 0.31932779 0.70885311 0.60146876 0.01851587 0.31313957 0.91879706 0.95659817 0.14994393 0.54833422 0.30513354 0.45460724 0.24789529 0.52949658 0.94037067 0.29862973 0.60686289 0.10982952 0.66824231 0.37239518 0.46049966 0.88436569 0.21185870 0.06023173 0.43157134 0.54564134 0.77626568 0.07887130 0.22742611
data/protocols/research.bvals ADDED
@@ -0,0 +1 @@
 
 
1
+ 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 1000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 2000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0 3000.0
data/protocols/research.bvecs ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.59576860 0.93816635 -0.98905967 0.62451169 0.60683766 0.46290349 0.52148610 0.67795329 0.57111780 -0.17229806 -0.60572887 0.30809534 0.09534500 -0.77316638 0.09213804 -0.93519076 -0.55432683 0.81317227 0.87438895 -0.32853522 -0.56029699 0.97598393 -0.23544716 0.73124810 -0.00744073 -0.91070654 -0.16119654 0.09450063 0.39933516 0.57567786 -0.50070270 -0.83540161 0.98983934 -0.60281077 0.26703580 0.14275761 0.76160413 0.22068804 0.46004974 -0.16201257 -0.76341115 -0.91346498 0.19530566 -0.83054568 -0.07599847 -0.32379331 0.20157094 -0.60199623 -0.96376113 -0.43532233 -0.37974241 -0.67767550 0.33952724 0.44205976 -0.70961734 0.80187651 0.74817156 0.36770201 -0.86826836 -0.41544466 0.88698753 -0.58844532 0.67213557 -0.48615765 0.05142893 -0.36298452 0.90965478 -0.05848180 0.42943351 -0.78648992 -0.65587245 -0.39711485 -0.74887479 0.36635901 -0.05166445 -0.35496186 0.04103047 -0.12970923 0.06803766 -0.79174685 -0.18015876 0.82118333 -0.24712053 0.33019542 0.62372888 0.18643620 -0.40296738 0.19068873 0.92264726 -0.12540350 -0.33939275 0.16937678 0.89443500 0.34132607 -0.11320959 -0.81549102 0.64271660 -0.56923596 -0.76110906 -0.18178130 -0.28013937 -0.91360323 0.62079660 -0.46983928 0.38745233 -0.38416406 0.86822315 0.38120650 0.97531028 0.14051826 0.09884027 0.91267749 0.12291292 0.09417199 0.03506780 0.75051710 -0.63253219 -0.93656587 -0.74230411 -0.17556086 -0.90079207 0.56642162 -0.07126760 -0.38884372 -0.81568460 -0.07870329 0.77216408 0.34610728 -0.14906497 0.49412932 -0.41089422 -0.70913609 0.73202853 0.04978052 0.25106660 0.14193245 -0.75510751 0.66482405 -0.54595570 -0.64863191 0.86777392 -0.61767795 0.38730006 0.53672683 -0.96571430 -0.08597049 -0.19297778 -0.01677332 -0.42439546 0.58300144 0.95287282 0.78830792 -0.15891744 0.44046530 0.43636594 -0.12828867 0.62440566 -0.62827991 0.05265537 0.30402380 0.72392095 0.89347943 -0.22828902 -0.35081051 -0.86751866 0.30659065 -0.53668306 0.50998312 0.30891840 0.56165136 0.19374169 -0.58996776 -0.53446692 -0.79846068 -0.35390888 -0.97788739 0.75071745 -0.99547768 0.84048957 -0.31808885 0.94484484 -0.84507777 -0.27683998 0.88646476 -0.53324067 -0.41203317 -0.89513761 0.95935617 0.49252736 0.02044253 -0.75474992 -0.27425308 -0.60748804 -0.14677795 0.74445785 0.65184168 0.07207788 0.13286927 -0.77145863 0.12854416 0.84895556 0.38505005 -0.46946387 -0.32491845 0.56555779 0.24922406 0.10881076 0.60604096 0.99616741 -0.67208291 0.73654120 0.00020812 -0.46032509 -0.37727868 -0.61581083 0.35902896 0.36400089 -0.77937346 0.11745982 -0.65124185 0.34467616 -0.59758113 0.51582304 0.71983068 0.52860193 -0.89761540 -0.08842218 0.24092043 -0.82485704 0.33566474 0.53588812 0.86327729 0.39890986 -0.29144269 0.27586801 -0.20370148 -0.90224938 0.24743918 0.68573160 0.92545984 0.79372111 0.00141729 -0.47546969 -0.52426327 -0.43561588 0.43586461 -0.96073423 -0.73996389 -0.35118151 -0.19461791 0.59799415 0.16707138 0.27516140 -0.49623143 -0.64003755 0.84463988 -0.02563607 -0.16777918 0.75162129 -0.24697176 0.06620066 -0.71414354 -0.10757112 -0.15534669 -0.87945643 0.53522975 -0.96915272 -0.01847400 -0.98015195 0.72625438
2
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 -0.34328993 -0.02894166 -0.07537980 -0.77822261 0.16522689 -0.09717810 0.72628440 -0.09066748 -0.57192510 -0.42570880 -0.06645117 0.95007460 -0.54106777 0.62788656 0.60148275 -0.34745037 0.23970275 -0.22071348 0.45331689 -0.92347344 -0.82719563 0.16614636 -0.86119434 0.32668414 -0.83868609 0.03894678 0.12418008 0.15327271 -0.76705287 0.53968238 0.69338938 0.29984505 -0.11459020 -0.33206066 0.83397117 0.95050176 -0.46440436 0.39494465 0.39803948 0.97133778 -0.39143607 -0.22386741 -0.90417263 -0.48421087 0.90476887 0.33809415 -0.97354662 -0.58847011 0.18757384 0.53221513 -0.23412573 -0.68869468 -0.32800806 0.86362704 0.58171516 -0.56702420 0.53449434 -0.54890950 0.43490556 0.90722617 0.26762841 0.77273332 0.71376276 -0.79077834 0.99864256 -0.69682954 -0.30236529 0.38688077 -0.87328527 -0.61725755 0.42880755 0.03881415 0.13169272 0.15159132 -0.96429629 0.86683029 0.78388462 -0.13470650 -0.30775216 -0.13527179 0.59493900 0.07050457 0.76497525 0.64939030 -0.70278082 -0.72691934 -0.49299344 -0.08314931 -0.38487194 -0.66612704 -0.76522024 0.28827414 -0.44369383 0.07146179 0.94669326 0.42041533 0.31113019 -0.73033240 0.24207340 -0.97875668 -0.58813087 0.14427350 -0.61873144 -0.38421131 -0.77768481 0.84994249 0.49572154 0.86362573 0.05213835 0.97147518 0.72495971 0.33554201 0.88218359 -0.02003787 -0.29239537 0.45134487 0.66200677 0.30802605 -0.65774550 -0.08149437 -0.37795979 -0.77430176 -0.75072201 0.51297984 -0.02603652 0.18323856 0.58426463 0.74015658 0.82866461 -0.86769512 0.70423284 -0.38430215 -0.16766046 -0.96939287 -0.45494868 -0.86386005 -0.53601743 -0.41311978 -0.82943151 -0.18515687 0.20623515 0.06647726 0.91913452 0.55014454 -0.07461521 0.99616771 0.39808667 -0.54237055 -0.11408583 0.70047299 -0.23070901 -0.54778626 0.65259125 -0.56524431 0.30300002 -0.89603466 0.76911011 0.49328279 0.50839647 -0.19727400 -0.68756584 -0.07298230 -0.34776838 -0.89275958 -0.23690190 -0.92239684 0.30665494 -0.28982189 0.53553365 -0.00057833 -0.68829445 0.78718234 -0.58433993 0.57476084 0.15904028 -0.20890286 0.09035692 0.08881355 -0.33727115 0.94161979 -0.07278939 0.42737516 0.50462476 0.45055703 0.43525339 0.66745109 -0.11119547 0.18188953 0.85647022 0.87327104 0.31801063 0.93861836 -0.74126614 0.71700485 -0.47129467 -0.30718999 0.06162447 0.73130663 -0.30652513 -0.95586512 0.37808679 0.71043815 -0.01998251 -0.59424799 -0.80730057 -0.17827675 -0.82406971 0.12498971 -0.06188978 -0.74004371 0.64270367 0.53716192 -0.36470688 0.25424118 0.17272700 -0.86935053 -0.74307542 0.02510924 -0.42406819 0.56495917 0.07704918 -0.49651760 -0.12763604 0.30301213 -0.54601361 -0.33753347 -0.91631661 0.96247567 -0.54996550 -0.93995524 0.50041170 0.11962207 -0.38316851 -0.21390578 0.87976651 0.04508451 0.16816510 -0.60994601 0.55493582 -0.32361836 -0.56216559 0.96810208 -0.70511581 0.84300859 -0.89431241 0.31580809 0.24785562 -0.54227393 -0.86733432 -0.78135457 -0.69126622 0.31346996 0.53654334 0.78059548 -0.17313862 -0.23968231 -0.19664312 -0.97660760 -0.05244464 0.85364998 -0.99780619 0.65146757 0.30182409 -0.44708130 0.47460028 0.75394102 -0.24229357 -0.65640243 -0.02491093 -0.68741560
3
+ 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.00000000 0.72609352 0.34497286 0.12680240 0.06599022 0.77746262 0.88106571 0.44784285 0.72949212 0.58883457 0.88830479 0.79289140 0.04935098 0.83555670 0.08928721 0.79355471 0.06853090 0.79703472 0.53855029 0.17305420 0.19814494 0.04259901 0.14089267 0.45045416 0.59879353 0.54456431 0.41121374 0.97907864 0.98365497 0.50215667 0.61427839 0.51817755 0.46064857 0.08418529 0.72550321 0.48289127 0.27598311 0.45198201 0.89180464 0.79367424 0.17393922 0.51379103 0.33980158 0.37990465 0.27519758 0.41906746 0.88365731 0.10759245 0.53972536 0.18968538 0.72611399 0.89497533 0.25778975 0.88155089 0.24234584 0.39755591 0.18835502 0.39313498 0.75066210 0.23867804 0.06592729 0.37633517 0.23793974 0.19691747 0.37191446 0.00825285 0.61860394 0.28478662 0.92027341 0.23012974 0.02065724 0.62125309 0.91694779 0.64949486 0.91804202 0.25973726 0.35015328 0.61954935 0.98235924 0.94903081 0.59568320 0.78332012 0.56629237 0.59476408 0.68502790 0.34214237 0.66093102 0.77108674 0.97812269 0.02440558 0.73522019 0.54703798 0.94244869 0.05587325 0.93722448 0.30158822 0.39777551 0.70008098 0.37760430 0.60175864 0.09482047 0.75869890 0.38015035 0.48143845 0.79475324 0.49506264 0.36057695 0.02118273 0.32989726 0.21459601 0.19102511 0.68166269 0.23330527 0.45458171 0.99535427 0.95565433 0.48271305 0.40205729 0.16722536 0.12788851 0.98108973 0.21382246 0.28217607 0.65676281 0.76527920 0.57791066 0.97991297 0.24979487 0.57652233 0.53953183 0.05423453 0.57898363 0.59113270 0.66032129 0.24041500 0.85439292 0.48332287 0.37748902 0.62236727 0.11821902 0.73823681 0.45214543 0.78361644 0.07204445 0.63973845 0.24865328 0.01609243 0.89682026 0.83997193 0.89826105 0.41163929 0.19699424 0.28018000 0.74085755 0.69748778 0.84721648 0.42504576 0.13633491 0.60161155 0.85951175 0.93201529 0.05649487 0.44313440 0.90936312 0.28268766 0.43736583 0.23491750 0.78608781 0.80988918 0.78598545 0.82737381 0.69908148 0.17967194 0.61064880 0.17919408 0.92165866 0.00979013 0.65441496 0.03370965 0.42405833 0.11032524 0.31932731 0.32123829 0.81774903 0.10572887 0.72540256 0.62027229 0.43169923 0.21575899 0.15451722 0.48680571 0.57377853 0.20923867 0.28545189 0.68143985 0.47293111 0.69335181 0.99549345 0.66898159 0.55757862 0.26419361 0.36922194 0.58907902 0.88272554 0.73572911 0.16855317 0.95189536 0.55594004 0.78555199 0.06180726 0.02529581 0.21080574 0.84347912 0.80937612 0.89051795 0.76872778 0.33959955 0.56155344 0.62605634 0.89798071 0.50666083 0.93555426 0.62957690 0.84713377 0.62452176 0.64996087 0.28347443 0.39057060 0.12489169 0.13095346 0.06175207 0.68000886 0.49034976 0.83309832 0.93236553 0.38717920 0.97799442 0.39707249 0.75282117 0.47097601 0.19696510 0.23232877 0.25055210 0.52606108 0.12035172 0.10219646 0.84278544 0.12472902 0.39798545 0.35270770 0.59296620 0.40565257 0.93478540 0.79775150 0.38003298 0.74858197 0.47868139 0.98013993 0.13449069 0.65750665 0.45857023 0.00052158 0.25610353 0.94727539 0.88090052 0.03620729 0.38092263 0.04513119 0.75418470 0.19667640 0.00379049
requirements.txt CHANGED
@@ -17,7 +17,7 @@ matplotlib>=3.8
17
  plotly>=5.20
18
  pillow>=10
19
  dipy>=1.9
20
- # dmipy-sim: main at the merge of dmipy-sim#598 (the field declared by the spec's pools)
21
- dmipy-sim @ git+https://github.com/dmrai-lab/dmipy-sim@1b79d2e06a958578bc523c84bacf54bae86fbb95
22
  dmipy-fit @ git+https://github.com/dmrai-lab/dmipy-fit@c3dcc546595cbde8e8e7c7bb2ff4e87f099d5dab
23
  dmipy-tract @ git+https://github.com/dmrai-lab/dmipy-tract@7da22c3a9473c50a08cc7c6246ec16b68a64d877
 
17
  plotly>=5.20
18
  pillow>=10
19
  dipy>=1.9
20
+ # dmipy-sim: main at the merge of #828 (the columnar field window; after #823, the streamed time-segment image)
21
+ dmipy-sim @ git+https://github.com/dmrai-lab/dmipy-sim@8d7b97f982e0c10e744d0065dd2cafbcc1c6d781
22
  dmipy-fit @ git+https://github.com/dmrai-lab/dmipy-fit@c3dcc546595cbde8e8e7c7bb2ff4e87f099d5dab
23
  dmipy-tract @ git+https://github.com/dmrai-lab/dmipy-tract@7da22c3a9473c50a08cc7c6246ec16b68a64d877
space/app.py CHANGED
@@ -31,11 +31,14 @@ from dataclasses import replace
31
 
32
  from . import pipeline as P
33
  from . import sources
 
34
  from . import viewers as V
35
 
36
  MAX_SHELLS = 4
37
  CUSTOM = "custom shells"
38
  UPLOADED = "uploaded scheme"
 
 
39
  PER_SHELL = 7 # on, timing class, b, directions, delta, Delta, TE
40
  NO_KNOB = P.NO_KNOB
41
  IDEAL = P.IDEAL
@@ -44,9 +47,12 @@ SAMPLE = 10_000 # streamlines kept in t
44
  OUTPUTS = ("result", "headline", "dwi_view", "tract_view", "mats", "timings", "tck", "volumes", "z_slider", "m_slider",
45
  "tract_view_b", "mats_b", "b_row", "floor_view", "accuracy", "spread_view",
46
  "explore_view", "metric_view", "ingredient_pool", "ingredient_contact", "ingredient_field", "layer_table", "layer_choice",
47
- "ez_slider", "em_slider", "truth_view", "fractions_view", "lobar_view", "roundtrip", "response_view") # the run button's outputs, in order
 
48
  EXPLORE_MODES = ("signal", "minus the previous layer", "B − A", "(B − A) ÷ floor")
49
- METRICS = ("DWI", "MD (µm²/ms)", "FA")
 
 
50
  _state = {"source": None, "error": None}
51
  _lock = threading.Lock()
52
  _RUNS = tempfile.mkdtemp(prefix="disco-runs-") # one directory per run tag, replaced on every run
@@ -104,9 +110,11 @@ def gradient_text(cfg, protocol, shapes, scanner):
104
  """The per-shell gradient table as Markdown, and whether every shell is playable on the menu's ``scanner``."""
105
  rows = P.playable(protocol, shapes, P.machine(cfg, scanner))
106
  lines = ["| shell | b (s/mm²) | timing | needs | limit |", "|---|---|---|---|---|"]
 
107
  for name, b, delta, Delta, G, G_max, ok in rows:
108
  limit = "any" if G_max is None else f"{G_max * 1e3:.0f} mT/m {'✓' if ok else '✗ cannot play'}"
109
- lines.append(f"| {name} | {b:g} | δ {delta * 1e3:g} / Δ {Delta * 1e3:g} ms | {G * 1e3:.0f} mT/m | {limit} |")
 
110
  return "\n".join(lines), all(r[-1] for r in rows)
111
 
112
 
@@ -196,8 +204,20 @@ def _fill(source, runs, ladder_on, prepared):
196
  return out, computed, time.perf_counter() - t0
197
 
198
 
 
 
 
 
 
 
199
  def _result_state(res, sample, load_seconds):
200
- """What the results tab needs, kept per session: float32 volumes, the peaks, the streamline sample, the numbers."""
 
 
 
 
 
 
201
  pk, amp = P.peaks(res.sh)
202
  fr = res.extras.get("fractions")
203
  return dict(dwi=res.dwi.astype(np.float32), floor=res.floor.astype(np.float32), floor_median=P.floor_stats(res)["median"], meas=res.meas,
@@ -222,11 +242,15 @@ def _explorer_state(results, ladder, ingredients, knob, source):
222
  layers = list(ladder) + [(name_a, ra.clean)] + ([(name_b, results["B"].clean)] if "B" in results else [])
223
  metrics = {}
224
  for label, vol in layers:
225
- try:
226
- md, fa = P.dti(vol, ra.meas, mask)
227
- except ValueError: # too few low-b rows for a tensor: no metric maps
228
- md = fa = None
229
- metrics[label] = (None if md is None else md.astype(np.float16), None if fa is None else fa.astype(np.float16))
 
 
 
 
230
  return dict(layers=[(label, vol.astype(np.float16)) for label, vol in layers], metrics=metrics, floor=ra.floor.astype(np.float32),
231
  meas=ra.meas, mask=mask, ingredients=source.ingredient_layers(ingredients), differences=P.layer_differences(layers, ra.meas, mask),
232
  snr=ra.snr, physics=ra.physics)
@@ -255,6 +279,10 @@ def _write_files(tag, res, source):
255
  sample as .tck, the DWI and FOD volumes, the source's own files; ``(sample, {"tck": [...], "volumes": [...]})``."""
256
  out = os.path.join(_RUNS, tag); shutil.rmtree(out, ignore_errors=True); os.makedirs(out)
257
  stem = f"{source.describe(source.cfg)['files']}_{tag}_{res.protocol.name.replace(' ', '_')}"
 
 
 
 
258
  tck = os.path.join(out, f"{stem}.tck"); res.tractogram.to_tck(tck)
259
  sample = P.sample_tractogram(res.tractogram, SAMPLE)
260
  sample_path = os.path.join(out, f"{stem}_sample{SAMPLE // 1000}k.tck"); sample.to_tck(sample_path)
@@ -262,24 +290,36 @@ def _write_files(tag, res, source):
262
  return sample, dict(tck=[tck, sample_path], volumes=[vols["dwi"], vols["bvals"], vols["bvecs"], vols["fod"]] + source.extra_files(res, out, stem))
263
 
264
 
265
- def explore(ex, layer, mode, metric, z, m):
 
 
 
 
 
 
 
 
 
 
266
  """The explorer's map: ``layer`` of the state's layers, in ``mode`` (the signal, its difference to the previous
267
- layer, B minus A, or that over the replay floor), as the DWI at measurement ``m`` or a tensor metric, slice ``z``."""
 
268
  if not ex:
269
  return None
270
  labels = [l for l, _ in ex["layers"]]; vols = dict(ex["layers"])
271
  if layer not in vols:
272
  layer = labels[-1]
273
- z = int(z); m = min(int(m), len(ex["meas"].bvals) - 1)
274
  k = METRICS.index(metric) if metric in METRICS else 0
275
 
276
  def field(label):
277
  if k == 0:
278
  return np.asarray(vols[label], np.float32)[..., m]
279
- md, fa = ex["metrics"][label]
280
- v = md if k == 1 else fa
281
  return None if v is None else np.asarray(v, np.float32)
282
- what = f"{metric} of {layer}" if k else f"{layer}: b = {ex['meas'].bvals[m]:g} s/mm², measurement {m}"
 
 
283
  x = field(layer)
284
  if x is None:
285
  return None
@@ -355,7 +395,7 @@ def compute(prepared, preset, n_b0, snr_on, snr, density, max_angle, step_mm, ke
355
  yield (f"**Replay DWI Explorer · the ingredient maps** … ({time.perf_counter() - t0:.0f} s so far)", 0.85)
356
  t = time.perf_counter(); ingredients = source.ingredients(meas_a, physics_a, prepared["runs"]["A"]); seconds["explorer · ingredients"] = time.perf_counter() - t
357
  spread = None
358
- if int(n_keys) > 1: # A's tracking repeated over further keys: the tractogram's own spread
359
  keys = [int(key) + 1 + i for i in range(int(n_keys) - 1)]
360
  mats = [results["A"].matrix]; scores = [results["A"].score]
361
  for i, (k, M, sc, secs) in enumerate(P.repeat_tracking(results["A"], source, tracking, keys)):
@@ -397,9 +437,11 @@ def present(payload, state):
397
  post["page · states"] = time.perf_counter() - t; t = time.perf_counter()
398
  labels = [l for l, _ in ex["layers"]]; name_a = labels[-2] if "B" in results else labels[-1]
399
  ra = rs["A"]; rb = rs.get("B")
400
- headline = source.score_text("A", results["A"])
 
 
401
  if rb:
402
- headline += "<br>" + source.score_text("B", results["B"]) + "<br>" + source.compare_text(source.compare(results["A"], results["B"]), knob)
403
  if spread:
404
  headline += (f"<br>**A over {spread['n']} tracker keys: {spread['key']} {spread['pearson_mean']:.3f} ± {spread['pearson_std']:.3f}**, "
405
  f"median pair count CV {spread['cv_median']:.2f}, {spread['pairs_always']} pairs in every run, {spread['pairs_any']} in any.")
@@ -413,26 +455,49 @@ def present(payload, state):
413
  rs["explorer"] = ex # the page state: A, B and the explorer's layers
414
  box = V.grid_box(ra["shape"], source.affine) if d["views"]["truth"] else ra["shape"]
415
  pa = source.cached(results["A"].meas, results["A"].physics)
416
- out = (rs, headline, V.dwi_slice(ra["dwi"], ra["meas"], z0, m0, peaks=ra["peaks"], peak_amp=ra["peak_amp"], overlay=True, label="A: "),
417
- V.tractogram3d(ra["tractogram"], regions, box, total=ra["n_streamlines"]),
418
- source.matrices(results["A"].matrix, results["A"].score, results["A"].reference),
 
 
419
  timings, [t_ for f in files.values() for t_ in f["tck"]], [v for f in files.values() for v in f["volumes"]],
420
  _slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
421
- V.tractogram3d(rb["tractogram"], regions, box, total=rb["n_streamlines"]) if rb else None,
422
- source.matrices(results["B"].matrix, results["B"].score, results["B"].reference) if rb else None,
423
- gr.update(visible=rb is not None),
424
  V.floor_slice(ra["floor"], z0, ra["floor_median"], label="A: ") if d["views"]["floor"] else None, source.accuracy(results["A"]),
425
  V.spread_matrices(spread) if spread else None,
426
  explore(ex, name_a, "minus the previous layer" if len(labels) > 1 else "signal", METRICS[0], z0, m0),
427
- explore(ex, name_a, "signal", METRICS[2], z0, m0), *ingredient_views(ex, z0), layer_table(ex),
428
  gr.update(choices=labels, value=name_a), _slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
429
  source.truth_view(ra["dwi"], ra["meas"], z0, m0), source.fractions_view(ra["extras"], z0),
430
- source.lobar(results["A"].matrix, results["A"].score, results["A"].reference), source.roundtrip_rows(results["A"]),
431
- source.response_view(pa, results["A"].extras))
 
 
432
  timings.append(["page · figures", f"{time.perf_counter() - t:.2f}"])
433
  return out
434
 
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  def run_pipeline(compute_fn, *args, progress=None):
437
  """The run button, a generator: the source's share of the runs the page has cached (:func:`prepare_runs`, a
438
  lookup), then the stage texts of ``compute_fn`` (:func:`compute`, or it wrapped for a GPU pool) into the headline
@@ -523,19 +588,63 @@ def _slider_update(value, maximum):
523
  return gr.update(value=int(value), maximum=int(maximum))
524
 
525
 
526
- def redraw_explorer(rs, layer, mode, metric, z, m):
527
  ex = rs and rs.get("explorer")
528
- return (explore(ex, layer, mode, metric, z, m), explore(ex, layer, mode, METRICS[2] if metric == METRICS[0] else metric, z, m), *ingredient_views(ex, z))
 
529
 
530
 
531
- def redraw_slice(rs, z, m, overlay, which):
532
  if not rs or which not in rs:
533
- return None, None, None, None
534
  r = rs[which]; source = _load()["source"]
535
  views = source.describe(source.cfg)["views"]
536
- return (V.dwi_slice(r["dwi"], r["meas"], int(z), int(m), peaks=r["peaks"], peak_amp=r["peak_amp"], overlay=bool(overlay), label=f"{which}: "),
 
537
  V.floor_slice(r["floor"], int(z), r["floor_median"], label=f"{which}: ") if views["floor"] else None,
538
- source.truth_view(r["dwi"], r["meas"], int(z), int(m)), source.fractions_view(r["extras"], int(z)))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
 
540
 
541
  def ground_truth_views():
@@ -624,7 +733,9 @@ def build(runner=None, cfg=None):
624
  physics_inputs = [widgets[name] for name in panel.fields]
625
  with gr.Row():
626
  snr_on = gr.Checkbox(value=True, label="add Rician noise")
627
- snr = gr.Slider(5, 100, value=30, step=1, label="SNR at M0 (a full water voxel before relaxation; each voxel's b = 0 SNR follows its tissue)")
 
 
628
  density = gr.Slider(tc["density"][0], tc["density"][1], value=tc["density"][2], step=tc["density"][3], label=tc["density"][4])
629
  max_angle = gr.Slider(tc["max_angle"][0], tc["max_angle"][1], value=tc["max_angle"][2], step=tc["max_angle"][3], label=tc["max_angle"][4])
630
  step_mm = gr.Slider(tc["step"][0], tc["step"][1], value=tc["step"][2], step=tc["step"][3], label=tc["step"][4])
@@ -657,6 +768,7 @@ def build(runner=None, cfg=None):
657
  with gr.Row():
658
  ez_slider = gr.Slider(0, 39, value=20, step=1, label="axial slice z")
659
  em_slider = gr.Slider(0, 363, value=0, step=1, label="measurement (DWI only)")
 
660
  with gr.Row():
661
  explore_view = gr.Image(label="the chosen layer and mode", type="pil")
662
  metric_view = gr.Image(label="the same for FA (or the chosen metric)", type="pil")
@@ -671,17 +783,25 @@ def build(runner=None, cfg=None):
671
  m_slider = gr.Slider(0, 363, value=0, step=1, label="measurement")
672
  overlay = gr.Checkbox(value=True, label="FOD principal directions")
673
  which = gr.Radio(["A", "B"], value="A", label="run")
 
674
  with gr.Column(scale=1, visible=d["views"]["truth"]):
675
  truth_view = gr.Image(label="the same slice with the input FOD's principal directions", type="pil")
676
  with gr.Column(scale=1):
677
  tract_view = gr.Plot(label="tractogram A")
678
  fractions_view = gr.Image(label="recovered vs input fractions", type="pil", visible=d["views"]["fractions"])
679
- mats = gr.Image(label="connectome A vs its truth", type="pil")
680
- lobar_view = gr.Image(label="connectome A vs its truth over the lobar groups", type="pil", visible=d["views"]["lobar"])
681
- with gr.Row(visible=False) as b_row:
682
- tract_view_b = gr.Plot(label="tractogram B")
683
- mats_b = gr.Image(label="connectome B vs its truth", type="pil")
684
- spread_view = gr.Image(label="A over N tracker keys: mean and spread per pair", type="pil")
 
 
 
 
 
 
 
685
  roundtrip = gr.Dataframe(headers=["what", "value"], label="the round trip: the reconstruction and the connectome against the input",
686
  interactive=False, wrap=True, visible=d["views"]["roundtrip"])
687
  with gr.Accordion("accuracy: what this replay is an approximation of", open=False):
@@ -699,6 +819,7 @@ def build(runner=None, cfg=None):
699
  def run_with_progress(*args, progress=gr.Progress()):
700
  yield from run_pipeline(compute_fn, *args, progress=progress)
701
  tissue_numbers = [widgets[name] for name in panel.catalogue] + [catalogue_note]
 
702
  field_preset.change(lambda name: [field_presets[name]] + S.catalogue_numbers(cfg, field_presets[name]), inputs=field_preset,
703
  outputs=[widgets["field_T"]] + tissue_numbers, show_progress="hidden")
704
  reset.click(lambda f: S.catalogue_numbers(cfg, f), inputs=widgets["field_T"], outputs=tissue_numbers, show_progress="hidden")
@@ -725,9 +846,11 @@ def build(runner=None, cfg=None):
725
  floor_view=floor_view, accuracy=accuracy, spread_view=spread_view, explore_view=explore_view, metric_view=metric_view,
726
  ingredient_pool=ingredient_pool, ingredient_contact=ingredient_contact, ingredient_field=ingredient_field,
727
  layer_table=layer_tbl, layer_choice=layer_choice, ez_slider=ez_slider, em_slider=em_slider, truth_view=truth_view,
728
- fractions_view=fractions_view, lobar_view=lobar_view, roundtrip=roundtrip, response_view=response_view)
729
- for ctl in (layer_choice, mode, metric, ez_slider, em_slider):
730
- ctl.change(redraw_explorer, inputs=[result, layer_choice, mode, metric, ez_slider, em_slider],
 
 
731
  outputs=[explore_view, metric_view, ingredient_pool, ingredient_contact, ingredient_field], show_progress="hidden")
732
  run_inputs = [preset, n_b0, snr_on, snr, density, max_angle, step_mm, key, scheme_file, knob, scanner, n_keys, ladder_on, *physics_inputs, *shell_inputs]
733
  if runner is not None: # the pool: what the run reserves, live as the inputs change
@@ -736,8 +859,13 @@ def build(runner=None, cfg=None):
736
  outputs=[outputs[name] for name in OUTPUTS], concurrency_limit=1, api_name="run_pipeline") # the endpoint tools/live.py drives
737
  if runner is not None: # the run's responses are now cached on the page: the reservation falls
738
  run_event.then(gpu_seconds_text, inputs=run_inputs, outputs=gpu_text, show_progress="hidden")
739
- for ctl in (z_slider, m_slider, overlay, which):
740
- ctl.change(redraw_slice, inputs=[result, z_slider, m_slider, overlay, which], outputs=[dwi_view, floor_view, truth_view, fractions_view], show_progress="hidden")
 
 
 
 
 
741
  gt_tab.select(ground_truth_views, outputs=[strands_view, gt_matrix])
742
  demo.load(lambda: (_load().get("error") and f"**the source did not load:** {_load()['error']}") or "", outputs=headline)
743
  return demo
 
31
 
32
  from . import pipeline as P
33
  from . import sources
34
+ from . import tensor as T
35
  from . import viewers as V
36
 
37
  MAX_SHELLS = 4
38
  CUSTOM = "custom shells"
39
  UPLOADED = "uploaded scheme"
40
+ SNR_NOTE = ("µFA is noise-limited: the b-tensor preset starts at SNR 100, because at SNR 30 with the tissue at 3 T the "
41
+ "spherical encoding's contrast in the densest strand voxels is below the noise and µFA reads 0 there.")
42
  PER_SHELL = 7 # on, timing class, b, directions, delta, Delta, TE
43
  NO_KNOB = P.NO_KNOB
44
  IDEAL = P.IDEAL
 
47
  OUTPUTS = ("result", "headline", "dwi_view", "tract_view", "mats", "timings", "tck", "volumes", "z_slider", "m_slider",
48
  "tract_view_b", "mats_b", "b_row", "floor_view", "accuracy", "spread_view",
49
  "explore_view", "metric_view", "ingredient_pool", "ingredient_contact", "ingredient_field", "layer_table", "layer_choice",
50
+ "ez_slider", "em_slider", "truth_view", "fractions_view", "lobar_view", "roundtrip", "response_view",
51
+ "tensor_row", "tensor_view", "tensor_pick", "powder_view", "tensor_view_b", "connectome_row") # the run button's outputs, in order
52
  EXPLORE_MODES = ("signal", "minus the previous layer", "B − A", "(B − A) ÷ floor")
53
+ METRICS = ("DWI", "MD (µm²/ms)", "FA", "µFA") # µFA on a b-tensor run (the linear vs spherical powder signal)
54
+ ROWS = ("every row", "LTE", "STE") # a b-tensor run's DWI rows: all, or one encoding's (the measurement slider counts within it)
55
+ PICK = 8 # pixels per voxel of the clickable µFA slice
56
  _state = {"source": None, "error": None}
57
  _lock = threading.Lock()
58
  _RUNS = tempfile.mkdtemp(prefix="disco-runs-") # one directory per run tag, replaced on every run
 
110
  """The per-shell gradient table as Markdown, and whether every shell is playable on the menu's ``scanner``."""
111
  rows = P.playable(protocol, shapes, P.machine(cfg, scanner))
112
  lines = ["| shell | b (s/mm²) | timing | needs | limit |", "|---|---|---|---|---|"]
113
+ from . import classes as C
114
  for name, b, delta, Delta, G, G_max, ok in rows:
115
  limit = "any" if G_max is None else f"{G_max * 1e3:.0f} mT/m {'✓' if ok else '✗ cannot play'}"
116
+ timing = C.describe(shapes[name]) if shapes.get(name, {}).get("kind") == "ogse" else f"δ {delta * 1e3:g} / Δ {Delta * 1e3:g} ms"
117
+ lines.append(f"| {name} | {b:g} | {timing} | {G * 1e3:.0f} mT/m | {limit} |")
118
  return "\n".join(lines), all(r[-1] for r in rows)
119
 
120
 
 
204
  return out, computed, time.perf_counter() - t0
205
 
206
 
207
+ def _tensor_state(tm):
208
+ """A b-tensor run's maps for the page: FA, MD and µFA in float32, the powder averages per (encoding, b), the b-values."""
209
+ return dict(fa=np.asarray(tm["fa"], np.float32), md=np.asarray(tm["md"], np.float32), ufa=np.asarray(tm["ufa"], np.float32),
210
+ powder={k: np.asarray(v, np.float32) for k, v in tm["powder"].items()}, bvals=list(tm["bvals"]))
211
+
212
+
213
  def _result_state(res, sample, load_seconds):
214
+ """What the results tab needs, kept per session: float32 volumes, the peaks, the streamline sample, the numbers; on
215
+ a b-tensor run the maps instead of the peaks, the connectome and the streamlines."""
216
+ if res.protocol.tensor:
217
+ return dict(dwi=res.dwi.astype(np.float32), floor=res.floor.astype(np.float32), floor_median=P.floor_stats(res)["median"], meas=res.meas,
218
+ peaks=None, peak_amp=None, matrix=None, score=None, seconds=res.seconds, load_seconds=load_seconds, tractogram=None,
219
+ n_streamlines=0, shape=res.dwi.shape[:3], name=res.protocol.name, extras=dict(fractions=None),
220
+ tensor=_tensor_state(res.extras["tensor"]))
221
  pk, amp = P.peaks(res.sh)
222
  fr = res.extras.get("fractions")
223
  return dict(dwi=res.dwi.astype(np.float32), floor=res.floor.astype(np.float32), floor_median=P.floor_stats(res)["median"], meas=res.meas,
 
242
  layers = list(ladder) + [(name_a, ra.clean)] + ([(name_b, results["B"].clean)] if "B" in results else [])
243
  metrics = {}
244
  for label, vol in layers:
245
+ ufa = None
246
+ if ra.protocol.tensor: # the b-tensor maps of the noise-free layer: DTI on the linear rows, the powder fit
247
+ tm = T.maps(vol, ra.meas, mask); md, fa, ufa = tm["md"], tm["fa"], tm["ufa"]
248
+ else:
249
+ try:
250
+ md, fa = P.dti(vol, ra.meas, mask)
251
+ except ValueError: # too few low-b rows for a tensor: no metric maps
252
+ md = fa = None
253
+ metrics[label] = tuple(None if x is None else np.asarray(x).astype(np.float16) for x in (md, fa, ufa))
254
  return dict(layers=[(label, vol.astype(np.float16)) for label, vol in layers], metrics=metrics, floor=ra.floor.astype(np.float32),
255
  meas=ra.meas, mask=mask, ingredients=source.ingredient_layers(ingredients), differences=P.layer_differences(layers, ra.meas, mask),
256
  snr=ra.snr, physics=ra.physics)
 
279
  sample as .tck, the DWI and FOD volumes, the source's own files; ``(sample, {"tck": [...], "volumes": [...]})``."""
280
  out = os.path.join(_RUNS, tag); shutil.rmtree(out, ignore_errors=True); os.makedirs(out)
281
  stem = f"{source.describe(source.cfg)['files']}_{tag}_{res.protocol.name.replace(' ', '_')}"
282
+ if res.protocol.tensor: # a b-tensor run: the DWI with its table and encodings, and the maps
283
+ import re
284
+ stem = re.sub(r"[^A-Za-z0-9_.+-]", "_", stem)
285
+ return None, dict(tck=[], volumes=P.write_tensor_volumes(res, out, prefix=stem, affine=source.affine))
286
  tck = os.path.join(out, f"{stem}.tck"); res.tractogram.to_tck(tck)
287
  sample = P.sample_tractogram(res.tractogram, SAMPLE)
288
  sample_path = os.path.join(out, f"{stem}_sample{SAMPLE // 1000}k.tck"); sample.to_tck(sample_path)
 
290
  return sample, dict(tck=[tck, sample_path], volumes=[vols["dwi"], vols["bvals"], vols["bvecs"], vols["fod"]] + source.extra_files(res, out, stem))
291
 
292
 
293
+ def row_of(meas, rows, m):
294
+ """The measurement index the slider's ``m`` names: the ``m``-th row of the encoding ``rows`` (``LTE`` / ``STE``) on
295
+ a b-tensor run, else row ``m``; clipped to the rows there are."""
296
+ enc = getattr(meas, "encoding", None)
297
+ if enc is None or rows not in ROWS[1:]:
298
+ return min(int(m), len(meas.bvals) - 1)
299
+ idx = np.flatnonzero(np.asarray(enc) == rows)
300
+ return int(idx[min(int(m), len(idx) - 1)])
301
+
302
+
303
+ def explore(ex, layer, mode, metric, z, m, rows=ROWS[0]):
304
  """The explorer's map: ``layer`` of the state's layers, in ``mode`` (the signal, its difference to the previous
305
+ layer, B minus A, or that over the replay floor), as the DWI at measurement ``m`` (on a b-tensor run the ``m``-th
306
+ row of the encoding ``rows``) or a metric (MD, FA, and on a b-tensor run µFA), slice ``z``."""
307
  if not ex:
308
  return None
309
  labels = [l for l, _ in ex["layers"]]; vols = dict(ex["layers"])
310
  if layer not in vols:
311
  layer = labels[-1]
312
+ z = int(z); m = row_of(ex["meas"], rows, m)
313
  k = METRICS.index(metric) if metric in METRICS else 0
314
 
315
  def field(label):
316
  if k == 0:
317
  return np.asarray(vols[label], np.float32)[..., m]
318
+ v = ex["metrics"][label][k - 1]
 
319
  return None if v is None else np.asarray(v, np.float32)
320
+ enc = getattr(ex["meas"], "encoding", None)
321
+ tag = "" if enc is None else f" ({enc[m]})"
322
+ what = f"{metric} of {layer}" if k else f"{layer}: b = {ex['meas'].bvals[m]:g} s/mm²{tag}, measurement {m}"
323
  x = field(layer)
324
  if x is None:
325
  return None
 
395
  yield (f"**Replay DWI Explorer · the ingredient maps** … ({time.perf_counter() - t0:.0f} s so far)", 0.85)
396
  t = time.perf_counter(); ingredients = source.ingredients(meas_a, physics_a, prepared["runs"]["A"]); seconds["explorer · ingredients"] = time.perf_counter() - t
397
  spread = None
398
+ if int(n_keys) > 1 and not results["A"].protocol.tensor: # A's tracking repeated over further keys: the tractogram's own spread
399
  keys = [int(key) + 1 + i for i in range(int(n_keys) - 1)]
400
  mats = [results["A"].matrix]; scores = [results["A"].score]
401
  for i, (k, M, sc, secs) in enumerate(P.repeat_tracking(results["A"], source, tracking, keys)):
 
437
  post["page · states"] = time.perf_counter() - t; t = time.perf_counter()
438
  labels = [l for l, _ in ex["layers"]]; name_a = labels[-2] if "B" in results else labels[-1]
439
  ra = rs["A"]; rb = rs.get("B")
440
+ tensor = results["A"].protocol.tensor
441
+ text = (lambda tag, r: tensor_text(tag, r)) if tensor else source.score_text
442
+ headline = text("A", results["A"])
443
  if rb:
444
+ headline += "<br>" + text("B", results["B"]) + ("" if tensor else "<br>" + source.compare_text(source.compare(results["A"], results["B"]), knob))
445
  if spread:
446
  headline += (f"<br>**A over {spread['n']} tracker keys: {spread['key']} {spread['pearson_mean']:.3f} ± {spread['pearson_std']:.3f}**, "
447
  f"median pair count CV {spread['cv_median']:.2f}, {spread['pairs_always']} pairs in every run, {spread['pairs_any']} in any.")
 
455
  rs["explorer"] = ex # the page state: A, B and the explorer's layers
456
  box = V.grid_box(ra["shape"], source.affine) if d["views"]["truth"] else ra["shape"]
457
  pa = source.cached(results["A"].meas, results["A"].physics)
458
+ connectome = not tensor
459
+ t_a, t_pick, t_b = tensor_views(rs, z0)
460
+ out = (rs, headline, V.dwi_slice(ra["dwi"], ra["meas"], z0, m0, peaks=ra["peaks"], peak_amp=ra["peak_amp"], overlay=connectome, label="A: "),
461
+ V.tractogram3d(ra["tractogram"], regions, box, total=ra["n_streamlines"]) if connectome else None,
462
+ source.matrices(results["A"].matrix, results["A"].score, results["A"].reference) if connectome else None,
463
  timings, [t_ for f in files.values() for t_ in f["tck"]], [v for f in files.values() for v in f["volumes"]],
464
  _slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
465
+ V.tractogram3d(rb["tractogram"], regions, box, total=rb["n_streamlines"]) if rb and connectome else None,
466
+ source.matrices(results["B"].matrix, results["B"].score, results["B"].reference) if rb and connectome else None,
467
+ gr.update(visible=rb is not None and connectome),
468
  V.floor_slice(ra["floor"], z0, ra["floor_median"], label="A: ") if d["views"]["floor"] else None, source.accuracy(results["A"]),
469
  V.spread_matrices(spread) if spread else None,
470
  explore(ex, name_a, "minus the previous layer" if len(labels) > 1 else "signal", METRICS[0], z0, m0),
471
+ explore(ex, name_a, "signal", METRICS[3] if tensor else METRICS[2], z0, m0), *ingredient_views(ex, z0), layer_table(ex),
472
  gr.update(choices=labels, value=name_a), _slider_update(z0, ra["dwi"].shape[2] - 1), _slider_update(m0, results["A"].protocol.n_meas - 1),
473
  source.truth_view(ra["dwi"], ra["meas"], z0, m0), source.fractions_view(ra["extras"], z0),
474
+ source.lobar(results["A"].matrix, results["A"].score, results["A"].reference) if connectome else None,
475
+ source.roundtrip_rows(results["A"]) if connectome else None,
476
+ source.response_view(pa, results["A"].extras) if connectome else None,
477
+ gr.update(visible=tensor), t_a, t_pick, None, t_b, gr.update(visible=connectome))
478
  timings.append(["page · figures", f"{time.perf_counter() - t:.2f}"])
479
  return out
480
 
481
 
482
+ def tensor_text(tag, res):
483
+ """The headline of a b-tensor run: the maps' medians over the voxels the ground truth's strands cross, and over the
484
+ densest quarter of them (strand length in the upper quartile) by their crossing count, the run's settings."""
485
+ tm = res.extras["tensor"]; physics = res.physics
486
+ n_dirs, length = _strands()
487
+ sig = np.isfinite(res.dwi[..., 0]); strand = sig & (length > 0)
488
+ dense = strand & (length >= np.quantile(length[strand], 0.75)) if strand.any() else strand
489
+ med = lambda x, m: float(np.nanmedian(np.asarray(x)[m])) if np.any(m) else float("nan")
490
+ snr = "" if res.snr is None else f", SNR {res.snr:g}"
491
+ by = ", ".join(f"{k}{'+' if k == 3 else ''} directions {med(tm['ufa'], dense & ((n_dirs >= 3) if k == 3 else (n_dirs == k))):.2f} / "
492
+ f"{med(tm['fa'], dense & ((n_dirs >= 3) if k == 3 else (n_dirs == k))):.2f}" for k in (1, 2, 3))
493
+ return (f"**{tag}: in the voxels the strands cross, µFA median {med(tm['ufa'], strand):.3f}, FA {med(tm['fa'], strand):.3f}, MD "
494
+ f"{med(tm['md'], strand):.3f} µm²/ms**; the densest quarter by its crossing count, µFA / FA: {by} "
495
+ f"({res.protocol.n_meas} measurements: linear and spherical b-tensors at b {', '.join(f'{b:g}' for b in tm['bvals'])} s/mm², "
496
+ f"{physics.label() if physics else 'bare diffusion'}{snr}, {res.seconds['total']:.1f} s; replay floor median "
497
+ f"{P.floor_stats(res)['median']:.4f}). µFA is the anisotropy the spherical encoding removes and the orientation "
498
+ "dispersion does not: high where FA is low in crossings. Click the µFA slice for a voxel's powder curves.")
499
+
500
+
501
  def run_pipeline(compute_fn, *args, progress=None):
502
  """The run button, a generator: the source's share of the runs the page has cached (:func:`prepare_runs`, a
503
  lookup), then the stage texts of ``compute_fn`` (:func:`compute`, or it wrapped for a GPU pool) into the headline
 
588
  return gr.update(value=int(value), maximum=int(maximum))
589
 
590
 
591
+ def redraw_explorer(rs, layer, mode, metric, z, m, rows=ROWS[0]):
592
  ex = rs and rs.get("explorer")
593
+ second = (METRICS[3] if ex and ex["meas"].encoding is not None else METRICS[2]) if metric == METRICS[0] else metric
594
+ return (explore(ex, layer, mode, metric, z, m, rows), explore(ex, layer, mode, second, z, m, rows), *ingredient_views(ex, z))
595
 
596
 
597
+ def redraw_slice(rs, z, m, overlay, which, rows=ROWS[0]):
598
  if not rs or which not in rs:
599
+ return None, None, None, None, None, None, None
600
  r = rs[which]; source = _load()["source"]
601
  views = source.describe(source.cfg)["views"]
602
+ m = row_of(r["meas"], rows, m)
603
+ return (V.dwi_slice(r["dwi"], r["meas"], int(z), m, peaks=r["peaks"], peak_amp=r["peak_amp"], overlay=bool(overlay) and r["peaks"] is not None, label=f"{which}: "),
604
  V.floor_slice(r["floor"], int(z), r["floor_median"], label=f"{which}: ") if views["floor"] else None,
605
+ source.truth_view(r["dwi"], r["meas"], int(z), m), source.fractions_view(r["extras"], int(z)), *tensor_views(rs, int(z)))
606
+
607
+
608
+ def tensor_views(rs, z):
609
+ """A b-tensor run's maps at slice ``z`` for A (with the ground truth's crossings outlined) and B, and the clickable
610
+ µFA slice of A; Nones on any other run."""
611
+ if not rs or "tensor" not in rs.get("A", {}) or rs["A"]["tensor"] is None:
612
+ return None, None, None
613
+ crossing = _crossings()
614
+ a = V.tensor_maps(rs["A"]["tensor"], crossing, z, label="A: ")
615
+ b = V.tensor_maps(rs["B"]["tensor"], crossing, z, label="B: ") if rs.get("B") and rs["B"].get("tensor") else None
616
+ return a, V.map_picker(rs["A"]["tensor"]["ufa"][:, :, z], PICK, vmin=0.0, vmax=1.0), b
617
+
618
+
619
+ def pick_voxel(rs, z, evt):
620
+ """The powder curve (linear and spherical, A and dashed B) of the voxel clicked on the µFA slice: ``evt.index`` is
621
+ the click's pixel ``(x, y)``, a voxel ``PICK`` pixels wide, the slice drawn with y up."""
622
+ if not rs or not rs.get("A", {}).get("tensor"):
623
+ return None
624
+ tm = rs["A"]["tensor"]; nx, ny = tm["ufa"].shape[:2]
625
+ x, y = (int(v) for v in evt.index)
626
+ i, j = min(x // PICK, nx - 1), min(ny - 1 - y // PICK, ny - 1)
627
+ other = rs["B"]["tensor"] if rs.get("B") and rs["B"].get("tensor") else None
628
+ return V.powder_curve(tm, (i, j, int(z)), other=other)
629
+
630
+
631
+ _CROSSINGS = {}
632
+
633
+
634
+ def _crossings():
635
+ """The voxels the ground truth's strands cross in two or more directions (the source's strand configuration),
636
+ computed once per process."""
637
+ return _strands()[0] >= 2
638
+
639
+
640
+ def _strands():
641
+ """``(n_dirs, length)`` per voxel of the ground truth's strands (the source's strand configuration), computed once
642
+ per process."""
643
+ if "c" not in _CROSSINGS:
644
+ from .sources.disco import strand_configuration
645
+ n_dirs, _, length = strand_configuration(shape=_load()["source"].mask.shape)
646
+ _CROSSINGS["c"] = (np.asarray(n_dirs), np.asarray(length))
647
+ return _CROSSINGS["c"]
648
 
649
 
650
  def ground_truth_views():
 
733
  physics_inputs = [widgets[name] for name in panel.fields]
734
  with gr.Row():
735
  snr_on = gr.Checkbox(value=True, label="add Rician noise")
736
+ snr = gr.Slider(5, 100, value=P.preset_snr(cfg, presets[0]), step=1, label="SNR at M0 (a full water voxel before relaxation; each voxel's b = 0 SNR follows its tissue)")
737
+ if any("tensor" in p for p in (cfg.get("presets") or {}).values()):
738
+ gr.Markdown(SNR_NOTE)
739
  density = gr.Slider(tc["density"][0], tc["density"][1], value=tc["density"][2], step=tc["density"][3], label=tc["density"][4])
740
  max_angle = gr.Slider(tc["max_angle"][0], tc["max_angle"][1], value=tc["max_angle"][2], step=tc["max_angle"][3], label=tc["max_angle"][4])
741
  step_mm = gr.Slider(tc["step"][0], tc["step"][1], value=tc["step"][2], step=tc["step"][3], label=tc["step"][4])
 
768
  with gr.Row():
769
  ez_slider = gr.Slider(0, 39, value=20, step=1, label="axial slice z")
770
  em_slider = gr.Slider(0, 363, value=0, step=1, label="measurement (DWI only)")
771
+ erows = gr.Radio(list(ROWS), value=ROWS[0], label="rows (a b-tensor run: the measurement counts within the encoding)")
772
  with gr.Row():
773
  explore_view = gr.Image(label="the chosen layer and mode", type="pil")
774
  metric_view = gr.Image(label="the same for FA (or the chosen metric)", type="pil")
 
783
  m_slider = gr.Slider(0, 363, value=0, step=1, label="measurement")
784
  overlay = gr.Checkbox(value=True, label="FOD principal directions")
785
  which = gr.Radio(["A", "B"], value="A", label="run")
786
+ rows_radio = gr.Radio(list(ROWS), value=ROWS[0], label="rows (b-tensor runs)")
787
  with gr.Column(scale=1, visible=d["views"]["truth"]):
788
  truth_view = gr.Image(label="the same slice with the input FOD's principal directions", type="pil")
789
  with gr.Column(scale=1):
790
  tract_view = gr.Plot(label="tractogram A")
791
  fractions_view = gr.Image(label="recovered vs input fractions", type="pil", visible=d["views"]["fractions"])
792
+ with gr.Column(visible=False) as tensor_row: # a b-tensor run: the maps and a voxel's powder curves
793
+ tensor_view = gr.Image(label="b-tensor maps of A: FA, µFA, MD (the slice above)", type="pil")
794
+ with gr.Row():
795
+ tensor_pick = gr.Image(label="µFA of A: click a voxel", type="pil", interactive=False)
796
+ powder_view = gr.Image(label="the clicked voxel's powder signal: linear and spherical, A and B (dashed)", type="pil")
797
+ tensor_view_b = gr.Image(label="b-tensor maps of B", type="pil")
798
+ with gr.Column() as connectome_row:
799
+ mats = gr.Image(label="connectome A vs its truth", type="pil")
800
+ lobar_view = gr.Image(label="connectome A vs its truth over the lobar groups", type="pil", visible=d["views"]["lobar"])
801
+ with gr.Row(visible=False) as b_row:
802
+ tract_view_b = gr.Plot(label="tractogram B")
803
+ mats_b = gr.Image(label="connectome B vs its truth", type="pil")
804
+ spread_view = gr.Image(label="A over N tracker keys: mean and spread per pair", type="pil")
805
  roundtrip = gr.Dataframe(headers=["what", "value"], label="the round trip: the reconstruction and the connectome against the input",
806
  interactive=False, wrap=True, visible=d["views"]["roundtrip"])
807
  with gr.Accordion("accuracy: what this replay is an approximation of", open=False):
 
819
  def run_with_progress(*args, progress=gr.Progress()):
820
  yield from run_pipeline(compute_fn, *args, progress=progress)
821
  tissue_numbers = [widgets[name] for name in panel.catalogue] + [catalogue_note]
822
+ preset.change(lambda name: P.preset_snr(cfg, name), inputs=preset, outputs=snr, show_progress="hidden")
823
  field_preset.change(lambda name: [field_presets[name]] + S.catalogue_numbers(cfg, field_presets[name]), inputs=field_preset,
824
  outputs=[widgets["field_T"]] + tissue_numbers, show_progress="hidden")
825
  reset.click(lambda f: S.catalogue_numbers(cfg, f), inputs=widgets["field_T"], outputs=tissue_numbers, show_progress="hidden")
 
846
  floor_view=floor_view, accuracy=accuracy, spread_view=spread_view, explore_view=explore_view, metric_view=metric_view,
847
  ingredient_pool=ingredient_pool, ingredient_contact=ingredient_contact, ingredient_field=ingredient_field,
848
  layer_table=layer_tbl, layer_choice=layer_choice, ez_slider=ez_slider, em_slider=em_slider, truth_view=truth_view,
849
+ fractions_view=fractions_view, lobar_view=lobar_view, roundtrip=roundtrip, response_view=response_view,
850
+ tensor_row=tensor_row, tensor_view=tensor_view, tensor_pick=tensor_pick, powder_view=powder_view,
851
+ tensor_view_b=tensor_view_b, connectome_row=connectome_row)
852
+ for ctl in (layer_choice, mode, metric, ez_slider, em_slider, erows):
853
+ ctl.change(redraw_explorer, inputs=[result, layer_choice, mode, metric, ez_slider, em_slider, erows],
854
  outputs=[explore_view, metric_view, ingredient_pool, ingredient_contact, ingredient_field], show_progress="hidden")
855
  run_inputs = [preset, n_b0, snr_on, snr, density, max_angle, step_mm, key, scheme_file, knob, scanner, n_keys, ladder_on, *physics_inputs, *shell_inputs]
856
  if runner is not None: # the pool: what the run reserves, live as the inputs change
 
859
  outputs=[outputs[name] for name in OUTPUTS], concurrency_limit=1, api_name="run_pipeline") # the endpoint tools/live.py drives
860
  if runner is not None: # the run's responses are now cached on the page: the reservation falls
861
  run_event.then(gpu_seconds_text, inputs=run_inputs, outputs=gpu_text, show_progress="hidden")
862
+ for ctl in (z_slider, m_slider, overlay, which, rows_radio):
863
+ ctl.change(redraw_slice, inputs=[result, z_slider, m_slider, overlay, which, rows_radio],
864
+ outputs=[dwi_view, floor_view, truth_view, fractions_view, tensor_view, tensor_pick, tensor_view_b], show_progress="hidden")
865
+
866
+ def on_pick(rs, z, evt: gr.SelectData):
867
+ return pick_voxel(rs, z, evt)
868
+ tensor_pick.select(on_pick, inputs=[result, z_slider], outputs=powder_view, show_progress="hidden")
869
  gt_tab.select(ground_truth_views, outputs=[strands_view, gt_matrix])
870
  demo.load(lambda: (_load().get("error") and f"**the source did not load:** {_load()['error']}") or "", outputs=headline)
871
  return demo
space/brain.toml CHANGED
@@ -173,3 +173,7 @@ response_per_class = 5.6
173
  1 = 1.5 # 36,605 seeds: 0.7 s
174
  2 = 3.5 # 292,840 seeds: 1.9-2.1 s
175
  4 = 35.0 # 2,342,720 seeds: 13.6-23.2 s
 
 
 
 
 
173
  1 = 1.5 # 36,605 seeds: 0.7 s
174
  2 = 3.5 # 292,840 seeds: 1.9-2.1 s
175
  4 = 35.0 # 2,342,720 seeds: 13.6-23.2 s
176
+
177
+ # The noise a run starts with: the SNR at M0 every preset starts at.
178
+ [noise]
179
+ snr = 30
space/classes.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The timing classes as waveforms: each stored class of the time-segment layout is ONE waveform shape (a square-pulse
2
+ PGSE, a stimulated echo, a cosine OGSE with its edges ramped), and a shell on it plays that shape at the amplitude
3
+ its b-value needs, along each of its directions. The shape is built once from the configuration's ``[shapes]`` entry
4
+ by dmipy-sim's own sequence builders (:func:`template`), on the layout's grid (TE 53.5 ms, 1000 samples), and checked
5
+ against the layout's stored partition when the layout loads; :func:`play` scales it to the rows. A class has a fixed
6
+ shape: an OGSE built at b = 1000 s/mm^2 with 200 T/m/s edges plays the same edge durations at every other b (the
7
+ layout stores that shape, nothing else)."""
8
+ from __future__ import annotations
9
+
10
+ import functools
11
+
12
+ import numpy as np
13
+
14
+ KINDS = ("pgse", "pgste", "ogse", "tensor")
15
+ N_T = 1000 # the layout's grid: 1000 samples over the echo time
16
+
17
+
18
+ def kind(sh):
19
+ """The class's pulse kind: ``pgse`` (the default), ``pgste`` or ``ogse``."""
20
+ k = sh.get("kind", "pgse")
21
+ if k not in KINDS:
22
+ raise ValueError(f"a timing class's kind is one of {KINDS}, got {k!r}")
23
+ return k
24
+
25
+
26
+ def _key(sh):
27
+ return tuple(sorted((k, v) for k, v in sh.items() if k != "label"))
28
+
29
+
30
+ @functools.lru_cache(maxsize=None)
31
+ def _template(key):
32
+ import dmipy_sim as d
33
+ sh = dict(key)
34
+ k = kind(sh)
35
+ if k == "pgse":
36
+ return d.pgse([[0.0, 0.0, 1.0]], float(sh["delta"]), float(sh["Delta"]), gradient_strengths=0.1, TE=float(sh["TE"]),
37
+ n_t=N_T, slew_rate=np.inf)
38
+ if k == "pgste": # TM = Delta - delta between the 90s; TE = 2 delta + TM
39
+ return d.pgste([[0.0, 0.0, 1.0]], float(sh["delta"]), float(sh["Delta"]) - float(sh["delta"]), gradient_strengths=0.1,
40
+ n_t=N_T, slew_rate=np.inf, ste_flip_angles=(90.0, 90.0, 90.0))
41
+ return d.ogse([[0.0, 0.0, 1.0]], float(sh["frequency"]), float(sh["duration"]), shape="cosine",
42
+ bvalues=[float(sh["b_built"]) * 1e6], TE=float(sh["TE"]), n_t=N_T, slew_rate=float(sh["slew_rate"]))
43
+
44
+
45
+ def template(sh):
46
+ """The class's waveform along z, one row (a ScannerSequence); a ``tensor`` class is its three shapes, one row each
47
+ along its own axis (:func:`space.tensor.template`): ``pgse`` square lobes ``delta`` wide ``Delta`` apart
48
+ at ``TE``; ``pgste`` square lobes storing for ``TM = Delta - delta``; ``ogse`` a cosine of ``frequency`` (Hz) over
49
+ ``duration`` (s) on each side of the 180, built at ``b_built`` (s/mm^2) with ``slew_rate`` (T/m/s) edges."""
50
+ if kind(sh) == "tensor":
51
+ from . import tensor as T
52
+ return T.template(sh)
53
+ return _template(_key(sh))
54
+
55
+
56
+ def tensor_rows(sh, encodings, bvals, dirs):
57
+ """The rows of a ``tensor`` class: each row's encoding (``b0``, ``LTE``, ``STE``) at its b (s/mm^2) about its axis,
58
+ the physical gradient ``(n, 1000, 3)`` (T/m) -- the class's waveform times the row's level matrix
59
+ (:func:`space.tensor.levels`); a b = 0 row plays nothing."""
60
+ from . import tensor as T
61
+ W = T.waveform(sh)
62
+ G = np.zeros((len(encodings), N_T, 3))
63
+ for i, (e, b, d) in enumerate(zip(encodings, bvals, dirs)):
64
+ if e != "b0" and float(b) > 0:
65
+ G[i] = W @ T.levels(sh, str(e), float(b) * 1e6, d)
66
+ return G
67
+
68
+
69
+ def tensor_peak(sh, encoding, b, n_dirs):
70
+ """The largest per-axis gradient (T/m) of a tensor shell's rows (``n_dirs`` axes of :func:`space.tensor.fibonacci`)."""
71
+ from . import tensor as T
72
+ return float(np.abs(tensor_rows(sh, [encoding] * int(n_dirs), [b] * int(n_dirs), T.fibonacci(int(n_dirs)))).max())
73
+
74
+
75
+ def unit_b(sh):
76
+ """``(profile (n_t,), b per (T/m)^2)``: the class's physical gradient along its axis normalised to a peak of 1, and
77
+ the b-value (s/m^2) of that shape at a peak amplitude of 1 T/m."""
78
+ from dmipy_sim.acquisition.waveforms import calc_b
79
+ t = template(sh)
80
+ G = np.asarray(t.G, np.float64)[0, :, 2]
81
+ peak = float(np.abs(G).max())
82
+ return G / peak, float(calc_b(t)[0]) / peak ** 2
83
+
84
+
85
+ def amplitude(sh, b):
86
+ """The peak gradient (T/m) the class plays to reach ``b`` (s/mm^2)."""
87
+ return float(np.sqrt(max(float(b), 0.0) * 1e6 / unit_b(sh)[1]))
88
+
89
+
90
+ def play(sh, bvals_SI, dirs):
91
+ """The ScannerSequence of rows on the class: its shape at each row's amplitude along each row's unit direction
92
+ (``bvals_SI`` in s/m^2, ``dirs`` ``(n, 3)``); a b = 0 row plays nothing."""
93
+ from dmipy_sim.acquisition.scanner_sequence import ScannerSequence
94
+ t = template(sh)
95
+ prof, unit = unit_b(sh)
96
+ b = np.asarray(bvals_SI, np.float64).ravel()
97
+ u = np.asarray(dirs, np.float64).reshape(-1, 3)
98
+ n = np.linalg.norm(u, axis=1)
99
+ u = np.where(n[:, None] > 0, u / np.where(n > 0, n, 1.0)[:, None], 0.0)
100
+ amp = np.sqrt(np.maximum(b, 0.0) / unit)
101
+ G = prof[None, :, None] * (amp[:, None] * u)[:, None, :]
102
+ return ScannerSequence(G=G.astype(np.float32), dt=t.dt, rf=t.rf, readout=t.readout)
103
+
104
+
105
+ def describe(sh):
106
+ """The class's timing in a few words, for the gradient table."""
107
+ k = kind(sh)
108
+ if k == "tensor":
109
+ return "b-tensor class (three shapes: linear, planar, spherical at any rotation)"
110
+ if k == "ogse":
111
+ return f"cosine {float(sh['frequency']):g} Hz, {float(sh['duration']) * 1e3:g} ms per side"
112
+ if k == "pgste":
113
+ return f"STE δ {float(sh['delta']) * 1e3:g} / Δ {float(sh['Delta']) * 1e3:g} ms"
114
+ return f"δ {float(sh['delta']) * 1e3:g} / Δ {float(sh['Delta']) * 1e3:g} ms"
space/config.toml CHANGED
@@ -3,14 +3,20 @@
3
  source = "disco" # space/sources/disco.py: the DiSCo phantom (demo: its layout, full: its columnar pack)
4
 
5
  [data]
6
- repo = "SubstrateCommons/disco-replay" # the public replay dataset
7
- moments = "disco/moments" # the shape-moment layout written by dmipy-sim's write_shape_moments
8
- revision = "afe5589d09a85b70119773cf2948dcc001fa8433" # the dataset revision the layout is read at (with the background moments, disco-space#29)
9
- source_manifest_sha256 = "90cbd8f238b3946c224b765e9afeb748817f735410cded75e719bf600911d236" # the columnar manifest the layout was contracted from (its manifest.json names it)
 
 
 
10
 
11
- # The timing classes ("shapes") the layout holds: square pulses (slew_rate = inf) at TE = 53.5 ms, PGSE unless
12
- # kind = "pgste" (a stimulated echo storing for TM = Delta - delta). delta / Delta / TE in seconds, the same numbers the
13
- # layout's manifest records (checked at load); label is what the app shows.
 
 
 
14
  [shapes."d10.2-D16.7"]
15
  label = "DiSCo δ 10.2 / Δ 16.7 ms (shells b = 0, 1000, 1925)"
16
  delta = 0.0102
@@ -49,6 +55,31 @@ delta = 0.0076
49
  Delta = 0.0459
50
  TE = 0.0535
51
  kind = "pgste"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  # DiSCo's own protocol: which timing class each shell of the dataset's gradient table plays (b in s/mm^2, the
54
  # table's rows are assigned to the nearest b here).
@@ -61,20 +92,35 @@ shells = [
61
  { b = 13183, shape = "d17.7-D35.8" },
62
  ]
63
 
64
- # Presets the app offers besides "DiSCo 364" (which comes from the dataset's table). b in s/mm^2.
 
 
65
  [presets."clinical b1000 x 30"]
66
- n_b0 = 1
67
- shells = [{ shape = "d12-D24", b = 1000, n_dirs = 30 }]
68
  [presets."research 3-shell x 90"]
69
- n_b0 = 3
70
- shells = [
71
- { shape = "d17-D30", b = 1000, n_dirs = 90 },
72
- { shape = "d17-D30", b = 2000, n_dirs = 90 },
73
- { shape = "d17-D30", b = 3000, n_dirs = 90 },
74
- ]
75
  [presets."Connectome 2.0 b6000 x 60"]
76
- n_b0 = 2
77
- shells = [{ shape = "d8-D20", b = 6000, n_dirs = 60 }]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  # The tissue and the scanner: the field presets the page offers (T), the default field,
80
  # and whether a run starts with the tiers on. Tissue defaults come from dmipy-sim's white-matter catalogue at the
@@ -88,7 +134,7 @@ pools = ["intra", "extra"] # the pack's seeded pools (its spec also nam
88
  # The scanner menu after "ideal": catalogue machines of dmipy-sim (ScannerLimits.of(key)), each applied with every term
89
  # its entry carries (docs/scanner.md); the phantom sits at a distance from isocentre along a scanner axis (the panel's
90
  # placement, at most 7.9 cm: the whole 1 mm cube inside the Swoop's 8 cm anchor). The Swoop's own gradient needs the
91
- # layout's background moments (shape_moments.stamp_background); a layout without them refuses it by name.
92
  [scanners]
93
  machines = [
94
  { label = "Hyperfine Swoop 64 mT (permanent magnet, field across the patient)", key = "hyperfine_swoop_64mT" },
@@ -99,8 +145,8 @@ default_offset_cm = 7.9
99
  max_offset_cm = 7.9
100
  delivery_per_meas = 0.0036 # s per measurement per replay: bore.delivered_moments over DiSCo's 31,802 live voxels, 1.3 s at 364 (a CPU host)
101
 
102
- # The mode: demo = the prebaked shape-moment layout (the hosted Spaces; seconds; the stored pulse
103
- # timings), full = the columnar replay pack itself (minutes per volume; any timing; the scheme upload), read from
104
  # DISCO_COLUMNS (a directory with the columns, or the Hub at 45 MB/s). DISCO_MODE in the environment overrides.
105
  [mode]
106
  mode = "demo"
@@ -108,11 +154,12 @@ columns = "hf://SubstrateCommons/disco-replay/disco"
108
  workers = 16
109
 
110
  # The compute backend: jax (the reference; the Docker Space) or torch (Hugging Face's shared GPU pool, disco-space#2);
111
- # DISCO_BACKEND in the environment overrides. resident = false rebuilds the layout's device tensors on every call
112
- # (a pool that drops the device between calls).
 
113
  [compute]
114
  backend = "jax"
115
- resident = true
116
 
117
  [tracking]
118
  density = 4 # seeds per region voxel = density^3 (4 -> 659,840 seeds, the DiSCo score's setting)
@@ -124,7 +171,8 @@ max_steps = 500
124
  [gate]
125
  pearson_count_min = 0.90
126
  missed_pairs_max = 0
127
- reference_max_abs_diff = 1e-3 # the replay of DiSCo 364 against disco/reference/disco_replay_bare.nii.gz
 
128
 
129
  # The reconstruction: DiSCo is single-fibre white matter, so its CSD is the single-tissue Tournier 2007 one on the
130
  # response white_matter_response_tournier07 estimates from the volume. DISCO_RECONSTRUCTION in the environment overrides.
 
3
  source = "disco" # space/sources/disco.py: the DiSCo phantom (demo: its layout, full: its columnar pack)
4
 
5
  [data]
6
+ repo = "SubstrateCommons/disco-time-segments" # the public time-segment layout of the DiSCo v2 walk
7
+ layout = "disco" # the layout's prefix in the dataset (dmipy_sim.replay.time_segments, format time_segments/2)
8
+ revision = "a38679f061f1487b6688ebb37b7ee949da000ea3" # the dataset revision the layout is read at (the OGSE class cos-f50-T20 and the b-tensor class qte-now-80)
9
+ manifest_sha256 = "0fe044fd6963de74c6174276e35dd2ddc219d9d4ef2a476df0150ada48e23ef9" # the layout manifest the page was accepted on (checked at load)
10
+ pack = "hf://SubstrateCommons/disco-replay/disco" # the replay pack the layout was contracted from: the acceptance test's reference
11
+ pack_revision = "8277ff05ea8237a98f1f8bdfe6a86ce37c4fcdc3"
12
+ strands = "substrate/DiSCo_Strands_Trajectories.tck" # the ground truth's strand centrelines (CC BY 4.0) in the replay dataset, read at pack_revision
13
 
14
+ # The timing classes ("shapes"), each ONE waveform the layout stores (space/classes.py builds it with dmipy-sim's own
15
+ # builders on the layout's grid, TE = 53.5 ms, 1000 samples; checked against the layout's partition at load): square
16
+ # pulses (slew_rate = inf), PGSE unless kind = "pgste" (a stimulated echo storing for TM = Delta - delta) or "ogse"
17
+ # (a cosine of `frequency` Hz over `duration` s on each side of the 180, its edges ramped at `slew_rate` T/m/s as built
18
+ # at `b_built` s/mm^2: the class stores that one shape at every b). delta / Delta / TE in seconds (an OGSE's delta /
19
+ # Delta are the scheme's nominal block and block separation); label is what the app shows.
20
  [shapes."d10.2-D16.7"]
21
  label = "DiSCo δ 10.2 / Δ 16.7 ms (shells b = 0, 1000, 1925)"
22
  delta = 0.0102
 
55
  Delta = 0.0459
56
  TE = 0.0535
57
  kind = "pgste"
58
+ # The paper's cosine OGSE (experiments/disco/disco_acq.py "ogse"): 50 Hz, one 20 ms period each side of the 180.
59
+ [shapes."cos-f50-T20"]
60
+ label = "cosine OGSE 50 Hz, one 20 ms period each side of the 180 (edges 200 T/m/s, as built at b = 1000)"
61
+ kind = "ogse"
62
+ frequency = 50.0
63
+ duration = 0.020
64
+ slew_rate = 200.0
65
+ b_built = 1000.0
66
+ delta = 0.019975
67
+ Delta = 0.020029
68
+ TE = 0.0535
69
+
70
+ # The b-tensor class: three gradient shapes on the spin echo, each in both encoding windows (six stored terms), so one
71
+ # class plays every linear, planar and spherical encoding of them at any rotation and b (space/tensor.py). The shapes
72
+ # are the three axes of NOW's maximum-b spherical encoding (Sjolund et al. 2015) at TE 53.5 ms designed at 80 mT/m and
73
+ # 200 T/m/s per axis (a 4 ms window for the 180), the physical gradient in `waveform` (data/); rotated, a linear or
74
+ # spherical row at b 2000 needs about 124 mT/m on one axis: Connectome-class hardware. delta / Delta are the scheme's
75
+ # nominal encoding windows (the CSD and the scheme never read them: a b-tensor run fits maps, not FODs).
76
+ [shapes."qte-now-80"]
77
+ label = "b-tensor class qte-now-80: NOW spherical encoding at TE 53.5 ms, 80 mT/m and 200 T/m/s per axis (Connectome-class when rotated)"
78
+ kind = "tensor"
79
+ waveform = "classes/qte-now-80.npy"
80
+ delta = 0.0229
81
+ Delta = 0.0253
82
+ TE = 0.0535
83
 
84
  # DiSCo's own protocol: which timing class each shell of the dataset's gradient table plays (b in s/mm^2, the
85
  # table's rows are assigned to the nearest b here).
 
92
  { b = 13183, shape = "d17.7-D35.8" },
93
  ]
94
 
95
+ # Presets the app offers besides "DiSCo 364" (which comes from the dataset's table): the paper's flagship
96
+ # acquisitions (experiments/disco/disco_acq.py), each its gradient table in data/protocols/ (the same files the paper
97
+ # replays: b = 0 rows first, then every shell's directions spread by electrostatic repulsion) on one stored class.
98
  [presets."clinical b1000 x 30"]
99
+ table = "protocols/clinical"
100
+ shape = "d12-D24"
101
  [presets."research 3-shell x 90"]
102
+ table = "protocols/research"
103
+ shape = "d17-D30"
 
 
 
 
104
  [presets."Connectome 2.0 b6000 x 60"]
105
+ table = "protocols/c2"
106
+ shape = "d8-D20"
107
+ [presets."cosine OGSE 50 Hz b1000 x 60"]
108
+ table = "protocols/ogse"
109
+ shape = "cos-f50-T20"
110
+ # Tensor-valued encoding: linear and spherical b-tensors interleaved at four b-values on the b-tensor class, the
111
+ # b-values and direction counts of Szczepankiewicz et al. 2019's clinical protocol (6, 6, 10, 16 per encoding); the
112
+ # same axes for both encodings at each b; one b = 0 row. Its run fits FA (DTI on the linear rows), MD and the
113
+ # microscopic FA (the powder averages), not a connectome.
114
+ [presets."b-tensor: linear + spherical (Szczepankiewicz 2019), b 100-2000 s/mm²"]
115
+ shape = "qte-now-80"
116
+ n_b0 = 1
117
+ snr = 100 # its own starting SNR: µFA is noise-limited (the other presets start at [noise] snr)
118
+ tensor = { bvalues = [100, 700, 1400, 2000], directions = [6, 6, 10, 16], encodings = ["LTE", "STE"] }
119
+
120
+ # The noise a run starts with: the SNR at M0 (a full water voxel before relaxation) every preset starts at unless it
121
+ # states its own.
122
+ [noise]
123
+ snr = 30
124
 
125
  # The tissue and the scanner: the field presets the page offers (T), the default field,
126
  # and whether a run starts with the tiers on. Tissue defaults come from dmipy-sim's white-matter catalogue at the
 
134
  # The scanner menu after "ideal": catalogue machines of dmipy-sim (ScannerLimits.of(key)), each applied with every term
135
  # its entry carries (docs/scanner.md); the phantom sits at a distance from isocentre along a scanner axis (the panel's
136
  # placement, at most 7.9 cm: the whole 1 mm cube inside the Swoop's 8 cm anchor). The Swoop's own gradient needs the
137
+ # layout's background vectors (segment_rows(background=True)); a layout without them refuses it by name.
138
  [scanners]
139
  machines = [
140
  { label = "Hyperfine Swoop 64 mT (permanent magnet, field across the patient)", key = "hyperfine_swoop_64mT" },
 
145
  max_offset_cm = 7.9
146
  delivery_per_meas = 0.0036 # s per measurement per replay: bore.delivered_moments over DiSCo's 31,802 live voxels, 1.3 s at 364 (a CPU host)
147
 
148
+ # The mode: demo = the time-segment layout (the hosted Spaces; seconds; the stored waveforms), full = the columnar
149
+ # replay pack itself (minutes per volume; any timing; the scheme upload), read from
150
  # DISCO_COLUMNS (a directory with the columns, or the Hub at 45 MB/s). DISCO_MODE in the environment overrides.
151
  [mode]
152
  mode = "demo"
 
154
  workers = 16
155
 
156
  # The compute backend: jax (the reference; the Docker Space) or torch (Hugging Face's shared GPU pool, disco-space#2);
157
+ # DISCO_BACKEND in the environment overrides. resident = false streams the layout's packed words to the device a
158
+ # chunk at a time on every image (nothing kept: a pool that drops the device between calls, a GPU smaller than one
159
+ # class's columns, a CPU host); true keeps the columns an image reads on the device across calls.
160
  [compute]
161
  backend = "jax"
162
+ resident = false
163
 
164
  [tracking]
165
  density = 4 # seeds per region voxel = density^3 (4 -> 659,840 seeds, the DiSCo score's setting)
 
171
  [gate]
172
  pearson_count_min = 0.90
173
  missed_pairs_max = 0
174
+ reference_max_abs_diff = 1e-3 # the layout's signal against the replay pack's own replay of the same rows at the gate's voxels, over the bare b = 0 signal
175
+ reference_voxels = 32 # voxels spread over the pack's index the reference is replayed at
176
 
177
  # The reconstruction: DiSCo is single-fibre white matter, so its CSD is the single-tissue Tournier 2007 one on the
178
  # response white_matter_response_tournier07 estimates from the volume. DISCO_RECONSTRUCTION in the environment overrides.
space/pipeline.py CHANGED
@@ -97,13 +97,15 @@ def backend(cfg):
97
  class Shell:
98
  """One shell: the timing class it plays, its b-value (s/mm^2) and its direction count. A shell made by
99
  :meth:`free` carries its own pulse timing (``delta``, ``Delta``, ``TE`` in seconds) instead of a stored class,
100
- and ``shape`` names it."""
 
101
  shape: str
102
  b: float
103
  n_dirs: int
104
  delta: Optional[float] = None
105
  Delta: Optional[float] = None
106
  TE: Optional[float] = None
 
107
 
108
  @classmethod
109
  def free(cls, b, n_dirs, delta, Delta, TE):
@@ -145,16 +147,26 @@ class Protocol:
145
  def n_meas(self):
146
  return self.n_b0 + sum(s.n_dirs for s in self.shells)
147
 
 
 
 
 
 
 
 
 
148
 
149
  @dataclass(frozen=True)
150
  class Measurements:
151
- """Per row: b (s/mm^2), unit direction, timing-class name, delta / Delta / TE (s)."""
 
152
  bvals: np.ndarray
153
  dirs: np.ndarray
154
  shape: np.ndarray
155
  delta: np.ndarray
156
  Delta: np.ndarray
157
  TE: np.ndarray
 
158
 
159
  @property
160
  def b0(self):
@@ -185,7 +197,10 @@ def measurements(protocol, shapes):
185
  b = np.asarray(protocol.bvals, np.float64)
186
  delta = np.array([timing[n]["delta"] for n in names]); Delta = np.array([timing[n]["Delta"] for n in names])
187
  TE = np.array([timing[n]["TE"] for n in names])
188
- return Measurements(b, dirs, np.asarray(names), delta, Delta, TE)
 
 
 
189
 
190
 
191
  def protocol_from_rows(bvals, dirs, group, make_shell, *, name):
@@ -230,15 +245,41 @@ def protocol_from_scheme(path, *, name="uploaded scheme"):
230
  return protocol_from_rows(b, np.asarray(enc.gradient_directions, np.float64), group.ravel(), make_shell, name=name)[0]
231
 
232
 
 
 
 
 
 
 
 
233
  def preset_protocol(cfg, name):
234
- """A ``[presets]`` entry of the config as a Protocol."""
 
 
235
  p = cfg["presets"][name]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  return Protocol(tuple(Shell(s["shape"], float(s["b"]), int(s["n_dirs"])) for s in p["shells"]), n_b0=int(p["n_b0"]), name=name)
237
 
238
 
239
  def retime(protocol, shape):
240
  """``protocol`` with every shell on the timing class ``shape`` (b-values, directions and counts kept): the A/B
241
- knob that swaps a PGSE class for its stimulated-echo twin, or one δ / Δ for another."""
 
 
 
242
  return Protocol(tuple(Shell(shape, s.b, s.n_dirs) for s in protocol.shells), n_b0=protocol.n_b0,
243
  directions=protocol.directions, bvals=protocol.bvals, name=f"{protocol.name} on {shape}")
244
 
@@ -258,6 +299,26 @@ def write_volumes(res, out_dir, *, prefix, affine):
258
  return paths
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  # ---- the physics: tissue and scanner --------------------------------------------------------------------------------
262
  CATALOGUE_FIELDS = (1.5, 3.0, 7.0) # where dmipy-sim's white-matter catalogue has cited relaxation
263
  CATALOGUE_POOLS = ("intra", "extra", "myelin") # the pools the catalogue has T2 and T1 for
@@ -1015,13 +1076,18 @@ def limits(key):
1015
  def playable(protocol, shapes, scanner=None):
1016
  """Per shell, the gradient it needs and whether ``scanner`` (a catalogue key, or None for no limit) can play it:
1017
  rows of ``(shell, b, delta, Delta, G_needed, G_max or None, ok)`` in SI. A stimulated-echo class needs the same
1018
- amplitude as its PGSE twin (the same δ and diffusion time)."""
 
 
1019
  G_max = float(limits(scanner).G_max) if scanner else None
1020
  rows = []
1021
  for s in protocol.shells:
1022
  t = dict(delta=s.delta, Delta=s.Delta) if s.free_timing else shapes[s.shape]
1023
  delta, Delta = float(t["delta"]), float(t["Delta"])
1024
- G = gradient_needed(s.b, delta, Delta)
 
 
 
1025
  rows.append((s.shape, s.b, delta, Delta, G, G_max, G_max is None or G <= G_max))
1026
  return rows
1027
 
@@ -1120,6 +1186,9 @@ class Result:
1120
  extras: dict = field(default_factory=dict)
1121
 
1122
 
 
 
 
1123
  def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0, physics=None, prepared=None):
1124
  """The pipeline as a generator: before each of the source's ``STAGES`` it yields ``(stage, k, n)`` (so a page
1125
  can show where the run is, from inside any worker), and last the :class:`Result`. ``physics`` is the tissue and
@@ -1131,10 +1200,20 @@ def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0,
1131
  def at(stage):
1132
  print(f"[pipeline] {stage} at +{time.perf_counter() - t_run:.1f} s", flush=True) # the server log shows where a run is
1133
  return stage, stages.index(stage), len(stages)
 
 
1134
  meas = source.validate(protocol, physics)
1135
  yield at("replay"); dwi, floor, s0_factor, t_replay = source.replay(meas, physics, prepared)
1136
  yield at("noise"); t0 = time.perf_counter(); noisy = add_noise(dwi, snr, s0_factor, seed=noise_seed, backend=source.backend); t_noise = time.perf_counter() - t0
1137
  signal = np.isfinite(dwi[..., 0])
 
 
 
 
 
 
 
 
1138
  yield at("csd"); sh, t_csd, extras = source.reconstruct(noisy, s0_factor, meas, signal)
1139
  yield at("track"); t0 = time.perf_counter(); fld, seeds = source.tracking_inputs(sh, tracking.density); tg, _ = track(fld, seeds, tracking, source.backend); t_track = time.perf_counter() - t0
1140
  seconds = dict(replay=t_replay, noise=t_noise, **t_csd, track=t_track)
 
97
  class Shell:
98
  """One shell: the timing class it plays, its b-value (s/mm^2) and its direction count. A shell made by
99
  :meth:`free` carries its own pulse timing (``delta``, ``Delta``, ``TE`` in seconds) instead of a stored class,
100
+ and ``shape`` names it. On a b-tensor class (:mod:`space.tensor`) ``encoding`` is the b-tensor's shape, ``LTE``
101
+ (linear) or ``STE`` (spherical), and a direction is the encoding's axis."""
102
  shape: str
103
  b: float
104
  n_dirs: int
105
  delta: Optional[float] = None
106
  Delta: Optional[float] = None
107
  TE: Optional[float] = None
108
+ encoding: Optional[str] = None
109
 
110
  @classmethod
111
  def free(cls, b, n_dirs, delta, Delta, TE):
 
147
  def n_meas(self):
148
  return self.n_b0 + sum(s.n_dirs for s in self.shells)
149
 
150
+ @property
151
+ def tensor(self):
152
+ """Whether the shells are b-tensor encodings (every shell names one; a protocol does not mix them)."""
153
+ enc = [s.encoding is not None for s in self.shells]
154
+ if any(enc) and not all(enc):
155
+ raise ValueError("a protocol's shells are all b-tensor encodings or none")
156
+ return all(enc)
157
+
158
 
159
  @dataclass(frozen=True)
160
  class Measurements:
161
+ """Per row: b (s/mm^2), unit direction, timing-class name, delta / Delta / TE (s), and on a b-tensor protocol the
162
+ encoding (``b0``, ``LTE``, ``STE``; None otherwise)."""
163
  bvals: np.ndarray
164
  dirs: np.ndarray
165
  shape: np.ndarray
166
  delta: np.ndarray
167
  Delta: np.ndarray
168
  TE: np.ndarray
169
+ encoding: Optional[np.ndarray] = None
170
 
171
  @property
172
  def b0(self):
 
197
  b = np.asarray(protocol.bvals, np.float64)
198
  delta = np.array([timing[n]["delta"] for n in names]); Delta = np.array([timing[n]["Delta"] for n in names])
199
  TE = np.array([timing[n]["TE"] for n in names])
200
+ enc = None
201
+ if protocol.tensor:
202
+ enc = np.asarray(["b0"] * protocol.n_b0 + [s.encoding for s in protocol.shells for _ in range(s.n_dirs)])
203
+ return Measurements(b, dirs, np.asarray(names), delta, Delta, TE, enc)
204
 
205
 
206
  def protocol_from_rows(bvals, dirs, group, make_shell, *, name):
 
245
  return protocol_from_rows(b, np.asarray(enc.gradient_directions, np.float64), group.ravel(), make_shell, name=name)[0]
246
 
247
 
248
+ def preset_snr(cfg, name):
249
+ """The SNR a preset starts at: its own ``snr`` when the preset states one (the b-tensor preset: µFA is
250
+ noise-limited), else ``[noise] snr``."""
251
+ p = cfg.get("presets", {}).get(name) or {}
252
+ return float(p.get("snr", cfg["noise"]["snr"]))
253
+
254
+
255
  def preset_protocol(cfg, name):
256
+ """A ``[presets]`` entry of the config as a Protocol: a stored gradient table (``table``: ``data/<table>.bvals`` and
257
+ ``.bvecs``, every row's own b and direction, each shell its rounded b, all on the class ``shape``), or ``shells``
258
+ of Fibonacci directions after ``n_b0`` b = 0 rows."""
259
  p = cfg["presets"][name]
260
+ if "tensor" in p: # b-tensor encodings interleaved per b, the same axes for each
261
+ from . import tensor as T
262
+ t = p["tensor"]
263
+ shells, dirs = [], [np.tile([0.0, 0.0, 1.0], (int(p.get("n_b0", 1)), 1))]
264
+ for b, n in zip(t["bvalues"], t["directions"]):
265
+ for e in t["encodings"]:
266
+ shells.append(Shell(p["shape"], float(b), int(n), encoding=e)); dirs.append(T.fibonacci(int(n)))
267
+ return Protocol(tuple(shells), n_b0=int(p.get("n_b0", 1)), directions=np.concatenate(dirs), name=name)
268
+ if "table" in p:
269
+ bv = np.loadtxt(os.path.join(DATA_DIR, p["table"] + ".bvals")).ravel()
270
+ dirs = np.loadtxt(os.path.join(DATA_DIR, p["table"] + ".bvecs"))
271
+ dirs = dirs.T if dirs.shape[0] == 3 else dirs
272
+ shell = np.round(bv, -1)
273
+ return protocol_from_rows(bv, dirs, shell, lambda g, rows: Shell(p["shape"], float(g), int(len(rows))), name=name)[0]
274
  return Protocol(tuple(Shell(s["shape"], float(s["b"]), int(s["n_dirs"])) for s in p["shells"]), n_b0=int(p["n_b0"]), name=name)
275
 
276
 
277
  def retime(protocol, shape):
278
  """``protocol`` with every shell on the timing class ``shape`` (b-values, directions and counts kept): the A/B
279
+ knob that swaps a PGSE class for its stimulated-echo twin, or one δ / Δ for another. A b-tensor protocol's shells
280
+ are encodings of its class, not timings: it is refused by name."""
281
+ if protocol.tensor:
282
+ raise ValueError("a b-tensor acquisition's shells are encodings of its one class: a pulse-timing knob does not apply")
283
  return Protocol(tuple(Shell(shape, s.b, s.n_dirs) for s in protocol.shells), n_b0=protocol.n_b0,
284
  directions=protocol.directions, bvals=protocol.bvals, name=f"{protocol.name} on {shape}")
285
 
 
299
  return paths
300
 
301
 
302
+ def write_tensor_volumes(res, out_dir, *, prefix, affine):
303
+ """A b-tensor run's files: the DWI as NIfTI with its bvals (s/mm^2), bvecs (each row's encoding axis, 3 x N) and
304
+ ``.btensor`` (each row's encoding, ``b0`` / ``LTE`` / ``STE``, one per line), and the FA, MD (µm²/ms) and µFA maps as
305
+ NIfTI (float32 on ``affine``, NaN as 0); returns the paths."""
306
+ import nibabel as nib
307
+ os.makedirs(out_dir, exist_ok=True)
308
+ aff = np.asarray(affine, np.float64); tm = res.extras["tensor"]; out = []
309
+ p = os.path.join(out_dir, f"{prefix}_dwi.nii.gz"); nib.save(nib.Nifti1Image(np.nan_to_num(res.dwi).astype(np.float32), aff), p); out.append(p)
310
+ p = os.path.join(out_dir, f"{prefix}.bvals"); np.savetxt(p, res.meas.bvals[None, :], fmt="%.1f"); out.append(p)
311
+ p = os.path.join(out_dir, f"{prefix}.bvecs"); np.savetxt(p, res.meas.dirs.T, fmt="%.8f"); out.append(p)
312
+ p = os.path.join(out_dir, f"{prefix}.btensor")
313
+ with open(p, "w") as f:
314
+ f.write("\n".join(str(e) for e in res.meas.encoding) + "\n")
315
+ out.append(p)
316
+ for key in ("fa", "md", "ufa"):
317
+ p = os.path.join(out_dir, f"{prefix}_{key}.nii.gz")
318
+ nib.save(nib.Nifti1Image(np.nan_to_num(np.asarray(tm[key], np.float64)).astype(np.float32), aff), p); out.append(p)
319
+ return out
320
+
321
+
322
  # ---- the physics: tissue and scanner --------------------------------------------------------------------------------
323
  CATALOGUE_FIELDS = (1.5, 3.0, 7.0) # where dmipy-sim's white-matter catalogue has cited relaxation
324
  CATALOGUE_POOLS = ("intra", "extra", "myelin") # the pools the catalogue has T2 and T1 for
 
1076
  def playable(protocol, shapes, scanner=None):
1077
  """Per shell, the gradient it needs and whether ``scanner`` (a catalogue key, or None for no limit) can play it:
1078
  rows of ``(shell, b, delta, Delta, G_needed, G_max or None, ok)`` in SI. A stimulated-echo class needs the same
1079
+ amplitude as its PGSE twin (the same δ and diffusion time); an OGSE class the peak of its own waveform
1080
+ (:func:`space.classes.amplitude`; its delta / Delta are the scheme's nominal block and separation)."""
1081
+ from . import classes as C
1082
  G_max = float(limits(scanner).G_max) if scanner else None
1083
  rows = []
1084
  for s in protocol.shells:
1085
  t = dict(delta=s.delta, Delta=s.Delta) if s.free_timing else shapes[s.shape]
1086
  delta, Delta = float(t["delta"]), float(t["Delta"])
1087
+ if t.get("kind") == "tensor":
1088
+ G = C.tensor_peak(t, s.encoding, s.b, s.n_dirs) # per axis, over the shell's rotations
1089
+ else:
1090
+ G = C.amplitude(t, s.b) if t.get("kind") == "ogse" else gradient_needed(s.b, delta, Delta)
1091
  rows.append((s.shape, s.b, delta, Delta, G, G_max, G_max is None or G <= G_max))
1092
  return rows
1093
 
 
1186
  extras: dict = field(default_factory=dict)
1187
 
1188
 
1189
+ TENSOR_STAGES = ("replay", "noise", "maps") # a b-tensor protocol's stages
1190
+
1191
+
1192
  def run_stages(source, protocol, *, snr=None, tracking=Tracking(), noise_seed=0, physics=None, prepared=None):
1193
  """The pipeline as a generator: before each of the source's ``STAGES`` it yields ``(stage, k, n)`` (so a page
1194
  can show where the run is, from inside any worker), and last the :class:`Result`. ``physics`` is the tissue and
 
1200
  def at(stage):
1201
  print(f"[pipeline] {stage} at +{time.perf_counter() - t_run:.1f} s", flush=True) # the server log shows where a run is
1202
  return stage, stages.index(stage), len(stages)
1203
+ if protocol.tensor: # a b-tensor protocol: microstructure maps, not a connectome
1204
+ stages = TENSOR_STAGES
1205
  meas = source.validate(protocol, physics)
1206
  yield at("replay"); dwi, floor, s0_factor, t_replay = source.replay(meas, physics, prepared)
1207
  yield at("noise"); t0 = time.perf_counter(); noisy = add_noise(dwi, snr, s0_factor, seed=noise_seed, backend=source.backend); t_noise = time.perf_counter() - t0
1208
  signal = np.isfinite(dwi[..., 0])
1209
+ if protocol.tensor:
1210
+ from . import tensor as T
1211
+ yield at("maps"); t0 = time.perf_counter(); tmaps = T.maps(noisy, meas, signal)
1212
+ seconds = dict(replay=t_replay, noise=t_noise, maps=time.perf_counter() - t0)
1213
+ seconds["total"] = sum(seconds.values())
1214
+ yield Result(protocol, meas, noisy, floor, s0_factor, None, None, None, None, None, snr=snr, physics=physics, clean=dwi,
1215
+ seconds=seconds, extras=dict(tensor=tmaps))
1216
+ return
1217
  yield at("csd"); sh, t_csd, extras = source.reconstruct(noisy, s0_factor, meas, signal)
1218
  yield at("track"); t0 = time.perf_counter(); fld, seeds = source.tracking_inputs(sh, tracking.density); tg, _ = track(fld, seeds, tracking, source.backend); t_track = time.perf_counter() - t0
1219
  seconds = dict(replay=t_replay, noise=t_noise, **t_csd, track=t_track)
space/reference.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The acceptance gate's reference: the replay pack the layout was contracted from, replayed walker by walker at a set
2
+ of voxels (only those voxels' rows are read, by range, from the Hub or a local copy). This is the full replay, not a
3
+ rendering: every stored band, every field mode, the contact, the pools; the layout's image of the same rows must equal
4
+ it to ``[gate] reference_max_abs_diff`` (S/S0)."""
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+
9
+
10
+ def open_pack(uri, workers=8):
11
+ """The columnar replay pack at ``uri`` (``hf://owner/name/prefix`` or a directory)."""
12
+ from dmipy_sim.replay.columnar import ColumnarPack
13
+ return ColumnarPack(uri, workers=workers)
14
+
15
+
16
+ def gate_voxels(pack, n):
17
+ """``n`` voxels spread evenly over the pack's index (its (voxel, pool) rows in order), as ijk triples: the spot
18
+ check's rule."""
19
+ keys = sorted({tuple(r["ijk"]) for r in pack.index["rows"]})
20
+ pick = np.unique(np.linspace(0, len(keys) - 1, min(int(n), len(keys))).round().astype(int))
21
+ return [keys[i] for i in pick]
22
+
23
+
24
+ def replay_at(pack, voxels, sequence, tissue=None, scanner=None):
25
+ """``(n_voxels, n_meas)``: each voxel's weighted ensemble magnitude under ``sequence`` (normalised by its walkers'
26
+ weight, as the layout's image is), from the pack's own rows at ``voxels`` (every band; the field modes and the
27
+ contact read when the tissue needs them)."""
28
+ from dmipy_sim.phantom.grid import Grid
29
+ grid = Grid.from_meta(pack.index["grid"])
30
+ need_field = tissue is not None and (getattr(tissue, "chi_iso", None) or getattr(tissue, "chi_aniso", None))
31
+ view = pack.view(voxels=voxels, contact=tissue is not None,
32
+ modes=(pack.path_groups[-1] if need_field and pack.path_groups else 0))
33
+ w, ew, E = view.walker_signals(sequence, tissue=tissue, scanner=scanner, tol=None)
34
+ ijk, _ = grid.bin(view.r0)
35
+ v = np.ravel_multi_index(ijk.T, tuple(grid.shape))
36
+ flat = np.array([np.ravel_multi_index(k, tuple(grid.shape)) for k in voxels])
37
+ pos = {int(f): i for i, f in enumerate(flat)}
38
+ idx = np.array([pos[int(x)] for x in v])
39
+ num = np.zeros((len(voxels), E.shape[1]), complex)
40
+ np.add.at(num, idx, np.asarray(ew)[:, None] * np.asarray(E))
41
+ den = np.bincount(idx, np.asarray(w, np.float64), minlength=len(voxels))
42
+ return np.abs(num / den[:, None])
space/sources/disco.py CHANGED
@@ -1,20 +1,22 @@
1
  """The DiSCo source: the DiSCo phantom's 40^3 grid replayed from its stored walk, tracked from the sixteen regions
2
  and scored against the dataset's strand-count and cross-sectional-area matrices (CC BY 4.0, ``data/SOURCE.md``).
3
 
4
- Two sources replay the same walk. :class:`Layout` (demo mode) is dmipy-sim's shape-moment layout: the DiSCo replay
5
- pack contracted once per stored pulse timing ("class"), so a protocol on those classes at any b-values, directions,
6
  tissue and field is an elementwise kernel on device-resident rows, in seconds. :class:`Columns` (full mode) is the
7
  columnar replay pack itself, read once per run for any pulse timing, in minutes. Both are a :class:`Disco`: the
8
  page's texts, the tissue panel of the white-matter catalogue, the regions, the ground truth and the GPU budget.
9
  """
10
  from __future__ import annotations
11
 
 
12
  import os
13
  import time
14
 
15
  import numpy as np
16
 
17
  from .. import pipeline as P
 
18
 
19
  N_REGIONS = 16
20
  VOXEL_M = 25e-6 # DiSCo's voxel; the strands' coordinate unit
@@ -119,7 +121,8 @@ class Disco(P.Source):
119
  truth_tab="2 · ground truth",
120
  truth_labels=("the strands", "the ground-truth matrices"),
121
  stages={"replay": "replaying the grid from the stored walk", "noise": "adding Rician noise", "csd": "fitting CSD (order 8)",
122
- "track": "tracking from the sixteen regions", "score": "scoring the connectome"},
 
123
  fixed=["the DiSCo phantom's geometry and its one stored walk", "the sixteen regions and the ground-truth matrices",
124
  "the pulse timing classes of the layout (demo mode)"],
125
  accuracy=("A replay is a measured approximation of the stored walk, not a rendering: the walk's two halves are replayed "
@@ -222,7 +225,8 @@ class Disco(P.Source):
222
  out[f"SNR → {v}"] = ("snr", float(v))
223
  out["noise → off"] = ("snr", None)
224
  for name, sh in cfg["shapes"].items():
225
- out[f"every shell's pulse timing → {name} ({sh['label']})"] = ("shape", name)
 
226
  return out
227
 
228
  @classmethod
@@ -299,7 +303,7 @@ class Disco(P.Source):
299
  """The ground truth tab: a sample of the strands in 3-D with the region markers, and the two matrices."""
300
  from dmipy_sim.io.strands import read_tck, read_diameters
301
  from .. import viewers as V
302
- strands = read_tck(os.path.join(P.DATA_DIR, "DiSCo_Strands_Trajectories.tck"), coordinate_unit_m=VOXEL_M)
303
  diameters = read_diameters(os.path.join(P.DATA_DIR, "DiSCo_Strands_Diameters.txt"), diameter_unit_m=1e-3)
304
  return (V.strands3d([s / VOXEL_M for s in strands], diameters, V.region_markers(self.regions), self.mask.shape),
305
  V.ground_truth_matrix(self.gt_count, self.gt_area, P.connected_pairs(self.gt_count)))
@@ -343,6 +347,52 @@ class Disco(P.Source):
343
  return [pool, contact, fld]
344
 
345
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  PAIRS = np.triu_indices(N_REGIONS, 1) # the 120 region pairs
347
 
348
 
@@ -363,48 +413,65 @@ def compare(a, b):
363
 
364
 
365
  class Layout(Disco):
366
- """Demo mode: dmipy-sim's shape-moment layout (``dmipy_sim.replay.shape_moments``) at the config's dataset
367
- revision, or a local copy; every stored class's timing is the config's, and a tissue needs the layout's tiers."""
 
368
  mode = "demo"
369
 
370
  @classmethod
371
  def load(cls, cfg, *, local=None):
372
- """The layout at ``local``, ``DISCO_MOMENTS``, or the config's dataset revision on the Hub."""
373
- return cls(cfg, local=local or os.environ.get("DISCO_MOMENTS"))
374
 
375
  def __init__(self, cfg, *, local=None):
376
- from dmipy_sim.replay.shape_moments import ShapeMoments
 
 
377
  super().__init__(cfg)
378
  d = cfg["data"]
379
- uri = local or f"hf://{d['repo']}/{d['moments']}"
380
- self.moments = ShapeMoments.open(uri, revision=d.get("revision") or None) if uri.startswith("hf://") else ShapeMoments(uri)
 
381
  self.resident = P.resident(cfg)
382
- want = d.get("source_manifest_sha256")
383
- got = self.moments.manifest["source"]["manifest_sha256"]
 
384
  if want and got != want:
385
- raise ValueError(f"the layout at {uri} was contracted from columnar manifest {got[:12]}, the config pins {want[:12]}")
386
- missing = [s for s in self.shapes if s not in self.moments.shapes]
 
387
  if missing:
388
- raise ValueError(f"the layout at {uri} lacks the timing classes {missing}; it holds {self.moments.shapes}")
389
- for name, t in self.shapes.items():
390
- enc = self.moments.manifest["shapes"][name].get("encoding") or {}
391
- for k in ("delta", "Delta", "TE"):
392
- v = enc.get(k)
393
- v = v[0] if isinstance(v, list) and v else v
394
- if v is not None and abs(float(v) - float(t[k])) > 1e-9:
395
- raise ValueError(f"class {name}: the layout's {k} is {v} s, the config's {t[k]} s")
396
- self.tiers = bool(self.moments.manifest.get("tiers"))
397
- self.M0 = None # the bare b = 0 map (every voxel's walker weight): set by warm()
398
- if self.tiers: # the pools with walkers are the spec's first n_pools ids
399
- t = self.moments.manifest["tiers"]
400
  spec_pools = sorted(t["substrate"]["pools"], key=lambda q: q["id"]) if t.get("substrate") else []
401
- seeded = tuple(q["name"] for q in spec_pools[:int(t["pools"])])
 
402
  if set(seeded) != set(self.pools):
403
  raise ValueError(f"the layout's seeded pools are {seeded}, the config's [physics] pools {self.pools}")
404
- self.unseeded = tuple(q["name"] for q in spec_pools[int(t["pools"]):])
405
- self.check_grid(self.moments.grid.shape, "the layout")
 
 
 
 
 
 
 
 
 
 
 
406
 
407
  def validate(self, protocol, physics):
 
408
  meas = P.measurements(protocol, self.shapes)
409
  if physics and not physics.bare and not self.tiers:
410
  raise ValueError("this layout holds bare diffusion only: switch the tissue panel off")
@@ -412,21 +479,35 @@ class Layout(Disco):
412
  raise ValueError(f"the tissue names the pools {physics.pools}; this layout's are {self.pools}")
413
  if physics and physics.scanner is not None:
414
  L = P.limits(physics.scanner)
415
- if L.has_field_law and not self.moments.background:
416
- raise ValueError(f"the {physics.scanner}'s own gradient needs the layout's background moments, and this layout has "
417
- "none (dmipy_sim.replay.shape_moments.stamp_background adds them in one pass)")
418
- if L.has_transmit_profile and any("rf" not in self.moments.manifest["shapes"][n] for n in np.unique(meas.shape)):
419
- raise ValueError(f"the {physics.scanner}'s transmit scale needs the classes' RF schedules, which this layout does not "
420
- "record (stamp_background records them)")
 
 
 
421
  return meas
422
 
423
- def delivered(self, name, bvals, dirs, physics):
424
- """What the run's machine delivers at every voxel with rows for the class ``name`` at ``bvals`` (s/m^2) along
425
- ``dirs``: the layout's :meth:`~dmipy_sim.replay.shape_moments.ShapeMoments.delivery` with the grid's centre at
426
- the physics' offset; None on the ideal scanner."""
 
 
 
 
 
 
 
 
 
 
 
427
  if physics is None or physics.scanner is None:
428
  return None
429
- return self.moments.delivery(name, bvals, dirs, P.limits(physics.scanner), self.moments.grid.centred_at(physics.offset_m))
430
 
431
  def replay(self, meas, physics=None, prepared=None):
432
  t0 = time.perf_counter()
@@ -436,36 +517,34 @@ class Layout(Disco):
436
  b0 = physics.b0_direction if physics else P.B0_ALONG_Z
437
  for name in np.unique(meas.shape):
438
  rows = meas.shape == name
439
- S[..., rows], f = self.moments.image(name, meas.bvals[rows] * 1e6, meas.dirs[rows], backend=self.backend,
440
- resident=True, tissue=tissue, scanner=scanner, b0_direction=b0,
441
- delivered=self.delivered(name, meas.bvals[rows] * 1e6, meas.dirs[rows], physics))
442
- floor = np.fmax(floor, f) # resident within a run: release() drops the device copies after it
443
- if self.M0 is None: # the bare b = 0 map: the walker weight of every voxel
444
- self.M0 = self.moments.image(meas.shape[0], np.zeros(1), np.array([P.B0_ALONG_Z]), backend=self.backend, resident=True)[0][..., 0]
445
  dwi, factor = P.s0_normalised(S, meas.b0, self.M0)
446
  return dwi, floor, factor, time.perf_counter() - t0
447
 
448
  def release(self):
449
  if not self.resident:
450
- self.moments.release()
451
 
452
  def ingredients(self, meas, physics=None, prepared=None):
453
  """What each tier multiplies into the sum, per voxel, for the run's first class (the layout's
454
- :meth:`~dmipy_sim.replay.shape_moments.ShapeMoments.tier_maps` on the device columns the replay left
455
- resident): ``intra_fraction`` (the walker weight in the intra-axonal pool over the voxel's),
456
- ``wall_contact_um`` (the walkers' boundary local time l under the class's gate, a length; the layout stores
457
- it signed as the exponent's term, -l, so the contact tier's weight is exp(rho2 c / D) = exp(-rho2 l / D)),
458
- ``contact_survival`` (that factor at the run's rho2, None without the contact tier), ``field_rad`` (the spread
459
- over the voxel's walkers of the dephasing phase the sheath's field gives them by the echo at the run's field
460
- and direction, the exact per-walker phase the kernel applies, in radians; None without the field tier), and
461
- ``D_walk`` (m^2/s)."""
462
  if not self.tiers:
463
  return None
464
  tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
465
  b0 = physics.b0_direction if physics else P.B0_ALONG_Z
466
- maps = self.moments.tier_maps(str(meas.shape[0]), tissue, scanner, backend=self.backend, resident=True, b0_direction=b0)
467
  contact = maps["contact"]
468
- return dict(D_walk=float(self.moments.manifest["tiers"]["D_walk"]), intra_fraction=maps["pool"]["intra"],
469
  wall_contact_um=None if contact is None else -contact * 1e6,
470
  contact_survival=maps["contact_weight"] if (physics and physics.contact) else None,
471
  field_rad=maps["phase_std"] if (physics and physics.field) else None)
@@ -477,33 +556,41 @@ class Layout(Disco):
477
 
478
  def warm(self):
479
  """DiSCo's own protocol replayed once at the default physics (every class it plays compiled at the row
480
- counts the default run uses), then one direction on every other class (its tier group's terms contracted
481
- and on the device). When the tiles are not kept resident (a pool that drops the device between calls), the
482
- padded host arrays are loaded into this process instead, so a forked worker inherits them and pays the
483
- transfer alone."""
484
- if not self.resident:
485
- self.moments.preload(list(self.shapes))
486
  return
487
  physics = self.default_physics()
488
  disco = disco_protocol(self.cfg)[0]
489
  self.replay(P.measurements(disco, self.shapes), physics)
490
- rest = [name for name in self.shapes if name not in {s.shape for s in disco.shells}]
 
491
  if rest:
492
  self.replay(P.measurements(P.Protocol(tuple(P.Shell(name, 1000.0, 1) for name in rest), n_b0=1, name="warm"), self.shapes), physics)
 
 
 
493
 
494
  def accuracy(self, res=None):
495
- m = self.moments.manifest; src = m["source"]
496
- rows = [["temporal bands kept (K)", str(m["K"])],
497
- ["band error at the built amplitude (worst class)", f"{m['band_error']:.2e}"],
498
- ["band tolerance (× the pack's floor)", f"{m['tol']:g}"],
499
- ["source pack", str(src.get("pack"))],
500
- ["source pack's certified median floor", f"{src['floor']:.4g}"],
501
- ["source manifest sha256", src["manifest_sha256"][:16]],
502
- ["layout written by", f"{m.get('code', {}).get('commit', '?')[:12]} on {m.get('created', '?')}"],
503
- ["pulses", "square (slew rate ∞), one TE per class"],
 
 
 
 
504
  ["tiers stored", "pool, contact, field" if self.tiers else "none (bare diffusion only)"],
505
- ["background moments (a magnet's own gradient)", "stored, certified to {:g} mT/m".format(self.moments.background["amplitude"] * 1e3)
506
- if self.moments.background else "not stored: a machine with its own gradient is refused"]]
507
  if res is not None and res.physics is not None and res.physics.scanner is not None:
508
  rows.append(["scanner", f"{res.physics.scanner}, the phantom's centre at "
509
  f"({', '.join(f'{x * 100:.1f}' for x in res.physics.offset_m)}) cm from isocentre: every catalogued term "
@@ -511,17 +598,17 @@ class Layout(Disco):
511
  rows += super().accuracy(res)
512
  if res is not None:
513
  for name in np.unique(res.meas.shape):
514
- sh = m["shapes"][name]; enc = sh.get("encoding") or {}
515
- first = {k: (v[0] if isinstance(v, list) and v else v) for k, v in enc.items()}
516
- rows.append([f"class {name}", f"δ {first.get('delta')} / Δ {first.get('Delta')} / TE {first.get('TE')} s, "
517
- f"built at {float(sh.get('amplitude_built') or 0) * 1e3:.0f} mT/m, pathway amplitude {sh.get('pathway') or 1}"])
518
  return rows
519
 
520
 
521
  class Columns(Disco):
522
  """Full mode: the columnar replay pack (:class:`dmipy_sim.replay.columnar.ColumnarPack`) at
523
- :func:`space.pipeline.columns_uri`, read once per run: one ScannerSequence per run (one echo time, one pulse kind, square
524
- pulses), the tissue and field of the run, the field along the pack's z."""
 
525
  mode = "full"
526
  RATE = {True: 45e6, False: 500e6} # bytes/s read from the Hub, from a local disk
527
 
@@ -534,15 +621,17 @@ class Columns(Disco):
534
  self.remote = self.uri.startswith("hf://")
535
 
536
  def kinds(self, meas):
537
- """The pulse kinds (``pgse`` / ``pgste``) of the classes the rows play; a free timing is PGSE."""
538
- return sorted({self.shapes[n].get("kind", "pgse") if n in self.shapes else "pgse" for n in np.unique(meas.shape)})
 
539
 
540
  def validate(self, protocol, physics):
541
  meas = P.measurements(protocol, self.shapes)
542
  if len(np.unique(np.round(meas.TE, 9))) != 1:
543
  raise ValueError("full mode replays one echo time per run")
544
- if len(self.kinds(meas)) != 1:
545
- raise ValueError("full mode replays one pulse kind (PGSE or stimulated echo) per run")
 
546
  if physics and not physics.along_z:
547
  raise ValueError("full mode replays the field along z only")
548
  if physics and physics.scanner is not None:
@@ -551,15 +640,27 @@ class Columns(Disco):
551
  return meas
552
 
553
  def sequence(self, meas):
554
- """The one ScannerSequence of the measurements: square pulses, every row's own delta / Delta at the rows'
555
- common TE; a stimulated echo stores for TM = Delta - delta between 90° pulses."""
 
556
  import dmipy_sim as d
 
 
557
  TE = float(np.unique(np.round(meas.TE, 9))[0])
558
- kind, = self.kinds(meas)
559
- if kind == "pgste":
560
- return d.pgste(meas.dirs.tolist(), meas.delta, meas.Delta - meas.delta, bvalues=meas.bvals * 1e6, n_t=1000,
561
- slew_rate=np.inf, ste_flip_angles=(90.0, 90.0, 90.0))
562
- return d.pgse(meas.dirs.tolist(), meas.delta, meas.Delta, bvalues=meas.bvals * 1e6, TE=TE, n_t=1000, slew_rate=np.inf)
 
 
 
 
 
 
 
 
 
563
 
564
  def plan(self, meas, physics=None):
565
  """What the replay will read (the pack's plan: bands, modes, tiers, rows, bytes) and ``estimated_seconds``
@@ -583,7 +684,7 @@ class Columns(Disco):
583
  m = self.pack.meta
584
  rows = [["source pack", str(m.get("id"))], ["source pack's certified median floor", f"{self.pack.floor:.4g}"],
585
  ["bands kept", str(self.pack.K)], ["band tolerance (× the pack's floor)", f"{P.BAND_TOL:g}"],
586
- ["pulses", "square (slew rate ∞), one TE and one pulse kind per run"]]
587
  plan = getattr(self, "last_plan", None)
588
  if plan:
589
  rows.append(["last replay read", f"{plan.get('rows', 0):,} rows, {plan.get('bytes', 0) / 1e9:.1f} GB, bands {plan.get('K')}, modes {plan.get('modes')}"])
 
1
  """The DiSCo source: the DiSCo phantom's 40^3 grid replayed from its stored walk, tracked from the sixteen regions
2
  and scored against the dataset's strand-count and cross-sectional-area matrices (CC BY 4.0, ``data/SOURCE.md``).
3
 
4
+ Two sources replay the same walk. :class:`Layout` (demo mode) is dmipy-sim's time-segment layout: the DiSCo walk
5
+ contracted once per stored waveform ("class"), so a protocol on those classes at any b-values, directions,
6
  tissue and field is an elementwise kernel on device-resident rows, in seconds. :class:`Columns` (full mode) is the
7
  columnar replay pack itself, read once per run for any pulse timing, in minutes. Both are a :class:`Disco`: the
8
  page's texts, the tissue panel of the white-matter catalogue, the regions, the ground truth and the GPU budget.
9
  """
10
  from __future__ import annotations
11
 
12
+ import functools
13
  import os
14
  import time
15
 
16
  import numpy as np
17
 
18
  from .. import pipeline as P
19
+ from .. import tensor as T
20
 
21
  N_REGIONS = 16
22
  VOXEL_M = 25e-6 # DiSCo's voxel; the strands' coordinate unit
 
121
  truth_tab="2 · ground truth",
122
  truth_labels=("the strands", "the ground-truth matrices"),
123
  stages={"replay": "replaying the grid from the stored walk", "noise": "adding Rician noise", "csd": "fitting CSD (order 8)",
124
+ "track": "tracking from the sixteen regions", "score": "scoring the connectome",
125
+ "maps": "fitting FA, MD and µFA (the b-tensor run's maps)"},
126
  fixed=["the DiSCo phantom's geometry and its one stored walk", "the sixteen regions and the ground-truth matrices",
127
  "the pulse timing classes of the layout (demo mode)"],
128
  accuracy=("A replay is a measured approximation of the stored walk, not a rendering: the walk's two halves are replayed "
 
225
  out[f"SNR → {v}"] = ("snr", float(v))
226
  out["noise → off"] = ("snr", None)
227
  for name, sh in cfg["shapes"].items():
228
+ if sh.get("kind") != "tensor": # the b-tensor class is its own acquisition (encodings, not shells)
229
+ out[f"every shell's pulse timing → {name} ({sh['label']})"] = ("shape", name)
230
  return out
231
 
232
  @classmethod
 
303
  """The ground truth tab: a sample of the strands in 3-D with the region markers, and the two matrices."""
304
  from dmipy_sim.io.strands import read_tck, read_diameters
305
  from .. import viewers as V
306
+ strands = read_tck(strands_path(self.cfg), coordinate_unit_m=VOXEL_M)
307
  diameters = read_diameters(os.path.join(P.DATA_DIR, "DiSCo_Strands_Diameters.txt"), diameter_unit_m=1e-3)
308
  return (V.strands3d([s / VOXEL_M for s in strands], diameters, V.region_markers(self.regions), self.mask.shape),
309
  V.ground_truth_matrix(self.gt_count, self.gt_area, P.connected_pairs(self.gt_count)))
 
347
  return [pool, contact, fld]
348
 
349
 
350
+ @functools.lru_cache(maxsize=2)
351
+ def strands_path(cfg=None):
352
+ """The DiSCo strand centrelines (``.tck``, voxel units; CC BY 4.0): ``[data] strands`` in the replay dataset
353
+ (``[data] pack``'s repository) at ``[data] pack_revision``, fetched into the Hub cache once."""
354
+ from huggingface_hub import hf_hub_download
355
+ d = (cfg or P.config())["data"]
356
+ repo = "/".join(d["pack"][len("hf://"):].split("/")[:2])
357
+ return hf_hub_download(repo, d["strands"], repo_type="dataset", revision=d["pack_revision"])
358
+
359
+
360
+ def strand_configuration(tck=None, shape=(40, 40, 40), *, angle_deg=30.0, share=0.10):
361
+ """The ground truth's fibre configuration per voxel from the released strand trajectories (voxel units):
362
+ ``(n_dirs, dispersion, length)`` -- the distinct strand directions crossing the voxel (the strands' length-weighted
363
+ mean tangents in it, clustered at ``angle_deg``, a direction counted when it carries at least ``share`` of the voxel's
364
+ strand length), ``1 - lambda_1 / trace`` of the length-weighted dyadic tensor of the tangents, and the strand length
365
+ in the voxel (voxel units)."""
366
+ from dmipy_sim.io.strands import read_tck
367
+ strands = read_tck(tck or strands_path(), coordinate_unit_m=1.0)
368
+ n_vox = int(np.prod(shape))
369
+ Tm = np.zeros((n_vox, 3, 3)); per = {}
370
+ for c in strands:
371
+ c = np.asarray(c, float)
372
+ if len(c) < 2:
373
+ continue
374
+ seg = np.diff(c, axis=0); mid = 0.5 * (c[1:] + c[:-1]); L = np.linalg.norm(seg, axis=1); ok = L > 0
375
+ t = seg[ok] / L[ok, None]; ijk = np.floor(mid[ok]).astype(int); inside = np.all((ijk >= 0) & (ijk < np.asarray(shape)), axis=1)
376
+ v = np.ravel_multi_index(ijk[inside].T, shape); w = L[ok][inside]; t = t[inside]
377
+ np.add.at(Tm, v, (t[:, :, None] * t[:, None, :]) * w[:, None, None])
378
+ for vox in np.unique(v):
379
+ k = v == vox
380
+ per.setdefault(int(vox), []).append((np.linalg.eigh(np.einsum("n,ni,nj->ij", w[k], t[k], t[k]))[1][:, -1], float(w[k].sum())))
381
+ ev = np.linalg.eigvalsh(Tm); tr = ev.sum(-1)
382
+ disp = np.where(tr > 0, 1.0 - ev[:, -1] / np.maximum(tr, 1e-30), np.nan)
383
+ ndirs = np.zeros(n_vox, int); cos = np.cos(np.radians(angle_deg))
384
+ for vox, items in per.items():
385
+ items = sorted(items, key=lambda x: -x[1]); total = sum(w for _, w in items); clusters = []
386
+ for d, w in items:
387
+ for cl in clusters:
388
+ if abs(d @ cl[0]) >= cos:
389
+ cl[1] += w; break
390
+ else:
391
+ clusters.append([d, w])
392
+ ndirs[vox] = sum(1 for _, w in clusters if w >= share * total)
393
+ return ndirs.reshape(shape), disp.reshape(shape), tr.reshape(shape)
394
+
395
+
396
  PAIRS = np.triu_indices(N_REGIONS, 1) # the 120 region pairs
397
 
398
 
 
413
 
414
 
415
  class Layout(Disco):
416
+ """Demo mode: dmipy-sim's time-segment layout (``dmipy_sim.replay.time_segments``) at the config's dataset revision,
417
+ or a local copy. Every stored class is one waveform (:mod:`space.classes`), checked at load to be the layout's
418
+ partition of that name; a shell plays it at its b-values and directions."""
419
  mode = "demo"
420
 
421
  @classmethod
422
  def load(cls, cfg, *, local=None):
423
+ """The layout at ``local``, ``DISCO_LAYOUT``, or the config's dataset revision on the Hub."""
424
+ return cls(cfg, local=local or os.environ.get("DISCO_LAYOUT"))
425
 
426
  def __init__(self, cfg, *, local=None):
427
+ import hashlib
428
+ from dmipy_sim.replay.time_segments import MANIFEST, TimeSegmentLayout
429
+ from .. import classes as C
430
  super().__init__(cfg)
431
  d = cfg["data"]
432
+ uri = local or f"hf://{d['repo']}/{d['layout']}"
433
+ names = list(self.shapes)
434
+ self.layout = TimeSegmentLayout.open(uri, revision=d.get("revision") or None, partitions=names)
435
  self.resident = P.resident(cfg)
436
+ want = d.get("manifest_sha256")
437
+ with open(os.path.join(self.layout.path, MANIFEST), "rb") as f:
438
+ got = hashlib.sha256(f.read()).hexdigest()
439
  if want and got != want:
440
+ raise ValueError(f"the layout at {uri} has manifest {got[:12]}, the config pins {want[:12]}")
441
+ self.manifest_sha256 = got
442
+ missing = [s for s in names if s not in self.layout.partitions]
443
  if missing:
444
+ raise ValueError(f"the layout at {uri} lacks the timing classes {missing}; it holds {list(self.layout.partitions)}")
445
+ for name, sh in self.shapes.items(): # each class's waveform is the stored partition's
446
+ got_name, _ = self.layout.fit(C.template(sh), partition=name)
447
+ if not self.layout.partitions[name].is_constant and C.kind(sh) not in ("ogse", "tensor"):
448
+ raise ValueError(f"class {name}: the layout stores shaped terms, the config a square-pulse {C.kind(sh)}")
449
+ m = self.layout.manifest
450
+ self.tiers = bool(m.get("tiers"))
451
+ self.M0 = self.layout.weights.sum(1).reshape(self.layout.grid.shape) # every voxel's walker weight: the bare b = 0 signal
452
+ if self.tiers: # the pools with walkers: the pool ids of the rows that weigh something
453
+ t = m["tiers"]
 
 
454
  spec_pools = sorted(t["substrate"]["pools"], key=lambda q: q["id"]) if t.get("substrate") else []
455
+ ids = self.seeded_pool_ids()
456
+ seeded = tuple(q["name"] for q in spec_pools if q["id"] in ids)
457
  if set(seeded) != set(self.pools):
458
  raise ValueError(f"the layout's seeded pools are {seeded}, the config's [physics] pools {self.pools}")
459
+ self.unseeded = tuple(q["name"] for q in spec_pools if q["id"] not in ids)
460
+ self.check_grid(self.layout.grid.shape, "the layout")
461
+
462
+ def seeded_pool_ids(self, chunk=1 << 16):
463
+ """The pool ids of the layout's rows with weight (a padding row weighs nothing), read once in chunks of tiles."""
464
+ w = self.layout._column("w"); pool = self.layout._column("pool"); ids = set()
465
+ for s in range(0, self.layout.n_tiles, chunk):
466
+ q = np.asarray(pool[s:s + chunk]); ids |= set(np.unique(q[np.asarray(w[s:s + chunk]) > 0]).tolist())
467
+ return ids
468
+
469
+ def background(self, name):
470
+ """Whether the class's sequence group carries the background vectors a magnet's own gradient needs."""
471
+ return bool(self.layout.manifest["groups"][self.layout.manifest["partitions"][name]["group"]].get("background"))
472
 
473
  def validate(self, protocol, physics):
474
+ from .. import classes as C
475
  meas = P.measurements(protocol, self.shapes)
476
  if physics and not physics.bare and not self.tiers:
477
  raise ValueError("this layout holds bare diffusion only: switch the tissue panel off")
 
479
  raise ValueError(f"the tissue names the pools {physics.pools}; this layout's are {self.pools}")
480
  if physics and physics.scanner is not None:
481
  L = P.limits(physics.scanner)
482
+ shaped = [n for n in np.unique(meas.shape) if not self.layout.partitions[n].is_constant]
483
+ if shaped:
484
+ raise ValueError(f"a machine's delivered gradient is one vector per constant segment, and the classes {shaped} are "
485
+ "shaped waveforms (an OGSE): choose the ideal scanner for them")
486
+ if L.has_field_law and not all(self.background(n) for n in np.unique(meas.shape)):
487
+ raise ValueError(f"the {physics.scanner}'s own gradient needs the layout's background vectors, and this layout "
488
+ "lacks them for a class of this run")
489
+ for name in np.unique(meas.shape):
490
+ C.kind(self.shapes[name])
491
  return meas
492
 
493
+ def sequence(self, name, bvals, dirs, encodings=None):
494
+ """The rows on class ``name`` (b in s/mm^2) as the class's waveform at their amplitudes and directions; on a
495
+ b-tensor class each row's ``encodings`` entry (``b0``, ``LTE``, ``STE``) about its direction."""
496
+ from .. import classes as C
497
+ sh = self.shapes[name]
498
+ if C.kind(sh) == "tensor":
499
+ if encodings is None:
500
+ raise ValueError(f"class {name} is a b-tensor class: its rows name their encodings")
501
+ return T._sequence(C.tensor_rows(sh, encodings, bvals, dirs))
502
+ return C.play(sh, np.asarray(bvals, np.float64) * 1e6, dirs)
503
+
504
+ def delivered(self, name, seq, physics):
505
+ """What the run's machine delivers at every voxel with rows for the class ``name`` playing ``seq``: the layout's
506
+ :meth:`~dmipy_sim.replay.time_segments.TimeSegmentLayout.delivery` with the grid's centre at the physics'
507
+ offset; None on the ideal scanner."""
508
  if physics is None or physics.scanner is None:
509
  return None
510
+ return self.layout.delivery(seq, P.limits(physics.scanner), self.layout.grid.centred_at(physics.offset_m), partition=name)
511
 
512
  def replay(self, meas, physics=None, prepared=None):
513
  t0 = time.perf_counter()
 
517
  b0 = physics.b0_direction if physics else P.B0_ALONG_Z
518
  for name in np.unique(meas.shape):
519
  rows = meas.shape == name
520
+ seq = self.sequence(name, meas.bvals[rows], meas.dirs[rows], None if meas.encoding is None else meas.encoding[rows])
521
+ img, f = self.layout.image(seq, partition=str(name), backend=self.backend, resident=self.resident, tissue=tissue,
522
+ scanner=scanner, b0_direction=b0, delivered=self.delivered(str(name), seq, physics))
523
+ S[..., rows] = img * self.M0[..., None] # the voxel's signal: its walkers' weight times the normalised one
524
+ floor = np.fmax(floor, f)
 
525
  dwi, factor = P.s0_normalised(S, meas.b0, self.M0)
526
  return dwi, floor, factor, time.perf_counter() - t0
527
 
528
  def release(self):
529
  if not self.resident:
530
+ self.layout.release()
531
 
532
  def ingredients(self, meas, physics=None, prepared=None):
533
  """What each tier multiplies into the sum, per voxel, for the run's first class (the layout's
534
+ :meth:`~dmipy_sim.replay.time_segments.TimeSegmentLayout.tier_maps`): ``intra_fraction`` (the walker weight in
535
+ the intra-axonal pool over the voxel's), ``wall_contact_um`` (the walkers' boundary local time l under the
536
+ class's gate, a length; the layout stores it signed as the exponent's term, -l, so the contact tier's weight is
537
+ exp(rho2 c / D) = exp(-rho2 l / D)), ``contact_survival`` (that factor at the run's rho2, None without the
538
+ contact tier), ``field_rad`` (the spread over the voxel's walkers of the dephasing phase the sheath's field
539
+ gives them by the echo at the run's field and direction, the exact per-walker phase the kernel applies, in
540
+ radians; None without the field tier), and ``D_walk`` (m^2/s)."""
 
541
  if not self.tiers:
542
  return None
543
  tissue, scanner = P.tissue_and_scanner(physics, self.unseeded)
544
  b0 = physics.b0_direction if physics else P.B0_ALONG_Z
545
+ maps = self.layout.tier_maps(str(meas.shape[0]), tissue, scanner, backend=self.backend, resident=self.resident, b0_direction=b0)
546
  contact = maps["contact"]
547
+ return dict(D_walk=float(self.layout.manifest["tiers"]["D_walk"]), intra_fraction=maps["pool"]["intra"],
548
  wall_contact_um=None if contact is None else -contact * 1e6,
549
  contact_survival=maps["contact_weight"] if (physics and physics.contact) else None,
550
  field_rad=maps["phase_std"] if (physics and physics.field) else None)
 
556
 
557
  def warm(self):
558
  """DiSCo's own protocol replayed once at the default physics (every class it plays compiled at the row
559
+ counts the default run uses), then one direction on every other class (on a b-tensor class one row of each
560
+ encoding). When the columns are not kept resident
561
+ (a pool that drops the device between calls), the padded host arrays are loaded into this process instead, so
562
+ a forked worker inherits them and pays the transfer alone."""
563
+ if not self.resident and self.backend == "torch":
564
+ self.layout.preload(list(self.shapes))
565
  return
566
  physics = self.default_physics()
567
  disco = disco_protocol(self.cfg)[0]
568
  self.replay(P.measurements(disco, self.shapes), physics)
569
+ played = {s.shape for s in disco.shells}
570
+ rest = [n for n, sh in self.shapes.items() if n not in played and sh.get("kind") != "tensor"]
571
  if rest:
572
  self.replay(P.measurements(P.Protocol(tuple(P.Shell(name, 1000.0, 1) for name in rest), n_b0=1, name="warm"), self.shapes), physics)
573
+ for name in [n for n, sh in self.shapes.items() if sh.get("kind") == "tensor"]: # a b-tensor class: one row per encoding
574
+ shells = tuple(P.Shell(name, 1000.0, 1, encoding=e) for e in ("LTE", "STE"))
575
+ self.replay(P.measurements(P.Protocol(shells, n_b0=1, name="warm"), self.shapes), physics)
576
 
577
  def accuracy(self, res=None):
578
+ from .. import classes as C
579
+ m = self.layout.manifest; src = m["source"]; cert = m["certificate"]; env = src["envelope"]
580
+ rows = [["source", f"the {src['kind']}'s coefficients ({len(src.get('blocks') or [])} blocks); classes appended from the "
581
+ "quantised pack carry its quantisation in their own term" if any(p.get("source") for p in m["partitions"].values())
582
+ else f"the {src['kind']}'s coefficients ({len(src.get('blocks') or [])} blocks)"],
583
+ ["floor target (the words' budget)", f"{float(src['floor_target']):.4g}"],
584
+ ["layout's worst signal error over its classes and tiers (err_max)", f"{float(cert['err_max']):.2e}"],
585
+ ["envelope: gradient per axis / B0 / ρ₂/D", f"{float(env['G_max']) * 1e3:.0f} mT/m ({env.get('scanner') or env.get('bound')}) / "
586
+ f"{float(env['B0_max_T']):g} T / {float(env['rho2_over_D_max']):g} 1/m"],
587
+ ["layout manifest sha256", self.manifest_sha256[:16]],
588
+ ["layout written by", f"{(m.get('code') or {}).get('commit') or '?'} on {m.get('created', '?')}"],
589
+ ["pulses", "one waveform per class: square lobes (slew rate ∞) for the PGSE and stimulated-echo classes, "
590
+ "the cosine OGSE with its 200 T/m/s edges as built at b = 1000"],
591
  ["tiers stored", "pool, contact, field" if self.tiers else "none (bare diffusion only)"],
592
+ ["background vectors (a magnet's own gradient)", "stored, certified to {:g} mT/m".format(float(src["background_amplitude"]) * 1e3)
593
+ if any(g.get("background") for g in m["groups"].values()) else "not stored: a machine with its own gradient is refused"]]
594
  if res is not None and res.physics is not None and res.physics.scanner is not None:
595
  rows.append(["scanner", f"{res.physics.scanner}, the phantom's centre at "
596
  f"({', '.join(f'{x * 100:.1f}' for x in res.physics.offset_m)}) cm from isocentre: every catalogued term "
 
598
  rows += super().accuracy(res)
599
  if res is not None:
600
  for name in np.unique(res.meas.shape):
601
+ p = m["partitions"][str(name)]
602
+ rows.append([f"class {name}", f"{C.describe(self.shapes[str(name)])}, TE {float(self.shapes[str(name)].get('TE', 0.0535)) * 1e3:g} ms; "
603
+ f"{4 * int(p['n_words'])} B per walker, rounding variance {float(p['rounding']['variance']):.2e} rad²"])
 
604
  return rows
605
 
606
 
607
  class Columns(Disco):
608
  """Full mode: the columnar replay pack (:class:`dmipy_sim.replay.columnar.ColumnarPack`) at
609
+ :func:`space.pipeline.columns_uri`, read once per run: one ScannerSequence per run (one echo time, one RF schedule;
610
+ the stored classes' waveforms, square pulses for a free timing), the tissue and field of the run, the field along
611
+ the pack's z."""
612
  mode = "full"
613
  RATE = {True: 45e6, False: 500e6} # bytes/s read from the Hub, from a local disk
614
 
 
621
  self.remote = self.uri.startswith("hf://")
622
 
623
  def kinds(self, meas):
624
+ """The pulse kinds (``pgse`` / ``pgste`` / ``ogse``) of the classes the rows play; a free timing is PGSE."""
625
+ from .. import classes as C
626
+ return sorted({C.kind(self.shapes[n]) if n in self.shapes else "pgse" for n in np.unique(meas.shape)})
627
 
628
  def validate(self, protocol, physics):
629
  meas = P.measurements(protocol, self.shapes)
630
  if len(np.unique(np.round(meas.TE, 9))) != 1:
631
  raise ValueError("full mode replays one echo time per run")
632
+ kinds = self.kinds(meas)
633
+ if len(kinds) != 1 and not set(kinds) <= {"pgse", "ogse"}:
634
+ raise ValueError("full mode replays one RF schedule per run (spin echoes, or a stimulated echo)")
635
  if physics and not physics.along_z:
636
  raise ValueError("full mode replays the field along z only")
637
  if physics and physics.scanner is not None:
 
640
  return meas
641
 
642
  def sequence(self, meas):
643
+ """The one ScannerSequence of the measurements, rows in order: a stored class's rows play its waveform
644
+ (:mod:`space.classes`, the layout's own), a free shell square PGSE lobes at its delta / Delta, all at the rows'
645
+ common TE on the layout's grid."""
646
  import dmipy_sim as d
647
+ from dmipy_sim.acquisition.scanner_sequence import ScannerSequence
648
+ from .. import classes as C
649
  TE = float(np.unique(np.round(meas.TE, 9))[0])
650
+ G = np.zeros((len(meas.bvals), C.N_T, 3), np.float32); tmpl = None
651
+ for name in np.unique(meas.shape):
652
+ rows = meas.shape == name
653
+ if name in self.shapes and C.kind(self.shapes[name]) == "tensor":
654
+ seq = T._sequence(C.tensor_rows(self.shapes[name], meas.encoding[rows], meas.bvals[rows], meas.dirs[rows]))
655
+ elif name in self.shapes:
656
+ seq = C.play(self.shapes[name], meas.bvals[rows] * 1e6, meas.dirs[rows])
657
+ else:
658
+ seq = d.pgse(meas.dirs[rows].tolist(), meas.delta[rows], meas.Delta[rows], bvalues=meas.bvals[rows] * 1e6, TE=TE,
659
+ n_t=C.N_T, slew_rate=np.inf)
660
+ if tmpl is not None and (seq.dt != tmpl.dt or seq.rf.to_dicts() != tmpl.rf.to_dicts()):
661
+ raise ValueError("full mode replays one RF schedule and grid per run")
662
+ tmpl = seq; G[rows] = np.asarray(seq.G, np.float32)
663
+ return ScannerSequence(G=G, dt=tmpl.dt, rf=tmpl.rf, readout=tmpl.readout)
664
 
665
  def plan(self, meas, physics=None):
666
  """What the replay will read (the pack's plan: bands, modes, tiers, rows, bytes) and ``estimated_seconds``
 
684
  m = self.pack.meta
685
  rows = [["source pack", str(m.get("id"))], ["source pack's certified median floor", f"{self.pack.floor:.4g}"],
686
  ["bands kept", str(self.pack.K)], ["band tolerance (× the pack's floor)", f"{P.BAND_TOL:g}"],
687
+ ["pulses", "the stored classes' waveforms, square (slew rate ∞) for a free timing; one TE and one RF schedule per run"]]
688
  plan = getattr(self, "last_plan", None)
689
  if plan:
690
  rows.append(["last replay read", f"{plan.get('rows', 0):,} rows, {plan.get('bytes', 0) / 1e9:.1f} GB, bands {plan.get('K')}, modes {plan.get('modes')}"])
space/tensor.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The b-tensor encoding class: three gradient shapes psi_1..3 on the spin echo's two encoding windows, stored as one
2
+ class of the layout (six terms: each shape before and after the 180), and the rows that play any b-tensor of them.
3
+
4
+ A row plays ``G(t) = sum_k psi_k(t) a_k``, the 3x3 level matrix ``A`` (row k the vector shape k is played along), so
5
+ its b-tensor is ``B = A^T M A`` with ``M`` the shapes' Gram matrix (``M_kl = int k_k k_l dt``, the moments to the
6
+ readout). Every encoding is a choice of ``A``: spherical ``A = sqrt(b/3) M^-1/2 R^T`` (``B = b/3 I`` at any rotation
7
+ ``R``), planar ``A = sqrt(b/2) M^-1/2 [u; v; 0]`` (``B = b/2 (u u^T + v v^T)``), linear ``A = sqrt(b) c d^T`` with ``c``
8
+ the shape mix of the most b per peak gradient (``c^T M c = 1``; ``B = b d d^T``). The layout reads every row from
9
+ the same stored vectors, exactly as a pulse pair's rows are read: the encoding is exact in the substrate (the mean of
10
+ the walkers' phase factors), not a Gaussian approximation.
11
+
12
+ The waveform is ``[tensor] waveform`` in ``data/``: the physical gradient ``(1000, 3)`` (T/m) on the layout's
13
+ spin-echo grid (TE 53.5 ms), axis k the shape psi_k; the page checks it against the stored partition at load.
14
+
15
+ The maps: MD the first cumulant of the spherical powder average, and the microscopic fractional anisotropy from the
16
+ linear against the spherical powder average (Lasic et al. 2014, Front Phys 2:11; Szczepankiewicz et al. 2016,
17
+ NeuroImage 125:653): the gamma model fitted to the two powder signals jointly with one intercept (the b = 0 rows'
18
+ noise shared by every ``ln S/S0``), ``V_LTE - V_STE = (4/5) (D_iso D_Delta)^2``, and muFA is the FA of the axisymmetric
19
+ microscopic tensor with that ``D_iso`` and ``D_Delta``."""
20
+ from __future__ import annotations
21
+
22
+ import functools
23
+ import os
24
+
25
+ import numpy as np
26
+
27
+ from . import pipeline as P
28
+
29
+ ENCODINGS = ("LTE", "PTE", "STE")
30
+ B_DELTA = {"LTE": 1.0, "PTE": -0.5, "STE": 0.0}
31
+ N_T = 1000
32
+ TE = 0.0535
33
+
34
+
35
+ @functools.lru_cache(maxsize=None)
36
+ def _waveform(path):
37
+ G = np.load(path)
38
+ if G.shape != (N_T, 3) or not np.isfinite(G).all():
39
+ raise ValueError(f"the b-tensor waveform {path} is not a finite (1000, 3) gradient")
40
+ return np.asarray(G, np.float64)
41
+
42
+
43
+ def waveform(tc):
44
+ """``(1000, 3)``: the class's physical gradient (T/m), axis k the shape psi_k at its designed amplitude."""
45
+ return _waveform(os.path.join(P.DATA_DIR, tc["waveform"]))
46
+
47
+
48
+ @functools.lru_cache(maxsize=None)
49
+ def _grid():
50
+ import dmipy_sim as d
51
+ return d.pgse([[0.0, 0.0, 1.0]], 0.0102, 0.0167, gradient_strengths=0.1, TE=TE, n_t=N_T, slew_rate=np.inf)
52
+
53
+
54
+ def _sequence(G):
55
+ """``G (n, 1000, 3)`` on the spin-echo grid, RF schedule and readout of the layout's group g0."""
56
+ from dmipy_sim.acquisition.scanner_sequence import ScannerSequence
57
+ g = _grid()
58
+ return ScannerSequence(G=np.asarray(G, np.float32), dt=g.dt, rf=g.rf, readout=g.readout)
59
+
60
+
61
+ def template(tc):
62
+ """The class as the layout stores it: three rows, row k shape psi_k along axis k."""
63
+ P_ = waveform(tc)
64
+ G = np.zeros((3, N_T, 3))
65
+ for k in range(3):
66
+ G[k, :, k] = P_[:, k]
67
+ return _sequence(G)
68
+
69
+
70
+ def gram(tc):
71
+ """``M (3, 3)`` (s/m^2 per (T/m)^2 of level): the b-tensor of ``sum_k psi_k a_k`` is ``A^T M A``."""
72
+ from dmipy_sim.acquisition.waveforms import btensor_from_gradient
73
+ g = _grid()
74
+ G = waveform(tc)[None] * np.asarray(g.effective_gate, np.float64)[None, :, None]
75
+ return btensor_from_gradient(G, g.dt)[0]
76
+
77
+
78
+ def _inv_sqrt(M):
79
+ w, V = np.linalg.eigh(M)
80
+ return V @ np.diag(w ** -0.5) @ V.T
81
+
82
+
83
+ def lte_mix(tc, n=4000):
84
+ """``c (3,)`` with ``c^T M c = 1``: the shape mix whose single waveform ``sum_k c_k psi_k`` gives the most b per
85
+ squared peak gradient (searched over ``n`` Fibonacci directions of the sphere of mixes)."""
86
+ M = gram(tc); W = waveform(tc)
87
+ c = fibonacci(n, hemisphere=True)
88
+ peak = np.abs(W @ c.T).max(axis=0)
89
+ gain = np.einsum("nk,kl,nl->n", c, M, c) / peak ** 2
90
+ best = c[int(np.argmax(gain))]
91
+ return best / np.sqrt(best @ M @ best)
92
+
93
+
94
+ def fibonacci(n, *, hemisphere=True):
95
+ """``n`` unit vectors spread on the (upper half-) sphere, deterministic."""
96
+ i = np.arange(n) + 0.5
97
+ z = 1.0 - i / n if hemisphere else 1.0 - 2.0 * i / n
98
+ phi = np.pi * (3.0 - np.sqrt(5.0)) * i
99
+ r = np.sqrt(np.clip(1.0 - z ** 2, 0.0, None))
100
+ return np.stack([r * np.cos(phi), r * np.sin(phi), z], axis=1)
101
+
102
+
103
+ def frame(d):
104
+ """``R (3, 3)`` orthonormal with first column ``d`` (the encoding's axis), the rest completing it."""
105
+ d = np.asarray(d, np.float64); d = d / np.linalg.norm(d)
106
+ ref = np.array([1.0, 0.0, 0.0]) if abs(d[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
107
+ u = ref - (ref @ d) * d; u /= np.linalg.norm(u)
108
+ return np.stack([d, u, np.cross(d, u)], axis=1)
109
+
110
+
111
+ def levels(tc, encoding, b_SI, d):
112
+ """``A (3, 3)``: the level matrix of one row of ``encoding`` at ``b_SI`` (s/m^2) about the axis ``d`` (the
113
+ direction of a linear encoding, the normal of a planar one, the first axis of a spherical one's rotation)."""
114
+ M = gram(tc); Mi = _inv_sqrt(M); R = frame(d)
115
+ if encoding == "LTE":
116
+ return np.sqrt(b_SI) * np.outer(lte_mix(tc), R[:, 0])
117
+ if encoding == "PTE":
118
+ return np.sqrt(b_SI / 2.0) * Mi @ np.stack([R[:, 1], R[:, 2], np.zeros(3)])
119
+ if encoding == "STE":
120
+ return np.sqrt(b_SI / 3.0) * Mi @ R.T
121
+ raise ValueError(f"an encoding is one of {ENCODINGS}, got {encoding!r}")
122
+
123
+
124
+ def rows(tc, bvals, n_dirs):
125
+ """The acquisition: one b = 0 row, then for each b (s/mm^2) ``n_dirs`` rows of each encoding (their axes spread on
126
+ the hemisphere). Returns ``(sequence, encoding (n,), b (n,) s/mm^2)``."""
127
+ W = waveform(tc)
128
+ dirs = fibonacci(int(n_dirs))
129
+ G = [np.zeros((N_T, 3))]; enc = ["b0"]; bs = [0.0]
130
+ for b in bvals:
131
+ for e in ENCODINGS:
132
+ for d in dirs:
133
+ G.append(W @ levels(tc, e, float(b) * 1e6, d)); enc.append(e); bs.append(float(b))
134
+ return _sequence(np.stack(G)), np.array(enc), np.array(bs)
135
+
136
+
137
+ def check(tc, seq, enc, bs):
138
+ """The rows' b-tensors as built: ``{encoding: (max |b - b_target| / b, max |b_delta - target|)}`` from
139
+ dmipy-sim's own b-tensor of the sequence, and the peak gradient per axis (T/m) of every row."""
140
+ from dmipy_sim.acquisition.waveforms import btensor_invariants, calc_btensor
141
+ B = calc_btensor(seq)
142
+ b, b_delta, _ = btensor_invariants(B)
143
+ out = {}
144
+ for e in ENCODINGS:
145
+ m = enc == e
146
+ if m.any():
147
+ out[e] = (float(np.max(np.abs(b[m] / 1e6 - bs[m]) / bs[m])), float(np.max(np.abs(b_delta[m] - B_DELTA[e]))))
148
+ return out, np.abs(np.asarray(seq.G, np.float64)).max(axis=(1, 2))
149
+
150
+
151
+ def powder(S, enc, bs, bvals):
152
+ """``{(encoding, b): S/S0 averaged over the encoding's rows at b}`` per voxel, ``S`` ``(..., n)`` normalised to its
153
+ b = 0 row."""
154
+ return {(e, float(b)): np.nanmean(S[..., (enc == e) & (bs == float(b))], axis=-1) for e in ENCODINGS for b in bvals}
155
+
156
+
157
+ def gamma_joint(b, yL, yS, wL, wS, w0, iters=80):
158
+ """``(MD, V_L, V_S, c)`` per voxel of the gamma model fitted jointly to the linear and the spherical powder signal
159
+ (``ln S/S0`` ``(..., n_b)`` at ``b`` ms/um^2): one mean diffusivity (the first cumulant, the same for every b-tensor
160
+ of one trace), one intercept ``c`` (the log of the voxel's b = 0 signal over its measured one) and a variance per
161
+ encoding, ``y_e(b) = c - ln(1 + r_e b MD) / r_e``, ``V_e = r_e MD^2``; weighted least squares, each point weighted by
162
+ the rows it averages (``wL``, ``wS`` ``(n_b,)``) and the b = 0 point ``y = 0`` by the b = 0 rows ``w0``. The shared MD
163
+ and intercept leave the curvature of each curve to its variance alone: the noise of the b = 0 rows (shared by every
164
+ ``ln S/S0``) is not read as curvature. A vectorised Gauss-Newton over every voxel, MD in [1e-4, 5] um^2/ms, r in
165
+ [1e-6, 50]."""
166
+ sh = yL.shape[:-1]; n_b = len(b)
167
+ Y = np.concatenate([yL.reshape(-1, n_b), yS.reshape(-1, n_b), np.zeros((int(np.prod(sh)), 1))], axis=1)
168
+ W = np.sqrt(np.concatenate([np.asarray(wL, float), np.asarray(wS, float), [float(w0)]]))
169
+ n = Y.shape[0]
170
+ p = np.stack([np.clip(-np.nan_to_num(0.5 * (Y[:, 0] + Y[:, n_b]) / b[0]), 1e-3, None), np.full(n, 0.1), np.full(n, 0.1),
171
+ np.zeros(n)], axis=1)
172
+ lo = np.array([1e-4, 1e-6, 1e-6, -2.0]); hi = np.array([5.0, 50.0, 50.0, 2.0])
173
+
174
+ def f(p):
175
+ g = lambda r: -np.log1p(r[:, None] * b[None] * p[:, 0:1]) / r[:, None]
176
+ return np.concatenate([p[:, 3:4] + g(p[:, 1]), p[:, 3:4] + g(p[:, 2]), p[:, 3:4]], axis=1)
177
+ for _ in range(int(iters)):
178
+ F = f(p); res = (Y - F) * W
179
+ J = np.empty((n, Y.shape[1], 4))
180
+ for k in range(4):
181
+ e = 1e-6 * np.maximum(np.abs(p[:, k]), 1e-3); q = p.copy(); q[:, k] += e
182
+ J[:, :, k] = (f(q) - F) / e[:, None] * W
183
+ A = np.einsum("nik,nil->nkl", J, J) + 1e-12 * np.eye(4)
184
+ g = np.einsum("nik,ni->nk", J, res)
185
+ with np.errstate(invalid="ignore"):
186
+ step = np.linalg.solve(A, g[..., None])[..., 0]
187
+ p = np.clip(p + np.nan_to_num(step), lo, hi)
188
+ md, rL, rS, c = (p[:, k].reshape(sh) for k in range(4))
189
+ return md, rL * md ** 2, rS * md ** 2, c
190
+
191
+
192
+ def md_ufa(pw, bvals, counts=None, n_b0=1):
193
+ """``(MD (um^2/ms), muFA)`` per voxel from the powder averages: the gamma model fitted jointly to the linear and the
194
+ spherical powder signal over the b-values with one MD and one intercept (:func:`gamma_joint`; ``counts``
195
+ ``{(encoding, b): rows averaged}`` and ``n_b0`` the b = 0 rows weight the points, equal when None), MD the shared first cumulant,
196
+ ``V_A = V_LTE - V_STE`` the anisotropic variance (the orientation dispersion of the microscopic tensors, ``(4/5)
197
+ (D_iso D_Delta)^2``), and muFA the FA of the axisymmetric microscopic tensor with ``D_iso = MD`` and that
198
+ ``D_Delta``; 0 where ``V_A`` is not positive (a spherical signal whose variance exceeds the linear one's: on
199
+ restricted tissue the two waveforms' spectra differ, and the spherical one sees more of the time dependence), NaN
200
+ where a signal is not positive."""
201
+ keys = sorted(float(x) for x in bvals) # the powder averages' own keys, s/mm^2
202
+ b = np.asarray(keys) * 1e-3 # ms/um^2
203
+ cnt = (lambda e: [1.0] * len(keys)) if counts is None else (lambda e: [float(counts[(e, k)]) for k in keys])
204
+ with np.errstate(divide="ignore", invalid="ignore"):
205
+ yL = np.stack([np.log(pw[("LTE", k)]) for k in keys], axis=-1); yS = np.stack([np.log(pw[("STE", k)]) for k in keys], axis=-1)
206
+ bad = ~np.isfinite(np.concatenate([yL, yS], axis=-1)).all(-1)
207
+ md, vL, vS, _ = gamma_joint(b, np.nan_to_num(yL), np.nan_to_num(yS), cnt("LTE"), cnt("STE"), 1.0 if counts is None else n_b0)
208
+ x = np.clip(1.25 * (vL - vS), 0.0, None) # (D_iso D_Delta)^2, (um^2/ms)^2
209
+ with np.errstate(divide="ignore", invalid="ignore"):
210
+ ufa = np.sqrt(1.5) * np.sqrt(6.0 * x / (3.0 * md ** 2 + 6.0 * x))
211
+ return np.where(bad, np.nan, md), np.where(bad, np.nan, ufa)
212
+
213
+
214
+ def dti_fa(S, enc, bs, axes, b_max=1400.0):
215
+ """FA per voxel of the diffusion tensor fitted log-linearly to the linear rows at b <= ``b_max`` (s/mm^2) and the
216
+ b = 0 rows (``S`` S/S0 ``(..., n)``, ``axes`` each row's encoding axis); NaN where a signal is not positive."""
217
+ m = (np.asarray(enc) == "LTE") & (np.asarray(bs) <= b_max)
218
+ if m.sum() < 6:
219
+ raise ValueError(f"a tensor fit needs six or more linear rows at b <= {b_max:g}, got {int(m.sum())}")
220
+ g = np.asarray(axes, np.float64)[m]; b = np.asarray(bs, np.float64)[m] * 1e-3
221
+ X = -b[:, None] * np.stack([g[:, 0] ** 2, g[:, 1] ** 2, g[:, 2] ** 2, 2 * g[:, 0] * g[:, 1], 2 * g[:, 0] * g[:, 2],
222
+ 2 * g[:, 1] * g[:, 2]], 1)
223
+ with np.errstate(divide="ignore", invalid="ignore"):
224
+ y = np.log(S[..., m])
225
+ d = np.einsum("pk,...k->...p", np.linalg.pinv(X), y)
226
+ D = np.stack([np.stack([d[..., 0], d[..., 3], d[..., 4]], -1), np.stack([d[..., 3], d[..., 1], d[..., 5]], -1),
227
+ np.stack([d[..., 4], d[..., 5], d[..., 2]], -1)], -2)
228
+ ok = np.isfinite(D).all(axis=(-1, -2))
229
+ ev = np.full(D.shape[:-1], np.nan); ev[ok] = np.linalg.eigvalsh(D[ok])
230
+ md = ev.mean(-1)
231
+ with np.errstate(divide="ignore", invalid="ignore"):
232
+ return np.sqrt(1.5 * ((ev - md[..., None]) ** 2).sum(-1) / (ev ** 2).sum(-1))
233
+
234
+
235
+ def maps(dwi, meas, signal):
236
+ """What a b-tensor run reconstructs, per voxel where ``signal``: ``fa`` (:func:`dti_fa` on the linear rows), ``md``
237
+ and ``ufa`` (:func:`md_ufa` on the powder averages), and ``powder`` ``{(encoding, b): S/S0}`` with ``bvals``, the
238
+ b-values the encodings share."""
239
+ enc, bs = np.asarray(meas.encoding), np.asarray(meas.bvals, np.float64)
240
+ bvals = sorted(set(float(b) for b, e in zip(bs, enc) if e == "LTE") & set(float(b) for b, e in zip(bs, enc) if e == "STE"))
241
+ S = np.where(signal[..., None], dwi, np.nan)
242
+ pw = {(e, b): np.nanmean(S[..., (enc == e) & (bs == b)], axis=-1) for e in ("LTE", "STE") for b in bvals}
243
+ counts = {(e, b): int(((enc == e) & (bs == b)).sum()) for e in ("LTE", "STE") for b in bvals}
244
+ md, ufa = md_ufa(pw, bvals, counts, n_b0=int((enc == "b0").sum()))
245
+ return dict(fa=dti_fa(S, enc, bs, meas.dirs), md=md, ufa=ufa, powder=pw, bvals=bvals)
space/viewers.py CHANGED
@@ -318,3 +318,66 @@ def response_curves(true, estimated, note=""):
318
  axes[1].set_title("GM and CSF" + (f" ({note})" if note else ""), fontsize=9); axes[1].legend(fontsize=7)
319
  fig.tight_layout()
320
  return _image(fig)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  axes[1].set_title("GM and CSF" + (f" ({note})" if note else ""), fontsize=9); axes[1].legend(fontsize=7)
319
  fig.tight_layout()
320
  return _image(fig)
321
+
322
+
323
+ def tensor_maps(maps, crossing, z, label="A: "):
324
+ """A b-tensor run's maps at axial slice ``z``: FA (DTI on the linear rows) beside the microscopic FA, both 0 to 1
325
+ with the voxels the ground truth's strands cross in two or more directions (``crossing``, boolean on the grid)
326
+ outlined, and MD (um^2/ms)."""
327
+ plt = _mpl()
328
+ fig, axes = plt.subplots(1, 3, figsize=(11.0, 3.6))
329
+ cr = np.asarray(crossing, float)[:, :, z].T
330
+ for ax, key, title, kw in ((axes[0], "fa", "FA (DTI, linear rows)", dict(vmin=0, vmax=1, cmap="viridis")),
331
+ (axes[1], "ufa", "µFA (linear vs spherical powder)", dict(vmin=0, vmax=1, cmap="viridis")),
332
+ (axes[2], "md", "MD (µm²/ms)", dict(vmin=0, vmax=1.0, cmap="magma"))):
333
+ im = ax.imshow(np.asarray(maps[key], np.float64)[:, :, z].T, origin="lower", **kw)
334
+ if key != "md":
335
+ ax.contour(cr, levels=[0.5], colors="w", linewidths=0.6)
336
+ ax.set_xticks([]); ax.set_yticks([]); ax.set_title(label + title, fontsize=9)
337
+ fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
338
+ fig.suptitle("white outline: voxels the ground truth's strands cross in two or more directions", fontsize=8)
339
+ return _image(fig)
340
+
341
+
342
+ def powder_curve(maps, ijk, other=None, labels=("A", "B")):
343
+ """The powder-averaged signal of voxel ``ijk`` against b, linear vs spherical (``other``: B's maps, dashed)."""
344
+ plt = _mpl()
345
+ fig, ax = plt.subplots(figsize=(4.6, 3.4))
346
+ for m, ls, tag in ((maps, "-", labels[0]), (other, "--", labels[1])):
347
+ if m is None:
348
+ continue
349
+ b = [0.0] + list(m["bvals"])
350
+ for e, c in (("LTE", "C0"), ("STE", "C3")):
351
+ y = [1.0] + [float(m["powder"][(e, x)][tuple(ijk)]) for x in m["bvals"]]
352
+ ax.plot(b, y, ls + "o", color=c, ms=3, label=f"{tag} {e}")
353
+ ax.set_yscale("log"); ax.set_xlabel("b (s/mm²)"); ax.set_ylabel("powder S/S0")
354
+ ax.set_title(f"voxel {tuple(int(x) for x in ijk)}: µFA {float(maps['ufa'][tuple(ijk)]):.2f}, FA {float(maps['fa'][tuple(ijk)]):.2f}", fontsize=9)
355
+ ax.legend(fontsize=7)
356
+ fig.tight_layout()
357
+ return _image(fig)
358
+
359
+
360
+ def tensor_waveform(G, dt, gate, label):
361
+ """The class's three shapes as played (physical gradient, mT/m) over the echo time, the 180 marked."""
362
+ plt = _mpl()
363
+ t = np.arange(G.shape[0]) * dt * 1e3
364
+ fig, ax = plt.subplots(figsize=(7.5, 2.8))
365
+ for k, c in enumerate(AXIS_COLOURS):
366
+ ax.plot(t, G[:, k] * 1e3, color=c, lw=1.2, label=f"ψ{k + 1}")
367
+ flip = np.nonzero(np.diff(np.asarray(gate)) != 0)[0]
368
+ for f in flip:
369
+ ax.axvline(t[f] + 0.5 * (t[1] - t[0]), color="0.5", ls=":", lw=1)
370
+ ax.set_xlabel("t (ms)"); ax.set_ylabel("G (mT/m)"); ax.set_title(label, fontsize=9); ax.legend(fontsize=8, ncol=3)
371
+ return _image(fig)
372
+
373
+
374
+ def map_picker(sl, scale, *, vmin=0.0, vmax=1.0, cmap="viridis"):
375
+ """A 2-D map ``sl`` (x, y) as an image ``scale`` pixels per voxel with y up and no margins, so a click's pixel names
376
+ its voxel exactly (``x // scale``, ``ny - 1 - y // scale``); NaN grey."""
377
+ from PIL import Image
378
+ import matplotlib
379
+ a = np.asarray(sl, np.float64).T[::-1] # rows top to bottom = y from high to low
380
+ rgba = matplotlib.colormaps[cmap]((np.clip(np.nan_to_num(a, nan=vmin), vmin, vmax) - vmin) / (vmax - vmin))
381
+ rgba[~np.isfinite(a)] = (0.5, 0.5, 0.5, 1.0)
382
+ img = (np.repeat(np.repeat(rgba[..., :3], int(scale), axis=0), int(scale), axis=1) * 255).astype(np.uint8)
383
+ return Image.fromarray(img)
tests/test_acceptance.py CHANGED
@@ -1,9 +1,20 @@
1
- """The gate: on a GPU with the layout reachable, DiSCo's own protocol replayed from the layout equals the published
2
- bare reference volume, and the noiseless pipeline scores the connectome within the reference's band with no ground-
3
- truth pair missed. Prints the per-stage timings (first call and steady) and writes them to ``gate.json``.
4
 
5
- JAX_PLATFORMS=cuda XLA_FLAGS=--xla_gpu_deterministic_ops=true pytest tests/test_acceptance.py -s
6
- DISCO_MOMENTS=/local/dir pytest ... # a local copy of the layout instead of the Hub
 
 
 
 
 
 
 
 
 
 
 
 
7
  """
8
  import json
9
  import os
@@ -13,95 +24,100 @@ import numpy as np
13
  import pytest
14
 
15
  from space import pipeline as P
 
16
  from space import sources
17
  from space.sources import disco as D
18
 
19
  CFG = P.config()
20
  OUT = os.environ.get("GATE_OUT", os.path.join(P.ROOT, "gate.json"))
 
 
 
 
 
 
 
 
21
 
22
 
23
  @pytest.fixture(scope="module")
24
  def layout():
25
- import jax
26
- if jax.devices()[0].platform != "gpu":
27
- pytest.skip("the gate runs on a GPU")
28
- pytest.importorskip("huggingface_hub")
29
  t0 = time.perf_counter()
30
  lay = sources.source(CFG)
31
- lay.warm()
32
  lay.load_seconds = time.perf_counter() - t0
33
  return lay
34
 
35
 
36
- @pytest.mark.parametrize("run", ["bare", "3T", "7T"])
37
- def test_discos_protocol_from_the_layout_is_the_published_reference_volume(layout, run):
38
- """S0-normalised, on every voxel the reference has signal: the replay through the moments is the replay the
39
- reference was made with, to the float32 arithmetic of the layout; bare, and in the catalogue's white matter at
40
- 3 T and 7 T with every tier on and the field along z (the references' setting)."""
41
- import nibabel as nib
42
- from huggingface_hub import hf_hub_download
43
- p, order = D.disco_protocol(CFG) # order: the table's rows in the protocol's order
 
 
 
 
 
 
 
44
  physics = None if run == "bare" else P.Physics.at(float(run[:-1]))
45
- m = layout.validate(p, physics)
46
- dwi, floor, factor, secs = layout.replay(m, physics)
47
- ref_path = hf_hub_download(CFG["data"]["repo"], f"disco/reference/disco_replay_{run}.nii.gz", repo_type="dataset", revision=CFG["data"]["revision"])
48
- ref = np.asarray(nib.load(ref_path).dataobj, np.float64)[..., order]
49
- both = np.isfinite(dwi) & (ref != 0)
50
- d = np.abs(dwi - ref)[both]
51
- print(f"\nreference check {run}: max |dS| {d.max():.2e}, 99.9 % {np.quantile(d, 0.999):.2e}, median {np.median(d):.2e} "
52
- f"over {both.any(-1).sum()} voxels; replay {secs:.2f} s")
53
- assert (~np.isfinite(dwi) & (ref != 0)).any(-1).sum() == 0, "voxels the reference has signal in and the layout has no rows"
54
- assert d.max() < CFG["gate"]["reference_max_abs_diff"]
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
 
57
  def test_the_noiseless_pipeline_scores_within_the_references_band(layout):
58
- """Pearson vs strand count at or above the gate's floor (the replay reference's band is 0.912 +- 0.003; the
59
- tracker on the reference volume measured 0.927) and no ground-truth pair missed; the timings recorded."""
60
  p, _ = D.disco_protocol(CFG)
61
  tr = P.Tracking(**{k: CFG["tracking"][k] for k in ("density", "step_mm", "max_angle", "max_steps")})
62
- first = P.run(layout, p, snr=None, tracking=tr) # includes every compile
63
  steady = P.run(layout, p, snr=None, tracking=tr)
64
  s = steady.score
65
- table = dict(device=str(__import__("jax").devices()[0]), layout_load_seconds=layout.load_seconds,
66
- first_call=first.seconds, steady=steady.seconds, score=s, streamlines=len(steady.tractogram),
67
- seeds=int(len(steady.seeds)), n_meas=p.n_meas)
68
- print("\ngate: " + json.dumps(table, indent=1))
69
- with open(OUT, "w") as f:
70
- json.dump(table, f, indent=1)
71
  assert s["pearson_count"] >= CFG["gate"]["pearson_count_min"], s
72
  assert s["missed_pairs"] <= CFG["gate"]["missed_pairs_max"], s
73
- assert first.score["pearson_count"] == s["pearson_count"] # the same key: the same tractogram (deterministic XLA ops)
74
 
75
 
76
  @pytest.mark.parametrize("machine", [m["key"] for m in CFG["scanners"]["machines"]])
77
- def test_a_machine_on_discos_protocol_moves_the_image_and_is_timed(layout, machine):
78
- """Each machine of the menu at 7.9 cm from isocentre along R-L on DiSCo's own protocol (none of them can play it:
79
- the gradient limit is the page's, the replay's cost is measured here at the gate's protocol), against the ideal
80
- scanner at the machine's field and direction: the delivered image is finite where the ideal one is and moves it;
81
- the first and steady replay seconds of both go to ``gate_scanners.json`` beside ``gate.json``. A machine with its
82
- own gradient needs the layout's background moments and is skipped by name on a layout without them."""
83
  from dataclasses import replace
84
  L = P.limits(machine)
85
- if L.has_field_law and not layout.moments.background:
86
- pytest.skip(f"{machine}'s own gradient needs the layout's background moments (shape_moments.stamp_background)")
87
  p, _ = D.disco_protocol(CFG)
88
  ideal = P.Physics.at(float(L.field_T), b0_direction=tuple(L.b0_axis or (0.0, 0.0, 1.0)))
89
  far = replace(ideal, scanner=machine, offset_m=(0.079, 0.0, 0.0))
90
  m = layout.validate(p, far)
91
- secs = {}
92
- for tag, ph in (("ideal", ideal), ("machine", far)):
93
- for k in ("first", "steady"):
94
- dwi, _, factor, s = layout.replay(m, ph)
95
- secs[f"{tag}_{k}"] = s
96
- if tag == "ideal":
97
- ref = dwi
98
  both = np.isfinite(ref)
99
  assert (np.isfinite(dwi) == both).all()
100
  moved = float(np.max(np.abs(dwi - ref)[both]))
101
- print(f"\n{machine} at 7.9 cm on DiSCo 364: max |dS| vs ideal {moved:.3e}; seconds {json.dumps(secs)}")
102
  assert moved > 1e-3
103
- path = os.path.join(os.path.dirname(OUT), "gate_scanners.json")
104
- table = json.load(open(path)) if os.path.exists(path) else {}
105
- table[machine] = dict(seconds=secs, max_abs_dS=moved, n_meas=p.n_meas, offset_m=list(far.offset_m), device=str(__import__("jax").devices()[0]))
106
- with open(path, "w") as f:
107
- json.dump(table, f, indent=1)
 
1
+ """The gate: the page's replay from the time-segment layout equals the replay pack's own replay of the same rows, and
2
+ the noiseless pipeline scores the connectome within the reference's band with no ground-truth pair missed.
 
3
 
4
+ The reference (:mod:`space.reference`) is the replay pack the layout was contracted from (``[data] pack``), replayed
5
+ walker by walker -- every band, every field mode, the contact -- at ``[gate] reference_voxels`` voxels spread over its
6
+ index; the page's signal at those voxels, over the voxel's bare b = 0 signal (the quantity both certificates bound;
7
+ S/S0 divides it by the relaxed b = 0 signal, down to 0.3 at 7 T, and is recorded beside it), must be within
8
+ ``[gate] reference_max_abs_diff`` of the reference's, for DiSCo's
9
+ own protocol bare and in the catalogue's white matter at 3 T and 7 T, and for every preset (the paper's flagship
10
+ acquisitions, the cosine OGSE among them) bare and at 3 T. It runs where the layout is (``DISCO_LAYOUT`` a local copy,
11
+ else the Hub) and reads the pack's rows at the gate's voxels (``DISCO_PACK`` a local copy, else the Hub); on a CPU
12
+ host every image streams the layout chunk by chunk (minutes per class), so it runs when asked:
13
+
14
+ DISCO_GATE=1 DISCO_LAYOUT=/local/layout DISCO_PACK=/local/coefficients pytest tests/test_acceptance.py -s
15
+ DISCO_GATE=1 JAX_PLATFORMS=cuda XLA_FLAGS=--xla_gpu_deterministic_ops=true pytest tests/test_acceptance.py -s
16
+
17
+ The per-case worst difference and timings go to ``gate.json`` (``GATE_OUT``).
18
  """
19
  import json
20
  import os
 
24
  import pytest
25
 
26
  from space import pipeline as P
27
+ from space import reference as R
28
  from space import sources
29
  from space.sources import disco as D
30
 
31
  CFG = P.config()
32
  OUT = os.environ.get("GATE_OUT", os.path.join(P.ROOT, "gate.json"))
33
+ pytestmark = pytest.mark.skipif(not os.environ.get("DISCO_GATE"), reason="the gate runs when asked (DISCO_GATE=1)")
34
+
35
+
36
+ def _record(key, value):
37
+ table = json.load(open(OUT)) if os.path.exists(OUT) else {}
38
+ table[key] = value
39
+ with open(OUT, "w") as f:
40
+ json.dump(table, f, indent=1, default=float)
41
 
42
 
43
  @pytest.fixture(scope="module")
44
  def layout():
 
 
 
 
45
  t0 = time.perf_counter()
46
  lay = sources.source(CFG)
 
47
  lay.load_seconds = time.perf_counter() - t0
48
  return lay
49
 
50
 
51
+ @pytest.fixture(scope="module")
52
+ def pack():
53
+ pk = R.open_pack(os.environ.get("DISCO_PACK") or CFG["data"]["pack"])
54
+ return pk, R.gate_voxels(pk, CFG["gate"]["reference_voxels"])
55
+
56
+
57
+ CASES = [(D.DISCO_364, run) for run in ("bare", "3T", "7T")] + [(name, run) for name in CFG["presets"] for run in ("bare", "3T")]
58
+
59
+
60
+ @pytest.mark.parametrize("preset,run", CASES)
61
+ def test_the_layout_is_the_replay_packs_replay_at_the_gates_voxels(layout, pack, preset, run):
62
+ """At the gate's voxels: the page's replay through the layout (its classes' waveforms, its tiers) is the pack's replay
63
+ of the same rows, to ``reference_max_abs_diff`` in units of the voxel's bare b = 0 signal."""
64
+ pk, voxels = pack
65
+ protocol = D.Disco.protocol(CFG, preset)
66
  physics = None if run == "bare" else P.Physics.at(float(run[:-1]))
67
+ meas = layout.validate(protocol, physics)
68
+ t0 = time.perf_counter()
69
+ dwi, floor, factor, secs = layout.replay(meas, physics)
70
+ tissue, scanner = P.tissue_and_scanner(physics, layout.unseeded)
71
+ ref = np.zeros((len(voxels), len(meas.bvals)))
72
+ t1 = time.perf_counter()
73
+ for name in np.unique(meas.shape):
74
+ rows = meas.shape == name
75
+ ref[:, rows] = R.replay_at(pk, voxels, layout.sequence(name, meas.bvals[rows], meas.dirs[rows], None if meas.encoding is None else meas.encoding[rows]), tissue, scanner)
76
+ t_ref = time.perf_counter() - t1
77
+ ref_S0 = ref[:, meas.b0].mean(1, keepdims=True)
78
+ got = np.stack([dwi[tuple(v)] for v in voxels])
79
+ d = np.abs(got - ref / ref_S0) # S/S0
80
+ d_S = np.abs(got * ref_S0 - ref) # the signal over the voxel's bare b = 0 signal: what the certificates bound
81
+ worst, worst_S = float(np.nanmax(d)), float(np.nanmax(d_S))
82
+ print(f"\n{preset} {run}: max |dS| {worst_S:.2e} (S over the bare b = 0 signal), max |d(S/S0)| {worst:.2e} over "
83
+ f"{len(voxels)} voxels x {len(meas.bvals)} measurements (S0 down to {float(ref_S0.min()):.3f}); "
84
+ f"layout replay {secs:.1f} s, reference {t_ref:.1f} s")
85
+ _record(f"{preset} | {run}", dict(worst_S=worst_S, worst_S_over_S0=worst, median_S=float(np.nanmedian(d_S)), S0_min=float(ref_S0.min()),
86
+ voxels=len(voxels), n_meas=int(len(meas.bvals)), replay_seconds=secs, reference_seconds=t_ref,
87
+ bound=CFG["gate"]["reference_max_abs_diff"], manifest_sha256=layout.manifest_sha256))
88
+ assert np.isfinite(got).all(), "a gate voxel the pack has rows in has no signal from the layout"
89
+ assert worst_S < CFG["gate"]["reference_max_abs_diff"]
90
 
91
 
92
  def test_the_noiseless_pipeline_scores_within_the_references_band(layout):
93
+ """Pearson vs strand count at or above the gate's floor and no ground-truth pair missed on DiSCo's own protocol;
94
+ the timings recorded (first call with every compile, then steady)."""
95
  p, _ = D.disco_protocol(CFG)
96
  tr = P.Tracking(**{k: CFG["tracking"][k] for k in ("density", "step_mm", "max_angle", "max_steps")})
97
+ first = P.run(layout, p, snr=None, tracking=tr)
98
  steady = P.run(layout, p, snr=None, tracking=tr)
99
  s = steady.score
100
+ _record("pipeline", dict(layout_load_seconds=layout.load_seconds, first_call=first.seconds, steady=steady.seconds, score=s,
101
+ streamlines=len(steady.tractogram), seeds=int(len(steady.seeds)), n_meas=p.n_meas))
 
 
 
 
102
  assert s["pearson_count"] >= CFG["gate"]["pearson_count_min"], s
103
  assert s["missed_pairs"] <= CFG["gate"]["missed_pairs_max"], s
104
+ assert first.score["pearson_count"] == s["pearson_count"]
105
 
106
 
107
  @pytest.mark.parametrize("machine", [m["key"] for m in CFG["scanners"]["machines"]])
108
+ def test_a_machine_on_discos_protocol_moves_the_image(layout, machine):
109
+ """Each machine of the menu at 7.9 cm from isocentre along R-L on DiSCo's own protocol against the ideal scanner at
110
+ the machine's field and direction: the delivered image is finite where the ideal one is and moves it."""
 
 
 
111
  from dataclasses import replace
112
  L = P.limits(machine)
 
 
113
  p, _ = D.disco_protocol(CFG)
114
  ideal = P.Physics.at(float(L.field_T), b0_direction=tuple(L.b0_axis or (0.0, 0.0, 1.0)))
115
  far = replace(ideal, scanner=machine, offset_m=(0.079, 0.0, 0.0))
116
  m = layout.validate(p, far)
117
+ ref, *_ = layout.replay(m, ideal)
118
+ dwi, _, _, secs = layout.replay(m, far)
 
 
 
 
 
119
  both = np.isfinite(ref)
120
  assert (np.isfinite(dwi) == both).all()
121
  moved = float(np.max(np.abs(dwi - ref)[both]))
122
+ _record(f"machine | {machine}", dict(max_abs_dS=moved, seconds=secs))
123
  assert moved > 1e-3
 
 
 
 
 
tests/test_app.py CHANGED
@@ -8,6 +8,7 @@ import pytest
8
 
9
  from space import app as A
10
  from space import pipeline as P
 
11
  from space.sources import disco as D
12
 
13
  CFG = P.config()
@@ -21,8 +22,8 @@ def test_the_three_kinds_of_acquisition():
21
  assert disco.name == "DiSCo 364" and disco.n_meas == 364 and disco.n_b0 == 4 # the table's own, not the slider's
22
  name = next(iter(CFG["presets"]))
23
  preset = A._protocol_from_inputs(S, CFG, name, 5, *ROWS)
24
- assert preset.name == name and preset.n_b0 == CFG["presets"][name]["n_b0"]
25
- assert [s.shape for s in preset.shells] == [s["shape"] for s in CFG["presets"][name]["shells"]]
26
  custom = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
27
  assert custom.n_b0 == 2 and [(s.shape, s.b, s.n_dirs) for s in custom.shells] == [("d12-D24", 1000.0, 30), ("d8-D20", 3000.0, 45)]
28
  assert not any(s.free_timing for s in custom.shells)
@@ -68,7 +69,8 @@ def test_a_knob_changes_one_thing_of_a():
68
  v = _values()
69
  prot = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
70
  ks = S.knobs(CFG)
71
- assert ks[A.NO_KNOB] is None and len(ks) == 1 + 5 + 2 + 3 + 1 + 3 + len(CFG["shapes"])
 
72
  for name, change in ks.items():
73
  if change is None:
74
  continue
@@ -219,7 +221,7 @@ def test_the_estimated_seconds_grow_with_the_run_and_stay_in_the_pools_window():
219
  assert A.estimated_seconds("nonsense", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS) == 480
220
  text = A.gpu_seconds_text("DiSCo 364", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS)
221
  assert f"reserves {ladder} s" in text and "logged out" in text.split("started by")[1].split(".")[0]
222
- assert len(A.OUTPUTS) == 30
223
 
224
 
225
  def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
@@ -230,15 +232,16 @@ def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
230
  rng = np.random.default_rng(0)
231
  bare = rng.uniform(0.3, 1.0, (4, 4, 2, 13)).astype(np.float16); a = (bare * 0.98).astype(np.float16); b = (bare * 0.97).astype(np.float16)
232
  md = rng.random((4, 4, 2)).astype(np.float16); fa = rng.random((4, 4, 2)).astype(np.float16)
233
- ex = dict(layers=[("bare diffusion", bare), ("A = bare + field", a), ("B = A with field → 7 T", b)], metrics={k: (md, fa) for k in ("bare diffusion", "A = bare + field", "B = A with field → 7 T")},
234
  floor=np.full((4, 4, 2), 0.01, np.float32), meas=m, mask=np.ones((4, 4, 2), bool),
235
  ingredients=S.ingredient_layers(dict(intra_fraction=rng.random((4, 4, 2)), wall_contact_um=rng.random((4, 4, 2)), contact_survival=None, field_rad=None, D_walk=6e-10)),
236
  differences=P.layer_differences([("bare diffusion", bare.astype(np.float32)), ("A", a.astype(np.float32)), ("B", b.astype(np.float32))], m, np.ones((4, 4, 2), bool)),
237
  snr=None, physics=None)
238
  for mode in A.EXPLORE_MODES:
239
- for metric in A.METRICS:
240
  img = A.explore(ex, "A = bare + field", mode, metric, 1, 5)
241
  assert img is not None and img.size[0] > 100, (mode, metric)
 
242
  assert A.explore(ex, "bare diffusion", "minus the previous layer", A.METRICS[0], 0, 0) is not None
243
  pool, contact, fld = A.ingredient_views(ex, 1)
244
  assert pool is not None and contact is not None and fld is None
@@ -283,3 +286,82 @@ def test_the_payload_carries_float32_volumes():
283
  assert light.dwi.dtype == np.float32 and light.clean.dtype == np.float32 and res.dwi.dtype == np.float64
284
  np.testing.assert_allclose(light.dwi, dwi, rtol=1e-6); assert light.snr == 30.0 and light.meas is m
285
  assert A._light(P.Result(p, m, dwi, floor, None, None, None, None, None, {})).clean is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  from space import app as A
10
  from space import pipeline as P
11
+ from space import viewers as V
12
  from space.sources import disco as D
13
 
14
  CFG = P.config()
 
22
  assert disco.name == "DiSCo 364" and disco.n_meas == 364 and disco.n_b0 == 4 # the table's own, not the slider's
23
  name = next(iter(CFG["presets"]))
24
  preset = A._protocol_from_inputs(S, CFG, name, 5, *ROWS)
25
+ assert preset.name == name and preset.n_b0 == P.preset_protocol(CFG, name).n_b0 == 3 # the table's own
26
+ assert {s.shape for s in preset.shells} == {CFG["presets"][name]["shape"]}
27
  custom = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
28
  assert custom.n_b0 == 2 and [(s.shape, s.b, s.n_dirs) for s in custom.shells] == [("d12-D24", 1000.0, 30), ("d8-D20", 3000.0, 45)]
29
  assert not any(s.free_timing for s in custom.shells)
 
69
  v = _values()
70
  prot = A._protocol_from_inputs(S, CFG, A.CUSTOM, 2, *ROWS)
71
  ks = S.knobs(CFG)
72
+ timing = [n for n, sh in CFG["shapes"].items() if sh.get("kind") != "tensor"]
73
+ assert ks[A.NO_KNOB] is None and len(ks) == 1 + 5 + 2 + 3 + 1 + 3 + len(timing)
74
  for name, change in ks.items():
75
  if change is None:
76
  continue
 
221
  assert A.estimated_seconds("nonsense", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS) == 480
222
  text = A.gpu_seconds_text("DiSCo 364", 1, True, 30, 2, 30.0, 0.5, 0, None, A.NO_KNOB, A.IDEAL, 1, True, *v, *ROWS)
223
  assert f"reserves {ladder} s" in text and "logged out" in text.split("started by")[1].split(".")[0]
224
+ assert len(A.OUTPUTS) == 36 # the 30 of a connectome run and the b-tensor run's six
225
 
226
 
227
  def test_the_explorer_draws_layers_differences_and_metrics_from_its_state():
 
232
  rng = np.random.default_rng(0)
233
  bare = rng.uniform(0.3, 1.0, (4, 4, 2, 13)).astype(np.float16); a = (bare * 0.98).astype(np.float16); b = (bare * 0.97).astype(np.float16)
234
  md = rng.random((4, 4, 2)).astype(np.float16); fa = rng.random((4, 4, 2)).astype(np.float16)
235
+ ex = dict(layers=[("bare diffusion", bare), ("A = bare + field", a), ("B = A with field → 7 T", b)], metrics={k: (md, fa, None) for k in ("bare diffusion", "A = bare + field", "B = A with field → 7 T")},
236
  floor=np.full((4, 4, 2), 0.01, np.float32), meas=m, mask=np.ones((4, 4, 2), bool),
237
  ingredients=S.ingredient_layers(dict(intra_fraction=rng.random((4, 4, 2)), wall_contact_um=rng.random((4, 4, 2)), contact_survival=None, field_rad=None, D_walk=6e-10)),
238
  differences=P.layer_differences([("bare diffusion", bare.astype(np.float32)), ("A", a.astype(np.float32)), ("B", b.astype(np.float32))], m, np.ones((4, 4, 2), bool)),
239
  snr=None, physics=None)
240
  for mode in A.EXPLORE_MODES:
241
+ for metric in A.METRICS[:3]:
242
  img = A.explore(ex, "A = bare + field", mode, metric, 1, 5)
243
  assert img is not None and img.size[0] > 100, (mode, metric)
244
+ assert A.explore(ex, "A = bare + field", "signal", A.METRICS[3], 1, 5) is None # no µFA off a b-tensor run
245
  assert A.explore(ex, "bare diffusion", "minus the previous layer", A.METRICS[0], 0, 0) is not None
246
  pool, contact, fld = A.ingredient_views(ex, 1)
247
  assert pool is not None and contact is not None and fld is None
 
286
  assert light.dwi.dtype == np.float32 and light.clean.dtype == np.float32 and res.dwi.dtype == np.float64
287
  np.testing.assert_allclose(light.dwi, dwi, rtol=1e-6); assert light.snr == 30.0 and light.meas is m
288
  assert A._light(P.Result(p, m, dwi, floor, None, None, None, None, None, {})).clean is None
289
+
290
+
291
+ def _tensor_rs():
292
+ """A results state of a b-tensor run on a 6 x 5 x 2 grid: A and B, their maps, the measurement rows."""
293
+ p = P.preset_protocol(CFG, next(n for n, v in CFG["presets"].items() if "tensor" in v))
294
+ m = P.measurements(p, CFG["shapes"])
295
+ rng = np.random.default_rng(1)
296
+ bvals = sorted(set(float(b) for b in m.bvals if b > 0))
297
+
298
+ def maps(scale):
299
+ return dict(fa=rng.random((6, 5, 2)) * scale, md=rng.random((6, 5, 2)), ufa=rng.random((6, 5, 2)) * scale, bvals=bvals,
300
+ powder={(e, b): np.exp(-b * 1e-3 * (0.5 if e == "LTE" else 0.7)) * np.ones((6, 5, 2)) for e in ("LTE", "STE") for b in bvals})
301
+ dwi = rng.uniform(0.2, 1.0, (6, 5, 2, len(m.bvals))).astype(np.float32)
302
+ run = lambda s: dict(dwi=dwi, floor=np.full((6, 5, 2), 0.01, np.float32), floor_median=0.01, meas=m, peaks=None, peak_amp=None,
303
+ extras=dict(fractions=None), tensor=A._tensor_state(maps(s)))
304
+ return dict(A=run(1.0), B=run(0.5)), m
305
+
306
+
307
+ def test_a_b_tensor_runs_rows_maps_and_voxel_click():
308
+ """On a b-tensor run the measurement slider counts within the chosen encoding; the µFA slice is a picker whose
309
+ click pixel names its voxel (y up); the clicked voxel's powder curves draw for A and B; the maps draw for both."""
310
+ rs, m = _tensor_rs()
311
+ enc = np.asarray(m.encoding)
312
+ assert A.row_of(m, "every row", 3) == 3
313
+ assert enc[A.row_of(m, "STE", 0)] == "STE" and enc[A.row_of(m, "LTE", 10 ** 6)] == "LTE"
314
+ assert A.row_of(m, "STE", 0) == int(np.flatnonzero(enc == "STE")[0])
315
+ pick = V.map_picker(rs["A"]["tensor"]["ufa"][:, :, 1], A.PICK)
316
+ assert pick.size == (6 * A.PICK, 5 * A.PICK)
317
+ seen = []
318
+ import space.viewers as VV
319
+ orig = VV.powder_curve
320
+ try:
321
+ VV.powder_curve = lambda tm, ijk, other=None: seen.append((tuple(ijk), other is not None)) or "img"
322
+ evt = types.SimpleNamespace(index=(2 * A.PICK + 3, 0)) # x in voxel 2, the top row = y 4
323
+ assert A.pick_voxel(rs, 1, evt) == "img" and seen[-1] == ((2, 4, 1), True)
324
+ finally:
325
+ VV.powder_curve = orig
326
+ assert V.powder_curve(rs["A"]["tensor"], (1, 1, 0), other=rs["B"]["tensor"]).size[0] > 100
327
+ n_dirs = np.ones((6, 5, 2), int); n_dirs[2:4, 1:3] = 2
328
+ A._CROSSINGS["c"] = (n_dirs, np.ones((6, 5, 2)))
329
+ try:
330
+ a, picker, b = A.tensor_views(rs, 0)
331
+ assert a is not None and picker is not None and b is not None
332
+ finally:
333
+ A._CROSSINGS.clear()
334
+ assert A.tensor_views(dict(A=dict(tensor=None)), 0) == (None, None, None)
335
+
336
+
337
+ def test_the_explorer_on_a_b_tensor_run_draws_the_encodings_rows_and_b_minus_a_of_mufa():
338
+ rs, m = _tensor_rs()
339
+ a = rs["A"]["tensor"]; b = rs["B"]["tensor"]
340
+ ex = dict(layers=[("A = bare diffusion", rs["A"]["dwi"].astype(np.float16)), ("B = A with SNR → 10", rs["B"]["dwi"].astype(np.float16))],
341
+ metrics={"A = bare diffusion": (a["md"], a["fa"], a["ufa"]), "B = A with SNR → 10": (b["md"], b["fa"], b["ufa"])},
342
+ floor=np.full((6, 5, 2), 0.01, np.float32), meas=m, mask=np.ones((6, 5, 2), bool), ingredients=[None, None, None],
343
+ differences=[], snr=None, physics=None)
344
+ for metric in A.METRICS:
345
+ for mode in A.EXPLORE_MODES:
346
+ assert A.explore(ex, "A = bare diffusion", mode, metric, 1, 2, "STE") is not None, (metric, mode)
347
+ assert A.explore(ex, "A = bare diffusion", "B − A", A.METRICS[3], 0, 0) is not None
348
+ with pytest.raises(ValueError, match="encodings of its one class"):
349
+ P.retime(P.preset_protocol(CFG, next(n for n, v in CFG["presets"].items() if "tensor" in v)), "d12-D24")
350
+
351
+
352
+ def test_every_stage_of_a_run_has_its_text():
353
+ """The progress text names every stage either kind of run passes (the connectome's and the b-tensor's)."""
354
+ texts = S.describe(CFG)["stages"]
355
+ assert set(P.TENSOR_STAGES) <= set(texts) and set(D.Disco.STAGES) <= set(texts)
356
+
357
+
358
+ def test_a_b_tensor_headline_reads_the_strand_voxels_by_crossing_count(monkeypatch):
359
+ rs, m = _tensor_rs()
360
+ n_dirs = np.zeros((6, 5, 2), int); n_dirs[:2] = 1; n_dirs[2:4] = 2; n_dirs[4:] = 3
361
+ length = np.ones((6, 5, 2)); length[0] = 0
362
+ monkeypatch.setitem(A._CROSSINGS, "c", (n_dirs, length))
363
+ tm = rs["A"]["tensor"]
364
+ res = types.SimpleNamespace(extras=dict(tensor=tm), physics=None, dwi=rs["A"]["dwi"], snr=30.0, seconds=dict(total=1.0),
365
+ protocol=types.SimpleNamespace(n_meas=len(m.bvals)), floor=rs["A"]["floor"])
366
+ text = A.tensor_text("A", res)
367
+ assert "voxels the strands cross" in text and "2 directions" in text and "3+ directions" in text and "SNR 30" in text
tests/test_pipeline.py CHANGED
@@ -13,12 +13,51 @@ def test_the_config_names_every_shell_and_preset_shape():
13
  shapes = set(CFG["shapes"])
14
  assert {s["shape"] for s in CFG["disco"]["shells"]} <= shapes
15
  for name, preset in CFG["presets"].items():
16
- assert {s["shape"] for s in preset["shells"]} <= shapes, name
17
  assert P.preset_protocol(CFG, name).name == name
18
  for s in CFG["shapes"].values():
19
  assert 0 < s["delta"] < s["Delta"] < s["TE"]
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  def test_a_protocols_measurements_are_its_rows_in_order():
23
  p = P.Protocol((P.Shell("d12-D24", 1000, 30), P.Shell("d8-D20", 6000, 12)), n_b0=2)
24
  m = P.measurements(p, CFG["shapes"])
@@ -122,9 +161,9 @@ def test_the_volumes_round_trip(tmp_path):
122
 
123
  def test_the_backend_and_residency_come_from_the_config_or_the_environment(monkeypatch):
124
  monkeypatch.delenv("DISCO_BACKEND", raising=False); monkeypatch.delenv("DISCO_RESIDENT", raising=False)
125
- assert P.backend(CFG) == CFG["compute"]["backend"] == "jax" and P.resident(CFG) is True
126
- monkeypatch.setenv("DISCO_BACKEND", "torch"); monkeypatch.setenv("DISCO_RESIDENT", "0")
127
- assert P.backend(CFG) == "torch" and P.resident(CFG) is False
128
  monkeypatch.setenv("DISCO_BACKEND", "numpy")
129
  with pytest.raises(ValueError, match="backend"):
130
  P.backend(CFG)
@@ -268,28 +307,30 @@ def test_floor_stats_are_over_the_voxels_with_signal():
268
 
269
 
270
  def test_the_layout_at_the_pinned_revision_holds_the_configs_classes_and_tiers():
271
- """The manifest the Space will download: every [shapes] class with the config's timing, and the tiers when the
272
- tissue panel starts on (needs the Hub; skipped offline)."""
 
273
  import json
 
 
274
  hf = pytest.importorskip("huggingface_hub")
275
  d = CFG["data"]
276
  import requests
277
  from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled
278
  try:
279
- path = hf.hf_hub_download(d["repo"], f"{d['moments']}/manifest.json", repo_type="dataset", revision=d["revision"])
280
  except (requests.ConnectionError, HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled) as e: # offline, or the Hub unreachable
281
  pytest.skip(f"the Hub is not reachable: {e!r}"[:120])
282
- with open(path) as f:
283
- m = json.load(f)
284
- assert m["source"]["manifest_sha256"] == d["source_manifest_sha256"]
285
- missing = [c for c in CFG["shapes"] if c not in m["columns"]]
286
  assert not missing, f"the layout at {d['revision'][:12]} lacks {missing}"
287
- for name, t in CFG["shapes"].items():
288
- enc = m["shapes"][name].get("encoding") or {}
289
- for k in ("delta", "Delta", "TE"):
290
- v = enc.get(k); v = v[0] if isinstance(v, list) else v
291
- if v is not None:
292
- assert abs(v - t[k]) < 1e-9, (name, k, v, t[k])
293
  if CFG["physics"]["default_on"]:
294
  assert m.get("tiers"), "the tissue panel starts on but the layout has no tiers"
295
 
@@ -328,7 +369,7 @@ def test_the_ingredients_are_the_layouts_tier_maps_in_the_pages_units():
328
  calls.append((shape, tissue is not None, scanner, backend, resident, tuple(b0_direction)))
329
  return dict(pool={"intra": np.full((2, 1, 1), 0.4), "extra": np.full((2, 1, 1), 0.6)}, contact=np.array([[[-2e-5]], [[np.nan]]]),
330
  contact_weight=np.full((2, 1, 1), 0.9), phase=np.zeros((2, 1, 1)), phase_std=np.full((2, 1, 1), 0.1))
331
- lay = D.Layout.__new__(D.Layout); lay.moments = _M(); lay.tiers = True; lay.backend = "torch"; lay.unseeded = ("myelin",)
332
  meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 6),), n_b0=1), P.config()["shapes"])
333
  ph = P.Physics.at(3.0)
334
  out = D.Layout.ingredients(lay, meas, ph)
@@ -344,27 +385,37 @@ def test_the_ingredients_are_the_layouts_tier_maps_in_the_pages_units():
344
 
345
 
346
  def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
347
- """On a stand-in layout: the ideal scanner asks the image for the commanded acquisition (no delivery); a machine
348
- asks the layout's delivery for every class at the machine's catalogue entry and the grid centred at the physics'
349
- offset, and hands it to that class's image; a machine with its own gradient on a layout without background moments,
350
- and with a transmit law on one without RF schedules, is refused by name."""
 
351
  from dmipy_sim.phantom import Grid
 
352
  seen = []
 
 
 
 
 
353
  class _M:
354
  grid = Grid(shape=(2, 1, 1), voxel_size_m=(25e-6,) * 3, origin_m=(0.0, 0.0, 0.0))
355
- background = None
356
- manifest = {"shapes": {"d12-D24": {}, "d8-D20": {}}}
357
- def delivery(self, shape, b, dirs, scanner, grid):
358
- seen.append(("delivery", shape, scanner.name, tuple(np.round(np.asarray(grid.isocenter_m) - np.asarray(self.grid.isocenter_m), 6))))
359
- return ("delivered", shape)
360
- def image(self, shape, b, dirs, *, backend, resident, tissue, scanner, b0_direction, delivered=None):
361
- seen.append(("image", shape, delivered, scanner, tuple(b0_direction)))
362
- return np.ones((2, 1, 1, len(b))), np.zeros((2, 1, 1))
363
- lay = D.Layout.__new__(D.Layout); lay.moments = _M(); lay.tiers = True; lay.backend = "jax"; lay.unseeded = ("myelin",)
364
- lay.M0 = np.ones((2, 1, 1)); lay.mask = np.ones((2, 1, 1), bool); lay.pools = ("intra", "extra"); lay.shapes = CFG["shapes"]
 
 
365
  meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 3), P.Shell("d8-D20", 2000, 3)), n_b0=1), CFG["shapes"])
366
- D.Layout.replay(lay, meas, P.Physics.at(3.0))
367
- assert [s[2] for s in seen if s[0] == "image"] == [None, None]
 
368
  seen.clear()
369
  ph = P.Physics.at(3.0, scanner="siemens_magnetom_prisma_3T", offset_m=(0.0, 0.0, 0.05))
370
  D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 1000, 3),), n_b0=1), ph)
@@ -372,9 +423,17 @@ def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
372
  assert [s for s in seen if s[0] == "delivery"] == [("delivery", "d12-D24", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05)),
373
  ("delivery", "d8-D20", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05))]
374
  assert [s[2] for s in seen if s[0] == "image"] == [("delivered", "d12-D24"), ("delivered", "d8-D20")]
 
 
375
  swoop = P.Physics.at(0.064, scanner="hyperfine_swoop_64mT", b0_direction=(0.0, 1.0, 0.0))
376
- with pytest.raises(ValueError, match="background moments"):
377
- D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
378
- lay.moments.background = {"amplitude": 5e-3}
379
- with pytest.raises(ValueError, match="RF schedules"):
380
  D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
 
 
 
 
 
 
 
 
 
 
13
  shapes = set(CFG["shapes"])
14
  assert {s["shape"] for s in CFG["disco"]["shells"]} <= shapes
15
  for name, preset in CFG["presets"].items():
16
+ assert preset["shape"] in shapes, name
17
  assert P.preset_protocol(CFG, name).name == name
18
  for s in CFG["shapes"].values():
19
  assert 0 < s["delta"] < s["Delta"] < s["TE"]
20
 
21
 
22
+ #: the paper's flagship acquisitions (experiments/disco/disco_acq.py): b = 0 rows, shells (b, directions), class
23
+ FLAGSHIP = {"clinical b1000 x 30": (3, [(1000, 30)], "d12-D24"), "research 3-shell x 90": (9, [(1000, 90), (2000, 90), (3000, 90)], "d17-D30"),
24
+ "Connectome 2.0 b6000 x 60": (6, [(6000, 60)], "d8-D20"), "cosine OGSE 50 Hz b1000 x 60": (6, [(1000, 60)], "cos-f50-T20")}
25
+
26
+
27
+ def test_the_presets_are_the_papers_flagship_tables():
28
+ """Every preset is its stored gradient table, the paper's own (data/protocols/, the same files the paper replays):
29
+ its b = 0 rows first, then each shell's directions, every row on the preset's class."""
30
+ assert set(FLAGSHIP) <= set(CFG["presets"])
31
+ for name, (n_b0, shells, shape) in FLAGSHIP.items():
32
+ p = P.preset_protocol(CFG, name)
33
+ assert p.n_b0 == n_b0 and [(s.b, s.n_dirs) for s in p.shells] == shells and {s.shape for s in p.shells} == {shape}
34
+ m = P.measurements(p, CFG["shapes"])
35
+ np.testing.assert_allclose(np.linalg.norm(m.dirs, axis=1), 1.0, atol=1e-6)
36
+
37
+
38
+ def test_each_class_plays_its_one_waveform():
39
+ """A class's rows are its one shape scaled to each row's b along each row's direction: the b of every row is its
40
+ own; a square PGSE class needs the textbook amplitude; the OGSE class keeps the 200 T/m/s edges of its b = 1000
41
+ build at every b (the shape is fixed), a b = 0 row plays nothing."""
42
+ from dmipy_sim.acquisition.waveforms import calc_b
43
+ from space import classes as C
44
+ u = np.array([[1.0, 0.0, 0.0], [0.0, 0.6, 0.8], [0.0, 0.0, 1.0]])
45
+ for name, sh in CFG["shapes"].items():
46
+ if C.kind(sh) == "tensor": # its rows are level matrices: tests/test_tensor.py
47
+ continue
48
+ b = np.array([500.0, 1000.0, 0.0]) if C.kind(sh) == "ogse" else np.array([1000.0, 3000.0, 0.0])
49
+ seq = C.play(sh, b * 1e6, u)
50
+ np.testing.assert_allclose(calc_b(seq), b * 1e6, rtol=1e-5, atol=1e3)
51
+ G = np.asarray(seq.G, np.float64)
52
+ assert not G[2].any()
53
+ if C.kind(sh) == "pgse":
54
+ assert abs(C.amplitude(sh, 1000) / P.gradient_needed(1000, sh["delta"], sh["Delta"]) - 1) < 0.02, name
55
+ sh = CFG["shapes"]["cos-f50-T20"]
56
+ G = np.abs(np.asarray(C.play(sh, [0.5e9, 1e9], u[:2]).G, np.float64)).max(axis=2)
57
+ np.testing.assert_allclose(G[0] / G[0].max(), G[1] / G[1].max(), atol=1e-6) # one shape at both b
58
+ assert 0.2 < C.amplitude(sh, 1000) < 0.3 # 0.25 T/m: a 50 Hz cosine is weak in b
59
+
60
+
61
  def test_a_protocols_measurements_are_its_rows_in_order():
62
  p = P.Protocol((P.Shell("d12-D24", 1000, 30), P.Shell("d8-D20", 6000, 12)), n_b0=2)
63
  m = P.measurements(p, CFG["shapes"])
 
161
 
162
  def test_the_backend_and_residency_come_from_the_config_or_the_environment(monkeypatch):
163
  monkeypatch.delenv("DISCO_BACKEND", raising=False); monkeypatch.delenv("DISCO_RESIDENT", raising=False)
164
+ assert P.backend(CFG) == CFG["compute"]["backend"] == "jax" and P.resident(CFG) is False
165
+ monkeypatch.setenv("DISCO_BACKEND", "torch"); monkeypatch.setenv("DISCO_RESIDENT", "1")
166
+ assert P.backend(CFG) == "torch" and P.resident(CFG) is True
167
  monkeypatch.setenv("DISCO_BACKEND", "numpy")
168
  with pytest.raises(ValueError, match="backend"):
169
  P.backend(CFG)
 
307
 
308
 
309
  def test_the_layout_at_the_pinned_revision_holds_the_configs_classes_and_tiers():
310
+ """The manifest the Space will download: every [shapes] class is a stored partition whose terms are that class's
311
+ waveform (its template fits them), and the tiers when the tissue panel starts on (needs the Hub; skipped offline)."""
312
+ import hashlib
313
  import json
314
+ from dmipy_sim.replay.time_segments import Partition, Shape
315
+ from space import classes as C
316
  hf = pytest.importorskip("huggingface_hub")
317
  d = CFG["data"]
318
  import requests
319
  from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled
320
  try:
321
+ path = hf.hf_hub_download(d["repo"], f"{d['layout']}/manifest.json", repo_type="dataset", revision=d["revision"])
322
  except (requests.ConnectionError, HfHubHTTPError, LocalEntryNotFoundError, OfflineModeIsEnabled) as e: # offline, or the Hub unreachable
323
  pytest.skip(f"the Hub is not reachable: {e!r}"[:120])
324
+ raw = open(path, "rb").read(); m = json.loads(raw)
325
+ if d.get("manifest_sha256"):
326
+ assert hashlib.sha256(raw).hexdigest() == d["manifest_sha256"]
327
+ missing = [c for c in CFG["shapes"] if c not in m["partitions"]]
328
  assert not missing, f"the layout at {d['revision'][:12]} lacks {missing}"
329
+ for name, sh in CFG["shapes"].items():
330
+ p = m["partitions"][name]
331
+ part = Partition(name, tuple(tuple(t["t"]) for t in p["segments"]), tuple(int(t["gate"]) for t in p["segments"]),
332
+ tuple(Shape.from_meta(t["shape"], name) for t in p["segments"]))
333
+ part.levels(C.template(sh)) # refused by name unless the waveform is the class's
 
334
  if CFG["physics"]["default_on"]:
335
  assert m.get("tiers"), "the tissue panel starts on but the layout has no tiers"
336
 
 
369
  calls.append((shape, tissue is not None, scanner, backend, resident, tuple(b0_direction)))
370
  return dict(pool={"intra": np.full((2, 1, 1), 0.4), "extra": np.full((2, 1, 1), 0.6)}, contact=np.array([[[-2e-5]], [[np.nan]]]),
371
  contact_weight=np.full((2, 1, 1), 0.9), phase=np.zeros((2, 1, 1)), phase_std=np.full((2, 1, 1), 0.1))
372
+ lay = D.Layout.__new__(D.Layout); lay.layout = _M(); lay.tiers = True; lay.backend = "torch"; lay.unseeded = ("myelin",); lay.resident = True
373
  meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 6),), n_b0=1), P.config()["shapes"])
374
  ph = P.Physics.at(3.0)
375
  out = D.Layout.ingredients(lay, meas, ph)
 
385
 
386
 
387
  def test_a_machine_reaches_the_layout_as_a_delivery_at_the_placement():
388
+ """On a stand-in layout: the ideal scanner asks the image for the commanded acquisition (the class's waveform, no
389
+ delivery); a machine asks the layout's delivery for every class with the grid centred at the physics' offset, and
390
+ hands it to that class's image; the image's normalised signal is scaled by every voxel's walker weight; a machine
391
+ with its own gradient on a class without background vectors, and any machine on a shaped class (the OGSE), is
392
+ refused by name."""
393
  from dmipy_sim.phantom import Grid
394
+ from space import classes as C
395
  seen = []
396
+
397
+ class _Part:
398
+ def __init__(self, constant):
399
+ self.is_constant = constant
400
+
401
  class _M:
402
  grid = Grid(shape=(2, 1, 1), voxel_size_m=(25e-6,) * 3, origin_m=(0.0, 0.0, 0.0))
403
+ partitions = {n: _Part(C.kind(sh) != "ogse") for n, sh in CFG["shapes"].items()}
404
+ manifest = {"partitions": {n: {"group": "g0"} for n in CFG["shapes"]}, "groups": {"g0": {}}}
405
+
406
+ def delivery(self, seq, scanner, grid, *, partition):
407
+ seen.append(("delivery", partition, scanner.name, tuple(np.round(np.asarray(grid.isocenter_m) - np.asarray(self.grid.isocenter_m), 6))))
408
+ return ("delivered", partition)
409
+
410
+ def image(self, seq, *, partition, backend, resident, tissue, scanner, b0_direction, delivered=None):
411
+ seen.append(("image", partition, delivered, scanner, tuple(b0_direction), np.asarray(seq.G).shape[0]))
412
+ return np.full((2, 1, 1, np.asarray(seq.G).shape[0]), 0.5), np.zeros((2, 1, 1))
413
+ lay = D.Layout.__new__(D.Layout); lay.layout = _M(); lay.tiers = True; lay.backend = "jax"; lay.unseeded = ("myelin",); lay.resident = False
414
+ lay.M0 = np.array([2.0, 4.0]).reshape(2, 1, 1); lay.mask = np.ones((2, 1, 1), bool); lay.pools = ("intra", "extra"); lay.shapes = CFG["shapes"]
415
  meas = P.measurements(P.Protocol((P.Shell("d12-D24", 1000, 3), P.Shell("d8-D20", 2000, 3)), n_b0=1), CFG["shapes"])
416
+ dwi, floor, factor, _ = D.Layout.replay(lay, meas, P.Physics.at(3.0))
417
+ assert [s[2] for s in seen if s[0] == "image"] == [None, None] and [s[5] for s in seen if s[0] == "image"] == [4, 3]
418
+ np.testing.assert_allclose(dwi, 1.0); np.testing.assert_allclose(factor.ravel(), [0.25, 0.5]) # S0 = 0.5 W over the fullest voxel's W
419
  seen.clear()
420
  ph = P.Physics.at(3.0, scanner="siemens_magnetom_prisma_3T", offset_m=(0.0, 0.0, 0.05))
421
  D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 1000, 3),), n_b0=1), ph)
 
423
  assert [s for s in seen if s[0] == "delivery"] == [("delivery", "d12-D24", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05)),
424
  ("delivery", "d8-D20", "siemens_magnetom_prisma_3T", (0.0, 0.0, -0.05))]
425
  assert [s[2] for s in seen if s[0] == "image"] == [("delivered", "d12-D24"), ("delivered", "d8-D20")]
426
+ with pytest.raises(ValueError, match="shaped waveforms"):
427
+ D.Layout.validate(lay, P.Protocol((P.Shell("cos-f50-T20", 1000, 3),), n_b0=1), ph)
428
  swoop = P.Physics.at(0.064, scanner="hyperfine_swoop_64mT", b0_direction=(0.0, 1.0, 0.0))
429
+ with pytest.raises(ValueError, match="background vectors"):
 
 
 
430
  D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
431
+ lay.layout.manifest["groups"]["g0"]["background"] = {"column": "bg_g0.npy"}
432
+ D.Layout.validate(lay, P.Protocol((P.Shell("d12-D24", 100, 3),), n_b0=1), swoop)
433
+
434
+
435
+ def test_each_preset_starts_at_its_own_snr():
436
+ """The b-tensor preset starts at SNR 100 (µFA is noise-limited); every other preset at ``[noise] snr``."""
437
+ for name, p in CFG["presets"].items():
438
+ assert P.preset_snr(CFG, name) == (100.0 if "tensor" in p else float(CFG["noise"]["snr"])) and CFG["noise"]["snr"] == 30
439
+ assert P.preset_snr(CFG, "DiSCo 364") == 30.0
tests/test_tensor.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The b-tensor class's encodings (space/tensor.py) without the layout: every row's b-tensor, read off the sequence by
2
+ dmipy-sim, is the encoding asked for; the class's waveform stays inside the scanner it was designed for; the maps
3
+ recover a known microscopic anisotropy from Gaussian compartments played through the same rows."""
4
+ import numpy as np
5
+ import pytest
6
+
7
+ from space import pipeline as P
8
+ from space import tensor as T
9
+
10
+ CFG = P.config()
11
+ TC = next(sh for sh in CFG["shapes"].values() if sh.get("kind") == "tensor")
12
+
13
+
14
+ def _rows(bvals=(500.0, 1000.0, 2000.0), n=12):
15
+ return T.rows(TC, list(bvals), n)
16
+
17
+
18
+ def test_every_row_is_the_encoding_asked_for():
19
+ """b to 1e-6 relative and b_delta to 1e-6 (1, -1/2, 0) for every row; the b = 0 row plays nothing."""
20
+ from dmipy_sim.acquisition.waveforms import btensor_invariants, calc_btensor
21
+ seq, enc, bs = _rows()
22
+ B = calc_btensor(seq)
23
+ b, b_delta, _ = btensor_invariants(B)
24
+ assert enc[0] == "b0" and abs(b[0]) < 1e-6
25
+ for e in T.ENCODINGS:
26
+ m = enc == e
27
+ np.testing.assert_allclose(b[m] / 1e6, bs[m], rtol=1e-6)
28
+ np.testing.assert_allclose(b_delta[m], T.B_DELTA[e], atol=1e-6)
29
+ ste = B[enc == "STE"] # spherical: b/3 I at every rotation
30
+ np.testing.assert_allclose(ste, (bs[enc == "STE"] * 1e6 / 3)[:, None, None] * np.eye(3), rtol=0, atol=1e-6 * 2e9)
31
+
32
+
33
+ def test_the_waveform_is_refocused_and_inside_its_scanner():
34
+ """Each shape's net moment at the echo vanishes and the designed amplitude and slew are within 80 mT/m and
35
+ 200 T/m/s per axis; the spherical row at b = 2000 at the design's own frame needs no more than the design."""
36
+ W = T.waveform(TC); g = T._grid(); e = np.asarray(g.effective_gate, np.float64)
37
+ net = np.abs((W * e[:, None]).sum(0) * g.dt)
38
+ assert (net < 1e-9 * np.abs(W).max() * 0.0535).all(), net
39
+ assert np.abs(W).max() <= 0.080 * (1 + 1e-6)
40
+ assert np.abs(np.diff(W, axis=0) / g.dt).max() <= 200.0 * 3 * (1 + 1e-6) # linear interpolation of a 200 T/m/s design
41
+
42
+
43
+ @pytest.mark.parametrize("bvals", [(500.0, 1000.0, 2000.0), (100.0, 700.0, 1400.0, 2000.0)])
44
+ def test_the_maps_recover_a_known_microscopic_anisotropy(bvals):
45
+ """Powder of randomly oriented axisymmetric Gaussian tensors (D_iso 1 um^2/ms, muFA 0.8) played through the rows'
46
+ own b-tensors: MD within 3 %, muFA within 0.03 over the b-values (the cumulant fits' own truncation); the powder
47
+ averages are read by the b-values as given (700 is not 0.7 x 1e3 in floating point)."""
48
+ seq, enc, bs = _rows(bvals=bvals, n=30)
49
+ from dmipy_sim.acquisition.waveforms import calc_btensor
50
+ B = calc_btensor(seq) * 1e-9 # s/m^2 -> ms/um^2
51
+ D_iso, mufa = 1.0, 0.8
52
+ x = mufa ** 2 * D_iso ** 2 / (3 - 2 * mufa ** 2) # (D_iso D_Delta)^2 of an axisymmetric tensor of that muFA
53
+ D_delta = np.sqrt(x) / D_iso
54
+ rng = np.random.default_rng(0)
55
+ u = rng.normal(size=(4000, 3)); u /= np.linalg.norm(u, axis=1)[:, None]
56
+ Dpar, Dperp = D_iso * (1 + 2 * D_delta), D_iso * (1 - D_delta)
57
+ D = Dperp * np.eye(3)[None] + (Dpar - Dperp) * u[:, :, None] * u[:, None, :]
58
+ S = np.exp(-np.einsum("mij,cij->mc", B, D)).mean(1)
59
+ S = S / S[0]
60
+ pw = T.powder(S[None, None, None, :], enc, bs, list(bvals))
61
+ md, got = T.md_ufa(pw, list(bvals))
62
+ assert abs(float(md.ravel()[0]) - D_iso) < 0.03 * D_iso
63
+ assert abs(float(got.ravel()[0]) - mufa) < 0.03
64
+
65
+
66
+ def test_the_tensor_preset_is_linear_and_spherical_rows_on_the_class_and_flags_a_prisma():
67
+ """The preset: one b = 0 row, then LTE and STE interleaved at b 100, 700, 1400, 2000 with 6, 6, 10, 16 axes, every
68
+ row's b-tensor the one asked for as dmipy-sim reads it off the sequence the Layout plays; rotated, the rows need
69
+ more than a Prisma's 80 mT/m on one axis, so the page flags that machine and passes the ideal scanner."""
70
+ from dmipy_sim.acquisition.waveforms import btensor_invariants, calc_btensor
71
+ from space import classes as C
72
+ name = next(n for n in CFG["presets"] if "tensor" in CFG["presets"][n])
73
+ p = P.preset_protocol(CFG, name)
74
+ assert p.tensor and p.n_b0 == 1 and [(s.encoding, s.b, s.n_dirs) for s in p.shells] == [
75
+ (e, b, n) for b, n in ((100.0, 6), (700.0, 6), (1400.0, 10), (2000.0, 16)) for e in ("LTE", "STE")]
76
+ m = P.measurements(p, CFG["shapes"])
77
+ G = C.tensor_rows(TC, m.encoding, m.bvals, m.dirs)
78
+ b, b_delta, _ = btensor_invariants(calc_btensor(T._sequence(G)))
79
+ np.testing.assert_allclose(b[1:] / 1e6, m.bvals[1:], rtol=1e-6)
80
+ np.testing.assert_allclose(b_delta[m.encoding == "LTE"], 1.0, atol=1e-6); np.testing.assert_allclose(b_delta[m.encoding == "STE"], 0.0, atol=1e-6)
81
+ rows = P.playable(p, CFG["shapes"], "siemens_magnetom_prisma_3T")
82
+ assert not all(r[-1] for r in rows) and all(r[-1] for r in P.playable(p, CFG["shapes"], None))
83
+ assert 0.10 < max(r[4] for r in rows) < 0.14 # ~124 mT/m at b 2000
84
+
85
+
86
+ def test_the_maps_hold_under_noise_on_one_b0_row():
87
+ """The same voxel as above, 3000 times, with Rician noise at SNR 15 and ONE b = 0 row, every row normalised by it
88
+ as the page does: the joint fit's shared intercept takes up the b = 0 row's noise, so the median muFA stays within
89
+ 0.08 of the truth and MD within 5 % (a fit through ln S/S0 = 0 at b = 0 reads that noise as curvature)."""
90
+ bvals = (100.0, 700.0, 1400.0, 2000.0)
91
+ seq, enc, bs = _rows(bvals=bvals, n=10)
92
+ from dmipy_sim.acquisition.waveforms import calc_btensor
93
+ B = calc_btensor(seq) * 1e-9
94
+ D_iso, mufa = 0.8, 0.7
95
+ x = mufa ** 2 * D_iso ** 2 / (3 - 2 * mufa ** 2); D_delta = np.sqrt(x) / D_iso
96
+ rng = np.random.default_rng(1)
97
+ u = rng.normal(size=(4000, 3)); u /= np.linalg.norm(u, axis=1)[:, None]
98
+ Dpar, Dperp = D_iso * (1 + 2 * D_delta), D_iso * (1 - D_delta)
99
+ D = Dperp * np.eye(3)[None] + (Dpar - Dperp) * u[:, :, None] * u[:, None, :]
100
+ S = np.exp(-np.einsum("mij,cij->mc", B, D)).mean(1)
101
+ sd = 1.0 / 15.0
102
+ noisy = np.sqrt((S[None] + rng.normal(0, sd, (3000, len(S)))) ** 2 + rng.normal(0, sd, (3000, len(S))) ** 2)
103
+ noisy = noisy / noisy[:, :1] # the one b = 0 row
104
+ pw = T.powder(noisy[:, None, None, :], enc, bs, list(bvals))
105
+ counts = {(e, b): int(((enc == e) & (bs == b)).sum()) for e in ("LTE", "STE") for b in bvals}
106
+ md, got = T.md_ufa(pw, list(bvals), counts, n_b0=1)
107
+ assert abs(float(np.nanmedian(got)) - mufa) < 0.08, float(np.nanmedian(got))
108
+ assert abs(float(np.nanmedian(md)) - D_iso) < 0.05 * D_iso
tests/test_viewers.py CHANGED
@@ -79,3 +79,15 @@ def test_the_map_slice_draws_plain_and_diverging():
79
  vol = np.random.default_rng(0).normal(size=(6, 6, 3)); vol[0, 0] = np.nan
80
  assert V.map_slice(vol, 1, "t").size[0] > 100 and V.map_slice(vol, 1, "d", symmetric=True).size[0] > 100
81
  assert V.map_slice(np.full((6, 6, 3), np.nan), 1, "empty", symmetric=True).size[0] > 100
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  vol = np.random.default_rng(0).normal(size=(6, 6, 3)); vol[0, 0] = np.nan
80
  assert V.map_slice(vol, 1, "t").size[0] > 100 and V.map_slice(vol, 1, "d", symmetric=True).size[0] > 100
81
  assert V.map_slice(np.full((6, 6, 3), np.nan), 1, "empty", symmetric=True).size[0] > 100
82
+
83
+
84
+ def test_the_tensor_views_draw_maps_with_the_crossings_and_a_voxels_powder_curve():
85
+ """The b-tensor results: FA, muFA (crossings outlined) and MD on a slice; a voxel's powder signal against b for
86
+ A and, dashed, B."""
87
+ rng = np.random.default_rng(1)
88
+ bv = [100.0, 700.0, 1400.0, 2000.0]
89
+ pw = {(e, b): np.exp(-b * 1e-3 * (0.6 if e == "STE" else 0.5)) * np.ones((4, 4, 4)) for e in ("LTE", "STE") for b in bv}
90
+ m = dict(fa=rng.random((4, 4, 4)), ufa=rng.random((4, 4, 4)), md=rng.random((4, 4, 4)), powder=pw, bvals=bv)
91
+ cross = np.zeros((4, 4, 4), bool); cross[1:3, 1:3] = True
92
+ assert V.tensor_maps(m, cross, 2).size[0] > 0
93
+ assert V.powder_curve(m, (1, 1, 2), other=m).size[0] > 0
tools/deploy.py CHANGED
@@ -44,6 +44,12 @@ def readme_for(config):
44
  return "README-zero.md" if config == DEFAULT_CONFIG else f"README-{os.path.splitext(config)[0]}-zero.md"
45
 
46
 
 
 
 
 
 
 
47
  def set_config(api, space, config):
48
  """``DISCO_CONFIG`` on the Space: the configuration's name, absent for the default."""
49
  if config == DEFAULT_CONFIG:
@@ -79,7 +85,7 @@ def main():
79
  for name in ("app.py", "pyproject.toml"):
80
  shutil.copy(os.path.join(root, name), staged)
81
  shutil.copy(os.path.join(root, readme_for(a.config)), os.path.join(staged, "README.md"))
82
- shutil.copy(os.path.join(root, "requirements-zero.txt"), os.path.join(staged, "requirements.txt"))
83
  folder = staged
84
  elif a.base_tag:
85
  staged = tempfile.mkdtemp(prefix="disco-space-")
@@ -91,7 +97,7 @@ def main():
91
  g.write(f.read().replace("BASE_TAG", a.base_tag))
92
  folder = staged
93
  info = api.upload_folder(folder_path=folder, repo_id=a.space, repo_type="space", ignore_patterns=IGNORE,
94
- delete_patterns=["Dockerfile", "Dockerfile.base", "Dockerfile.space", "entrypoint.sh", "README-*zero.md", "requirements-zero.txt"] if a.zero
95
  else (["Dockerfile.base", "Dockerfile.space"] if a.base_tag else None),
96
  commit_message=f"disco-space {sha[:12]}" + (f" on base {a.base_tag[:12]}" if a.base_tag else "") + (f", {a.config}" if a.config != DEFAULT_CONFIG else ""))
97
  if staged:
 
44
  return "README-zero.md" if config == DEFAULT_CONFIG else f"README-{os.path.splitext(config)[0]}-zero.md"
45
 
46
 
47
+ def requirements_for(config):
48
+ """The ZeroGPU requirements of a configuration: ``requirements-zero.txt`` for the default (the DiSCo page's pins),
49
+ ``requirements-<stem>-zero.txt`` else (the brain Space keeps the pins its packs were accepted on)."""
50
+ return "requirements-zero.txt" if config == DEFAULT_CONFIG else f"requirements-{os.path.splitext(config)[0]}-zero.txt"
51
+
52
+
53
  def set_config(api, space, config):
54
  """``DISCO_CONFIG`` on the Space: the configuration's name, absent for the default."""
55
  if config == DEFAULT_CONFIG:
 
85
  for name in ("app.py", "pyproject.toml"):
86
  shutil.copy(os.path.join(root, name), staged)
87
  shutil.copy(os.path.join(root, readme_for(a.config)), os.path.join(staged, "README.md"))
88
+ shutil.copy(os.path.join(root, requirements_for(a.config)), os.path.join(staged, "requirements.txt"))
89
  folder = staged
90
  elif a.base_tag:
91
  staged = tempfile.mkdtemp(prefix="disco-space-")
 
97
  g.write(f.read().replace("BASE_TAG", a.base_tag))
98
  folder = staged
99
  info = api.upload_folder(folder_path=folder, repo_id=a.space, repo_type="space", ignore_patterns=IGNORE,
100
+ delete_patterns=["Dockerfile", "Dockerfile.base", "Dockerfile.space", "entrypoint.sh", "README-*zero.md", "requirements-*zero.txt"] if a.zero
101
  else (["Dockerfile.base", "Dockerfile.space"] if a.base_tag else None),
102
  commit_message=f"disco-space {sha[:12]}" + (f" on base {a.base_tag[:12]}" if a.base_tag else "") + (f", {a.config}" if a.config != DEFAULT_CONFIG else ""))
103
  if staged:
tools/smoke_tensor.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The b-tensor preset through the page's own run button without a browser: A at 3 T with B = A with the field off
2
+ (the knob), the explorer's ladder on; every output of :data:`space.app.OUTPUTS` is produced and the b-tensor ones are
3
+ images; then a voxel click, an STE row of the DWI slice and the explorer's B − A of µFA.
4
+
5
+ DISCO_LAYOUT=/local/layout python tools/smoke_tensor.py [--snr 30]
6
+ """
7
+ import argparse
8
+ import json
9
+ import time
10
+ import types
11
+
12
+ import numpy as np
13
+
14
+
15
+ def main():
16
+ ap = argparse.ArgumentParser(); ap.add_argument("--snr", type=float, default=30.0); ap.add_argument("--knob", default="field tier → off")
17
+ a = ap.parse_args()
18
+ from space import app as A
19
+ from space import pipeline as P
20
+ st = A._load()
21
+ if st["error"]:
22
+ raise SystemExit(st["error"])
23
+ src, cfg = st["source"], st["cfg"]; S = type(src)
24
+ preset = next(n for n, v in cfg["presets"].items() if "tensor" in v)
25
+ panel = S.panel(cfg)
26
+ vals = {c.name: c.value for row in panel.rows for c in row}
27
+ values = [vals[f] for f in panel.fields]
28
+ shells = [False, "d12-D24", 1000, 30, 10.0, 20.0, 60.0] * A.MAX_SHELLS
29
+ args = (preset, 1, True, a.snr, 2, 30.0, 0.5, 0, None, a.knob, A.IDEAL, 1, True, *values, *shells)
30
+ t0 = time.perf_counter(); last = None; texts = []
31
+ for item in A.run_pipeline(A.compute, *args):
32
+ last = item
33
+ if isinstance(item, tuple) and isinstance(item[1], str):
34
+ texts.append(item[1])
35
+ out = dict(zip(A.OUTPUTS, last)); secs = time.perf_counter() - t0
36
+ rs = out["result"]
37
+ kinds = {k: type(v).__name__ for k, v in out.items()}
38
+ for k in ("tensor_view", "tensor_pick", "tensor_view_b", "dwi_view", "explore_view", "metric_view"):
39
+ assert out[k] is not None and hasattr(out[k], "size"), (k, kinds[k])
40
+ assert out["tract_view"] is None and out["mats"] is None
41
+ evt = types.SimpleNamespace(index=(20 * A.PICK + 3, 19 * A.PICK + 3))
42
+ curve = A.pick_voxel(rs, 20, evt)
43
+ ste = A.redraw_slice(rs, 20, 3, True, "A", "STE")
44
+ ex = A.redraw_explorer(rs, rs["explorer"]["layers"][-1][0], "B − A", A.METRICS[3], 20, 0, "LTE")
45
+ tm = rs["A"]["tensor"]; sig = np.isfinite(rs["A"]["dwi"][..., 0])
46
+ report = dict(seconds=secs, headline=out["headline"][:600], stages=texts[-6:], outputs=kinds,
47
+ ufa_median=float(np.nanmedian(tm["ufa"][sig])), fa_median=float(np.nanmedian(tm["fa"][sig])),
48
+ md_median=float(np.nanmedian(tm["md"][sig])), voxel_curve=curve is not None, ste_slice=ste[0] is not None,
49
+ explorer_b_minus_a_mufa=ex[0] is not None, files=[str(f).rsplit("/", 1)[-1] for f in out["volumes"]],
50
+ timings=out["timings"])
51
+ print(json.dumps(report, indent=1, default=str))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()