diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..4f5f4eb --- /dev/null +++ b/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-line-length = 100 +extend-ignore = E203, W503 +exclude = + .git, + __pycache__, + build, + dist, + .venv, + *.egg-info diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0fd92e3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,133 @@ +# ============================================================================= +# Git Attributes for TIDE Pipeline +# ============================================================================= + +# Auto detect text files and normalize line endings +* text=auto + +# ============================================================================= +# Source Code +# ============================================================================= + +*.py text diff=python +*.pyx text diff=python +*.pxd text diff=python +*.pxi text diff=python + +# ============================================================================= +# Documentation +# ============================================================================= + +*.md text diff=markdown +*.rst text +*.txt text +*.html text diff=html +*.css text diff=css +*.js text +*.json text + +# ============================================================================= +# Configuration +# ============================================================================= + +*.yml text +*.yaml text +*.toml text +*.ini text +*.cfg text +*.conf text +*.config text + +# ============================================================================= +# Shell Scripts +# ============================================================================= + +*.sh text eol=lf +*.bash text eol=lf +*.zsh text eol=lf +*.fish text eol=lf + +# Windows batch files +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# ============================================================================= +# Binary Files +# ============================================================================= + +# Images +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg text +*.webp binary + +# Fonts +*.ttf binary +*.eot binary +*.woff binary +*.woff2 binary + +# Archives +*.zip binary +*.tar binary +*.tar.gz binary +*.tgz binary +*.gz binary + +# ============================================================================= +# Data Files (Large Binary) +# ============================================================================= + +# NIfTI files +*.nii binary +*.nii.gz binary filter=lfs diff=lfs merge=lfs -text + +# Tractography files +*.trk binary filter=lfs diff=lfs merge=lfs -text +*.tck binary filter=lfs diff=lfs merge=lfs -text + +# Mesh files +*.msh binary filter=lfs diff=lfs merge=lfs -text + +# SimNIBS files +*.stmpx binary + +# HDF5 files +*.h5 binary filter=lfs diff=lfs merge=lfs -text +*.hdf5 binary filter=lfs diff=lfs merge=lfs -text + +# MATLAB files +*.mat binary + +# ============================================================================= +# GitHub Linguist Overrides +# ============================================================================= + +# Exclude from language statistics +tests/* linguist-detectable=false +docs/* linguist-documentation +examples/* linguist-documentation + +# Vendored files (if any) +# vendor/* linguist-vendored + +# ============================================================================= +# Diff Drivers +# ============================================================================= + +# Better diffs for Jupyter notebooks (requires nbstripout) +*.ipynb diff=jupyternotebook + +# ============================================================================= +# Export Ignore (for git archive) +# ============================================================================= + +.gitattributes export-ignore +.gitignore export-ignore +.github/ export-ignore +tests/ export-ignore +docs/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..33b72cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,134 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_call: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Linux tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + test tools + # SimNIBS is a system dependency (not on PyPI) and is intentionally not + # installed; SimNIBS-dependent tests are skipped below. Installing the + # package editable pulls the *pinned* runtime deps from pyproject + # (numpy 1.26.4, scipy, nibabel, dipy 1.9.0, pandas, matplotlib, ...), + # all wheel-available — no source builds, no numpy-2 resolution. + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install pytest==9.0.3 + - name: Run tests + # Individual SimNIBS integration cases carry their own skip markers; + # all SimNIBS-independent geometry tests remain part of the CI gate. + run: python -m pytest tests/ -q + + windows-test: + name: Windows tests (Python 3.11) + runs-on: windows-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Install package + test tools + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install pytest==9.0.3 + - name: Run tests + run: python -m pytest tests/ -q + + coverage: + name: Coverage regression gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Install package + coverage tools + run: | + python -m pip install --upgrade pip + python -m pip install -e . + python -m pip install pytest==9.0.3 pytest-cov==4.1.0 + - name: Run branch coverage gate + env: + COVERAGE_FILE: ${{ runner.temp }}/.coverage + run: >- + python -m pytest tests/ -q + --cov=tide + --cov-config="${GITHUB_WORKSPACE}/pyproject.toml" + --cov-report=term-missing + --cov-fail-under=45 + + package-smoke: + name: Wheel install + CLI smoke test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Install project as a regular package + run: | + python -m pip install --upgrade pip + python -m pip install --no-deps . + - name: Verify installed package, bundled config, and console entry point + working-directory: /tmp + run: | + python -I -c "import importlib.metadata as m, importlib.resources as r, tide; print(m.version('tide-pipeline')); print(tide.__file__); assert r.files('tide').joinpath('assets').joinpath('logo.ansi').is_file(); assert r.files('tide').joinpath('data').joinpath('config_template.yml').is_file()" + python -m tide --help + tide --help + tide --init-config /tmp/tide-ci-config.yml + test -s /tmp/tide-ci-config.yml + grep -q '^subject:' /tmp/tide-ci-config.yml + + lint: + name: Lint (format + critical errors) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Verify release metadata synchronization + run: python scripts/check_release_metadata.py + - name: Verify README logo asset + run: test -s src/tide/assets/logo.png + - name: Install lint tools + run: >- + python -m pip install + black==26.3.1 + isort==5.13.2 + flake8==7.0.0 + mypy==1.8.0 + types-PyYAML==6.0.12.12 + - name: black (format check) + run: black --check --diff . + - name: isort (import order check) + run: isort --check-only --diff . + - name: flake8 (syntax / undefined names only) + # Style-only findings (unused imports, line length) are deliberately not + # gated to avoid churning the scientific core; this catches real defects. + run: flake8 . --select=E9,F63,F7,F82 --show-source --statistics + - name: mypy (incremental typed-module gate) + run: mypy --cache-dir="${RUNNER_TEMP}/mypy-cache" src/tide diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..83a623a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,254 @@ +name: Release + +# Trusted Publishing (OIDC): no API token stored. +# +# Configure pending publishers independently on PyPI and TestPyPI for: +# owner: marcotag93 +# repository: TIDE +# workflow: release.yml +# environment: pypi / testpypi +# +# A manual run from an exact version tag publishes only to TestPyPI. Publishing +# a GitHub Release from that same tag rebuilds the artifacts, verifies that they +# are byte-identical to TestPyPI, and only then promotes them to PyPI. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + ci: + name: CI quality gate + uses: ./.github/workflows/ci.yml + + build: + name: Validate + build sdist/wheel + needs: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Install uv + uses: astral-sh/setup-uv@e58605a9b6da7c637471fab8847a5e5a6b8df081 # v5 + with: + version: "0.12.1" + - name: Verify package and citation versions + run: python scripts/check_release_metadata.py + - name: Verify GitHub release tag + if: github.event_name == 'release' + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: python scripts/check_release_metadata.py --tag "${RELEASE_TAG}" + - name: Verify manual TestPyPI tag + if: github.event_name == 'workflow_dispatch' + run: | + if [ "${GITHUB_REF_TYPE}" != "tag" ]; then + echo "TestPyPI publication must be dispatched from a version tag." >&2 + exit 1 + fi + python scripts/check_release_metadata.py --tag "${GITHUB_REF_NAME}" + - name: Set reproducible build timestamp + run: echo "SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)" >> "${GITHUB_ENV}" + - name: Build reproducible distributions + run: uv build + - name: Validate PyPI metadata and README rendering + run: uvx --from twine==7.0.0 twine check dist/* + - name: Prepare immutable release metadata + run: | + mkdir -p release-metadata + cp uv.lock release-metadata/uv.lock + python -c "from scripts.check_release_metadata import package_version; print(package_version())" > release-metadata/version.txt + ( + cd dist + sha256sum * > ../release-metadata/SHA256SUMS + ) + - name: Upload distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: dist + path: dist/ + if-no-files-found: error + - name: Upload release metadata + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: release-metadata + path: release-metadata/ + if-no-files-found: error + + smoke: + name: Verify built wheel + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist/ + - name: Install wheel without runtime dependencies + run: python -m pip install --no-deps dist/*.whl + - name: Verify package, bundled config, and console script + working-directory: /tmp + run: | + python -I -c "import importlib.metadata as m, importlib.resources as r, tide; print(m.version('tide-pipeline')); print(tide.__file__); assert r.files('tide').joinpath('assets').joinpath('logo.ansi').is_file(); assert r.files('tide').joinpath('data').joinpath('config_template.yml').is_file()" + python -m tide --help + tide --help + tide --init-config /tmp/tide-smoke-config.yml + test -s /tmp/tide-smoke-config.yml + grep -q '^subject:' /tmp/tide-smoke-config.yml + + testpypi: + name: Publish to TestPyPI + needs: [build, smoke] + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/project/tide-pipeline/ + permissions: + contents: read + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + print-hash: true + + testpypi-smoke: + name: Verify exact TestPyPI artifacts + needs: [build, testpypi] + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + - name: Download locally built distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist/ + - name: Download release metadata + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-metadata + path: release-metadata/ + - name: Verify TestPyPI hashes and download exact artifacts + run: | + python - <<'PY' + import hashlib + import json + import time + import urllib.request + from pathlib import Path + + version = Path("release-metadata/version.txt").read_text().strip() + api_url = f"https://test.pypi.org/pypi/tide-pipeline/{version}/json" + last_error = None + for attempt in range(24): + try: + with urllib.request.urlopen(api_url, timeout=30) as response: + payload = json.load(response) + break + except Exception as exc: + last_error = exc + if attempt == 23: + raise RuntimeError(f"TestPyPI did not index {version}: {last_error}") + time.sleep(5) + + expected = { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() + for path in Path("dist").iterdir() + if path.is_file() + } + published = {entry["filename"]: entry for entry in payload["urls"]} + if set(expected) != set(published): + raise RuntimeError( + f"Artifact set differs: local={sorted(expected)}, " + f"TestPyPI={sorted(published)}" + ) + + destination = Path("testpypi-dist") + destination.mkdir() + for filename, expected_hash in expected.items(): + entry = published[filename] + if entry["digests"]["sha256"] != expected_hash: + raise RuntimeError(f"TestPyPI hash differs for {filename}") + with urllib.request.urlopen(entry["url"], timeout=60) as response: + content = response.read() + if hashlib.sha256(content).hexdigest() != expected_hash: + raise RuntimeError(f"Downloaded hash differs for {filename}") + (destination / filename).write_bytes(content) + print(f"Verified {filename}: sha256:{expected_hash}") + PY + - name: Install the exact TestPyPI wheel with runtime dependencies + run: python -m pip install testpypi-dist/*.whl + - name: Smoke-test the TestPyPI wheel + working-directory: /tmp + run: | + python -I -c "import importlib.metadata as m, importlib.resources as r, tide; print(m.version('tide-pipeline')); assert r.files('tide').joinpath('data').joinpath('config_template.yml').is_file()" + python -m tide --help + tide --help + + pypi: + name: Publish to PyPI + if: github.event_name == 'release' + needs: [build, smoke, testpypi-smoke] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/tide-pipeline/ + permissions: + contents: read + id-token: write + steps: + - name: Download artifacts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 + with: + print-hash: true + + github-assets: + name: Attach distributions and frozen environment + if: github.event_name == 'release' + needs: [build, pypi] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download distributions + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: dist + path: dist/ + - name: Download release metadata + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: release-metadata + path: release-metadata/ + - name: Attach release artifacts + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + gh release upload "${RELEASE_TAG}" \ + dist/* release-metadata/SHA256SUMS release-metadata/uv.lock \ + --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6ec4a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,174 @@ +# ============================================================================= +# Repository hygiene — local drafts & binaries not shipped with the package +# ============================================================================= +/*.pdf +/*.png + + +# ============================================================================= +# Python +# ============================================================================= + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.pyc + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.dmypy.json +pytest_cache/ + +# Translations +*.mo +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# ============================================================================= +# IDE / Editors +# ============================================================================= + +# VSCode +.vscode/ +*.code-workspace + +# PyCharm +.idea/ +*.iml +*.ipr +*.iws + +# Spyder +.spyderproject +.spyproject + +# Rope +.ropeproject + +# Jupyter Notebook +.ipynb_checkpoints +*.ipynb + +# ============================================================================= +# OS Generated Files +# ============================================================================= + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +*.lnk + +# Linux +*~ +.directory + +# ============================================================================= +# Project Specific +# ============================================================================= + +# SimNIBS outputs +*.msh +*.nii +*.nii.gz +*.trk +*.tck +*.trx + +# Large data files +*.hdf5 +*.h5 +*.mat + +# Log files +*.log +logs/ + +# Temporary files +tmp/ +temp/ +*.tmp +*.bak +*.swp + +# Results directories (but keep .gitkeep) +results/ +output/ +!results/.gitkeep +!output/.gitkeep + +# PyVista cache +.pyvista/ + +# ============================================================================= +# Testing +# ============================================================================= + +# Test data (large files) +tests/data/ +!tests/data/.gitkeep + +# Test outputs +tests/output/ diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..f00b5f7 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,45 @@ +cff-version: 1.2.0 +message: "If you use TIDE in your research, please cite the accompanying preprint. The TIDE software release is archived separately on Zenodo." +type: software +title: "TIDE: Tractography-Informed Dose Estimation" +authors: + - family-names: "Tagliaferri" + given-names: "Marco" + email: "marco.tagliaferri@unitn.it" + orcid: "https://orcid.org/0000-0002-1800-3977" +version: "1.30.0" +url: "https://github.com/marcotag93/TIDE" +repository-code: "https://github.com/marcotag93/TIDE" +license: "GPL-3.0-or-later" +identifiers: + - type: doi + value: "10.5281/zenodo.22019737" + description: "Zenodo DOI for the TIDE software release." +abstract: "TIDE is a SimNIBS-based research pipeline for tractography-informed estimation of individualised transcranial magnetic stimulation intensity using motor-threshold calibration and the gradient activating function along subject-specific streamlines." +keywords: + - "transcranial magnetic stimulation" + - "TMS" + - "SimNIBS" + - "tractography" + - "diffusion MRI" + - "activating function" + - "electric field" + - "dose estimation" + - "neurostimulation" + - "Python" +preferred-citation: + type: article + title: "TIDE: Tractography-Informed Dose Estimation for individualised TMS intensity" + authors: + - family-names: "Tagliaferri" + given-names: "Marco" + - family-names: "Cattaneo" + given-names: "Luigi" + - family-names: "Miniussi" + given-names: "Carlo" + - family-names: "Brancaccio" + given-names: "Arianna" + journal: "bioRxiv" + year: 2026 + status: preprint + notes: "Manuscript in preparation. bioRxiv DOI placeholder: 10.1101/." diff --git a/README.md b/README.md new file mode 100644 index 0000000..197964e --- /dev/null +++ b/README.md @@ -0,0 +1,846 @@ +

+ TIDE logo +

+ +

TIDE

+ +

+ Tractography-Informed Dose Estimation
+ Individualised TMS intensity estimation from subject-specific tractography and SimNIBS electric-field modelling +

+ +

+ SimNIBSdiffusion MRI tractographyactivating functionTMS dosing +

+ +

+ Python 3.11-3.12 + PyPI version + SimNIBS reference environment 4.5 + Beta status + GPL-3.0 license + Research use only +

+ +> [!IMPORTANT] +> +> **Research use only.** TIDE is research software for computational TMS modelling. It is **not a medical device**, has not been clinically validated for individual treatment decisions, and has no regulatory clearance. It must not be used for diagnosis, treatment planning, or clinical decision-making. The activating function and tractography-derived quantities used by TIDE are model-based proxies, not direct measurements of axonal recruitment. + +--- + +## 👤 Author + +**Marco Tagliaferri** — *PhD Candidate in Neuroscience* +🏛️ [Center for Mind/Brain Sciences (CIMeC)](https://www.cimec.unitn.it/), University of Trento, Italy + +[![Email](https://img.shields.io/badge/Email-marco.tagliaferri%40unitn.it-D14836?style=flat&logo=gmail&logoColor=white)](mailto:marco.tagliaferri@unitn.it) +[![Email](https://img.shields.io/badge/Email-marco.tagliaferri93%40gmail.com-D14836?style=flat&logo=gmail&logoColor=white)](mailto:marco.tagliaferri93@gmail.com) +[![ORCID](https://img.shields.io/badge/ORCID-0000--0002--1800--3977-A6CE39?style=flat&logo=orcid&logoColor=white)](https://orcid.org/0000-0002-1800-3977) +[![GitHub](https://img.shields.io/badge/GitHub-marcotag93-181717?style=flat&logo=github)](https://github.com/marcotag93) + +If you use TIDE in your research, please cite the accompanying preprint: + +**APA:** + +> Tagliaferri, M., Cattaneo, L., Miniussi, C., & Brancaccio, A. (2026). *TIDE: Tractography-Informed Dose Estimation for individualised TMS intensity*. **bioRxiv**. DOI: `10.1101/` + +**BibTeX:** + +```bibtex +@article{Tagliaferri_TIDE_2026, + author = {Tagliaferri, Marco and Cattaneo, Luigi and Miniussi, Carlo and Brancaccio, Arianna}, + title = {{TIDE}: Tractography-Informed Dose Estimation for individualised TMS intensity}, + journal = {bioRxiv}, + year = {2026}, + doi = {10.1101/}, + url = {https://doi.org/10.1101/}, + note = {Preprint} +} +``` + +> [!NOTE] +> **Manuscript status.** The manuscript describing TIDE is currently in preparation. The peer-reviewed article citation will replace the preprint citation once the manuscript is formally published. + +The TIDE software release associated with this work is archived on Zenodo under DOI [`10.5281/zenodo.22019737`](https://doi.org/10.5281/zenodo.22019737). Software metadata and the preferred preprint citation are provided in [`CITATION.cff`](https://github.com/marcotag93/TIDE/blob/main/CITATION.cff) and are available through GitHub's **Cite this repository** function. + +### SimNIBS citations + +TIDE uses **SimNIBS** as its finite-element electric-field modelling and TMS simulation backend. Therefore, publications using TIDE should **cite the TIDE preprint above as the primary method citation** and additionally cite the relevant SimNIBS publication(s) for the simulation components used. + +For the SimNIBS TMS modelling framework, please cite: + +> Thielscher, A., Antunes, A., & Saturnino, G. B. (2015). Field modeling for transcranial magnetic stimulation: A useful tool to understand the physiological effects of TMS? *37th Annual International Conference of the IEEE Engineering in Medicine and Biology Society (EMBC)*, 222–225. https://doi.org/10.1109/EMBC.2015.7318340 + +If your TIDE configuration uses **Auxiliary Dipole Method (ADM) coil-position optimisation** (`options.adm_optimization: true`), please also cite: + +> Gomez, L. J., Dannhauer, M., & Peterchev, A. V. (2021). Fast computational optimization of TMS coil placement for individualized electric field targeting. *NeuroImage, 228*, 117696. https://doi.org/10.1016/j.neuroimage.2020.117696 + +For analyses relying on other SimNIBS-specific modules, head-model pipelines, or coil datasets, please follow the corresponding module-specific citation guidance in the SimNIBS documentation. + +--- + +## 📋 Table of Contents + +- [Overview](#overview) +- [Key Features](#key-features) +- [How TIDE Works](#how-tide-works) +- [Getting Started](#getting-started) +- [Configuration](#configuration) +- [Workflows](#workflows) +- [Python API](#python-api) +- [Outputs](#outputs) +- [Advanced Usage](#advanced-usage) +- [For Developers](#for-developers) +- [License](#license) +- [Acknowledgments](#acknowledgments) +- [Contact](#contact) + +--- + +## Overview + +**TIDE** is an open-source, SimNIBS-based pipeline for estimating an individualised TMS intensity for a tractography-defined target pathway. It combines: + +1. an empirical **resting motor threshold (RMT)** measured at motor cortex; +2. subject-specific **finite-element electric-field modelling**; +3. diffusion MRI **tractography** of the corticospinal tract (CST) and target pathway; +4. the gradient-term **activating function (AF)** evaluated along streamlines. + +The CST at the motor hotspot provides the calibration reference. TIDE estimates the target intensity required to reproduce the bundle-level AF efficiency observed in the CST at the measured RMT. + +TIDE is designed for **research on pathway-informed TMS dosing**. It does not model the full nonlinear biophysics of axonal excitation, and tractography streamlines must not be interpreted as direct anatomical measurements of individual axons. + +--- + +## ✨ Key Features + +### 🎯 Individualised intensity estimation + +Estimate a target-specific stimulation intensity in **% of maximum stimulator output** using each participant's measured RMT as the empirical calibration anchor. + +### 🧠 Tractography-informed electric-field analysis + +Sample the SimNIBS vector E-field along subject-specific streamlines and compute the gradient activating function: + +```text +AF = d(E · T) / ds +``` + +where `E` is the electric-field vector, `T` is the local streamline tangent, and `s` is physical arc length. + +### 📏 Arc-length AF implementation + +Geometry and E-field samples are jointly interpolated to a common physical support (≤ 0.5 mm spacing), smoothed using a 2.5 mm physical Gaussian scale, and differentiated along arc length. AF polarity is preserved internally; magnitude is used for activation-threshold aggregation. + +### 🗺️ Grid search + +Evaluate multiple candidate target positions and generate a spatial map of TIDE-estimated intensity, together with per-point reproducibility configurations and QC information. + +### ⚖️ Weighted and surface-constrained analyses + +Optionally incorporate **SIFT2 streamline weights** and a FreeSurfer grey-white interface surface. Weighted and unweighted results are both retained for auditability. + +### 🧭 Neuronavigation export + +After a successful estimation, optionally append the final target pose to a **Softaxic `.stmpx`** template. + +### 📊 Reproducible reports and visualisation + +Generate human-readable TXT reports, structured JSON sidecars, self-contained HTML reports, tractogram/NIfTI derivatives, optional 3D visualisations, and replayable YAML configurations. + +### ⚡ Exact fixed-pose cache + +Reuse deterministic SimNIBS results for identical fixed coil poses through a content-addressed cache without caching or approximating downstream AF calculations. + +--- + +## How TIDE Works + +At a high level, TIDE applies the same numerical core to the **motor calibration pathway** and the **target pathway**: + +```text +CST at M1 Target pathway + │ │ + ├─ coil pose / optimisation ├─ coil pose / optimisation + ├─ SimNIBS FEM E-field ├─ SimNIBS FEM E-field + ├─ vector E-field sampling ├─ vector E-field sampling + ├─ AF = d(E·T)/ds ├─ AF = d(E·T)/ds + └─ bundle-level AF metric └─ bundle-level AF metric + │ │ + └──────────────┬───────────────────┘ + │ + RMT calibration + │ + ▼ + target intensity estimate +``` + +For each surviving streamline, TIDE identifies the AF magnitude required to sustain activation over a configured contiguous length. The primary cross-streamline summary is the **median of the top 5%** of the resulting per-streamline threshold distribution. Optional SIFT2 weights produce a weighted counterpart. + +If `M_CST` and `M_target` are the corresponding bundle metrics, the raw target estimate is: + +```text +I_TIDE,raw = RMT × (M_CST / M_target) +``` + +TIDE also reports the **Stimulation Efficiency Index**: + +```text +SEI = M_target / M_CST +``` + +`SEI > 1` indicates that the target pathway is more efficient than the CST under the simulated configuration; `SEI < 1` indicates lower efficiency. + +The raw estimate is retained in the outputs. A configurable intensity clamp is additionally reported for QC/operational use; by default it is bounded relative to RMT and by the device maximum. Clamp status is explicit (`WITHIN_RANGE`, `CLAMPED_LOW`, `CLAMPED_HIGH`, or `DEVICE_LIMITED`). + +--- + +## Getting Started + +### Prerequisites + +Before running TIDE you need: + +- **SimNIBS** installed separately (the reference development environment uses SimNIBS 4.5); +- **Python 3.11–3.12** for the TIDE package/launcher; the reference computational runtime is the Python bundled with **SimNIBS 4.5 (Python 3.11)**; +- a subject-specific SimNIBS `m2m_*` head model containing a single `.msh` head mesh; +- the subject's T1-weighted anatomical image; +- a CST tractogram for motor calibration (`.trk`, loaded in RASMM space); +- a target tractogram (`.trk`); +- the measured motor threshold in `%` maximum stimulator output; +- a SimNIBS-compatible TMS coil model and the device maximum `dI/dt`. + +Optional inputs include SIFT2 weights, a FreeSurfer surface in scanner RAS, and a Softaxic STMPX template. + +> [!TIP] +> Generate a fresh annotated configuration anywhere with `tide --init-config config.yml`. The same canonical template is also available as [`config_template.yml`](https://github.com/marcotag93/TIDE/blob/main/config_template.yml) in the repository. + +### Naming: repository, package, import, and command + +TIDE intentionally uses different names for different distribution layers: + + +| Layer | Name | +| --------------------------------------- | ----------------- | +| Research software / GitHub repository | **TIDE** | +| PyPI distribution | `tide-pipeline` | +| Python import package | `tide` | +| Command-line entry point | `tide` | + +This means that users install the **distribution** as `tide-pipeline` but run the software with the shorter `tide` command. + +> [!WARNING] +> The PyPI project named `tide` is an unrelated package. Do **not** use `pip install tide` to install this software; use `tide-pipeline`. + +### 1. Clone the repository (source installation only) + +Skip this step when installing the published package from PyPI. + +```bash +git clone https://github.com/marcotag93/TIDE.git +cd TIDE +``` + +### 2. Install TIDE + +#### Recommended: PyPI installation + +TIDE is distributed on PyPI as [`tide-pipeline`](https://pypi.org/project/tide-pipeline/). The recommended approach for normal users is to keep the initial TIDE launcher isolated from existing scientific Python environments, then explicitly bootstrap the same published release into SimNIBS. With [`pipx`](https://pipx.pypa.io/): + +```bash +pipx install --python 3.11 tide-pipeline +tide --bootstrap +``` + +Or with [`uv`](https://docs.astral.sh/uv/guides/tools/): + +```bash +uv tool install --python 3.11 tide-pipeline +tide --bootstrap +``` + +This avoids resolving TIDE's pinned numerical dependencies directly into an existing FSL, Conda, system-Python, or other research environment. `tide --bootstrap` then locates the SimNIBS Python explicitly, verifies its numerics-critical versions against TIDE's pins, and installs the matching non-editable `tide-pipeline` release there. + +A conventional interpreter-specific installation is also supported when you deliberately want TIDE in that Python: + +```bash +python -m pip install tide-pipeline +python -m tide --bootstrap +``` + +Using `python -m tide --bootstrap` guarantees that the bootstrap is executed by the same interpreter into which `tide-pipeline` was just installed. The bootstrap aborts on a SimNIBS dependency mismatch unless `--force` is explicitly supplied. + +After a successful bootstrap: + +```bash +tide --help +``` + +If more than one `tide` executable exists on `PATH`, use the SimNIBS interpreter explicitly or inspect the selected launcher with `type -a tide` / `command -v tide`. + +> [!WARNING] +> SimNIBS is a **system dependency** and is intentionally not installed from PyPI by TIDE. The computational workflows must execute under the SimNIBS Python environment. + +#### Recommended source installation + +For development or direct use of a source checkout, the recommended installation path is the bundled SimNIBS-aware installer: + +```bash +python install.py --simnibs-env --editable +``` + +This command is intentionally safe to launch even from another Python environment (for example FSL or a system Python): `install.py` uses only the standard library to locate the SimNIBS installation, selects the SimNIBS Python explicitly, verifies the numerics-critical dependency versions against TIDE's pins, installs TIDE into that environment, and checks that `import tide` succeeds with the same interpreter. + +After installation: + +```bash +tide --help +``` + +If your shell has multiple `tide` launchers on `PATH`, the interpreter-explicit form is always unambiguous: + +```bash +/path/to/SimNIBS/simnibs_env/bin/python -m tide --help +``` + +If you already know the exact SimNIBS interpreter and have independently verified its dependency versions, you may install directly with it: + +```bash +/path/to/SimNIBS/simnibs_env/bin/python -m pip install -e . +``` + +#### Advanced: install into the current Python environment + +A standard pip install remains supported: + +```bash +python -m pip install . +``` + +This installs TIDE into **that exact Python interpreter**. It does not automatically redirect the installation into SimNIBS. This mode is useful for development, packaging checks, or for installing a temporary launcher that will subsequently bootstrap TIDE into SimNIBS. For normal source-based pipeline use, prefer `python install.py --simnibs-env --editable`. + +If you deliberately use the current-environment route, verify it before invoking a console script: + +```bash +python -c "import tide; print(tide.__version__, tide.__file__)" +python -m tide --help +``` + +> [!IMPORTANT] +> Prefer `python -m pip` over a bare `pip` command. A bare `pip`, `python`, and `tide` can each resolve to different environments on neuroimaging workstations that expose FSL, SimNIBS, Conda, system Python, or user-local executables on the same `PATH`. + +#### Troubleshooting: `ModuleNotFoundError: No module named 'tide'` + +If the `tide` executable exists but immediately fails with `ModuleNotFoundError`, the most common cause is that the console script and the installed package come from different Python environments, or that the executable is stale from an older/failed installation. Diagnose the active command first: + +```bash +type -a python python3 pip tide +pip --version +head -n 1 "$(command -v tide)" +python -m pip show tide-pipeline +python -c "import sys, site; print(sys.executable); print(site.getusersitepackages())" +``` + +For a source checkout, the most reliable repair is to install directly with the SimNIBS interpreter and then invoke the command from the same environment: + +```bash +/path/to/SimNIBS/simnibs_env/bin/python -m pip install -e . +/path/to/SimNIBS/simnibs_env/bin/python -c "import tide; print(tide.__version__, tide.__file__)" +/path/to/SimNIBS/simnibs_env/bin/python -m tide --help +/path/to/SimNIBS/simnibs_env/bin/tide --help +``` + +If `command -v tide` still resolves to an older `~/.local/bin/tide`, but the interpreter-explicit `python -m tide --help` command works, the installation itself is healthy and the problem is only command resolution. Refresh Bash's command cache (`hash -r`), remove/uninstall the stale launcher from the Python environment that created it, or place the intended environment's `bin` directory before `~/.local/bin` on `PATH`. Do not copy a launcher manually between Python environments: console scripts are tied to the interpreter that generated them. + +### 3. Create a configuration + +Generate the complete annotated template from any installation: + +```bash +tide --init-config config.yml +``` + +If no output path is supplied, TIDE writes `./config.yml`: + +```bash +tide --init-config +``` + +For safety, `--init-config` never overwrites an existing file. Replace every `/path/to/...` placeholder with an absolute path and edit the subject, coil, calibration, and target sections for your experiment. + +### 4. Run an estimation + +```bash +tide --config config.yml --workflow estimation +``` + +A successful CLI run exits with status `0` and prints `PIPELINE COMPLETE` only after the required workflow outputs have been written. + +--- + +## Configuration + +TIDE uses a YAML configuration file. Run `tide --init-config config.yml` to materialize the complete annotated reference from the installed package; the same canonical file is available as [`config_template.yml`](https://github.com/marcotag93/TIDE/blob/main/config_template.yml). The example below shows only the core fields required to understand a standard estimation run. + +```yaml +subject: + id: "sub-001" + derivatives_path: /absolute/path/to/derivatives/sub-001 + m2m_path: /absolute/path/to/m2m_sub-001 + files: + t1w: /absolute/path/to/sub-001_T1w.nii.gz + # weights_cst: /absolute/path/to/CST_weights.txt + # weights_target: /absolute/path/to/target_weights.txt + # surface: /absolute/path/to/lh.white.scanner.white + +workflow: estimation + +coil: + coil_model: "MagVenture_C-B60.ccd" + coil_path: "" # empty = auto-detect SimNIBS coil directory + coil_distance_mm: 4.0 + device_didt_max: 161e6 # A/s; set this for your stimulator/coil + +options: + roi_size_mm: 20.0 + activation_length_mm: 6.0 + field_mode: "af" # required for estimation and grid workflows + gwi_threshold_mm: 3.0 + adm_optimization: true + mso_floor_ratio: 0.70 + mso_ceiling_ratio: 1.40 + generate_visualizations: true + generate_3d_visualization: false + +experiment: + calibration: + label: "M1" + bundle_path: /absolute/path/to/CST_left.trk + coords: [-13.28, -26.71, 63.0] + scalp_coords: [-13.28, -26.71, 85.0] + orientation: "C3" # or [x, y, z] or a rigid 4x4 matsimnibs matrix + measured_rmt_mso: 38.0 + + target: + label: "TARGET" + bundle_path: /absolute/path/to/target_bundle.trk + coords: [-40.0, 35.0, 30.0] + scalp_coords: [-60.0, 35.0, 30.0] + orientation: "F3" # or [x, y, z] or a rigid 4x4 matrix + cortical_medoid: false +``` + +### Orientation priority + +For calibration and target sites, `orientation` accepts: + +1. **Rigid 4×4 `matsimnibs` matrix** — used directly; optimisation is skipped. +2. **Three-coordinate vector** `[x, y, z]` — used as the SimNIBS `pos_ydir` reference. +3. **EEG 10–20 label** such as `"F3"` or `"F8"`. + +For `--workflow grid`, the target orientation must be a vector or EEG label, **not** a 4×4 matrix, because each grid point is independently optimised from the supplied seed. + +### Important configuration rules + +- `field_mode: "af"` is required for `estimation` and `grid`. +- Use absolute paths wherever possible. +- Tractograms are loaded in **RASMM** space using the T1w image as anatomical reference. +- A configured weight or surface file is treated as an explicit input: invalid/missing files fail rather than silently falling back. +- The final coil pose is checked for geometric QC before dose estimation. +- A saved estimation configuration contains the resolved final matrices and can be replayed without re-running optimisation. + +--- + +## Workflows + + +| Workflow | Command | Purpose | +| ------------------ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| **Estimation** | `tide --config config.yml --workflow estimation` | Full CST-calibrated TIDE intensity estimation for one target. | +| **Grid search** | `tide --config config.yml --workflow grid` | Evaluate a set of candidate target positions and produce a spatial intensity map. | +| **Simulation** | `tide --config config.yml --workflow simulation` | Run a standard TMS E-field simulation; can map AF or`e_parallel` along a target bundle. | +| **Optimization** | `tide --config config.yml --workflow optimization` | Run coil-position optimisation only. | + +### Estimation + +```bash +tide --config config.yml --workflow estimation +``` + +The estimation workflow processes M1/CST and target branches in parallel where possible, then combines their bundle metrics to produce weighted and unweighted intensity estimates, SEI, QC fields, and a reproducibility configuration. + +### Grid search + +Add a nested grid block under `experiment.target`: + +```yaml +experiment: + target: + label: "TARGET" + bundle_path: /absolute/path/to/target_bundle.trk + coords: [-40.0, 35.0, 30.0] + scalp_coords: [-60.0, 35.0, 30.0] + orientation: "F3" + grid: + search_radius_mm: 20.0 + step_size_mm: 4.0 + cortex_depth_mm: 2.0 +``` + +Then run: + +```bash +tide --config config.yml --workflow grid +``` + +Each successful grid point receives its own replayable estimation configuration containing the resolved coordinates, scalp position, and final 4×4 target pose. + +### Standard simulation + +```bash +tide --config config.yml --workflow simulation +``` + +Use this workflow when you need a conventional simulation or bundle field mapping without forming the CST-to-target dose ratio. `field_mode: "e_parallel"` is supported here. + +### Standard optimisation + +```bash +tide --config config.yml --workflow optimization +``` + +This workflow returns the optimised coil pose without running the full TIDE dose-estimation pipeline. + +--- + +## Python API + +The `tide` package can also be used programmatically. For most applications, the recommended API is to load the same YAML configuration used by the CLI and call a workflow entry point directly. + +### Run a workflow from Python + +```python +from tide.utils.config import SimNIBSConfig +from tide.workflows.estimation import run_estimation_workflow + +config = SimNIBSConfig.from_yaml("config.yml") +run_estimation_workflow(config, console_ui=False) +``` + +The main workflow entry points are: + +```python +from tide.workflows.estimation import run_estimation_workflow +from tide.workflows.grid_search import run_grid_search_workflow +from tide.workflows.standard import run_standard_optimization, run_standard_simulation +``` + +Each accepts a `SimNIBSConfig` loaded with `SimNIBSConfig.from_yaml(...)`. For library use, you can explicitly call `validate_workflow_config(config, workflow)` before dispatching a workflow; the estimation, grid-search, and simulation entry points also perform their own workflow validation. + +For applications that need progress reporting during a grid search, `run_grid_search_workflow()` accepts a callback: + +```python +from tide.utils.config import SimNIBSConfig +from tide.workflows.grid_search import run_grid_search_workflow + +config = SimNIBSConfig.from_yaml("config.yml") + + +def on_progress(completed: int, total: int, label: str) -> None: + print(f"{completed}/{total}: {label}") + + +run_grid_search_workflow( + config, + progress_callback=on_progress, + console_ui=False, +) +``` + +### Lower-level scientific functions + +Advanced users can access the numerical building blocks directly. Two useful entry points are: + +```python +from tide.core.physics import calculate_scalar_map +from tide.interfaces.unified_estimation import run_unified_estimation +``` + +- `calculate_scalar_map(...)` computes signed activating-function or `E_parallel` values along streamlines. +- `run_unified_estimation(...)` performs CST-to-target intensity estimation from already generated AF tractograms, with optional SIFT2 weights and grey-white-interface surface constraints. + +> [!NOTE] +> The workflow API is the preferred programmatic interface because it applies TIDE's configuration preflight, SimNIBS orchestration, output handling, and workflow-level safety checks. Lower-level functions are intended for custom analyses by users who understand their input and unit contracts. + +--- + +## Outputs + +### Estimation + +A typical estimation run writes: + +```text +/TIDE_/ +├── TIDE_Results_.txt +├── TIDE_Results_.json +├── TIDE_Results_.html +├── config_estimation_*.yml +├── sim_m1/ +│ ├── CST_M1_af.trk +│ └── CST_M1_af.nii.gz # when visualisation is enabled +├── sim_target/ +│ ├── _af.trk +│ └── _af.nii.gz # when visualisation is enabled +└── visualizations/ # optional figures / interactive renders +``` + +The report includes, among other fields: + +- raw and clamped weighted/unweighted intensity estimates; +- clamp/QC status; +- CST and target bundle AF metrics; +- weighted and unweighted SEI; +- intensity multipliers; +- coil matrices and pose QC; +- alignment/depth diagnostics; +- aggregator-sensitivity diagnostics; and +- provenance/configuration information. + +### Grid search + +A grid run additionally produces: + +```text +/TIDE_grid_search_<...>/ +├── TIDE_grid_results.csv +├── TIDE_Grid_Summary_.txt +├── TIDE_Grid_Summary_.json +├── TIDE_Grid_Summary_.html +├── calibration_m1/ +├── simulations/ +│ ├── grid_P01/ +│ ├── grid_P02/ +│ └── ... +├── QC/ +└── visualization/ + ├── grid_mso_raw_map.nii.gz + ├── grid_mso_map.nii.gz + ├── grid_mso_flag_map.nii.gz + └── grid_interactive.html # when 3D visualisation is enabled +``` + +Historical machine-readable names containing `mso` are intentionally retained for backwards compatibility even though human-facing reports use intensity notation. + +--- + +## Advanced Usage + +### SIFT2 weighting + +Provide one weight per original streamline: + +```yaml +subject: + files: + weights_cst: /absolute/path/to/CST_weights.txt + weights_target: /absolute/path/to/target_weights.txt +``` + +TIDE tracks original streamline identities through filtering/dropping so surviving streamlines remain aligned with their corresponding weights. + +### Surface-constrained analysis + +Provide a FreeSurfer surface in scanner RAS: + +```yaml +subject: + files: + surface: /absolute/path/to/lh.white.scanner.white + +options: + gwi_threshold_mm: 3.0 +``` + +The surface constraint is applied only when the surface is explicitly configured. + +### Softaxic STMPX export + +After a successful estimation: + +```bash +tide --config config.yml --workflow estimation --stmpx /path/to/session.stmpx +``` + +TIDE validates the template before the workflow starts and writes: + +```text +/path/to/session_updated.stmpx +``` + +The STMPX file is an **output template**, not a source of simulation pose parameters. The YAML configuration remains authoritative for the SimNIBS input pose. + +### Fixed-pose cache + +TIDE caches deterministic SimNIBS artifacts for exact 4×4 fixed poses. The default cache root is: + +```text +$XDG_CACHE_HOME/tide/fixed_pose +``` + +or, when `XDG_CACHE_HOME` is not set: + +```text +~/.cache/tide/fixed_pose +``` + +Useful commands: + +```bash +tide --cache-info +tide --cache-clear +tide --config config.yml --workflow estimation --no-cache +``` + +Configuration equivalents: + +```yaml +subject: + cache_dir: /absolute/path/to/tide_cache # relocate + cache_max_size_gb: 100 # optional LRU cap; 0/omitted = unlimited +``` + +Set `cache_dir: no` to disable the fixed-pose cache from YAML. + +### Console and logging + +```bash +# Standard output +tide --config config.yml --workflow estimation --verbosity standard + +# More detail +tide --config config.yml --workflow estimation --verbosity verbose + +# Minimal output +tide --config config.yml --workflow estimation --verbosity quiet + +# Disable the rich grid console UI +tide --config config.yml --workflow grid --no-console-ui +``` + +### Optional 3D visualisation + +3D rendering is an optional dependency: + +```bash +python -m pip install ".[viz]" +``` + +Enable it in YAML: + +```yaml +options: + generate_3d_visualization: true +``` + +--- + +## For Developers + +### Project structure + +```text +TIDE/ +├── main.py # source-checkout CLI shim +├── config_template.yml # canonical annotated configuration template +├── install.py # installation convenience wrapper +├── pyproject.toml # package metadata and pinned direct dependencies +├── uv.lock # frozen development/reproduction environment +├── src/tide/ +│ ├── cli.py # `tide` console entry point + --init-config +│ ├── core/ # AF, geometry, tractography, scientific I/O +│ ├── interfaces/ # SimNIBS, sampling, estimation, visualisation, STMPX +│ ├── workflows/ # estimation, grid, simulation, optimisation +│ ├── console/ # rich terminal UI / worker reporting +│ └── utils/ # config, logging, cache, SimNIBS discovery +├── tests/ # pytest suite +└── .github/workflows/ # CI and release automation +``` + +### Development install + +```bash +python -m pip install -e ".[dev]" +``` + +Run the checks used by CI: + +```bash +black --check --diff . +isort --check-only --diff . +flake8 . --select=E9,F63,F7,F82 --show-source --statistics +mypy src/tide +pytest tests/ --cov=tide --cov-branch --cov-fail-under=45 +``` + +SimNIBS-dependent end-to-end runs should be executed in a separate scratch output directory and must never overwrite reference derivatives. + +The build backend is pinned to **Hatchling 1.27.0** in `pyproject.toml`, matching TIDE's reproducibility-oriented policy of pinning the software versions that define its tested packaging and numerical environment. + +### Frozen environment + +`pip install tide-pipeline` resolves the direct dependency pins declared in `pyproject.toml`; it does not consume `uv.lock`. To reproduce the complete tested development environment from a checkout, use: + +```bash +uv sync --locked --extra dev +``` + +Each GitHub Release also carries the exact `uv.lock` used for that release, the wheel and source distribution, and `SHA256SUMS`. SimNIBS remains a separately installed system dependency; the reference computational runtime is SimNIBS 4.5 with Python 3.11. + +### Publishing a release + +The package does not need to exist on either index beforehand. For the first release, create a **pending trusted publisher** separately on [TestPyPI](https://test.pypi.org/manage/account/publishing/) and [PyPI](https://pypi.org/manage/account/publishing/) with owner `marcotag93`, repository `TIDE`, workflow `release.yml`, project `tide-pipeline`, and environments `testpypi` and `pypi`, respectively. In GitHub, create matching `testpypi` and `pypi` environments and require manual approval on `pypi`. + +Use this order: + +1. Run all CI checks and `python scripts/check_release_metadata.py` on `main`. +2. Create and push the exact version tag, for example `v1.30.0`. +3. Dispatch the **Release** workflow from that tag to publish only to TestPyPI: `gh workflow run release.yml --ref v1.30.0`. +4. Wait for the workflow to verify the published filenames, SHA-256 hashes, installation, and CLI on TestPyPI. +5. Create the GitHub Release from the same tag. The workflow rebuilds deterministically, repeats the TestPyPI verification, pauses for the `pypi` environment approval, and then publishes to PyPI. +6. Verify `python -m pip install tide-pipeline==1.30.0` in a clean Python 3.11 environment, then archive the GitHub release on Zenodo. + +Never upload a wheel manually after a failed workflow. PyPI artifacts are immutable: diagnose the failure and, if any artifact was already published, issue a new patch version. + +--- + +## License + +TIDE is released under the **GNU General Public License v3.0 or later (GPL-3.0-or-later)**. See [`LICENSE`](https://github.com/marcotag93/TIDE/blob/main/LICENSE) for the full license text. + +--- + +## Acknowledgments + +TIDE builds on open scientific software, including: + +- [SimNIBS](https://simnibs.github.io/simnibs/) for finite-element TMS modelling and coil optimisation; +- [DIPY](https://dipy.org/) for tractography I/O and geometric processing; +- [NiBabel](https://nipy.org/nibabel/) for neuroimaging I/O; +- [SciPy](https://scipy.org/) and [NumPy](https://numpy.org/) for numerical computing; and +- [PyVista](https://pyvista.org/) / [VTK](https://vtk.org/) for optional 3D visualisation. + +--- + +## Contact + +For scientific or software questions, bug reports, or feature requests, please use the repository's GitHub Issues page or contact: + +- **Academic email:** [marco.tagliaferri@unitn.it](mailto:marco.tagliaferri@unitn.it) +- **Permanent email:** [marco.tagliaferri93@gmail.com](mailto:marco.tagliaferri93@gmail.com) + +--- + +

+ Made with ❤️ for the TMS research community +

diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..81d865b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security policy + +## Supported versions + +Security fixes are provided for the latest published TIDE release. Users should +upgrade to the newest patch release before reporting a problem that may already +have been fixed. + +## Reporting a vulnerability + +Do not disclose suspected vulnerabilities in a public GitHub issue. Use +[GitHub private vulnerability reporting](https://github.com/marcotag93/TIDE/security/advisories/new) +and include the affected version, reproduction steps, impact, and any proposed +mitigation. If private reporting is unavailable, email +[marco.tagliaferri@unitn.it](mailto:marco.tagliaferri@unitn.it) with the subject +`TIDE security report`. + +You should receive an acknowledgment within five business days. Please allow +time for investigation and a coordinated fix before public disclosure. + +TIDE is research software and is not a medical device. Safety or scientific +validity concerns that are not software vulnerabilities should be reported as +regular issues without including private participant or clinical data. diff --git a/config_template.yml b/config_template.yml new file mode 100644 index 0000000..8e1cf5d --- /dev/null +++ b/config_template.yml @@ -0,0 +1,140 @@ +# ============================================================================= +# TIDE Pipeline — Annotated Configuration Template +# ============================================================================= +# Replace every "/path/to/..." placeholder with an absolute path on your +# system before running. Comments explain each field, accepted formats, and +# typical value ranges. +# ============================================================================= + +# ============================================================================= +# subject — paths to the subject-specific inputs +# ============================================================================= +subject: + id: "sub-XXX" # Subject identifier; used as folder/file prefix. + derivatives_path: /path/to/derivatives/sub-XXX # Pipeline writes outputs under this directory. + # cache_dir: /path/to/tide_cache # Optional — fixed-pose cache base dir. + # Omit to use the default (~/.cache/tide). + # Set to "no" to disable the cache (same as --no-cache). + # cache_max_size_gb: 0 # Optional — fixed-pose cache size cap in GB + # (LRU eviction at startup). 0/omitted = unlimited. + m2m_path: /path/to/derivatives/sub-XXX/m2m_sub-XXX # SimNIBS head model (output of charm/headreco). + + # --------------------------------------------------------------------------- + # files — required and optional inputs + # --------------------------------------------------------------------------- + files: + t1w: /path/to/sub-XXX_T1w.nii.gz # Subject T1w in scanner RAS (acpc-aligned recommended). + weights_cst: /path/to/CST_left.txt # Optional — SIFT2 streamline weights for CST (.txt). + weights_target: /path/to/target_bundle.txt # Optional — SIFT2 weights (.txt) or TractSeg map (.nii/.nii.gz). + surface: /path/to/lh.white.scanner.white # FreeSurfer pial/white surface in SCANNER RAS. + # Convert with: + # mris_convert --to-scanner lh.white lh.white.scanner.white + +# ============================================================================= +# workflow — pipeline to run (overridden by the --workflow CLI flag) +# ============================================================================= +# One of: estimation | grid | simulation | optimization. Omit or leave blank to +# auto-select (estimation when a target bundle is set, otherwise simulation). +workflow: estimation + +# ============================================================================= +# coil — TMS device and coil model +# ============================================================================= +coil: + coil_model: "MagVenture_C-B60.ccd" # File name of the coil definition shipped with SimNIBS. + coil_path: "" # Custom .ccd path or coil directory; leave empty for auto-detect. + coil_distance_mm: 4.0 # Scalp-to-coil distance in mm (default 4.0). + device_didt_max: 161e6 # Max dI/dt of your stimulator in A/s (e.g. MagVenture X100 = 161e6). + +# ============================================================================= +# options — algorithm knobs +# ============================================================================= +options: + # --- ROI / activation ---------------------------------------------------- + roi_size_mm: 20.0 # Radius (mm) of the spherical ROI used for AF analysis (typical 20-40). + activation_length_mm: 6.0 # Required contiguous activated axonal length (mm). Default 6.0; 4.0 is also common. + field_mode: "af" # "af" required for estimation/grid, it refers to activating function (d(E*T)); "e_parallel" is parallel e-field (E * T). + # AF uses uniform 0.5 mm arc-length resampling and 2.5 mm physical smoothing. + + # --- Visualization ------------------------------------------------------- + generate_3d_visualization: false # Render PyVista PNGs plus sampled lightweight HTML previews. + generate_visualization: true # Save NIfTI overlays of |AF| / E-field for use in FSLeyes/MRIcroGL. + visualization_dpi: 300 # DPI for any matplotlib figures emitted by the pipeline. + + # --- Softaxic export ----------------------------------------------------- + # stmpx_dataset_name: "20260717-TIDE-SUB_XXX" # Optional replacement for the + # output value. + + # --- Parallelism --------------------------------------------------------- + max_workers: 4 # Worker count for grid search and parallel stages. + no_parallel: false # true = force single-process (debug / low-memory machines). + + # --- Streamline quality filter ------------------------------------------ + max_angular_deviation_deg: 0.0 # Drop streamlines whose consecutive tangent vectors deviate more than + # this within the ROI. Set 0.0 to disable. + + # --- MSO clamping -------------------------------------------------------- + mso_floor_ratio: 0.70 # Lower bound for estimated MSO as a fraction of RMT (0.70 = MSO >= 70% RMT). + mso_ceiling_ratio: 1.40 # Upper bound for estimated MSO as a fraction of RMT (1.40 = MSO <= 140% RMT). + + # --- TMS optimization (used unless a 4x4 orientation matrix is provided) - + adm_optimization: true # true = ADM (fast, ROI-mean E only); false = direct (slow, full 3D E). + # ADM is REQUIRED for AF-driven cost? — NO; ADM cannot drive AF (see note above). + opt_search_radius: 10.0 # Scalp search radius in mm (10 mm -> ~20 mm diameter disc). + opt_search_angle: 30.0 # Total angular sweep in degrees, centered on the orientation reference. + opt_angle_resolution: 10.0 # Angular step in degrees. + opt_spatial_resolution: 2.0 # Spatial step in mm on the SimNIBS scalp grid. + +# ============================================================================= +# experiment — calibration site, target site, and grid-search settings +# ============================================================================= +# `tide --stmpx /path/session.stmpx` appends the completed Target Estimation +# pose to `_updated.stmpx` after an estimation run. It does not use +# the STMPX pose as a simulation input; orientation fields below remain the +# authoritative SimNIBS inputs. +# ============================================================================= +experiment: + + # --------------------------------------------------------------------------- + # 1. calibration — motor cortex / CST reference site + # --------------------------------------------------------------------------- + calibration: + label: "M1" # Free-form label used in output file names. + bundle_path: /path/to/CST_left.trk # CST tractogram (TRK in scanner RAS). + orientation: # Optional 4x4 matsimnibs OR [x,y,z] vector OR EEG label. + coords: [-13.28, -26.71, 63.00] # Cortical M1 hotspot in RAS (mm). + scalp_coords: [-13.28, -26.71, 85.00] # Scalp projection of the hotspot in RAS (mm). + measured_rmt_mso: 38.0 # Measured resting motor threshold as % MSO. + + # --------------------------------------------------------------------------- + # 2. target — region whose dose you want to estimate + # --------------------------------------------------------------------------- + # Note: for TIDE workflows the absolute intensity is not used (unit dI/dt is + # assumed). Intensity fields are kept for the standard simulation workflow. + # The nested `grid` block drives `--workflow grid`; it reuses this target's + # coords / scalp_coords / orientation as the grid center and per-point seed. + target: + label: "TARGET_LABEL" # Used in output file/directory names. + bundle_path: /path/to/target_bundle.trk # Target tractogram (TRK in scanner RAS). + cortical_medoid: false # true = auto-replace `coords` with the medoid of the + # most-cortical streamline endpoints near `coords`. + orientation: # 4x4 matrix OR [x,y,z] vector OR EEG label (e.g. "F8"). + coords: [-11.00, -47.18, 59.00] # Cortical target in RAS (mm). + scalp_coords: [-11.00, -47.18, 83.00] # Scalp coords in RAS (mm); auto-projected if omitted. + didt: # Optional dI/dt override (A/s). Highest priority. + mso: 100 # MSO % used by the standard simulation workflow only. + + # ------------------------------------------------------------------------- + # grid — settings for `--workflow grid` (only the search geometry) + # ------------------------------------------------------------------------- + # The grid search explores scalp positions around this target, reusing the + # target `coords` (center), `scalp_coords` (scalp seed), and `orientation` + # (per-point pos_ydir seed) above. Only the search geometry lives here. + # IMPORTANT: for a grid run the target `orientation` MUST be a vector or EEG + # label, NOT a 4x4 matrix (each grid point is independently optimized from + # that seed). + grid: + search_radius_mm: 20.0 # Half-extent (mm) of the grid on the scalp. + step_size_mm: 4.0 # Spacing (mm) between adjacent grid points (default 4.0). + cortex_depth_mm: 2.0 # Depth (mm) below the cortical surface for grid construction + # (default 2.0; ~2-3 T1w slices). diff --git a/install.py b/install.py new file mode 100644 index 0000000..5957862 --- /dev/null +++ b/install.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +""" +TIDE Pipeline - Cross-Platform Installation Script +=================================================== +Convenience wrapper for TIDE installation. For normal pipeline use from a source +checkout, ``--simnibs-env`` is the recommended mode: it detects the SimNIBS-bundled +Python, verifies its dependency versions match TIDE's pins, and installs into that +environment. Without ``--simnibs-env`` the script intentionally installs into the +**current** Python interpreter, which is useful for development and packaging checks. + +Equivalent manual commands: + python -m pip install . # install into this exact Python + python -m pip install ".[viz]" # optional 3D rendering (pyvista, vtk) + python -m pip install ".[dev]" # development dependencies + +Works on: Windows, Linux, macOS + +Usage: + python install.py --simnibs-env --editable # Recommended source installation + python install.py --simnibs-env # Install into detected SimNIBS env + python install.py --simnibs-env --viz # SimNIBS env + viz extras + python install.py --dev # Current interpreter + dev dependencies + python install.py # Current interpreter (advanced) +""" + +import argparse +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Optional + + +# ANSI color codes (disabled on Windows CMD without ANSI support) +class Colors: + """Cross-platform terminal colors.""" + + def __init__(self): + # Enable ANSI on Windows 10+ + if sys.platform == "win32": + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + self._enabled = True + except Exception: + self._enabled = False + else: + self._enabled = True + + @property + def RED(self): + return "\033[0;31m" if self._enabled else "" + + @property + def GREEN(self): + return "\033[0;32m" if self._enabled else "" + + @property + def YELLOW(self): + return "\033[1;33m" if self._enabled else "" + + @property + def BLUE(self): + return "\033[0;34m" if self._enabled else "" + + @property + def NC(self): + return "\033[0m" if self._enabled else "" + + +C = Colors() + + +def print_banner(): + """Print installation banner.""" + print(f"{C.BLUE}╔══════════════════════════════════════════════════════════════╗{C.NC}") + print(f"{C.BLUE}║ TIDE Pipeline - Installation Script ║{C.NC}") + print(f"{C.BLUE}╚══════════════════════════════════════════════════════════════╝{C.NC}") + print() + + +def _python_scripts_dir(target_python: Path) -> Path: + """Return the directory containing the installed ``tide`` console script. + + ``sysconfig.get_path('scripts')`` covers normal/venv installs, while a + user install may instead write to ``site.USER_BASE/bin`` (or ``Scripts`` + on Windows). Query both from the target interpreter and prefer the one + where the generated entry point actually exists. + """ + code = ( + "import json, site, sys, sysconfig; " + "suffix = 'Scripts' if sys.platform == 'win32' else 'bin'; " + "print(json.dumps([sysconfig.get_path('scripts'), " + "str(__import__('pathlib').Path(site.getuserbase()) / suffix)]))" + ) + result = subprocess.run( + [str(target_python), "-c", code], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + try: + candidates = [Path(item) for item in json.loads(result.stdout)] + except (json.JSONDecodeError, TypeError): + candidates = [] + script_name = "tide.exe" if sys.platform == "win32" else "tide" + for directory in candidates: + if (directory / script_name).exists(): + return directory + if candidates: + return candidates[0] + return target_python.parent + + +def verify_tide_install(target_python: Path) -> tuple[bool, str]: + """Verify distribution metadata and ``import tide`` under the target Python.""" + code = ( + "import importlib.metadata as m, tide; " + "print('version=' + m.version('tide-pipeline')); " + "print('module=' + str(tide.__file__))" + ) + env = os.environ.copy() + for var in ("PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP"): + env.pop(var, None) + result = subprocess.run( + [str(target_python), "-c", code], + capture_output=True, + text=True, + check=False, + env=env, + ) + detail = (result.stdout + result.stderr).strip() + return result.returncode == 0, detail + + +def _resolved_tide_on_path() -> Optional[Path]: + """Return the ``tide`` command currently selected by PATH, if any.""" + resolved = shutil.which("tide") + return Path(resolved).resolve() if resolved else None + + +def print_success(target_python: Path): + """Print success message and warn about stale PATH launchers.""" + bin_dir = _python_scripts_dir(target_python) + tide_cmd = (bin_dir / ("tide.exe" if sys.platform == "win32" else "tide")).resolve() + path_tide = _resolved_tide_on_path() + + print() + print(f"{C.GREEN}╔══════════════════════════════════════════════════════════════╗{C.NC}") + print(f"{C.GREEN}║ Installation Complete! ║{C.NC}") + print(f"{C.GREEN}╚══════════════════════════════════════════════════════════════╝{C.NC}") + print() + print(f"Target Python: {C.GREEN}{target_python}{C.NC}") + print("Console script registered:") + print(f" {C.GREEN}{tide_cmd}{C.NC}") + + if path_tide is not None and path_tide != tide_cmd: + print() + print( + f"{C.YELLOW}WARNING: your shell currently resolves 'tide' to a different script:{C.NC}" + ) + print(f" PATH tide: {path_tide}") + print(f" Installed tide: {tide_cmd}") + print("This usually means an older launcher from another Python environment is") + print("earlier on PATH. Run the installed path above directly, or adjust PATH.") + if sys.platform != "win32": + print("For Bash, run 'hash -r' after changing/removing a stale launcher.") + + print() + print("Interpreter-safe checks:") + print(f' {target_python} -c "import tide; print(tide.__version__, tide.__file__)"') + print(f" {target_python} -m tide --help") + print() + print("You can now run the TIDE Pipeline:") + print() + print(f" {C.BLUE}Estimation:{C.NC} tide --config config.yml --workflow estimation") + print(f" {C.BLUE}Grid search:{C.NC} tide --config config.yml --workflow grid") + print(f" {C.BLUE}Create config:{C.NC} tide --init-config config.yml") + print(f" {C.BLUE}Show help:{C.NC} tide --help") + print(f" {C.BLUE}Show version:{C.NC} tide --version") + print() + + +def find_simnibs_binary() -> Path: + """ + Find the SimNIBS binary/executable in PATH. + + Returns: + Path to simnibs executable + + Raises: + FileNotFoundError: If simnibs not found + """ + # On Windows, look for simnibs.exe or simnibs.cmd + if sys.platform == "win32": + candidates = ["simnibs.exe", "simnibs.cmd", "simnibs.bat", "simnibs"] + else: + candidates = ["simnibs"] + + for name in candidates: + simnibs_bin = shutil.which(name) + if simnibs_bin: + return Path(simnibs_bin).resolve() + + # Try common installation paths + common_paths = [] + + if sys.platform == "win32": + # Windows common paths + user_home = Path.home() + common_paths = [ + user_home / "SimNIBS-4.5" / "bin" / "simnibs.exe", + user_home / "SimNIBS-4.5" / "bin" / "simnibs.cmd", + Path("C:/SimNIBS-4.5/bin/simnibs.exe"), + Path("C:/SimNIBS-4.5/bin/simnibs.cmd"), + user_home / "AppData" / "Local" / "SimNIBS" / "bin" / "simnibs.exe", + ] + else: + # Linux/macOS common paths + user_home = Path.home() + common_paths = [ + user_home / "SimNIBS-4.5" / "bin" / "simnibs", + user_home / "simnibs_env" / "bin" / "simnibs", + Path("/usr/local/SimNIBS-4.5/bin/simnibs"), + Path("/opt/SimNIBS-4.5/bin/simnibs"), + ] + + for path in common_paths: + if path.exists(): + return path.resolve() + + raise FileNotFoundError( + "Could not find 'simnibs' command.\n" + "Please ensure SimNIBS is installed and added to your PATH.\n" + "Installation guide: https://simnibs.github.io/simnibs/" + ) + + +def find_simnibs_python(simnibs_root: Path) -> Path: + """ + Find Python executable in SimNIBS environment. + + Args: + simnibs_root: Root directory of SimNIBS installation + + Returns: + Path to Python executable + + Raises: + FileNotFoundError: If Python not found + """ + if sys.platform == "win32": + # Windows paths + candidates = [ + simnibs_root / "simnibs_env" / "Scripts" / "python.exe", + simnibs_root / "simnibs_env" / "python.exe", + simnibs_root / "python.exe", + ] + else: + # Linux/macOS paths + candidates = [ + simnibs_root / "simnibs_env" / "bin" / "python3", + simnibs_root / "simnibs_env" / "bin" / "python", + simnibs_root / "bin" / "python3", + simnibs_root / "bin" / "python", + ] + + for path in candidates: + if path.exists() and os.access(path, os.X_OK if sys.platform != "win32" else os.F_OK): + return path.resolve() + + raise FileNotFoundError( + f"Could not find Python in SimNIBS installation at: {simnibs_root}\n" + f"Searched:\n" + "\n".join(f" - {p}" for p in candidates) + ) + + +def find_simnibs_pip(simnibs_python: Path) -> list: + """ + Get pip command for SimNIBS environment. + + Args: + simnibs_python: Path to SimNIBS Python + + Returns: + List of command parts for pip + """ + # Use python -m pip for maximum compatibility + return [str(simnibs_python), "-m", "pip"] + + +def run_pip(pip_cmd: list, args: list, quiet: bool = False) -> bool: + """ + Run pip with given arguments. + + Args: + pip_cmd: Base pip command (list) + args: Additional pip arguments + quiet: Suppress output + + Returns: + True if successful + """ + cmd = pip_cmd + args + if quiet: + cmd.append("--quiet") + + try: + subprocess.run(cmd, check=True, capture_output=quiet, text=True) + return True + except subprocess.CalledProcessError as e: + if quiet: + print(f"{C.RED}Error:{C.NC} {e.stderr if e.stderr else e}") + return False + + +def verify_simnibs_import(simnibs_python: Path) -> tuple: + """ + Verify SimNIBS can be imported and get version. + + Returns: + Tuple of (success, version_string) + """ + try: + result = subprocess.run( + [str(simnibs_python), "-c", "import simnibs; print(simnibs.__version__)"], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + return True, result.stdout.strip() + return False, None + except Exception: + return False, None + + +def resolve_simnibs_target(script_dir: Path, force: bool) -> tuple: + """Locate the SimNIBS python, verify its deps match the pins, return targets. + + Returns ``(simnibs_python, pip_cmd)``. Exits on a version mismatch unless + ``force`` is set. Reuses ``tide.cli._verify_simnibs_deps`` from the source + tree so the pin list has a single source of truth. + """ + try: + simnibs_bin = find_simnibs_binary() + simnibs_root = simnibs_bin.parent.parent + simnibs_python = find_simnibs_python(simnibs_root) + except FileNotFoundError as exc: + print(f"{C.RED}Error:{C.NC} {exc}") + sys.exit(1) + + print(f" SimNIBS python: {C.GREEN}{simnibs_python}{C.NC}") + success, simnibs_version = verify_simnibs_import(simnibs_python) + if success: + print(f" SimNIBS version: {C.GREEN}{simnibs_version}{C.NC}") + + sys.path.insert(0, str(script_dir / "src")) + try: + from tide.cli import _verify_simnibs_deps + except Exception as exc: # pragma: no cover - defensive + print(f"{C.YELLOW}Warning: could not run dependency verification: {exc}{C.NC}") + return simnibs_python, find_simnibs_pip(simnibs_python) + + print(" Verifying dependency versions against tide pins...") + ok, lines = _verify_simnibs_deps(simnibs_python) + for line in lines: + print(f" {line}") + if not ok and not force: + print( + f"{C.RED}Error:{C.NC} SimNIBS dependency versions differ from tide's " + "pins; installing could change SimNIBS packages and shift numerics." + ) + print("Re-run with --force to override once you have verified compatibility.") + sys.exit(1) + if not ok and force: + print(f"{C.YELLOW}--force set: proceeding despite the mismatch above.{C.NC}") + + return simnibs_python, find_simnibs_pip(simnibs_python) + + +def main(): + """Main installation routine.""" + parser = argparse.ArgumentParser( + description="Install the TIDE Pipeline (python -m pip wrapper)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python install.py --simnibs-env --editable Recommended source installation + python install.py --simnibs-env Install into the detected SimNIBS env + python install.py --simnibs-env --viz SimNIBS env + optional 3D rendering + python install.py --dev Current interpreter + development dependencies + python install.py Current interpreter (advanced) + """, + ) + parser.add_argument( + "--core", + action="store_true", + help="Core dependencies only (default; kept for compatibility)", + ) + parser.add_argument( + "--viz", + action="store_true", + help="Add optional 3D rendering dependencies (pyvista, vtk)", + ) + parser.add_argument("--dev", action="store_true", help="Add development dependencies") + parser.add_argument( + "--simnibs-env", + action="store_true", + dest="simnibs_env", + help="Install into the detected SimNIBS python environment " + "(recommended for pipeline use; default: the current interpreter)", + ) + parser.add_argument( + "-e", "--editable", action="store_true", help="Editable install (python -m pip install -e)" + ) + parser.add_argument( + "--force", + action="store_true", + help="With --simnibs-env, install even if dependency versions mismatch", + ) + parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output") + + args = parser.parse_args() + quiet = not args.verbose + + print_banner() + + # Directory holding pyproject.toml; also the install target ".". + script_dir = Path(__file__).parent.resolve() + os.chdir(script_dir) + + extras = [] + if args.viz: + extras.append("viz") + if args.dev: + extras.append("dev") + target_spec = "." + (f"[{','.join(extras)}]" if extras else "") + + if args.simnibs_env: + print(f"{C.YELLOW}[1/2]{C.NC} Resolving SimNIBS environment...") + target_python, pip_cmd = resolve_simnibs_target(script_dir, args.force) + else: + target_python = Path(sys.executable) + pip_cmd = [sys.executable, "-m", "pip"] + print(f"{C.YELLOW}[1/2]{C.NC} Target: {C.GREEN}{target_python}{C.NC} (current interpreter)") + print( + f"{C.YELLOW}Note:{C.NC} this mode does not redirect TIDE into SimNIBS. " + "For normal source-based pipeline use, prefer " + "'python install.py --simnibs-env --editable'." + ) + + print(f"{C.YELLOW}[2/2]{C.NC} Installing TIDE Pipeline...") + print(f" Spec: {C.BLUE}{target_spec}{C.NC}{' (editable)' if args.editable else ''}") + print() + + install_args = ["install"] + if args.editable: + install_args.append("-e") + install_args.append(target_spec) + if not run_pip(pip_cmd, install_args, quiet=quiet): + print(f"{C.RED}Failed to install TIDE Pipeline{C.NC}") + sys.exit(1) + + ok, detail = verify_tide_install(target_python) + if not ok: + print(f"{C.RED}Installation verification failed.{C.NC}") + print(f"Target Python: {target_python}") + if detail: + print(detail) + print( + "The package was not importable by the interpreter used for the " + "installation. Re-run using the explicit interpreter form " + "' -m pip install ...' and remove any stale tide script " + "from another environment." + ) + sys.exit(1) + + if args.verbose and detail: + print(detail) + print_success(target_python) + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..c54e943 --- /dev/null +++ b/main.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +TIDE Pipeline - Development Entry Point +======================================= + +Thin wrapper around :func:`tide.cli.main` for running the pipeline directly +from a source checkout (without installing the package). + +Once installed via ``python -m pip install -e .``, prefer the registered console +script:: + + tide --config config.yml --workflow estimation + +Check install mode: +~/SimNIBS-4.5/simnibs_env/bin/python -m pip show tide-pipeline | grep -E "Location|Editable" + +""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def _bootstrap_src_path() -> None: + """Make ``src/`` importable when running from a source checkout.""" + src_path = Path(__file__).resolve().parent / "src" + if src_path.is_dir() and str(src_path) not in sys.path: + sys.path.insert(0, str(src_path)) + + +def main() -> None: + _bootstrap_src_path() + from tide.cli import main as cli_main + + cli_main() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ff711b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,164 @@ +[build-system] +requires = ["hatchling==1.27.0"] +build-backend = "hatchling.build" + +[project] +name = "tide-pipeline" +dynamic = ["version"] +description = "TIDE (Tractography-Informed Dose Estimation) Pipeline — A SimNIBS-based tool for TMS target intensity estimation using the Activating Function" +readme = "README.md" +requires-python = ">=3.11,<3.13" +license = "GPL-3.0-or-later" +license-files = ["LICENSE"] +authors = [{ name = "Marco Tagliaferri", email = "marco.tagliaferri@unitn.it" }] +maintainers = [{ name = "Marco Tagliaferri", email = "marco.tagliaferri@unitn.it" }] +keywords = [ + "TMS", + "SimNIBS", + "neuroimaging", + "brain-stimulation", + "activating-function", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Science/Research", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Medical Science Apps.", + "Topic :: Scientific/Engineering :: Bio-Informatics", +] + +# SimNIBS is a system dependency, installed separately (not on PyPI); it must +# never be listed here. See https://simnibs.github.io/simnibs/ and +# `tide --bootstrap` for installing into the SimNIBS environment. +dependencies = [ + "numpy==1.26.4", + "scipy==1.12.0", + "nibabel==5.2.1", + "dipy==1.9.0", + "pandas==2.2.3", + "PyYAML==6.0.1", + "defusedxml==0.7.1", + "scikit-learn==1.5.2", + "matplotlib==3.8.3", +] + +[project.optional-dependencies] +# Optional 3D rendering (lazy-imported by interfaces/visualization_3d.py). +viz = ["pyvista==0.43.3", "vtk==9.3.0"] +dev = [ + "pytest==9.0.3", + "pytest-cov==4.1.0", + "pytest-xdist==3.5.0", + "black==26.3.1", + "isort==5.13.2", + "flake8==7.0.0", + "mypy==1.8.0", + "types-PyYAML==6.0.12.12", +] + +[project.scripts] +tide = "tide.cli:main" + +[project.urls] +Homepage = "https://github.com/marcotag93/TIDE" +Repository = "https://github.com/marcotag93/TIDE" +Documentation = "https://github.com/marcotag93/TIDE#readme" +"Bug Tracker" = "https://github.com/marcotag93/TIDE/issues" + +[tool.hatch.version] +path = "src/tide/__init__.py" + +[tool.hatch.build] +exclude = ["CLAUDE.md"] + +[tool.hatch.build.targets.wheel] +packages = ["src/tide"] + +[tool.hatch.build.targets.wheel.force-include] +"config_template.yml" = "tide/data/config_template.yml" + +# ============================================================================= +# Tool Configurations +# ============================================================================= + +[tool.black] +line-length = 100 +target-version = ["py311"] +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | __pycache__ +)/ +''' + +[tool.isort] +profile = "black" +line_length = 100 +known_first_party = ["tide"] +skip = [".git", ".venv", "build", "dist"] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +exclude = ["tests/", "build/", "dist/"] + +# Explicit baseline for legacy modules. New modules and already-clean modules +# remain checked; entries should be removed as their annotations are repaired. +[[tool.mypy.overrides]] +module = [ + "tide.cli", + "tide.console.console_ui", + "tide.console.renderer", + "tide.console.terminal", + "tide.console.worker_reporter", + "tide.core.geometry", + "tide.core.io", + "tide.core.physics", + "tide.core.tractography", + "tide.core._reporting", + "tide.interfaces.grid_visualization", + "tide.interfaces.sampling", + "tide.interfaces.simnibs_interface", + "tide.interfaces.unified_estimation", + "tide.interfaces.visualization_3d", + "tide.utils.config", + "tide.utils.logging", + "tide.workflows._grid_reporting", + "tide.workflows._shared", + "tide.workflows.estimation", + "tide.workflows.grid_search", + "tide.workflows.standard", +] +ignore_errors = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +filterwarnings = ["ignore::DeprecationWarning", "ignore::UserWarning"] + +[tool.coverage.run] +source = ["src/tide"] +branch = true +omit = ["*/tests/*", "*/__pycache__/*"] + +[tool.coverage.report] +fail_under = 45 +show_missing = true diff --git a/scripts/check_release_metadata.py b/scripts/check_release_metadata.py new file mode 100644 index 0000000..385131d --- /dev/null +++ b/scripts/check_release_metadata.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Validate release metadata that must stay synchronized before publishing.""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = ROOT / "src" / "tide" / "__init__.py" +CITATION_FILE = ROOT / "CITATION.cff" +README_FILE = ROOT / "README.md" + + +def _extract(pattern: str, text: str, *, label: str) -> str: + match = re.search(pattern, text, flags=re.MULTILINE) + if not match: + raise ValueError(f"Could not read {label}.") + return match.group(1).strip() + + +def package_version() -> str: + return _extract( + r'^__version__\s*=\s*["\']([^"\']+)["\']\s*$', + VERSION_FILE.read_text(encoding="utf-8"), + label="src/tide/__init__.py version", + ) + + +def citation_version() -> str: + value = _extract( + r'^version:\s*["\']?([^"\'\n]+)["\']?\s*$', + CITATION_FILE.read_text(encoding="utf-8"), + label="CITATION.cff version", + ) + return value.strip() + + +def readme_logo_version() -> str: + return _extract( + r"raw\.githubusercontent\.com/marcotag93/TIDE/v([^/]+)/src/tide/assets/logo\.png", + README_FILE.read_text(encoding="utf-8"), + label="README.md immutable logo version", + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--tag", + help="Optional GitHub release tag; must equal v.", + ) + args = parser.parse_args() + + package = package_version() + citation = citation_version() + logo = readme_logo_version() + + errors = [] + if citation != package: + errors.append( + f"CITATION.cff version ({citation}) does not match package version ({package})." + ) + + if logo != package: + errors.append( + f"README.md logo version ({logo}) does not match package version ({package})." + ) + + if args.tag is not None: + expected_tag = f"v{package}" + if args.tag != expected_tag: + errors.append( + f"GitHub release tag ({args.tag}) does not match expected tag ({expected_tag})." + ) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print(f"Release metadata OK: version={package}") + if args.tag is not None: + print(f"Release tag OK: {args.tag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/tide/__init__.py b/src/tide/__init__.py new file mode 100644 index 0000000..291a44c --- /dev/null +++ b/src/tide/__init__.py @@ -0,0 +1,3 @@ +"""TIDE Pipeline package.""" + +__version__ = "1.30.0" diff --git a/src/tide/__main__.py b/src/tide/__main__.py new file mode 100644 index 0000000..0c7ceac --- /dev/null +++ b/src/tide/__main__.py @@ -0,0 +1,10 @@ +"""Allow TIDE to be invoked as ``python -m tide``. + +This provides an interpreter-explicit fallback when multiple Python +environments expose different ``tide`` console scripts on ``PATH``. +""" + +from tide.cli import main + +if __name__ == "__main__": + main() diff --git a/src/tide/assets/logo.ansi b/src/tide/assets/logo.ansi new file mode 100644 index 0000000..6d42035 --- /dev/null +++ b/src/tide/assets/logo.ansi @@ -0,0 +1,17 @@ + ▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄ + ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄ + ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ + ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ + ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ + ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +▄▄ ▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▀▀▀▀▀▀▀▀▀▀▀ + ▀▀▀▀▀▄ + ▀▀▀▀▀ diff --git a/src/tide/assets/logo.png b/src/tide/assets/logo.png new file mode 100644 index 0000000..956eeee Binary files /dev/null and b/src/tide/assets/logo.png differ diff --git a/src/tide/banner.py b/src/tide/banner.py new file mode 100644 index 0000000..56d8298 --- /dev/null +++ b/src/tide/banner.py @@ -0,0 +1,201 @@ +""" +TIDE Pipeline Logo Banner +========================= + +Renders the TIDE ANSI logo and metadata blocks (author, version) used by +the CLI ``--help`` and ``--version`` outputs. + +The banner renders only on TTY-capable streams that meet the minimum width. +On unsupported terminals (pipes, narrow viewports, missing asset) the helper +silently returns so the pipeline behaves identically to before. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from importlib.resources import files +from pathlib import Path +from typing import IO, List, Optional + +from . import __version__ as VERSION + +_LOGO_RESOURCE = files("tide").joinpath("assets/logo.ansi") +LOGO_PATH: Path = Path(str(_LOGO_RESOURCE)) +LOGO_NATIVE_WIDTH: int = 50 + + +def _read_logo_text() -> Optional[str]: + try: + return _LOGO_RESOURCE.read_text(encoding="utf-8") + except (FileNotFoundError, OSError): + return None + + +LOGO_MIN_WIDTH: int = LOGO_NATIVE_WIDTH + +TITLE: str = "TIDE" +SUBTITLE: str = "Tractography-Informed Dose Estimation" +TAGLINE: str = "TMS Target Intensity Estimation via the Activating Function" + +AUTHOR_NAME: str = "Marco Tagliaferri" +AUTHOR_ROLE: str = "PhD Candidate in Cognitive Neuroscience" +AUTHOR_AFFILIATION: str = "CIMeC, University of Trento (Italy)" +AUTHOR_EMAILS: tuple = ( + "marco.tagliaferri@unitn.it", + "marco.tagliaferri93@gmail.com", +) +LICENSE_NAME: str = "GNU GPL v3.0" + +RESEARCH_USE_HEADING: str = "Research use only" +RESEARCH_USE_LINES: tuple = ( + "TIDE is intended for research use only. It is not a medical device and", + "must not be used to diagnose or treat patients, or to guide clinical", + "decisions. It has not been evaluated by any regulatory authority and holds", + "no regulatory clearance: neither CE marking under the European Medical", + "Device Regulation nor United States Food and Drug Administration approval.", + "Any clinical use would require independent validation and the appropriate", + "regulatory authorisation.", +) + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_CYAN = "\033[38;5;44m" +_CYAN_BRIGHT = "\033[38;5;51m" +_GRAY = "\033[38;5;245m" +_WHITE = "\033[38;5;255m" + + +def _stream_supports_ansi(stream: IO[str]) -> bool: + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("TIDE_NO_LOGO"): + return False + isatty = getattr(stream, "isatty", None) + return bool(isatty and isatty()) + + +def _center(text: str, width: int) -> str: + pad = max(0, (width - len(text)) // 2) + return " " * pad + text + + +def _terminal_width(default: int = 80) -> int: + return shutil.get_terminal_size((default, 24)).columns + + +def _render_logo_block(target: IO[str], columns: int) -> bool: + content = _read_logo_text() + if content is None: + return False + + pad = max(0, (columns - LOGO_NATIVE_WIDTH) // 2) + indent = " " * pad + + target.write("\n") + for line in content.splitlines(): + target.write(indent + line + "\n") + target.write("\n") + return True + + +def _render_title_block(target: IO[str], columns: int) -> None: + target.write(f"{_BOLD}{_CYAN_BRIGHT}{_center(TITLE, columns)}{_RESET}\n") + target.write(f"{_BOLD}{_CYAN}{_center(SUBTITLE, columns)}{_RESET}\n") + target.write(f"{_DIM}{_GRAY}{_center(TAGLINE, columns)}{_RESET}\n") + + +def render_logo(stream: Optional[IO[str]] = None) -> bool: + """Render the ANSI logo + title block. Returns True if rendered.""" + target = stream if stream is not None else sys.stdout + + if not _stream_supports_ansi(target): + return False + + columns = _terminal_width(LOGO_MIN_WIDTH) + if columns < LOGO_MIN_WIDTH: + return False + + if not _render_logo_block(target, columns): + return False + + _render_title_block(target, columns) + target.write("\n") + target.flush() + return True + + +def render_version(stream: Optional[IO[str]] = None) -> bool: + """Render the full version screen: logo, title, version, author. Returns True if rendered.""" + target = stream if stream is not None else sys.stdout + tty = _stream_supports_ansi(target) + columns = _terminal_width(LOGO_MIN_WIDTH) + + if tty and columns >= LOGO_MIN_WIDTH: + _render_logo_block(target, columns) + _render_title_block(target, columns) + target.write("\n") + target.write(f"{_BOLD}{_WHITE}{_center(f'Version {VERSION}', columns)}{_RESET}\n\n") + else: + target.write(f"{TITLE} - {SUBTITLE}\n") + target.write(f"{TAGLINE}\n") + target.write(f"Version {VERSION}\n\n") + + for line in _author_lines(tty=tty): + target.write(line + "\n") + target.write("\n") + target.write(format_research_use_block(tty=tty) + "\n\n") + target.flush() + return True + + +def _author_lines(tty: bool) -> List[str]: + bold_cyan = f"{_BOLD}{_CYAN}" if tty else "" + bold = _BOLD if tty else "" + dim = _DIM if tty else "" + reset = _RESET if tty else "" + + lines: List[str] = [] + lines.append(f"{bold_cyan}Author{reset}") + lines.append(f" {bold}{AUTHOR_NAME}{reset}") + lines.append(f" {AUTHOR_ROLE}") + lines.append(f" {AUTHOR_AFFILIATION}") + lines.append("") + lines.append(f"{bold_cyan}Contact{reset}") + for email in AUTHOR_EMAILS: + lines.append(f" {email}") + lines.append("") + lines.append(f"{dim}Licensed under {LICENSE_NAME}.{reset}") + return lines + + +def format_research_use_block(tty: bool) -> str: + """Research-Use-Only statement shown by ``--version`` and ``--help``.""" + bold_cyan = f"{_BOLD}{_CYAN}" if tty else "" + dim = _DIM if tty else "" + reset = _RESET if tty else "" + + lines = [f"{bold_cyan}{RESEARCH_USE_HEADING}:{reset}"] + lines.extend(f" {dim}{line}{reset}" for line in RESEARCH_USE_LINES) + return "\n".join(lines) + + +def format_author_block(tty: bool) -> str: + """Author block formatted for inclusion in the argparse epilog.""" + bold_cyan = f"{_BOLD}{_CYAN}" if tty else "" + bold = _BOLD if tty else "" + dim = _DIM if tty else "" + reset = _RESET if tty else "" + + emails = ", ".join(AUTHOR_EMAILS) + lines = [ + f"{bold_cyan}Author:{reset}", + f" {bold}{AUTHOR_NAME}{reset} - {AUTHOR_ROLE}", + f" {AUTHOR_AFFILIATION}", + f" {emails}", + "", + f"{dim}Version {VERSION} - Licensed under {LICENSE_NAME}.{reset}", + ] + return "\n".join(lines) diff --git a/src/tide/cli.py b/src/tide/cli.py new file mode 100644 index 0000000..def95ab --- /dev/null +++ b/src/tide/cli.py @@ -0,0 +1,956 @@ +""" +TIDE Pipeline - CLI Entry Point +================================ + +This module provides the ``tide`` console script registered in +``pyproject.toml``. It runs the headless workflows and ensures the SimNIBS +python environment is active before importing simnibs-dependent code. + +Examples: + tide --config config.yml --workflow estimation # Estimation + tide --config config.yml --workflow grid # Grid search + tide --init-config config.yml # Write annotated config template + tide --version # Version + author + research-use notice + tide --help # Show help +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import time +from importlib import resources +from pathlib import Path +from typing import TYPE_CHECKING + +from tide.utils import simnibs_env + +if TYPE_CHECKING: + from tide.utils.config import SimNIBSConfig + +_RELAUNCH_MARKER = "_TIDE_SIMNIBS_RELAUNCHED" + +_SIMNIBS_INSTALL_URL = "https://simnibs.github.io/simnibs/" + +# Numerics-critical dependencies whose versions must match between the SimNIBS +# environment and tide's pins before installing into it. A mismatch would either +# break SimNIBS or shift the pipeline's frozen numerics. +_CORE_PACKAGES = ("numpy", "scipy", "nibabel", "dipy", "pandas") + +# Valid workflow selections. Single source of truth for the --workflow flag and +# the top-level `workflow` config entry. +WORKFLOW_CHOICES = ("estimation", "grid", "simulation", "optimization") + + +def _config_template_text() -> str: + """Return the bundled configuration template as text. + + Installed wheels include ``config_template.yml`` as ``tide/data`` package + data. Editable/source checkouts fall back to the repository-root template, + which remains the single canonical source file. + """ + try: + resource = resources.files("tide").joinpath("data").joinpath("config_template.yml") + if resource.is_file(): + return resource.read_text(encoding="utf-8") + except (FileNotFoundError, ModuleNotFoundError): + pass + + source_template = _repo_root() / "config_template.yml" + if source_template.is_file(): + return source_template.read_text(encoding="utf-8") + + raise FileNotFoundError( + "The bundled TIDE configuration template could not be located. " + "Reinstall tide-pipeline or use a complete source checkout." + ) + + +def write_config_template(destination: Path) -> Path: + """Write a fresh configuration template without overwriting existing files.""" + destination = destination.expanduser().resolve() + if destination.exists(): + raise FileExistsError(f"Refusing to overwrite existing configuration: {destination}") + + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(_config_template_text(), encoding="utf-8") + return destination + + +def _locate_simnibs_python() -> Path: + """Return the absolute path to the SimNIBS-bundled Python interpreter. + + Exits the process with an actionable message if SimNIBS or its python + cannot be found. + """ + simnibs_root = simnibs_env.simnibs_root() + if simnibs_root is None: + print("Error: 'simnibs' command not found in PATH.", file=sys.stderr) + print("Please ensure SimNIBS is installed and in your PATH.", file=sys.stderr) + sys.exit(1) + + candidates = simnibs_env.python_candidates(simnibs_root) + simnibs_python = simnibs_env.select_python(candidates) + if simnibs_python is not None: + return simnibs_python + + print(f"Error: Could not find python in SimNIBS: {simnibs_root}", file=sys.stderr) + print("Searched paths:", file=sys.stderr) + for candidate in candidates: + print(f" - {candidate}", file=sys.stderr) + sys.exit(1) + + +def _locate_simnibs_pip(simnibs_python: Path) -> Path: + """Return the pip executable next to the SimNIBS python interpreter.""" + candidates = simnibs_env.pip_candidates(simnibs_python) + pip = simnibs_env.select_pip(candidates) + if pip is not None: + return pip + print( + f"Error: Could not find pip alongside SimNIBS python: {simnibs_python}", + file=sys.stderr, + ) + sys.exit(1) + + +def _repo_root() -> Path: + """Return the repository root (parent of the ``src/`` directory).""" + return Path(__file__).resolve().parents[2] + + +def _source_checkout_src_dir(): + """Return the ``src/`` dir if tide runs from a source checkout, else None. + + A source checkout has a ``src/tide`` layout with a sibling ``pyproject.toml`` + and is not located under a ``site-packages``/``dist-packages`` directory. + """ + import tide as _tide_pkg + + tide_parent = Path(_tide_pkg.__file__).resolve().parent.parent + lowered = {part.lower() for part in tide_parent.parts} + if "site-packages" in lowered or "dist-packages" in lowered: + return None + if (tide_parent.parent / "pyproject.toml").exists(): + return tide_parent + return None + + +def _tide_importable_under(python: Path, env: dict = None) -> bool: + """Return True if ``import tide`` succeeds under the given interpreter. + + ``env`` should be the environment the relaunch will use so the probe sees the + same import path (no injected PYTHONPATH), reflecting the target env's own + packages rather than the caller's. + """ + try: + result = subprocess.run( + [str(python), "-c", "import tide"], + capture_output=True, + text=True, + timeout=30, + env=env, + ) + return result.returncode == 0 + except Exception: + return False + + +def _pins_from_pyproject(pyproject_path: Path) -> dict: + """Parse ``==`` pins for the core packages from ``[project.dependencies]``.""" + pins: dict = {} + try: + text = pyproject_path.read_text(encoding="utf-8") + except OSError: + return pins + block = re.search(r"^dependencies\s*=\s*\[(.*?)\]", text, re.DOTALL | re.MULTILINE) + scope = block.group(1) if block else text + for name, version in re.findall(r'"([A-Za-z0-9_.-]+)==([^"\s;]+)"', scope): + if name.lower() in _CORE_PACKAGES: + pins[name.lower()] = version + return pins + + +def _expected_core_pins() -> dict: + """Return ``{package: pinned_version}`` for the numerics-critical core. + + Prefers tide's installed distribution metadata (the authoritative pins); + falls back to the source-checkout ``pyproject.toml`` when tide is not + installed as a distribution. + """ + from importlib import metadata + + pins: dict = {} + try: + requirements = metadata.requires("tide-pipeline") or [] + except metadata.PackageNotFoundError: + requirements = [] + + for requirement in requirements: + if ";" in requirement: # skip extras (viz/dev markers) + continue + match = re.match(r"^([A-Za-z0-9_.-]+)==([^\s;]+)$", requirement.strip()) + if match and match.group(1).lower() in _CORE_PACKAGES: + pins[match.group(1).lower()] = match.group(2) + + if len(pins) < len(_CORE_PACKAGES): + src_dir = _source_checkout_src_dir() + if src_dir is not None: + for name, version in _pins_from_pyproject(src_dir.parent / "pyproject.toml").items(): + pins.setdefault(name, version) + return pins + + +def _simnibs_core_versions(simnibs_python: Path) -> dict: + """Return ``{package: version|None}`` for the core packages in the SimNIBS env.""" + code = ( + "import importlib.metadata as m, json;" + f"names={list(_CORE_PACKAGES)!r};" + "out={}\n" + "for n in names:\n" + " try:\n" + " out[n]=m.version(n)\n" + " except Exception:\n" + " out[n]=None\n" + "print(json.dumps(out))" + ) + try: + result = subprocess.run( + [str(simnibs_python), "-c", code], + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode == 0: + return json.loads(result.stdout.strip()) + except (subprocess.SubprocessError, json.JSONDecodeError): + pass + return {name: None for name in _CORE_PACKAGES} + + +def _verify_simnibs_deps(simnibs_python: Path) -> tuple: + """Compare SimNIBS-env core versions against tide's pins. + + Returns ``(ok, lines)`` where each line reports a package as match or + mismatch. + """ + expected = _expected_core_pins() + found = _simnibs_core_versions(simnibs_python) + + ok = True + lines: list = [] + for name in _CORE_PACKAGES: + want = expected.get(name) + have = found.get(name) + if want is None: + lines.append(f" {name}: pin unknown (skipped)") + continue + if have != want: + ok = False + status = "OK" if have == want else "MISMATCH" + lines.append(f" {name}: expected {want}, found {have or 'absent'} [{status}]") + return ok, lines + + +def _exit_simnibs_import_failed(exc: ImportError) -> None: + """Exit after a failed simnibs import in the relaunched interpreter.""" + print( + "Error: Failed to import simnibs even after relaunching with SimNIBS python.", + file=sys.stderr, + ) + print(f"Import error: {exc}", file=sys.stderr) + print(f"\nEnsure SimNIBS 4.5+ is installed: {_SIMNIBS_INSTALL_URL}", file=sys.stderr) + print("Then install tide into it: tide --bootstrap", file=sys.stderr) + sys.exit(1) + + +def _exit_tide_not_in_simnibs_env(simnibs_python: Path) -> None: + """Exit when tide is installed outside the SimNIBS env and cannot relaunch.""" + print( + "Error: tide is installed outside the SimNIBS environment and is not " + "importable by the SimNIBS python.", + file=sys.stderr, + ) + print(f"SimNIBS python: {simnibs_python}", file=sys.stderr) + print("\nInstall tide into the SimNIBS environment, then re-run:", file=sys.stderr) + print(" tide --bootstrap", file=sys.stderr) + sys.exit(1) + + +def _build_relaunch_env(simnibs_python: Path) -> dict: + """Build a clean environment dict for re-execing under SimNIBS python. + + Injects a PYTHONPATH that points at the tide source directory so the + relaunched interpreter can import ``tide`` even if the package is not + installed in the SimNIBS environment. + """ + env = os.environ.copy() + env[_RELAUNCH_MARKER] = "1" + + for var in ("PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP"): + env.pop(var, None) + + # Only inject PYTHONPATH for a source checkout (src/ layout with a sibling + # pyproject.toml). For an installed package that directory is the venv's + # site-packages; injecting it into the SimNIBS python would shadow SimNIBS's + # own numpy/scipy and corrupt the numerics. + src_dir = _source_checkout_src_dir() + if src_dir is not None: + env["PYTHONPATH"] = str(src_dir) + + if sys.platform == "win32": + simnibs_env_root = simnibs_python.parent.parent + simnibs_env_dir = simnibs_python.parent + simnibs_scripts = simnibs_env_root / "Scripts" + simnibs_lib = simnibs_env_root / "Library" / "bin" + simnibs_dll = simnibs_env_root / "DLLs" + + current_path = env.get("PATH", "") + filtered_paths = [] + for entry in current_path.split(";"): + entry_lower = entry.lower() + if any(m in entry_lower for m in ("python", "anaconda", "miniconda", "conda")): + if "simnibs" in entry_lower: + filtered_paths.append(entry) + else: + filtered_paths.append(entry) + + new_paths = [str(simnibs_env_dir), str(simnibs_scripts), str(simnibs_lib), str(simnibs_dll)] + env["PATH"] = ";".join(new_paths) + ";" + ";".join(filtered_paths) + env["CONDA_PREFIX"] = str(simnibs_env_root) + + return env + + +def ensure_simnibs_environment() -> None: + """Ensure the running interpreter has access to the simnibs package. + + If simnibs cannot be imported, locate the SimNIBS python and re-exec the + current command under it. Uses an env-var marker to prevent infinite loops. + """ + already_relaunched = os.environ.get(_RELAUNCH_MARKER) == "1" + + try: + import simnibs # noqa: F401 + + return + except ImportError as exc: + if already_relaunched: + _exit_simnibs_import_failed(exc) + + print("--- Locating SimNIBS environment... ---", file=sys.stderr) + simnibs_python = _locate_simnibs_python() + + env = _build_relaunch_env(simnibs_python) + + # No PYTHONPATH means tide is an installed package (not a source checkout) + if "PYTHONPATH" not in env and not _tide_importable_under(simnibs_python, env): + _exit_tide_not_in_simnibs_env(simnibs_python) + + print(f"--- Relaunching via: {simnibs_python} ---", file=sys.stderr) + cmd = [str(simnibs_python), sys.argv[0]] + sys.argv[1:] + + try: + if sys.platform == "win32": + result = subprocess.run(cmd, env=env) + sys.exit(result.returncode) + else: + os.execve(str(simnibs_python), cmd, env) + except Exception as exc: + print(f"Failed to relaunch: {exc}", file=sys.stderr) + sys.exit(1) + + +def run_bootstrap(editable: bool = True, force: bool = False) -> None: + """Install the tide package into the detected SimNIBS python environment. + + This is an opt-in convenience for users who installed ``tide`` under a + different interpreter (system pip, pyenv, conda, ...) and want it available + under the SimNIBS-bundled python. Before installing, the SimNIBS env's + numerics-critical dependency versions are verified against tide's pins; a + mismatch aborts (use ``force`` to override) so SimNIBS packages are never + silently changed. The exact pip command is printed before execution. + """ + print("--- Locating SimNIBS environment... ---", file=sys.stderr) + simnibs_python = _locate_simnibs_python() + + print("--- Verifying SimNIBS dependency versions against tide pins ---", file=sys.stderr) + ok, lines = _verify_simnibs_deps(simnibs_python) + for line in lines: + print(line, file=sys.stderr) + if not ok: + if not force: + print( + "\nError: SimNIBS dependency versions differ from tide's pins; " + "installing could change SimNIBS packages and shift the pipeline " + "numerics.", + file=sys.stderr, + ) + print("Re-run 'tide --bootstrap --force' to override.", file=sys.stderr) + sys.exit(1) + print("\n--force set: proceeding despite the mismatch above.", file=sys.stderr) + + src_dir = _source_checkout_src_dir() + # Always invoke pip as a module of the exact SimNIBS interpreter. Calling a + # standalone ``pip`` script can target a different Python when PATHs, user + # installs, or stale environment launchers overlap. + install_args: list = [str(simnibs_python), "-m", "pip", "install"] + if src_dir is not None: + if editable: + install_args.append("-e") + install_args.append(str(src_dir.parent)) + else: + from tide import __version__ + + install_args.append(f"tide-pipeline=={__version__}") + + print(f"--- Bootstrapping tide via: {' '.join(install_args)} ---", file=sys.stderr) + try: + result = subprocess.run(install_args, check=False) + except Exception as exc: + print(f"Bootstrap failed: {exc}", file=sys.stderr) + sys.exit(1) + + if result.returncode != 0: + print( + f"Bootstrap pip exited with status {result.returncode}.", + file=sys.stderr, + ) + sys.exit(result.returncode) + + probe_env = os.environ.copy() + for var in ("PYTHONPATH", "PYTHONHOME", "PYTHONSTARTUP"): + probe_env.pop(var, None) + if not _tide_importable_under(simnibs_python, probe_env): + print( + "Bootstrap installation completed, but 'import tide' still fails " + f"under {simnibs_python}.", + file=sys.stderr, + ) + print( + "Inspect the installation with: " f"{simnibs_python} -m pip show tide-pipeline", + file=sys.stderr, + ) + sys.exit(1) + + print( + f"\ntide installed into {simnibs_python.parent}. " + "Re-run without --bootstrap to use the pipeline.", + file=sys.stderr, + ) + + +def run_cache_command(argv: list) -> None: + """Handle ``--cache-info`` / ``--cache-clear`` and exit. + + Runs before the SimNIBS relaunch, so it stays standard-library only. When a + ``--config`` is supplied its ``subject.cache_dir`` is exported to + ``TIDE_CACHE_DIR`` so the command targets the configured store. + """ + from tide.utils.artifacts import ( + CACHE_DISABLE_TOKENS, + cache_total_size, + clear_cache, + fixed_pose_cache_root, + iter_cache_entries, + ) + + config_path = None + config_requested = False + for index, arg in enumerate(argv): + if arg == "--config": + config_requested = True + config_path = argv[index + 1] if index + 1 < len(argv) else "" + elif arg.startswith("--config="): + config_requested = True + config_path = arg.split("=", 1)[1] + + if config_requested and not config_path: + print("Error: Could not read cache configuration: path is missing", file=sys.stderr) + sys.exit(1) + + if config_path: + import yaml + + try: + loaded = yaml.safe_load(Path(config_path).read_text(encoding="utf-8")) + raw = {} if loaded is None else loaded + if not isinstance(raw, dict): + raise ValueError("configuration root must be a YAML mapping") + subject = raw.get("subject", {}) + if subject is None: + subject = {} + if not isinstance(subject, dict): + raise ValueError("subject must be a YAML mapping") + cache_dir = subject.get("cache_dir") + disabled_scalar = cache_dir is False or ( + isinstance(cache_dir, (int, float)) + and not isinstance(cache_dir, bool) + and cache_dir == 0 + ) + if cache_dir is not None and not isinstance(cache_dir, str) and not disabled_scalar: + raise ValueError("subject.cache_dir must be a path or disable token") + if cache_dir and str(cache_dir).strip().lower() not in CACHE_DISABLE_TOKENS: + os.environ["TIDE_CACHE_DIR"] = str(Path(cache_dir).expanduser()) + except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError) as exc: + print( + f"Error: Could not read cache configuration '{config_path}': {exc}", + file=sys.stderr, + ) + sys.exit(1) + + root = fixed_pose_cache_root() + + if "--cache-clear" in argv: + removed, freed = clear_cache(root) + print(f"Fixed-pose cache: removed {removed} entries, freed {freed / 1024**3:.2f} GB") + print(f"Cache root: {root}") + return + + entries = iter_cache_entries(root) + total = cache_total_size(root) + print(f"Cache root: {root}") + print(f"Entries: {len(entries)}") + print(f"Total size: {total / 1024**3:.2f} GB") + + +class _TideHelpFormatter(argparse.RawDescriptionHelpFormatter): + """Help formatter that colors section headings on TTY streams.""" + + _RESET = "\033[0m" + _BOLD = "\033[1m" + _CYAN = "\033[38;5;44m" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._tty = sys.stdout.isatty() and not os.environ.get("NO_COLOR") + + def start_section(self, heading): + if self._tty and heading: + heading = f"{self._BOLD}{self._CYAN}{heading}{self._RESET}" + super().start_section(heading) + + +def _styled_description(tty: bool) -> str: + title = "T.I.D.E. (Tractography-Informed Dose Estimation) Pipeline" + subtitle = "TMS target intensity estimation via the Activating Function." + if tty: + return f"\033[1m\033[38;5;44m{title}\033[0m\n\033[2m{subtitle}\033[0m" + return f"{title}\n{subtitle}" + + +def _styled_epilog(tty: bool) -> str: + bold_cyan = "\033[1m\033[38;5;44m" if tty else "" + dim = "\033[2m" if tty else "" + reset = "\033[0m" if tty else "" + sections = [ + ( + "Workflows", + [ + ("estimation", "Full TIDE estimation with CST calibration and target optimization"), + ("grid", "Grid search for optimal target position"), + ("simulation", "Standard simulation only"), + ("optimization", "Standard optimization only"), + ], + ), + ( + "Verbosity levels", + [ + ("quiet", "Minimal output (highlights, warnings, errors only)"), + ("standard", "Normal output (default)"), + ("verbose", "Full debug output"), + ], + ), + ( + "Softaxic export", + [ + ( + "--stmpx PATH", + "Estimation-only template; writes _updated.stmpx", + ), + ( + "rotation", + "Softaxic columns are SimNIBS columns 1, 0, and negated 2", + ), + ("translation", "Copied directly from matsimnibs column 3"), + ], + ), + ] + lines: list = [] + key_width = max(len(key) for _, items in sections for key, _ in items) + for title, items in sections: + lines.append(f"{bold_cyan}{title}:{reset}") + for key, value in items: + lines.append(f" {key.ljust(key_width)} {value}") + lines.append("") + + examples = [ + ("tide --config config.yml --workflow estimation", "Full estimation"), + ( + "tide --config config.yml --workflow estimation --stmpx session.stmpx", + "Full estimation with automatic Softaxic export", + ), + ("tide --config config.yml --workflow grid", "Grid search"), + ("tide --config config.yml --workflow simulation", "Standard simulation"), + ("tide --config config.yml --verbosity quiet", "Quiet run"), + ("tide --init-config config.yml", "Write an annotated configuration template"), + ("tide --version", "Show version, author, and research-use information"), + ] + lines.append(f"{bold_cyan}Examples:{reset}") + for cmd, label in examples: + lines.append(f" {dim}# {label}{reset}") + lines.append(f" {cmd}") + lines.append("") + + from tide.banner import format_author_block, format_research_use_block + + lines.append(format_author_block(tty)) + lines.append("") + lines.append(format_research_use_block(tty)) + + return "\n".join(lines).rstrip() + "\n" + + +def create_argument_parser() -> argparse.ArgumentParser: + """Create the CLI argument parser.""" + tty = sys.stdout.isatty() and not os.environ.get("NO_COLOR") + parser = argparse.ArgumentParser( + prog="tide", + description=_styled_description(tty), + formatter_class=_TideHelpFormatter, + epilog=_styled_epilog(tty), + ) + + parser.add_argument( + "--config", + type=Path, + help="Path to config.yml (presence triggers headless mode)", + ) + parser.add_argument( + "--stmpx", + type=Path, + help=( + "Softaxic STMPX template to update after a successful estimation. " + "Writes _updated.stmpx beside the input." + ), + ) + parser.add_argument( + "--workflow", + choices=list(WORKFLOW_CHOICES), + help="Workflow selection (overrides the config `workflow` entry)", + ) + # Deprecated no-op: retained so existing scripts passing --no-gui keep working. + parser.add_argument( + "--no-gui", + action="store_true", + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--verbosity", + choices=["quiet", "standard", "verbose"], + default="standard", + help="Output verbosity level (default: standard)", + ) + parser.add_argument( + "--no-console-ui", + action="store_true", + help="Disable rich console UI for grid search (use simple logging)", + ) + parser.add_argument( + "-v", + "--version", + action="store_true", + help="Show version, author, and research-use information, and exit", + ) + parser.add_argument( + "--init-config", + nargs="?", + const=Path("config.yml"), + type=Path, + metavar="PATH", + help=( + "Write the bundled annotated configuration template and exit " + "(default path: ./config.yml). Existing files are never overwritten." + ), + ) + parser.add_argument( + "--bootstrap", + action="store_true", + help=( + "Install tide into the detected SimNIBS python environment and exit. " + "Verifies the SimNIBS env's core dependency versions match tide's " + "pins first. Useful when 'tide' was installed under a different " + "interpreter." + ), + ) + parser.add_argument( + "--force", + action="store_true", + help="With --bootstrap, install even if dependency versions mismatch.", + ) + parser.add_argument( + "--cache-info", + action="store_true", + help="Print fixed-pose cache root, entry count, and total size, then exit.", + ) + parser.add_argument( + "--cache-clear", + action="store_true", + help="Remove all fixed-pose cache entries and exit.", + ) + parser.add_argument( + "--no-cache", + action="store_true", + help="Disable the fixed-pose cache for this run (no lookup or publication).", + ) + + return parser + + +def _prepare_anatomy(config: SimNIBSConfig) -> None: + """Ensure the T1w file exists at the derivatives root.""" + source = config.subject.t1w_path + is_compressed_nifti = source.name.lower().endswith(".nii.gz") + suffix = ".nii.gz" if is_compressed_nifti else source.suffix + dest = config.subject.derivatives_path / f"t1w{suffix}" + if not dest.exists(): + shutil.copy(source, dest) + if is_compressed_nifti: + legacy_dest = config.subject.derivatives_path / "t1w.gz" + if not legacy_dest.exists(): + try: + os.link(dest, legacy_dest) + except OSError: + shutil.copy(dest, legacy_dest) + + +def run_headless(args: argparse.Namespace) -> None: + """Run the pipeline in headless (CLI) mode.""" + start_time = time.time() + + from tide.banner import render_logo + + render_logo() + + if not args.config: + print("Error: --config is required for headless mode.", file=sys.stderr) + print( + "Usage: tide --config [--workflow estimation|grid|simulation|optimization]", + file=sys.stderr, + ) + sys.exit(1) + + if not args.config.exists(): + print(f"Error: Config file not found: {args.config}", file=sys.stderr) + sys.exit(1) + + from tide.utils.config import SimNIBSConfig, orientation_is_matrix, validate_workflow_config + from tide.utils.logging import setup_logging + + log = logging.getLogger(__name__) + + try: + config = SimNIBSConfig.from_yaml(args.config) + except Exception as exc: + print(f"Configuration Error: {exc}") + sys.exit(1) + + # Resolve the effective workflow: the --workflow flag overrides the + # top-level `workflow` config entry; the resolved value then drives the + # identical dispatch below regardless of its source. + workflow = args.workflow if args.workflow is not None else config.workflow + if workflow is not None and workflow not in WORKFLOW_CHOICES: + print( + f"Configuration Error: invalid workflow '{workflow}'. " + f"Valid choices: {', '.join(WORKFLOW_CHOICES)}.", + file=sys.stderr, + ) + sys.exit(1) + + if workflow is None: + workflow = "estimation" if config.target.bundle_path else "simulation" + + stmpx_path = getattr(args, "stmpx", None) + if stmpx_path is not None and workflow != "estimation": + print( + "Configuration Error: --stmpx is supported only with the estimation workflow.", + file=sys.stderr, + ) + sys.exit(1) + if stmpx_path is not None: + from tide.interfaces.stmpx import validate_stmpx_input + + try: + validate_stmpx_input(stmpx_path) + except (FileNotFoundError, ValueError) as exc: + print(f"Configuration Error: {exc}", file=sys.stderr) + sys.exit(1) + + # Grid explores scalp positions around the target and uses target.orientation + # as the per-point pos_ydir seed; a 4x4 matrix cannot seed that search. + if workflow == "grid" and orientation_is_matrix(config.grid.orientation): + print( + "Configuration Error: the grid workflow needs a coordinate seed for " + "orientation, but experiment.target.orientation is a 4x4 matrix, " + "which cannot seed the per-point optimization.\n" + "Suggestion: set experiment.target.orientation to an [x, y, z] vector " + 'or an EEG label (e.g. "F3"), or run --workflow estimation to use the ' + "matrix as a fixed coil pose.", + file=sys.stderr, + ) + sys.exit(1) + + try: + validate_workflow_config(config, workflow) + except (FileNotFoundError, ValueError) as exc: + print(f"Configuration Error: {exc}", file=sys.stderr) + sys.exit(1) + + config.subject.derivatives_path.mkdir(parents=True, exist_ok=True) + setup_logging(config.subject.derivatives_path, config.subject.id, args.verbosity) + + # Disable the fixed-pose cache when requested (--no-cache or `cache_dir: no`). + # Set before any worker pool spawns so children inherit it (all 4 workflows). + cache_disabled = getattr(args, "no_cache", False) or config.subject.cache_disabled + if cache_disabled: + os.environ["TIDE_FIXED_POSE_CACHE"] = "0" + log.debug("Fixed-pose cache disabled (--no-cache or cache_dir: no).") + + # Route the optional subject.cache_dir into the fixed-pose cache resolver. + # Set before any worker pool spawns so children inherit it (all 4 workflows). + if config.subject.cache_dir is not None: + os.environ["TIDE_CACHE_DIR"] = str(config.subject.cache_dir) + log.debug(f"Fixed-pose cache dir set from config: {config.subject.cache_dir}") + + # Opt-in LRU size cap: enforce once in the parent before any pool spawns. + # No-op when unlimited (default) or the cache is disabled. + from tide.utils.artifacts import ( + enforce_cache_limit, + fixed_pose_cache_root, + resolve_cache_max_bytes, + ) + + max_bytes = ( + None if cache_disabled else resolve_cache_max_bytes(config.subject.cache_max_size_gb) + ) + if max_bytes is not None: + evicted, freed = enforce_cache_limit(fixed_pose_cache_root(), max_bytes) + if evicted: + log.info( + f"Fixed-pose cache: evicted {evicted} LRU entr" + f"{'y' if evicted == 1 else 'ies'}, freed {freed / 1024**3:.2f} GB" + ) + + log.highlight(f"=== TIDE Pipeline - {config.subject.id} ===") + + _prepare_anatomy(config) + + use_console_ui = not getattr(args, "no_console_ui", False) + + try: + if workflow == "grid": + from tide.workflows.grid_search import run_grid_search_workflow + + run_grid_search_workflow(config, console_ui=use_console_ui) + elif workflow == "estimation": + from tide.workflows.estimation import run_estimation_workflow + + run_estimation_workflow(config, console_ui=use_console_ui) + if stmpx_path is not None: + from tide.interfaces.stmpx import export_target_to_stmpx + + summary_path = ( + config.subject.derivatives_path + / f"TIDE_{config.target.label}" + / f"TIDE_Results_{config.target.label}.txt" + ) + stmpx_output = export_target_to_stmpx( + stmpx_path, + summary_path, + dataset_name=config.options.stmpx_dataset_name, + ) + log.highlight(f"STMPX export: {stmpx_output.resolve()}") + elif workflow == "simulation": + from tide.workflows.standard import run_standard_simulation + + run_standard_simulation(config) + elif workflow == "optimization": + from tide.workflows.standard import run_standard_optimization + + run_standard_optimization(config) + except Exception as exc: + print(f"Pipeline Error: {exc}", file=sys.stderr) + sys.exit(1) + + duration = time.time() - start_time + minutes = int(duration // 60) + seconds = duration % 60 + + log.highlight("=== PIPELINE COMPLETE ===") + log.highlight(f"Total time: {minutes}m {seconds:.1f}s") + + +def main() -> None: + """Console-script entry point registered in pyproject.toml.""" + argv = sys.argv[1:] + + if any(arg in ("-v", "--version") for arg in argv): + from tide.banner import render_version + + render_version() + sys.exit(0) + + if any(arg in ("-h", "--help") for arg in argv): + from tide.banner import render_logo + + render_logo() + parser = create_argument_parser() + parser.print_help() + sys.exit(0) + + if "--init-config" in argv: + parser = create_argument_parser() + args = parser.parse_args(argv) + try: + output_path = write_config_template(args.init_config) + except (FileExistsError, FileNotFoundError, OSError) as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + print(f"Wrote TIDE configuration template: {output_path}") + sys.exit(0) + + if "--bootstrap" in argv: + run_bootstrap(force="--force" in argv) + sys.exit(0) + + if "--cache-info" in argv or "--cache-clear" in argv: + run_cache_command(argv) + sys.exit(0) + + ensure_simnibs_environment() + + parser = create_argument_parser() + args = parser.parse_args() + + if args.config is None and args.workflow is None: + from tide.banner import render_logo + + render_logo() + create_argument_parser().print_help() + print( + "\nError: --config is required (with optional --workflow).", + file=sys.stderr, + ) + sys.exit(1) + + run_headless(args) + + +if __name__ == "__main__": + main() diff --git a/src/tide/console/__init__.py b/src/tide/console/__init__.py new file mode 100644 index 0000000..e39c33a --- /dev/null +++ b/src/tide/console/__init__.py @@ -0,0 +1,135 @@ +""" +TIDE Console UI Package +======================= + +Rich terminal console UI for the grid search workflow. + +This package provides a professional terminal-based interface for monitoring +parallel grid search execution, with real-time progress visualization, +worker status tracking, and optional focus mode for detailed worker output. + +Main Components: + ConsoleUI: Main orchestrator class for the console UI + StatusReporter: Helper class for workers to report status + create_console_ui: Factory function to create appropriate UI instance + +Example Usage: + from tide.console import ConsoleUI, create_console_ui + + # Using factory (recommended - handles non-interactive gracefully) + ui = create_console_ui( + subject_id='sub-001', + num_workers=4, + total_points=25, + enabled=True + ) + + with ui: + # Workers send status to ui.status_queue + # UI automatically renders progress + pass + + ui.render_final_summary(results, elapsed_time) + + # Or with ConsoleUI directly + ui = ConsoleUI(subject_id='sub-001', num_workers=4, total_points=25) + with ui: + # ... processing ... + pass + +Worker Usage: + from tide.console import StatusReporter, WorkerPhase + + def worker_function(task, status_queue, worker_id): + reporter = StatusReporter(status_queue, worker_id) + reporter.started(task.point_label) + + reporter.phase(WorkerPhase.OPTIMIZATION) + # ... do optimization ... + reporter.progress(100) + + reporter.phase(WorkerPhase.FEM_SIMULATION) + # ... do simulation ... + reporter.progress(100) + + reporter.completed({'weighted_mso': 45.2, 'unweighted_mso': 47.1}) +""" + +from .console_ui import ( + WORKFLOW_STEPS, + ConsoleUI, + NullConsoleUI, + UIState, + WorkerState, + create_console_ui, +) +from .estimation_reporter import process_pipeline_task_with_reporting +from .ipc import ( + PHASE_ORDER, + MessageType, + NullStatusReporter, + StatusReporter, + WorkerMessage, + WorkerPhase, + create_status_reporter, + phase_to_percent, +) +from .renderer import Renderer, TableColumn +from .styles import ( + BoxChars, + Colors, + ProgressChars, + StatusIcons, + Symbols, + phase_color, + status_icon, + supports_colors, + supports_unicode, +) +from .terminal import Terminal, get_terminal +from .worker_reporter import ( + WorkerLoggingHandler, + process_grid_point_with_reporting, + setup_worker_logging, +) + +__all__ = [ + # Main UI + "ConsoleUI", + "NullConsoleUI", + "create_console_ui", + "WorkerState", + "UIState", + "WORKFLOW_STEPS", + # IPC + "MessageType", + "WorkerMessage", + "WorkerPhase", + "StatusReporter", + "NullStatusReporter", + "create_status_reporter", + "PHASE_ORDER", + "phase_to_percent", + # Rendering + "Renderer", + "TableColumn", + # Terminal + "Terminal", + "get_terminal", + # Styles + "Colors", + "BoxChars", + "ProgressChars", + "StatusIcons", + "Symbols", + "supports_unicode", + "supports_colors", + "status_icon", + "phase_color", + # Worker + "process_grid_point_with_reporting", + "setup_worker_logging", + "WorkerLoggingHandler", + # Estimation + "process_pipeline_task_with_reporting", +] diff --git a/src/tide/console/console_ui.py b/src/tide/console/console_ui.py new file mode 100644 index 0000000..426e9fe --- /dev/null +++ b/src/tide/console/console_ui.py @@ -0,0 +1,1045 @@ +""" +Console UI Module +================= + +Main orchestrator for the rich terminal console UI. +Coordinates rendering, input handling, and worker status tracking. +""" + +import logging +import os +import signal +import sys +import threading +import time +from dataclasses import dataclass, field +from multiprocessing import Queue +from multiprocessing.context import BaseContext +from queue import Empty +from typing import Dict, List, Optional + +from .ipc import MessageType, WorkerMessage +from .renderer import Renderer, TableColumn +from .styles import Colors +from .terminal import Terminal + +# Workflow step names for display +WORKFLOW_STEPS = { + 1: "M1 Calibration", + 2: "CST Analysis", + 3: "Fixed Scalp Center", + 4: "Grid Orientation", + 5: "Grid Generation", + 6: "Grid Search Processing", + 7: "Results Writing", + 8: "Visualization", +} + + +@dataclass +class WorkerState: + """ + Track state of a single worker process. + + Attributes: + worker_id: Worker ID (0-indexed) + point_label: Grid point being processed + phase: Current processing phase + progress: Progress within phase (0-100) + start_time: When worker started current point + status: Overall status (idle, running, complete, failed) + """ + + worker_id: int + point_label: str = "" + phase: str = "Idle" + progress: int = 0 + start_time: float = 0.0 + status: str = "idle" + last_log: str = "" + log_buffer: List[str] = field(default_factory=list) + + +class WarningCaptureHandler(logging.Handler): + """ + Custom handler to capture WARNING logs and attach them to the current step. + """ + + def __init__(self, ui_instance): + super().__init__() + self.ui = ui_instance + + def emit(self, record): + if record.levelno >= logging.WARNING: + msg = self.format(record) + # Only capture if in sequential mode and valid step + if self.ui._state.mode == "sequential": + step = self.ui._state.current_step + if step not in self.ui._state.step_warnings: + self.ui._state.step_warnings[step] = [] + # Avoid duplicates + if msg not in self.ui._state.step_warnings[step]: + self.ui._state.step_warnings[step].append(msg) + + +@dataclass +class UIState: + """ + Complete UI state for rendering. + + Attributes: + subject_id: Subject identifier + workflow_name: Name of current workflow + current_step: Current step number + total_steps: Total number of steps + num_workers: Number of worker processes + total_points: Total grid points to process + completed_points: Number of completed points + workers: Dict of worker states by ID + completed_results: List of completed results + focus_mode: Whether focus mode is active + focused_worker: Which worker is focused (0-indexed) + start_time: When UI started + mode: UI mode ('sequential' or 'parallel') + sequential_step_name: Name of current sequential step + sequential_step_status: Status of sequential step + sequential_spinner_frame: Animation frame for spinner + completed_steps: List of completed step numbers + """ + + subject_id: str = "" + workflow_name: str = "Grid Search" + current_step: int = 1 + total_steps: int = 8 + num_workers: int = 0 + total_points: int = 0 + completed_points: int = 0 + workers: Dict[int, WorkerState] = field(default_factory=dict) + completed_results: List[dict] = field(default_factory=list) + focus_mode: bool = False + focused_worker: int = 0 + scroll_offset: int = 0 + start_time: float = field(default_factory=time.time) + mode: str = "sequential" + sequential_step_name: str = "" + sequential_step_status: str = "pending" + sequential_spinner_frame: int = 0 + sequential_step_detail: str = "" + completed_steps: List[int] = field(default_factory=list) + step_warnings: Dict[int, List[str]] = field(default_factory=dict) + + +class ConsoleUI: + """ + Rich terminal console UI for grid search workflow. + + Provides real-time visualization of worker status and progress + during parallel grid search execution. + + Features: + - Real-time progress visualization + - Worker status table with phases + - Completed results list + - Focus mode for individual worker output + - Graceful fallback for non-interactive terminals + + Usage: + ui = ConsoleUI( + subject_id='sub-001', + num_workers=4, + total_points=25 + ) + + with ui: + # Workers send status to ui.status_queue + # UI automatically renders progress + pass + + # After completion: + ui.render_final_summary(results, elapsed_time) + """ + + # Default table columns for worker display + WORKER_COLUMNS = [ + TableColumn("Worker", 10, "center", "worker"), + TableColumn("Grid Point", 12, "center", "grid_point"), + TableColumn("Current Phase", 18, "left", "current_phase"), + TableColumn("Last Activity", 30, "left", "last_log"), + TableColumn("Time", 10, "center", "time"), + ] + + def __init__( + self, + subject_id: str, + num_workers: int = 0, + total_points: int = 0, + current_step: int = 1, + total_steps: int = 7, + workflow_name: str = "Grid Search", + mode: str = "sequential", + step_names: Optional[Dict[int, str]] = None, + mp_context: Optional[BaseContext] = None, + ): + """ + Initialize console UI. + + Args: + subject_id: Subject identifier for header display + num_workers: Number of parallel workers (0 for sequential mode) + total_points: Total grid points to process + current_step: Current workflow step number + total_steps: Total workflow steps + workflow_name: Name of current workflow + mode: UI mode ('sequential' or 'parallel') + step_names: Optional dictionary of step names (step_num -> name) + mp_context: Optional multiprocessing context + """ + self._terminal = Terminal() + + # Use provided step names or default + self._step_names = step_names if step_names else WORKFLOW_STEPS + + self._state = UIState( + subject_id=subject_id, + workflow_name=workflow_name, + current_step=current_step, + total_steps=total_steps, + num_workers=num_workers, + total_points=total_points, + mode=mode, + sequential_step_name=self._step_names.get(current_step, f"Step {current_step}"), + ) + + # Initialize worker states for parallel mode + for i in range(num_workers): + self._state.workers[i] = WorkerState(worker_id=i) + + # IPC queue for status messages from workers + if mp_context is None: + self._mp_manager = None + self._status_queue = Queue() + else: + self._mp_manager = mp_context.Manager() + self._status_queue = self._mp_manager.Queue() + + # Rendering + cols, _ = self._terminal.get_size() + self._renderer = Renderer(terminal_width=cols) + + # Control flags + self._running = False + self._render_thread: Optional[threading.Thread] = None + self._shutdown_event = threading.Event() + + # Maximum log lines to cache per worker + self._max_log_lines = 100 + + # Signal handling + self._original_sigint = None + + @property + def status_queue(self) -> Queue: + """Get the status queue for workers to send updates.""" + return self._status_queue + + @property + def is_interactive(self) -> bool: + """Check if running in interactive terminal.""" + return self._terminal.is_interactive + + @property + def num_workers(self) -> int: + """Get number of workers.""" + return self._state.num_workers + + # ========================================================================= + # Lifecycle Methods + # ========================================================================= + + def start(self) -> None: + """Start the UI rendering loop.""" + if not self.is_interactive: + return # Fallback to simple logging + + self._running = True + self._state.start_time = time.time() + self._shutdown_event.clear() + + # Setup terminal for UI rendering (alternate buffer, hide cursor) + self._terminal.setup() + + # Setup resize handler (sets flag in Terminal) + self._terminal.setup_resize_handler(True) + + # Setup signal handler for graceful shutdown + self._original_sigint = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, self._handle_sigint) + + # Add warning capture handler + self._warning_handler = WarningCaptureHandler(self) + self._warning_handler.setLevel(logging.WARNING) + # Use a simple formatter for warnings in the UI + self._warning_handler.setFormatter(logging.Formatter("%(message)s")) + logging.getLogger().addHandler(self._warning_handler) + + # Render initial frame + self._render_frame() + + # Start render thread + self._render_thread = threading.Thread( + target=self._render_loop, daemon=True, name="ConsoleUI-Render" + ) + self._render_thread.start() + + def stop(self) -> None: + """Stop the UI and cleanup.""" + self._running = False + self._shutdown_event.set() + + if self._render_thread and self._render_thread.is_alive(): + self._render_thread.join(timeout=1.0) + + # Cleanup terminal (show cursor, exit alternate buffer) + if self.is_interactive: + self._terminal.cleanup() + + # Restore signal handler + if self._original_sigint: + signal.signal(signal.SIGINT, self._original_sigint) + + # Remove warning handler + if hasattr(self, "_warning_handler"): + logging.getLogger().removeHandler(self._warning_handler) + + if self._mp_manager is not None: + self._mp_manager.shutdown() + self._mp_manager = None + + def __enter__(self): + """Context manager entry.""" + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.stop() + return False + + def _handle_sigint(self, signum, frame): + """Handle Ctrl+C gracefully.""" + self._running = False + self._shutdown_event.set() + # Re-raise to allow proper cleanup + if self._original_sigint: + self._original_sigint(signum, frame) + + # ========================================================================= + # Step Management Methods + # ========================================================================= + + def update_step(self, step: int, status: str = "running") -> None: + """ + Update the current sequential step. + + Call this method to update the UI when starting or completing + a sequential workflow step (Steps 1-5). + + Args: + step: Step number (1-7) + status: Step status ('pending', 'running', or 'complete') + """ + if status == "complete" and step not in self._state.completed_steps: + self._state.completed_steps.append(step) + + # Force sequential mode when updating step + self._state.mode = "sequential" + + self._state.current_step = step + self._state.sequential_step_name = self._step_names.get(step, f"Step {step}") + self._state.sequential_step_status = status + + if status == "running": + self._state.start_time = time.time() + # Clear detail when starting a new step + self._state.sequential_step_detail = "" + + def update_step_detail(self, detail: str) -> None: + """ + Update the sub-step detail text shown under the current step. + + Call this method to provide more context about what's happening + during a sequential step (e.g., "Running M1 optimization...", + "Loading tractogram...", etc.) + + Args: + detail: Sub-step detail text to display + """ + self._state.sequential_step_detail = detail + + def transition_to_parallel( + self, + num_workers: int, + total_points: int, + step_num: int = 6, + ) -> None: + """ + Transition from sequential mode to parallel mode for Step 6. + + Call this method before starting parallel grid search processing + to switch the UI from sequential step display to worker table view. + + Args: + num_workers: Number of parallel workers + total_points: Total grid points to process + step_num: Step number to associate with parallel processing (default: 6) + """ + self._state.mode = "parallel" + self._state.num_workers = num_workers + self._state.total_points = total_points + self._state.current_step = step_num + self._state.completed_points = 0 + self._state.start_time = time.time() + self._state.sequential_step_name = self._step_names.get(step_num, "Processing") + + # Initialize worker states + self._state.workers.clear() + for i in range(num_workers): + self._state.workers[i] = WorkerState(worker_id=i) + + # Clear step detail when transitioning + self._state.sequential_step_detail = "" + + # Note: Do NOT call clear_and_home() here - it causes visual + # "flicker" that makes it appear as two separate UIs. + # The render loop will naturally update to show the new mode. + + # ========================================================================= + # Render Loop + # ========================================================================= + + def _render_loop(self) -> None: + """Main rendering loop (runs in separate thread).""" + last_render = 0 + render_interval = 0.1 # 10 FPS + spinner_interval = 0.08 # Spinner updates at ~12 FPS + last_spinner = 0 + + while self._running and not self._shutdown_event.is_set(): + # Process incoming messages (parallel mode only) + if self._state.mode == "parallel": + self._process_messages() + + # Check for resize event (thread-safe) + resize_info = self._terminal.check_resize() + if resize_info: + self._on_resize(*resize_info) + + # Check for keypress + key = self._terminal.get_keypress(timeout=0.05) + if key: + self._handle_keypress(key) + + # Update spinner for sequential mode + now = time.time() + if self._state.mode == "sequential" and now - last_spinner >= spinner_interval: + self._state.sequential_spinner_frame += 1 + last_spinner = now + + # Render at fixed interval + if now - last_render >= render_interval: + self._render_frame() + last_render = now + + def _process_messages(self) -> None: + """Process all pending messages from workers.""" + while True: + try: + msg_dict = self._status_queue.get_nowait() + msg = WorkerMessage.from_dict(msg_dict) + self._handle_message(msg) + except Empty: + break + except Exception: + break + + def _handle_message(self, msg: WorkerMessage) -> None: + """Handle a single worker message.""" + worker = self._state.workers.get(msg.worker_id) + if not worker: + return + + if msg.msg_type == MessageType.WORKER_STARTED: + worker.point_label = msg.point_label + worker.phase = "Starting..." + worker.start_time = msg.timestamp + worker.status = "running" + worker.progress = 0 + + elif msg.msg_type == MessageType.PHASE_CHANGED: + worker.phase = msg.phase + worker.progress = msg.progress + + elif msg.msg_type == MessageType.PROGRESS_UPDATE: + worker.progress = msg.progress + + elif msg.msg_type == MessageType.WORKER_COMPLETED: + self._state.completed_points += 1 + + if msg.data: + self._state.completed_results.append( + { + "label": msg.point_label, + "weighted_mso": msg.data.get("weighted_mso", 0), + "unweighted_mso": msg.data.get("unweighted_mso", 0), + "success": True, + } + ) + + # Reset worker for next task + worker.point_label = "" + worker.phase = "Idle" + worker.status = "idle" + worker.progress = 0 + worker.start_time = 0 + worker.last_log = "Completed" + + elif msg.msg_type == MessageType.WORKER_FAILED: + self._state.completed_points += 1 + + error_msg = "Failed" + if msg.data: + error_msg = msg.data.get("error", "Failed") + + self._state.completed_results.append( + { + "label": msg.point_label, + "weighted_mso": 999.9, + "unweighted_mso": 999.9, + "success": False, + "error": error_msg, + } + ) + + # Reset worker + worker.point_label = "" + worker.phase = "Idle" + worker.status = "idle" + worker.progress = 0 + worker.start_time = 0 + worker.last_log = f"Failed: {error_msg}" + + elif msg.msg_type == MessageType.LOG_MESSAGE: + if msg.data: + level = msg.data.get("level", "INFO") + message = msg.data.get("message", "") + log_entry = f"[{level}] {message}" + + # Update worker state + worker.last_log = message + worker.log_buffer.append(log_entry) + + # Limit buffer size per worker + if len(worker.log_buffer) > self._max_log_lines: + worker.log_buffer.pop(0) + + def _handle_keypress(self, key: str) -> None: + """Handle keyboard input.""" + if key.lower() == "f": + # Toggle focus mode + self._state.focus_mode = not self._state.focus_mode + self._terminal.clear_and_home() + + elif key.lower() == "q": + # Exit focus mode or quit entire pipeline + if self._state.focus_mode: + self._state.focus_mode = False + self._state.scroll_offset = 0 + self._terminal.clear_and_home() + else: + # Signal main process to shut down + os.kill(os.getpid(), signal.SIGINT) + + elif key in "1234567890": + # Select worker to focus (1-indexed) + num = int(key) + worker_id = 9 if num == 0 else num - 1 + if worker_id < self._state.num_workers: + if self._state.focus_mode: + self._state.focused_worker = worker_id + self._state.scroll_offset = 0 + self._terminal.clear_and_home() + else: + self._state.focus_mode = True + self._state.focused_worker = worker_id + self._state.scroll_offset = 0 + self._terminal.clear_and_home() + + # Scrolling keys + elif key == "\x1b[A": # Up + self._state.scroll_offset = max(0, self._state.scroll_offset - 1) + elif key == "\x1b[B": # Down + self._state.scroll_offset += 1 + elif key == "\x1b[5~": # Page Up + _, rows = self._terminal.get_size() + self._state.scroll_offset = max(0, self._state.scroll_offset - (rows // 2)) + elif key == "\x1b[6~": # Page Down + _, rows = self._terminal.get_size() + self._state.scroll_offset += rows // 2 + elif key.lower() == "h": # Home + self._state.scroll_offset = 0 + + # ========================================================================= + # Rendering Methods + # ========================================================================= + + def _render_frame(self) -> None: + """Render a single frame.""" + if self._state.focus_mode: + self._render_focus_mode() + else: + self._render_overview_mode() + + def _render_overview_mode(self) -> None: + """Render the overview mode (sequential or parallel).""" + lines = [] + + # Header box (same for both modes) + lines.append( + self._renderer.render_header_box( + f"TIDE {self._state.workflow_name} Pipeline", f"Subject: {self._state.subject_id}" + ) + ) + lines.append("") + + if self._state.mode == "sequential": + # Sequential mode: show step with spinner + elapsed = time.time() - self._state.start_time + current_warnings = self._state.step_warnings.get(self._state.current_step, []) + + lines.append( + self._renderer.render_sequential_step( + self._state.current_step, + self._state.total_steps, + self._state.sequential_step_name, + self._state.sequential_step_status, + elapsed, + self._state.sequential_spinner_frame, + detail=self._state.sequential_step_detail, + warnings=current_warnings, + ) + ) + lines.append("") + + # Show completed steps summary + if self._state.completed_steps: + lines.append( + self._renderer.render_completed_steps( + self._state.completed_steps, self._step_names, self._state.step_warnings + ) + ) + lines.append("") + + # Status line for sequential mode + lines.append(self._renderer.render_status_line(elapsed, "q to quit")) + else: + # Parallel mode: existing worker table view + lines.append( + self._renderer.render_step_indicator( + self._state.current_step, + self._state.total_steps, + self._state.sequential_step_name, + self._state.num_workers, + ) + ) + lines.append("") + + # Show completed steps summary to keep context + if self._state.completed_steps: + lines.append( + self._renderer.render_completed_steps( + self._state.completed_steps, self._step_names, self._state.step_warnings + ) + ) + lines.append("") + + # Progress bar + lines.append( + self._renderer.render_progress_bar( + self._state.completed_points, + self._state.total_points, + width=50, + label="Progress", + ) + ) + lines.append("") + + # Worker table + worker_data = self._build_worker_table_data() + lines.append(self._renderer.render_worker_table(worker_data, self.WORKER_COLUMNS)) + lines.append("") + + # Completed results + if self._state.completed_results: + lines.append( + self._renderer.render_completed_list( + self._state.completed_results, max_items=None + ) + ) + lines.append("") + + # Status line for parallel mode + elapsed = time.time() - self._state.start_time + lines.append( + self._renderer.render_status_line( + elapsed, "Arrows to scroll | 1-9 to focus | q to quit" + ) + ) + + # Render to terminal + self._render_lines(lines) + + def _render_focus_mode(self) -> None: + """Render focus mode showing single worker output.""" + worker = self._state.workers.get(self._state.focused_worker) + lines = [] + + # Focus header + lines.append( + self._renderer.render_focus_header( + self._state.focused_worker, + worker.point_label if worker else "", + self._state.num_workers, + ) + ) + lines.append("") + + # Log output + _, term_rows = self._terminal.get_size() + max_log_lines = term_rows - 8 # Reserve space for header/footer + + logs = worker.log_buffer if worker else [] + if logs: + displayed = logs[-max_log_lines:] + for line in displayed: + # Truncate long lines + cols, _ = self._terminal.get_size() + if len(line) > cols - 2: + line = line[: cols - 5] + "..." + lines.append(line) + else: + lines.append(f"{Colors.GRAY}(Waiting for log output...){Colors.RESET}") + + # Render to terminal + self._render_lines(lines) + + def _build_worker_table_data(self) -> List[dict]: + """Build worker data for table rendering.""" + worker_data = [] + + for worker_id in sorted(self._state.workers.keys()): + worker = self._state.workers[worker_id] + + # Calculate elapsed time + elapsed_str = "" + if worker.start_time > 0 and worker.status == "running": + elapsed_sec = time.time() - worker.start_time + minutes = int(elapsed_sec // 60) + seconds = int(elapsed_sec % 60) + elapsed_str = f"{minutes:02d}:{seconds:02d}" + + worker_data.append( + { + "worker": f"Worker {worker_id + 1}", + "grid_point": worker.point_label or "-", + "current_phase": worker.phase, + "last_log": worker.last_log or "-", + "time": elapsed_str, + "status": worker.status, + } + ) + + return worker_data + + def _render_lines(self, lines: List[str]) -> None: + """Render lines to terminal with proper cursor positioning, scrolling, and clipping.""" + _, rows = self._terminal.get_size() + + # Ensure scroll offset is valid + num_lines = len(lines) + if num_lines <= rows: + self._state.scroll_offset = 0 + else: + self._state.scroll_offset = min(self._state.scroll_offset, num_lines - rows) + + # Slice lines based on scroll offset + start = self._state.scroll_offset + end = start + rows + displayed_lines = lines[start:end] + + # Move to top-left and clear to end of screen + output = self._terminal.move_cursor(1, 1) + output += self._terminal.clear_to_end_of_screen() + + # Add content - be careful not to trigger a scroll on the last line + output += "\n".join(displayed_lines).rstrip("\n") + + # Write to terminal + self._terminal.write(output) + + def _on_resize(self, cols: int, rows: int) -> None: + """Handle terminal resize.""" + self._renderer.set_width(cols) + if self._running: + self._terminal.clear_and_home() + self._render_frame() + + # ========================================================================= + # Summary Rendering + # ========================================================================= + + def render_final_summary( + self, + results: List[dict], + elapsed_time: float, + output_files: Optional[List[tuple]] = None, + show_table: bool = True, + stats_summary: Optional[Dict[str, Dict[str, float]]] = None, + ) -> None: + """ + Render final summary after completion. + + Args: + results: List of result dicts with 'label', 'weighted_mso', etc. + elapsed_time: Total elapsed time in seconds + output_files: Optional list of (label, path) tuples for output files + show_table: Whether to show the detailed results table + stats_summary: Optional dictionary containing 'weighted' and 'unweighted' stats + """ + # Ensure UI is stopped + self.stop() + + # Calculate summary stats + successes = sum(1 for r in results if r.get("success", True)) + failures = len(results) - successes + + # Format elapsed time + minutes = int(elapsed_time // 60) + seconds = elapsed_time % 60 + time_str = f"{minutes}m {seconds:.1f}s" + + # Build summary + print() # Blank line after UI + + summary_rows = [ + ("Total time", time_str), + ("Grid points", f"{successes}/{len(results)} successful"), + ] + + if failures > 0: + summary_rows.append(("Failed", str(failures))) + + print(self._renderer.render_summary_box("PIPELINE COMPLETE", summary_rows)) + + # Render statistical summary if provided + if stats_summary: + print() + stats_cols = [ + TableColumn("Metric", 25, "left", "metric"), + TableColumn("Unweighted I", 20, "right", "unweighted"), + TableColumn("Weighted I", 20, "right", "weighted"), + ] + + w_stats = stats_summary.get("weighted", {}) + u_stats = stats_summary.get("unweighted", {}) + + # Prepare rows + stats_data = [ + { + "metric": "Mean", + "unweighted": f"{u_stats.get('mean', 0):.2f}", + "weighted": f"{w_stats.get('mean', 0):.2f}", + "status": "idle", # for default white color + }, + { + "metric": "Median", + "unweighted": f"{u_stats.get('median', 0):.2f}", + "weighted": f"{w_stats.get('median', 0):.2f}", + "status": "idle", + }, + { + "metric": "Std Dev", + "unweighted": f"{u_stats.get('std', 0):.2f}", + "weighted": f"{w_stats.get('std', 0):.2f}", + "status": "idle", + }, + { + "metric": "Mean (w/o outliers)*", + "unweighted": f"{u_stats.get('mean_no_outliers', 0):.2f}", + "weighted": f"{w_stats.get('mean_no_outliers', 0):.2f}", + "status": "idle", + }, + { + "metric": "Outliers (>2 SD)", + "unweighted": f"{u_stats.get('outlier_count', 0)}", + "weighted": f"{w_stats.get('outlier_count', 0)}", + "status": "idle", + }, + ] + + # Optional multiplier rows. Multiplier (M_CST/M_target) lets the + # reader rescale intensity to a different RMT without rerunning, so it + # belongs in the same statistical block as the intensity summary. + mult_w = stats_summary.get("multiplier_weighted") + mult_u = stats_summary.get("multiplier_unweighted") + if mult_w is not None or mult_u is not None: + mult_w = mult_w or {} + mult_u = mult_u or {} + stats_data.extend( + [ + { + "metric": "Mean Multiplier", + "unweighted": f"{mult_u.get('mean', 0):.4f}", + "weighted": f"{mult_w.get('mean', 0):.4f}", + "status": "idle", + }, + { + "metric": "Median Multiplier", + "unweighted": f"{mult_u.get('median', 0):.4f}", + "weighted": f"{mult_w.get('median', 0):.4f}", + "status": "idle", + }, + ] + ) + + print(self._renderer.render_section_header("Statistical Summary")) + print(self._renderer.render_worker_table(stats_data, stats_cols)) + print( + f" {Colors.GRAY}* Mean excluding values outside [mean \u00b1 2 * std]{Colors.RESET}" + ) + if mult_w is not None or mult_u is not None: + print( + f" {Colors.GRAY}Multiplier = M_CST / M_target " + f"(I_raw = RMT \u00d7 multiplier){Colors.RESET}" + ) + + # Output files + if output_files: + print() + print(self._renderer.render_output_files_list(output_files)) + + # Render results list if requested + if show_table and results: + print() + # Sort results by label if possible + sorted_results = sorted(results, key=lambda x: x.get("label", "")) + print(self._renderer.render_completed_list(sorted_results, max_items=None)) + + print() + + +class NullConsoleUI: + """ + No-op console UI for non-interactive mode. + + Provides the same interface as ConsoleUI but does nothing, + allowing the same code to work with or without the UI. + """ + + def __init__( + self, + *args, + mp_context: Optional[BaseContext] = None, + **kwargs, + ): + """Initialize null UI (ignores all arguments).""" + if mp_context is None: + self._mp_manager = None + self._status_queue = Queue() + else: + self._mp_manager = mp_context.Manager() + self._status_queue = self._mp_manager.Queue() + + @property + def status_queue(self) -> Queue: + """Get status queue (messages are discarded).""" + return self._status_queue + + @property + def is_interactive(self) -> bool: + """Always returns False.""" + return False + + @property + def num_workers(self) -> int: + """Returns 0.""" + return 0 + + def start(self) -> None: + """No-op.""" + pass + + def stop(self) -> None: + """No-op.""" + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def render_final_summary(self, *args, **kwargs) -> None: + """No-op.""" + pass + + def update_step(self, step: int, status: str = "running") -> None: + """No-op.""" + pass + + def update_step_detail(self, detail: str) -> None: + """No-op.""" + pass + + def transition_to_parallel(self, num_workers: int, total_points: int) -> None: + """No-op.""" + pass + + +def create_console_ui( + subject_id: str, + num_workers: int = 0, + total_points: int = 0, + enabled: bool = True, + mode: str = "sequential", + mp_context: Optional[BaseContext] = None, + step_names: Optional[Dict[int, str]] = None, + **kwargs, +) -> ConsoleUI: + """ + Factory function to create appropriate console UI. + + Args: + subject_id: Subject identifier + num_workers: Number of workers (0 for sequential mode) + total_points: Total grid points + enabled: Whether UI should be enabled + mode: UI mode ('sequential' or 'parallel') + mp_context: Optional multiprocessing context + step_names: Optional custom step names + **kwargs: Additional arguments for ConsoleUI + + Returns: + ConsoleUI or NullConsoleUI instance + """ + if not enabled or not sys.stdout.isatty(): + return NullConsoleUI(mp_context=mp_context) + + return ConsoleUI( + subject_id=subject_id, + num_workers=num_workers, + total_points=total_points, + mode=mode, + mp_context=mp_context, + step_names=step_names, + **kwargs, + ) diff --git a/src/tide/console/estimation_reporter.py b/src/tide/console/estimation_reporter.py new file mode 100644 index 0000000..59153b9 --- /dev/null +++ b/src/tide/console/estimation_reporter.py @@ -0,0 +1,149 @@ +""" +Estimation Workflow Reporter +=========================== + +Provides status reporting for the parallel estimation workflow. +Wraps the pipeline task processing function to add status updates and logging. +""" + +import logging +import multiprocessing as mp +import os +from pathlib import Path +from typing import Optional + +# Import the base pipeline task types +from tide.workflows.estimation import PipelineResult, PipelineTask, _run_pipeline_task + +from .ipc import WorkerPhase, create_status_reporter +from .worker_reporter import setup_worker_logging + + +def process_pipeline_task_with_reporting( + task: PipelineTask, + status_queue: Optional[mp.Queue], + worker_id: int, + log_dir: Optional[Path] = None, +) -> PipelineResult: + """ + Process a pipeline task with status reporting to the console UI. + + Wraps _run_pipeline_task with: + 1. Status updates via queue + 2. Log redirection + 3. Phase tracking + + Args: + task: PipelineTask (m1 or target) + status_queue: Queue for sending status updates + worker_id: Worker ID + log_dir: Directory for worker logs + + Returns: + PipelineResult + """ + # Create status reporter + reporter = create_status_reporter(status_queue, worker_id) + reporter.started(task.label) + + # Redirect stdout/stderr to capture underlying tool output + saved_stdout_fd = None + saved_stderr_fd = None + + try: + # 1. Setup IO Redirection + if log_dir: + log_dir.mkdir(parents=True, exist_ok=True) + stdout_path = log_dir / f"worker_{worker_id}_{task.label}_stdout.log" + stderr_path = log_dir / f"worker_{worker_id}_{task.label}_stderr.log" + stdout_fd = os.open(str(stdout_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644) + stderr_fd = os.open(str(stderr_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644) + else: + stdout_fd = os.open(os.devnull, os.O_WRONLY) + stderr_fd = os.open(os.devnull, os.O_WRONLY) + + saved_stdout_fd = os.dup(1) + saved_stderr_fd = os.dup(2) + + os.dup2(stdout_fd, 1) + os.dup2(stderr_fd, 2) + + os.close(stdout_fd) + os.close(stderr_fd) + + # 2. Setup Logging Handler + log_handler = None + if log_dir: + log_handler = setup_worker_logging(reporter, log_dir, task.label, worker_id) + + # 3. Define phase callback to update reporter from log interception + # Note: Since _run_pipeline_task is monolithic, we'll infer phase from logs + # or just update generic progress. + # Ideally, we would inject a reporter into _run_pipeline_task, but to minimize + # changes to the core logic, we'll rely on the fact that _run_pipeline_task + # logs informative messages. + + # We can also manually update phases before/after major blocks if we + # were to decompose _run_pipeline_task, but here we wrap the whole thing. + # To make it livelier, we'll set initial phase. + + if task.needs_optimization: + reporter.phase(WorkerPhase.OPTIMIZATION, 0) + else: + reporter.phase(WorkerPhase.FEM_SIMULATION, 0) + + # 4. Run the Task + result = _run_pipeline_task(task) + + # 5. Report completion + if result.success: + # Calculate what we can for the UI summary + # For intermediate tasks, we might not have final intensity yet + # But we can report success + reporter.completed({}) + else: + reporter.failed(result.error_message or "Unknown error") + + return result + + except Exception as e: + reporter.failed(str(e)) + # Return generic failure result matching the expected type + return PipelineResult( + task_type=task.task_type, + label=task.label, + success=False, + opt_matrix=None, + opt_scalp_coords=None, + mesh_path=None, + trk_path=None, + streamlines=None, + af_values=None, + len_values=None, + e_vecs_list=None, + roi_masks=None, + roi_segments=None, + error_message=str(e), + ) + + finally: + # Cleanup logging + if log_handler: + root = logging.getLogger() + root.removeHandler(log_handler) + log_handler.close() + + # Restore IO + if saved_stdout_fd is not None: + try: + os.dup2(saved_stdout_fd, 1) + os.close(saved_stdout_fd) + except Exception: + pass + + if saved_stderr_fd is not None: + try: + os.dup2(saved_stderr_fd, 2) + os.close(saved_stderr_fd) + except Exception: + pass diff --git a/src/tide/console/ipc.py b/src/tide/console/ipc.py new file mode 100644 index 0000000..a5abf29 --- /dev/null +++ b/src/tide/console/ipc.py @@ -0,0 +1,352 @@ +""" +Inter-Process Communication Module +================================== + +Message protocol and queue management for worker status reporting. +Used to communicate between worker processes and the main UI process. +""" + +import time +from dataclasses import dataclass, field +from enum import Enum, auto +from multiprocessing import Queue +from typing import Any, Dict, List, Optional + + +class MessageType(Enum): + """Types of IPC messages from workers to main process.""" + + WORKER_STARTED = auto() # Worker began processing a grid point + PHASE_CHANGED = auto() # Worker entered a new processing phase + PROGRESS_UPDATE = auto() # Incremental progress within current phase + WORKER_COMPLETED = auto() # Worker finished successfully + WORKER_FAILED = auto() # Worker finished with error + LOG_MESSAGE = auto() # Log message from worker (for focus mode) + HEARTBEAT = auto() # Periodic heartbeat for liveness detection + + +class WorkerPhase(Enum): + """ + Processing phases for grid point workflow. + + These correspond to the major steps in process_grid_point(). + """ + + STARTING = "Starting" + OPTIMIZATION = "Optimization" + FEM_SIMULATION = "FEM Simulation" + EFIELD_SAMPLING = "E-field Sampling" + ACTIVATING_FUNCTION = "Activating Function" + BUNDLE_ANALYSIS = "Bundle Analysis" + SAVING_RESULTS = "Saving Results" + COMPLETE = "Complete" + FAILED = "Failed" + IDLE = "Idle" + + +# Phase order for progress calculation +PHASE_ORDER: List[WorkerPhase] = [ + WorkerPhase.STARTING, + WorkerPhase.OPTIMIZATION, + WorkerPhase.FEM_SIMULATION, + WorkerPhase.EFIELD_SAMPLING, + WorkerPhase.ACTIVATING_FUNCTION, + WorkerPhase.BUNDLE_ANALYSIS, + WorkerPhase.SAVING_RESULTS, + WorkerPhase.COMPLETE, +] + + +def phase_to_percent(phase: WorkerPhase) -> int: + """ + Convert phase to approximate progress percentage. + + Args: + phase: Current worker phase + + Returns: + Progress percentage (0-100) + """ + try: + idx = PHASE_ORDER.index(phase) + return int((idx / (len(PHASE_ORDER) - 1)) * 100) + except ValueError: + return 0 + + +@dataclass +class WorkerMessage: + """ + Message from worker to main process. + + This is the primary communication unit between workers and the + console UI. Messages are serialized to dict for queue transport. + + Attributes: + msg_type: Type of message (from MessageType enum) + worker_id: Which worker sent this (0-indexed) + timestamp: When message was created (Unix timestamp) + point_label: Grid point being processed (e.g., 'grid_P00') + phase: Current processing phase name + progress: Progress within phase (0-100) + data: Additional payload (results, errors, log messages) + """ + + msg_type: MessageType + worker_id: int + timestamp: float = field(default_factory=time.time) + point_label: str = "" + phase: str = "" + progress: int = 0 + data: Optional[Dict[str, Any]] = None + + def to_dict(self) -> Dict[str, Any]: + """ + Serialize message for queue transport. + + Returns: + Dictionary representation of message + """ + return { + "msg_type": self.msg_type.value, + "worker_id": self.worker_id, + "timestamp": self.timestamp, + "point_label": self.point_label, + "phase": self.phase, + "progress": self.progress, + "data": self.data, + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "WorkerMessage": + """ + Deserialize message from queue. + + Args: + d: Dictionary from queue + + Returns: + WorkerMessage instance + """ + return cls( + msg_type=MessageType(d["msg_type"]), + worker_id=d["worker_id"], + timestamp=d["timestamp"], + point_label=d["point_label"], + phase=d["phase"], + progress=d["progress"], + data=d.get("data"), + ) + + +class StatusReporter: + """ + Helper class for workers to report status to the console UI. + + This provides a simple interface for workers to send status updates + without needing to construct WorkerMessage objects directly. + + Usage in worker: + reporter = StatusReporter(queue, worker_id=0) + reporter.started('grid_P00') + reporter.phase(WorkerPhase.OPTIMIZATION) + reporter.progress(50) + reporter.completed({'weighted_mso': 45.2, 'unweighted_mso': 47.1}) + + Attributes: + queue: Multiprocessing queue to send messages + worker_id: This worker's ID (0-indexed) + """ + + def __init__(self, queue: Queue, worker_id: int): + """ + Initialize status reporter. + + Args: + queue: Multiprocessing queue for sending messages + worker_id: This worker's ID (0-indexed) + """ + self._queue = queue + self._worker_id = worker_id + self._current_point = "" + self._current_phase = WorkerPhase.IDLE + + @property + def worker_id(self) -> int: + """Get this worker's ID.""" + return self._worker_id + + @property + def current_point(self) -> str: + """Get current grid point being processed.""" + return self._current_point + + @property + def current_phase(self) -> WorkerPhase: + """Get current processing phase.""" + return self._current_phase + + def started(self, point_label: str) -> None: + """ + Report that worker started processing a grid point. + + Args: + point_label: Grid point label (e.g., 'grid_P00') + """ + self._current_point = point_label + self._current_phase = WorkerPhase.STARTING + self._send( + MessageType.WORKER_STARTED, + point_label=point_label, + phase=WorkerPhase.STARTING.value, + ) + + def phase(self, phase: WorkerPhase, progress: int = 0) -> None: + """ + Report entering a new processing phase. + + Args: + phase: The new phase + progress: Progress within phase (0-100), default 0 + """ + self._current_phase = phase + self._send( + MessageType.PHASE_CHANGED, + phase=phase.value, + progress=progress, + ) + + def progress(self, percent: int) -> None: + """ + Report progress within current phase. + + Args: + percent: Progress percentage (0-100) + """ + self._send( + MessageType.PROGRESS_UPDATE, + progress=min(100, max(0, percent)), + ) + + def completed(self, result: Dict[str, Any]) -> None: + """ + Report successful completion of grid point processing. + + Args: + result: Result data dictionary containing at least: + - weighted_mso: float + - unweighted_mso: float + - success: bool (should be True) + """ + self._current_phase = WorkerPhase.COMPLETE + self._send( + MessageType.WORKER_COMPLETED, + phase=WorkerPhase.COMPLETE.value, + progress=100, + data=result, + ) + + def failed(self, error: str) -> None: + """ + Report failure during processing. + + Args: + error: Error message or description + """ + self._current_phase = WorkerPhase.FAILED + self._send( + MessageType.WORKER_FAILED, + phase=WorkerPhase.FAILED.value, + data={"error": error}, + ) + + def log(self, level: str, message: str) -> None: + """ + Send a log message (captured for focus mode display). + + Args: + level: Log level (DEBUG, INFO, WARNING, ERROR) + message: Log message text + """ + self._send( + MessageType.LOG_MESSAGE, + data={"level": level, "message": message}, + ) + + def heartbeat(self) -> None: + """ + Send heartbeat for liveness detection. + + Call periodically during long operations to indicate + the worker is still alive and working. + """ + self._send(MessageType.HEARTBEAT) + + def reset(self) -> None: + """ + Reset reporter state for processing a new grid point. + + Called automatically after completed() or failed(), but + can be called manually if needed. + """ + self._current_point = "" + self._current_phase = WorkerPhase.IDLE + + def _send(self, msg_type: MessageType, **kwargs) -> None: + """ + Send a message to the queue. + + Args: + msg_type: Message type + **kwargs: Additional message fields + """ + msg = WorkerMessage( + msg_type=msg_type, + worker_id=self._worker_id, + point_label=kwargs.get("point_label", self._current_point), + phase=kwargs.get("phase", self._current_phase.value), + progress=kwargs.get("progress", 0), + data=kwargs.get("data"), + ) + + try: + # Use put_nowait to avoid blocking if queue is full + self._queue.put_nowait(msg.to_dict()) + except Exception: + # Non-critical: don't crash worker if queue is full or broken + pass + + +class NullStatusReporter(StatusReporter): + """ + No-op status reporter for when console UI is disabled. + + All methods are no-ops, allowing the same worker code to run + with or without the console UI. + """ + + def __init__(self): + """Initialize null reporter (no queue needed).""" + self._worker_id = 0 + self._current_point = "" + self._current_phase = WorkerPhase.IDLE + + def _send(self, msg_type: MessageType, **kwargs) -> None: + """No-op send.""" + pass + + +def create_status_reporter(queue: Optional[Queue], worker_id: int) -> StatusReporter: + """ + Factory function to create appropriate status reporter. + + Args: + queue: Status queue (or None for null reporter) + worker_id: Worker ID + + Returns: + StatusReporter or NullStatusReporter instance + """ + if queue is None: + return NullStatusReporter() + return StatusReporter(queue, worker_id) diff --git a/src/tide/console/renderer.py b/src/tide/console/renderer.py new file mode 100644 index 0000000..7b1e6bd --- /dev/null +++ b/src/tide/console/renderer.py @@ -0,0 +1,720 @@ +""" +UI Renderer Module +================== + +High-level rendering primitives for the console UI. +Renders boxes, tables, progress bars, and other visual components. +""" + +import re +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from .styles import BoxChars, Colors, ProgressChars, StatusIcons, phase_color + + +@dataclass +class TableColumn: + """ + Table column definition. + + Attributes: + header: Column header text + width: Column width in characters + align: Text alignment ('left', 'center', 'right') + key: Key to use when extracting data from row dicts + """ + + header: str + width: int + align: str = "left" + key: Optional[str] = None + + def __post_init__(self): + if self.key is None: + # Convert header to snake_case key + self.key = self.header.lower().replace(" ", "_") + + +class Renderer: + """ + High-level UI rendering engine. + + Renders visual components for the console UI: + - Header boxes with subject ID + - Step progress indicators + - Progress bars with percentage + - Worker status tables + - Completed results lists + - Summary sections + """ + + def __init__(self, terminal_width: int = 80): + """ + Initialize renderer. + + Args: + terminal_width: Available terminal width for rendering + """ + self.width = min(terminal_width, 100) # Cap at reasonable width + self._min_width = 60 # Minimum width for proper rendering + + def set_width(self, width: int) -> None: + """Update terminal width for rendering.""" + self.width = max(self._min_width, min(width, 100)) + + # ========================================================================= + # Header Components + # ========================================================================= + + def render_header_box( + self, title: str, subtitle: str = "", border_color: str = Colors.CYAN + ) -> str: + """ + Render a boxed header. + + Example output: + ╭──────────────────────────────────────────────────────╮ + │ TIDE Grid Search Pipeline - sub-01 │ + ╰──────────────────────────────────────────────────────╯ + + Args: + title: Main title text + subtitle: Optional subtitle below title + border_color: ANSI color for border + + Returns: + Rendered header string with newlines + """ + lines = [] + inner_width = self.width - 2 # Account for side borders + + # Top border + top = f"{border_color}{BoxChars.TOP_LEFT}{BoxChars.HORIZONTAL * inner_width}{BoxChars.TOP_RIGHT}{Colors.RESET}" + lines.append(top) + + # Title line (centered) + title_text = title[: inner_width - 2] # Truncate if needed + title_padded = title_text.center(inner_width) + title_line = f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}{Colors.BOLD}{Colors.WHITE}{title_padded}{Colors.RESET}{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + lines.append(title_line) + + # Subtitle line (if provided) + if subtitle: + sub_text = subtitle[: inner_width - 2] + sub_padded = sub_text.center(inner_width) + sub_line = f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}{Colors.GRAY}{sub_padded}{Colors.RESET}{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + lines.append(sub_line) + + # Bottom border + bottom = f"{border_color}{BoxChars.BOTTOM_LEFT}{BoxChars.HORIZONTAL * inner_width}{BoxChars.BOTTOM_RIGHT}{Colors.RESET}" + lines.append(bottom) + + return "\n".join(lines) + + def render_section_header(self, text: str, color: str = Colors.CYAN) -> str: + """ + Render a section header with decorative lines. + + Example: ── Step 6/7: Grid Search ────────────────── + + Args: + text: Header text + color: ANSI color for decorative elements + + Returns: + Rendered section header string + """ + prefix = f"{color}{BoxChars.HORIZONTAL * 2}{Colors.RESET} " + text_part = f"{Colors.BOLD}{text}{Colors.RESET} " + prefix_len = 3 # "── " + text_len = len(text) + 1 # text + space + + remaining = self.width - prefix_len - text_len + suffix = f"{color}{BoxChars.HORIZONTAL * max(0, remaining)}{Colors.RESET}" + + return f"{prefix}{text_part}{suffix}" + + # ========================================================================= + # Progress Components + # ========================================================================= + + def render_step_indicator(self, current: int, total: int, label: str, workers: int = 1) -> str: + """ + Render step progress indicator. + + Example: Step 6/7: Grid Search (4 workers) + + Args: + current: Current step number + total: Total number of steps + label: Step label + workers: Number of workers (shown if > 1) + + Returns: + Rendered step indicator string + """ + worker_info = f" ({workers} worker{'s' if workers > 1 else ''})" if workers > 1 else "" + step_part = f"{Colors.CYAN}Step {current}/{total}:{Colors.RESET} " + label_part = f"{Colors.BOLD}{Colors.WHITE}{label}{Colors.RESET}" + worker_part = f"{Colors.GRAY}{worker_info}{Colors.RESET}" + + return f"{step_part}{label_part}{worker_part}" + + def render_sequential_step( + self, + step: int, + total_steps: int, + step_name: str, + status: str, + elapsed_seconds: float, + spinner_frame: int = 0, + detail: str = "", + warnings: Optional[List[str]] = None, + ) -> str: + """ + Render a sequential step with spinner animation. + + Example output: + Step 2/7: CST Analysis + Processing... (0:42 elapsed) + └─ Sampling E-field on tractogram... + ! Warning: Some issue occurred + + Args: + step: Current step number + total_steps: Total number of steps + step_name: Name of the current step + status: 'pending', 'running', or 'complete' + elapsed_seconds: Time elapsed for this step + spinner_frame: Animation frame index + detail: Optional sub-step detail text + warnings: Optional list of warning messages + + Returns: + Rendered step display string + """ + lines = [] + + # Step indicator line + step_line = self.render_step_indicator(step, total_steps, step_name, workers=1) + lines.append(step_line) + lines.append("") + + # Status with spinner or checkmark + if status == "running": + spinner = StatusIcons.SPINNER[spinner_frame % len(StatusIcons.SPINNER)] + elapsed_str = self._format_elapsed(elapsed_seconds) + status_line = ( + f" {Colors.CYAN}{spinner}{Colors.RESET} " f"Processing... ({elapsed_str} elapsed)" + ) + elif status == "complete": + status_line = f" {Colors.GREEN}{StatusIcons.COMPLETE}{Colors.RESET} Complete" + else: # pending + status_line = f" {Colors.GRAY}{StatusIcons.PENDING}{Colors.RESET} Pending" + + lines.append(status_line) + + # Add detail line if provided and status is running + if detail and status == "running": + detail_line = f" {Colors.GRAY}\u2514\u2500 {detail}{Colors.RESET}" + lines.append(detail_line) + + # Add warnings if any + if warnings: + for w in warnings: + # Indent warning with tree connector + lines.append( + f" {Colors.GRAY}\u2514\u2500{Colors.RESET} {Colors.YELLOW}! {w}{Colors.RESET}" + ) + + return "\n".join(lines) + + def render_completed_steps( + self, + completed_steps: List[int], + step_names: Dict[int, str], + step_warnings: Optional[Dict[int, List[str]]] = None, + ) -> str: + """ + Render list of completed sequential steps. + + Args: + completed_steps: List of completed step numbers + step_names: Mapping of step numbers to names + step_warnings: Optional mapping of step numbers to warning messages + + Returns: + Rendered completed steps string + """ + if not completed_steps: + return "" + + lines = [f"{Colors.GRAY}Completed:{Colors.RESET}"] + + for step_num in sorted(completed_steps): + step_name = step_names.get(step_num, f"Step {step_num}") + lines.append( + f" {Colors.GREEN}{StatusIcons.COMPLETE}{Colors.RESET} " + f"Step {step_num}: {step_name}" + ) + + # Check for warnings for this step + if step_warnings and step_num in step_warnings: + for w in step_warnings[step_num]: + lines.append( + f" {Colors.GRAY}\u2514\u2500{Colors.RESET} {Colors.YELLOW}! {w}{Colors.RESET}" + ) + + return "\n".join(lines) + + def _format_elapsed(self, seconds: float) -> str: + """Format elapsed time as M:SS.""" + minutes = int(seconds // 60) + secs = int(seconds % 60) + return f"{minutes}:{secs:02d}" + + def render_progress_bar( + self, + completed: int, + total: int, + width: int = 40, + label: str = "Progress", + show_percentage: bool = True, + show_count: bool = True, + filled_color: str = Colors.GREEN, + empty_color: str = Colors.DARK_GRAY, + ) -> str: + """ + Render a Unicode progress bar. + + Example: Progress: [████████████░░░░░░░░] 8/25 (32%) + + Args: + completed: Number of completed items + total: Total number of items + width: Bar width in characters + label: Label before the bar + show_percentage: Show percentage after bar + show_count: Show count (completed/total) + filled_color: Color for filled portion + empty_color: Color for empty portion + + Returns: + Rendered progress bar string + """ + if total == 0: + percent = 0 + filled_width = 0 + else: + percent = int((completed / total) * 100) + filled_width = int((completed / total) * width) + + # Build bar characters + filled = ProgressChars.FILLED * filled_width + empty = ProgressChars.EMPTY * (width - filled_width) + bar = f"{filled_color}{filled}{Colors.RESET}{empty_color}{empty}{Colors.RESET}" + + # Build info parts + parts = [] + if label: + parts.append(f"{Colors.GRAY}{label}:{Colors.RESET}") + + parts.append(f"[{bar}]") + + if show_count: + parts.append(f"{completed}/{total}") + + if show_percentage: + parts.append(f"({Colors.CYAN}{percent:3d}%{Colors.RESET})") + + return " ".join(parts) + + # ========================================================================= + # Table Components + # ========================================================================= + + def render_worker_table(self, workers: List[Dict[str, Any]], columns: List[TableColumn]) -> str: + """ + Render a worker status table with borders. + + Example: + ┌──────────┬─────────────┬──────────────────┬──────────┐ + │ Worker │ Grid Point │ Current Phase │ Time │ + ├──────────┼─────────────┼──────────────────┼──────────┤ + │ Worker 1 │ grid_P07 │ FEM Simulation │ 00:42 │ + │ Worker 2 │ grid_P08 │ E-field Sampling │ 00:38 │ + └──────────┴─────────────┴──────────────────┴──────────┘ + + Args: + workers: List of worker state dicts + columns: List of TableColumn definitions + + Returns: + Rendered table string with newlines + """ + lines = [] + border_color = Colors.DARK_GRAY + + # Calculate column widths + col_widths = [c.width for c in columns] + + # Top border + top_border = self._make_table_border(col_widths, "top", border_color) + lines.append(top_border) + + # Header row + header_cells = [] + for col in columns: + cell = self._align_text(col.header, col.width, "center") + header_cells.append(f"{Colors.BOLD}{Colors.WHITE}{cell}{Colors.RESET}") + + header_line = self._make_table_row(header_cells, col_widths, border_color) + lines.append(header_line) + + # Header separator + sep = self._make_table_border(col_widths, "middle", border_color) + lines.append(sep) + + # Data rows + for i, worker in enumerate(workers): + row_cells = [] + status = worker.get("status", "idle") + row_color = self._get_row_color(status) + + for col in columns: + value = str(worker.get(col.key, "-")) + cell = self._align_text(value, col.width, col.align) + + # Apply special coloring for phase column + if col.key == "current_phase": + phase = worker.get("current_phase", "") + cell_color = phase_color(phase) + row_cells.append(f"{cell_color}{cell}{Colors.RESET}") + else: + row_cells.append(f"{row_color}{cell}{Colors.RESET}") + + row_line = self._make_table_row(row_cells, col_widths, border_color) + lines.append(row_line) + + # Add separator between workers + if i < len(workers) - 1: + lines.append(self._make_table_border(col_widths, "middle", border_color)) + + # Bottom border + bottom_border = self._make_table_border(col_widths, "bottom", border_color) + lines.append(bottom_border) + + return "\n".join(lines) + + def _make_table_border(self, col_widths: List[int], position: str, color: str) -> str: + """Create a table border line.""" + if position == "top": + left, mid, right = BoxChars.TOP_LEFT, BoxChars.T_DOWN, BoxChars.TOP_RIGHT + elif position == "middle": + left, mid, right = BoxChars.T_RIGHT, BoxChars.CROSS, BoxChars.T_LEFT + else: # bottom + left, mid, right = BoxChars.BOTTOM_LEFT, BoxChars.T_UP, BoxChars.BOTTOM_RIGHT + + parts = [left] + for i, width in enumerate(col_widths): + parts.append(BoxChars.HORIZONTAL * width) + parts.append(mid if i < len(col_widths) - 1 else right) + + return f"{color}{''.join(parts)}{Colors.RESET}" + + def _make_table_row(self, cells: List[str], col_widths: List[int], border_color: str) -> str: + """Create a table data row.""" + sep = f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + return sep + sep.join(cells) + sep + + @staticmethod + def _align_text(text: str, width: int, align: str) -> str: + """Align text within specified width, truncating if needed.""" + # Remove ANSI codes for length calculation + ansi_pattern = re.compile(r"\x1b\[[0-9;]*m") + clean_text = ansi_pattern.sub("", text) + + if len(clean_text) > width: + text = text[: width - 1] + "\u2026" # Truncate with ellipsis + + clean_text = ansi_pattern.sub("", text) + padding = width - len(clean_text) + + if align == "center": + left_pad = padding // 2 + right_pad = padding - left_pad + return " " * left_pad + text + " " * right_pad + elif align == "right": + return " " * padding + text + else: # left + return text + " " * padding + + @staticmethod + def _get_row_color(status: str) -> str: + """Get color for a worker row based on status.""" + colors = { + "running": Colors.WHITE, + "complete": Colors.GREEN, + "failed": Colors.RED, + "idle": Colors.GRAY, + } + return colors.get(status.lower(), Colors.WHITE) + + # ========================================================================= + # Results Components + # ========================================================================= + + def render_completed_list( + self, results: List[Dict[str, Any]], max_items: Optional[int] = None + ) -> str: + """ + Render list of completed results. + + Args: + results: List of result dicts + max_items: Optional maximum items (if None, shows all) + + Returns: + Rendered list string with newlines + """ + if not results: + return "" + + lines = [f"{Colors.GRAY}Completed:{Colors.RESET}"] + + # Show results (either all or limited by max_items) + displayed = results[-max_items:] if max_items and len(results) > max_items else results + + for r in displayed: + success = r.get("success", True) + icon = StatusIcons.COMPLETE if success else StatusIcons.FAILED + icon_color = Colors.GREEN if success else Colors.RED + label = r.get("label", "?") + + if success: + w_mso = r.get("weighted_mso", 0) + u_mso = r.get("unweighted_mso", 0) + intensity_text = ( + f"{u_mso:.1f}% max output (Unweighted I) - " + f"{w_mso:.1f}% max output (Weighted I)" + ) + lines.append( + f" {icon_color}{icon}{Colors.RESET} {label}: " + f"{Colors.CYAN}{intensity_text}{Colors.RESET}" + ) + + # Multiplier (M_CST/M_target) — surfaced inline so single-run + # workflows (estimation) can read it without a stats panel. + mult_w = r.get("multiplier_weighted") + mult_u = r.get("multiplier_unweighted") + if mult_w is not None or mult_u is not None: + mult_text = ( + f"Multiplier: " + f"{(mult_u if mult_u is not None else 0):.4f} (Unweighted) - " + f"{(mult_w if mult_w is not None else 0):.4f} (Weighted)" + ) + lines.append(f" {Colors.GRAY}{mult_text}{Colors.RESET}") + else: + error = r.get("error", "Failed") + lines.append( + f" {icon_color}{icon}{Colors.RESET} {label}: " + f"{Colors.RED}{error}{Colors.RESET}" + ) + + return "\n".join(lines) + + def render_results_table(self, results: List[Dict[str, Any]], max_rows: int = 10) -> str: + """ + Render results as a formatted table. + + Args: + results: List of result dicts + max_rows: Maximum rows to display + + Returns: + Rendered table string + """ + columns = [ + TableColumn("Grid Point", 12, "left", "label"), + TableColumn("Cortex Coords", 18, "center", "cortex_coords"), + TableColumn("Unweighted I", 14, "right", "unweighted_mso"), + TableColumn("Weighted I", 14, "right", "weighted_mso"), + ] + + # Format results for table + table_data = [] + for r in results[:max_rows]: + coords = r.get("cortex_coord", [0, 0, 0]) + if isinstance(coords, list) and len(coords) >= 3: + coords_str = f"[{coords[0]:.1f}, {coords[1]:.1f}, ...]" + else: + coords_str = str(coords)[:16] + + table_data.append( + { + "label": r.get("label", "?"), + "cortex_coords": coords_str, + "unweighted_mso": f"{r.get('unweighted_mso', 0):.1f}%", + "weighted_mso": f"{r.get('weighted_mso', 0):.1f}%", + "status": "complete" if r.get("success", True) else "failed", + } + ) + + return self.render_worker_table(table_data, columns) + + # ========================================================================= + # Summary Components + # ========================================================================= + + def render_summary_box( + self, title: str, rows: List[Tuple[str, str]], border_color: str = Colors.GREEN + ) -> str: + """ + Render a summary box with key-value pairs. + + Example: + ╭──────────────────────────────────────────────────────╮ + │ PIPELINE COMPLETE │ + ├──────────────────────────────────────────────────────┤ + │ Total time: 15m 32.4s │ + │ Grid points: 25/25 successful │ + ╰──────────────────────────────────────────────────────╯ + + Args: + title: Box title + rows: List of (label, value) tuples + border_color: ANSI color for border + + Returns: + Rendered summary box string + """ + lines = [] + inner_width = self.width - 2 + + # Top border + lines.append( + f"{border_color}{BoxChars.TOP_LEFT}" + f"{BoxChars.HORIZONTAL * inner_width}" + f"{BoxChars.TOP_RIGHT}{Colors.RESET}" + ) + + # Title + title_padded = title.center(inner_width) + lines.append( + f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + f"{Colors.BOLD}{Colors.WHITE}{title_padded}{Colors.RESET}" + f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + ) + + # Title separator + lines.append( + f"{border_color}{BoxChars.T_RIGHT}" + f"{BoxChars.HORIZONTAL * inner_width}" + f"{BoxChars.T_LEFT}{Colors.RESET}" + ) + + # Content rows + for label, value in rows: + content = f" {label}: {Colors.CYAN}{value}{Colors.RESET}" + # Calculate padding (accounting for ANSI codes) + visible_len = len(label) + len(str(value)) + 4 # " " + ": " + value + padding = inner_width - visible_len + padded_content = content + " " * max(0, padding) + + lines.append( + f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + f"{padded_content}" + f"{border_color}{BoxChars.VERTICAL}{Colors.RESET}" + ) + + # Bottom border + lines.append( + f"{border_color}{BoxChars.BOTTOM_LEFT}" + f"{BoxChars.HORIZONTAL * inner_width}" + f"{BoxChars.BOTTOM_RIGHT}{Colors.RESET}" + ) + + return "\n".join(lines) + + def render_output_files_list(self, files: List[Tuple[str, str]]) -> str: + """ + Render list of output files using absolute, copy-pasteable paths. + + Example: + Output files: + → Results CSV: /full/abs/path/TIDE_grid_results.csv + → Summary: /full/abs/path/TIDE_Grid_Summary_target.txt + + Args: + files: List of (label, path) tuples. Paths are resolved to + their absolute form so the user can copy them straight + from the terminal without truncation. + + Returns: + Rendered file list string + """ + from pathlib import Path + + lines = [f"{Colors.GRAY}Output files:{Colors.RESET}"] + + for label, path in files: + try: + path_str = str(Path(path).expanduser().resolve()) + except (OSError, RuntimeError): + path_str = str(path) + + lines.append( + f" {Colors.CYAN}{StatusIcons.ARROW_RIGHT}{Colors.RESET} " + f"{label}: {Colors.WHITE}{path_str}{Colors.RESET}" + ) + + return "\n".join(lines) + + # ========================================================================= + # Status Line Components + # ========================================================================= + + def render_status_line( + self, elapsed_seconds: float, hint: str = "Press 'f' for focus mode" + ) -> str: + """ + Render bottom status line with elapsed time and hints. + + Args: + elapsed_seconds: Elapsed time in seconds + hint: Keyboard hint text + + Returns: + Rendered status line string + """ + minutes = int(elapsed_seconds // 60) + seconds = int(elapsed_seconds % 60) + time_str = f"{minutes}m {seconds}s" + + return f"{Colors.GRAY}Elapsed: {time_str} | " f"{hint}{Colors.RESET}" + + def render_focus_header(self, worker_id: int, point_label: str, num_workers: int) -> str: + """ + Render header for focus mode. + + Args: + worker_id: Currently focused worker (0-indexed) + point_label: Grid point being processed + num_workers: Total number of workers + + Returns: + Rendered focus header string + """ + sep_line = f"{Colors.CYAN}{BoxChars.DOUBLE_HORIZONTAL * self.width}{Colors.RESET}" + title = f"Focus Mode: Worker {worker_id + 1} - {point_label or 'Idle'}" + hint = f"Press 'q' to exit | 0-{num_workers - 1} to switch worker" + + lines = [ + sep_line, + f"{Colors.BOLD}{Colors.WHITE}{title}{Colors.RESET}", + f"{Colors.GRAY}{hint}{Colors.RESET}", + sep_line, + ] + + return "\n".join(lines) diff --git a/src/tide/console/styles.py b/src/tide/console/styles.py new file mode 100644 index 0000000..cfa1337 --- /dev/null +++ b/src/tide/console/styles.py @@ -0,0 +1,328 @@ +""" +Console UI Styles and Visual Constants +====================================== + +ANSI color codes and Unicode characters for the terminal console UI. +Colors are matched to the GUI theme (Cyan primary, Green success). +""" + +from typing import List + + +class Colors: + """ + ANSI escape codes for terminal colors. + + Color scheme matches the GUI theme from theme.py: + - Primary: Cyan (#00BCD4) + - Success: Green (#16a34a) + - Warning: Amber (#d97706) + - Error: Red (#dc2626) + """ + + # Reset + RESET = "\033[0m" + + # Text styles + BOLD = "\033[1m" + DIM = "\033[2m" + ITALIC = "\033[3m" + UNDERLINE = "\033[4m" + + # Primary colors (256-color mode for better matching) + CYAN = "\033[38;5;44m" # Primary accent (#00BCD4) + CYAN_BRIGHT = "\033[38;5;51m" # Hover state + GREEN = "\033[38;5;34m" # Success (#16a34a) + GREEN_BRIGHT = "\033[38;5;40m" # Success bright + YELLOW = "\033[38;5;178m" # Warning (#d97706) + RED = "\033[38;5;160m" # Error (#dc2626) + + # Neutral colors + WHITE = "\033[38;5;255m" # Text primary + GRAY = "\033[38;5;245m" # Text secondary + DARK_GRAY = "\033[38;5;240m" # Borders, muted + LIGHT_GRAY = "\033[38;5;250m" # Subtle text + + # Background colors + BG_DARK = "\033[48;5;235m" # Dark background + BG_HIGHLIGHT = "\033[48;5;238m" # Row highlight + BG_CYAN = "\033[48;5;23m" # Cyan tint background + BG_GREEN = "\033[48;5;22m" # Success background + BG_RED = "\033[48;5;52m" # Error background + + @classmethod + def colorize(cls, text: str, *styles: str) -> str: + """ + Apply multiple color/style codes to text. + + Args: + text: Text to colorize + *styles: Color/style constants to apply + + Returns: + Styled text with reset at end + """ + prefix = "".join(styles) + return f"{prefix}{text}{cls.RESET}" + + +class BoxChars: + """ + Unicode box-drawing characters. + + Uses rounded corners for a modern look matching the GUI border-radius. + """ + + # Single-line rounded corners + TOP_LEFT = "\u256d" # ╭ + TOP_RIGHT = "\u256e" # ╮ + BOTTOM_LEFT = "\u2570" # ╰ + BOTTOM_RIGHT = "\u256f" # ╯ + + # Lines + HORIZONTAL = "\u2500" # ─ + VERTICAL = "\u2502" # │ + + # T-junctions + T_DOWN = "\u252c" # ┬ + T_UP = "\u2534" # ┴ + T_RIGHT = "\u251c" # ├ + T_LEFT = "\u2524" # ┤ + + # Cross + CROSS = "\u253c" # ┼ + + # Double line (for emphasis) + DOUBLE_HORIZONTAL = "\u2550" # ═ + DOUBLE_VERTICAL = "\u2551" # ║ + + # Heavy lines (for section separators) + HEAVY_HORIZONTAL = "\u2501" # ━ + HEAVY_VERTICAL = "\u2503" # ┃ + + +class ProgressChars: + """ + Unicode characters for progress bars. + """ + + # Main progress characters + FILLED = "\u2588" # █ Full block + EMPTY = "\u2591" # ░ Light shade + + # Partial fills (1/8 to 7/8) + PARTIAL: List[str] = [ + "\u258f", # ▏ 1/8 + "\u258e", # ▎ 2/8 + "\u258d", # ▍ 3/8 + "\u258c", # ▌ 4/8 + "\u258b", # ▋ 5/8 + "\u258a", # ▊ 6/8 + "\u2589", # ▉ 7/8 + ] + + # Alternative style (thinner) + BAR_START = "\u2595" # ▕ + BAR_END = "\u258f" # ▏ + + +class StatusIcons: + """ + Unicode icons for status indication. + """ + + # Status indicators + RUNNING = "\u25cf" # ● Filled circle + COMPLETE = "\u2713" # ✓ Check mark + FAILED = "\u2717" # ✗ X mark + PENDING = "\u25cb" # ○ Empty circle + WARNING = "\u26a0" # ⚠ Warning + INFO = "\u2139" # ℹ Info + + # Arrows + ARROW_RIGHT = "\u2192" # → + ARROW_LEFT = "\u2190" # ← + ARROW_UP = "\u2191" # ↑ + ARROW_DOWN = "\u2193" # ↓ + + # Bullets + BULLET = "\u2022" # • + DIAMOND = "\u25c6" # ◆ + TRIANGLE = "\u25b6" # ▶ + + # Spinner frames (for animation) + SPINNER: List[str] = [ + "\u280b", # ⠋ + "\u2819", # ⠙ + "\u2839", # ⠹ + "\u2838", # ⠸ + "\u283c", # ⠼ + "\u2834", # ⠴ + "\u2826", # ⠦ + "\u2827", # ⠧ + "\u2807", # ⠇ + "\u280f", # ⠏ + ] + + # Alternative spinner (simpler) + SPINNER_SIMPLE: List[str] = ["|", "/", "-", "\\"] + + +class Symbols: + """ + Additional Unicode symbols for UI elements. + """ + + # Section markers + SECTION_START = "\u2500\u2500" # ── + SECTION_END = "\u2500\u2500" # ── + + # List markers + LIST_ITEM = "\u2023" # ‣ + + # Time/clock + CLOCK = "\u23f1" # ⏱ + HOURGLASS = "\u23f3" # ⏳ + + # File/folder + FOLDER = "\u1f4c1" # 📁 (may not render in all terminals) + FILE = "\u1f4c4" # 📄 + + # Simple alternatives for better compatibility + FOLDER_ASCII = ">" + FILE_ASCII = "-" + + +def supports_unicode() -> bool: + """ + Check if the terminal likely supports Unicode. + + Returns: + True if Unicode is likely supported + """ + import os + import sys + + # Check encoding + encoding = getattr(sys.stdout, "encoding", "") or "" + if "utf" in encoding.lower(): + return True + + # Check LANG environment variable + lang = os.environ.get("LANG", "").lower() + if "utf" in lang: + return True + + # Check terminal type + term = os.environ.get("TERM", "").lower() + if any(t in term for t in ["xterm", "vt100", "screen", "tmux", "linux"]): + return True + + # Windows Terminal and modern Windows support Unicode + if sys.platform == "win32": + # Windows 10 1903+ supports UTF-8 by default + try: + import ctypes + + _ = ctypes.windll.kernel32 + # Check if running in Windows Terminal or modern console + return True + except Exception: + pass + + return False + + +def supports_colors() -> bool: + """ + Check if the terminal supports ANSI colors. + + Returns: + True if colors are likely supported + """ + import os + import sys + + # Check if stdout is a TTY + if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty(): + return False + + # Check TERM environment variable + term = os.environ.get("TERM", "").lower() + if term in ("dumb", ""): + return False + + # Check NO_COLOR environment variable (standard) + if os.environ.get("NO_COLOR"): + return False + + # Check FORCE_COLOR environment variable + if os.environ.get("FORCE_COLOR"): + return True + + # Windows check + if sys.platform == "win32": + # Enable ANSI on Windows 10+ + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + # Enable virtual terminal processing + kernel32.SetConsoleMode( + kernel32.GetStdHandle(-11), # STD_OUTPUT_HANDLE + 7, # ENABLE_PROCESSED_OUTPUT | ENABLE_WRAP_AT_EOL_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING + ) + return True + except Exception: + return False + + return True + + +# Convenience functions for common patterns + + +def status_icon(status: str) -> str: + """ + Get colored status icon for a status string. + + Args: + status: One of 'running', 'complete', 'failed', 'pending', 'warning' + + Returns: + Colored status icon string + """ + icons = { + "running": Colors.colorize(StatusIcons.RUNNING, Colors.CYAN), + "complete": Colors.colorize(StatusIcons.COMPLETE, Colors.GREEN), + "failed": Colors.colorize(StatusIcons.FAILED, Colors.RED), + "pending": Colors.colorize(StatusIcons.PENDING, Colors.GRAY), + "warning": Colors.colorize(StatusIcons.WARNING, Colors.YELLOW), + "idle": Colors.colorize(StatusIcons.PENDING, Colors.DARK_GRAY), + } + return icons.get(status.lower(), StatusIcons.PENDING) + + +def phase_color(phase: str) -> str: + """ + Get appropriate color for a workflow phase. + + Args: + phase: Phase name (e.g., 'Optimization', 'FEM Simulation') + + Returns: + ANSI color code + """ + phase_colors = { + "optimization": Colors.YELLOW, + "fem simulation": Colors.CYAN, + "e-field sampling": Colors.CYAN_BRIGHT, + "activating function": Colors.GREEN, + "bundle analysis": Colors.GREEN_BRIGHT, + "saving results": Colors.GRAY, + "complete": Colors.GREEN, + "failed": Colors.RED, + "idle": Colors.DARK_GRAY, + "starting": Colors.LIGHT_GRAY, + } + return phase_colors.get(phase.lower(), Colors.WHITE) diff --git a/src/tide/console/terminal.py b/src/tide/console/terminal.py new file mode 100644 index 0000000..4371973 --- /dev/null +++ b/src/tide/console/terminal.py @@ -0,0 +1,468 @@ +""" +Terminal Control Module +======================= + +Low-level terminal control using ANSI escape sequences. +Provides cursor manipulation, screen control, and keyboard input handling. +""" + +import os +import signal +import sys +from contextlib import contextmanager +from typing import Optional, Tuple + + +class Terminal: + """ + Low-level terminal control for the console UI. + + Handles: + - Terminal size detection and resize events + - Cursor positioning and visibility + - Screen clearing and buffer management + - Non-blocking keyboard input + - Raw mode for keyboard capture + + This class uses ANSI escape sequences and is compatible with + most Unix terminals and Windows Terminal (Windows 10+). + """ + + def __init__(self): + """Initialize terminal controller.""" + self._original_settings = None + self._is_tty = sys.stdout.isatty() + self._in_raw_mode = False + self._resize_pending = False + self._width, self._height = self.get_size() + + @property + def is_interactive(self) -> bool: + """ + Check if running in an interactive terminal. + + Returns: + True if stdout is connected to a TTY + """ + return self._is_tty + + def get_size(self) -> Tuple[int, int]: + """ + Get terminal dimensions. + + Returns: + Tuple of (columns, rows) + """ + try: + size = os.get_terminal_size() + return size.columns, size.lines + except OSError: + # Fallback for non-TTY or when size detection fails + return 80, 24 + + def setup_resize_handler(self, enabled: bool = True) -> None: + """ + Enable or disable the terminal resize (SIGWINCH) handler. + + When enabled, SIGWINCH signals will set a flag that can be + checked via check_resize(). + + Args: + enabled: Whether to enable the handler. + """ + # SIGWINCH is Unix-only + if hasattr(signal, "SIGWINCH"): + if enabled: + signal.signal(signal.SIGWINCH, self._handle_resize) + else: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + + def _handle_resize(self, signum: int, frame) -> None: + """Handle SIGWINCH resize signal. Sets flag for thread-safe processing.""" + self._resize_pending = True + # Update cached size immediately (safe from signal handler) + try: + size = os.get_terminal_size() + self._width, self._height = size.columns, size.lines + except OSError: + pass + + def check_resize(self) -> Optional[Tuple[int, int]]: + """ + Check if a resize event is pending. + + Returns: + Tuple of (cols, rows) if resized, None otherwise. + """ + if self._resize_pending: + self._resize_pending = False + return self._width, self._height + return None + + # ========================================================================= + # Cursor Control + # ========================================================================= + + @staticmethod + def move_cursor(row: int, col: int) -> str: + """ + Get ANSI sequence to move cursor to position. + + Args: + row: Target row (1-indexed) + col: Target column (1-indexed) + + Returns: + ANSI escape sequence + """ + return f"\033[{row};{col}H" + + @staticmethod + def cursor_up(n: int = 1) -> str: + """Move cursor up n lines.""" + return f"\033[{n}A" + + @staticmethod + def cursor_down(n: int = 1) -> str: + """Move cursor down n lines.""" + return f"\033[{n}B" + + @staticmethod + def cursor_forward(n: int = 1) -> str: + """Move cursor forward (right) n columns.""" + return f"\033[{n}C" + + @staticmethod + def cursor_back(n: int = 1) -> str: + """Move cursor back (left) n columns.""" + return f"\033[{n}D" + + @staticmethod + def cursor_to_column(col: int) -> str: + """Move cursor to column n (1-indexed).""" + return f"\033[{col}G" + + @staticmethod + def hide_cursor() -> str: + """Get ANSI sequence to hide cursor.""" + return "\033[?25l" + + @staticmethod + def show_cursor() -> str: + """Get ANSI sequence to show cursor.""" + return "\033[?25h" + + @staticmethod + def save_cursor() -> str: + """Get ANSI sequence to save cursor position.""" + return "\033[s" + + @staticmethod + def restore_cursor() -> str: + """Get ANSI sequence to restore cursor position.""" + return "\033[u" + + # ========================================================================= + # Screen Control + # ========================================================================= + + @staticmethod + def clear_screen() -> str: + """Get ANSI sequence to clear entire screen.""" + return "\033[2J" + + @staticmethod + def clear_line() -> str: + """Get ANSI sequence to clear current line.""" + return "\033[2K" + + @staticmethod + def clear_to_end_of_line() -> str: + """Get ANSI sequence to clear from cursor to end of line.""" + return "\033[K" + + @staticmethod + def clear_to_end_of_screen() -> str: + """Get ANSI sequence to clear from cursor to end of screen.""" + return "\033[J" + + @staticmethod + def clear_to_start_of_screen() -> str: + """Get ANSI sequence to clear from cursor to start of screen.""" + return "\033[1J" + + @staticmethod + def scroll_up(n: int = 1) -> str: + """Scroll screen up n lines.""" + return f"\033[{n}S" + + @staticmethod + def scroll_down(n: int = 1) -> str: + """Scroll screen down n lines.""" + return f"\033[{n}T" + + # ========================================================================= + # Alternate Buffer + # ========================================================================= + + @contextmanager + def alternate_buffer(self): + """ + Context manager for using the alternate screen buffer. + + The alternate buffer preserves the original terminal content + and restores it when the context exits. + + Usage: + with terminal.alternate_buffer(): + # Draw UI in alternate buffer + pass + # Original content is restored + """ + if not self._is_tty: + yield + return + + # Enter alternate buffer + sys.stdout.write("\033[?1049h") + sys.stdout.flush() + try: + yield + finally: + # Exit alternate buffer + sys.stdout.write("\033[?1049l") + sys.stdout.flush() + + # ========================================================================= + # Keyboard Input + # ========================================================================= + + def get_keypress(self, timeout: float = 0.1) -> Optional[str]: + """ + Non-blocking keypress detection. + + Args: + timeout: Maximum time to wait for input in seconds + + Returns: + Key character if pressed, None if no input + """ + if sys.platform == "win32": + return self._get_keypress_windows(timeout) + else: + return self._get_keypress_unix(timeout) + + def _get_keypress_unix(self, timeout: float) -> Optional[str]: + """Unix implementation of non-blocking keypress.""" + import select + import termios + import tty + + if not self._is_tty: + return None + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + + try: + tty.setraw(fd) + rlist, _, _ = select.select([sys.stdin], [], [], timeout) + if rlist: + ch = sys.stdin.read(1) + # Handle escape sequences (arrow keys, etc.) + if ch == "\x1b": + # Check for more characters (extended escape sequences) + # We use a short timeout and read hasta 5 more chars + for _ in range(5): + rlist, _, _ = select.select([sys.stdin], [], [], 0.001) + if rlist: + ch += sys.stdin.read(1) + else: + break + return ch + return None + except Exception: + return None + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + def _get_keypress_windows(self, timeout: float) -> Optional[str]: + """Windows implementation of non-blocking keypress.""" + try: + import msvcrt + import time + + start = time.time() + while (time.time() - start) < timeout: + if msvcrt.kbhit(): + ch = msvcrt.getch() + # Handle extended keys + if ch in (b"\x00", b"\xe0"): + ch = msvcrt.getch() + return f'\x1b[{ch.decode("utf-8", errors="ignore")}' + return ch.decode("utf-8", errors="ignore") + time.sleep(0.01) + return None + except ImportError: + return None + + @contextmanager + def raw_mode(self): + """ + Context manager for raw terminal mode (Unix only). + + In raw mode, input is not line-buffered and special + characters are not processed. + """ + if sys.platform == "win32" or not self._is_tty: + yield + return + + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + self._original_settings = old_settings + + try: + tty.setraw(fd) + self._in_raw_mode = True + yield + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + self._in_raw_mode = False + + # ========================================================================= + # Utility Methods + # ========================================================================= + + def write(self, text: str) -> None: + """ + Write text to terminal and flush. + + Args: + text: Text to write (may include ANSI sequences) + """ + sys.stdout.write(text) + sys.stdout.flush() + + def write_at(self, row: int, col: int, text: str) -> None: + """ + Write text at specific position. + + Args: + row: Target row (1-indexed) + col: Target column (1-indexed) + text: Text to write + """ + self.write(f"{self.move_cursor(row, col)}{text}") + + def clear_and_home(self) -> None: + """Clear screen and move cursor to top-left.""" + self.write(f"{self.clear_screen()}{self.move_cursor(1, 1)}") + + def _save_terminal_state(self) -> None: + """Save current terminal state (Unix only).""" + if sys.platform != "win32" and self._is_tty: + try: + import termios + + self._original_settings = termios.tcgetattr(sys.stdin.fileno()) + except Exception: + pass + + def _restore_terminal_state(self) -> None: + """Restore saved terminal state (Unix only).""" + if sys.platform != "win32" and self._is_tty and self._original_settings: + try: + import termios + + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._original_settings) + except Exception: + pass + + def setup(self) -> None: + """Setup terminal for UI rendering.""" + if not self._is_tty: + return + + # Save current terminal state + self._save_terminal_state() + + # Enter alternate screen buffer + self.write("\033[?1049h") + + # Hide cursor + self.write(self.hide_cursor()) + + # Move to top-left + self.write(self.move_cursor(1, 1)) + + def cleanup(self) -> None: + """Restore terminal to original state.""" + if not self._is_tty: + return + + # Show cursor + self.write(self.show_cursor()) + + # Exit alternate screen buffer + self.write("\033[?1049l") + + # Restore terminal state + self._restore_terminal_state() + + def bell(self) -> None: + """Sound terminal bell.""" + self.write("\a") + + def set_title(self, title: str) -> None: + """ + Set terminal window title. + + Args: + title: New window title + """ + # Works on xterm, GNOME Terminal, Windows Terminal, etc. + self.write(f"\033]0;{title}\a") + + # ========================================================================= + # Line Drawing Helpers + # ========================================================================= + + def draw_horizontal_line(self, row: int, col: int, width: int, char: str = "\u2500") -> None: + """ + Draw a horizontal line. + + Args: + row: Starting row (1-indexed) + col: Starting column (1-indexed) + width: Line width in characters + char: Character to use for the line + """ + self.write_at(row, col, char * width) + + def draw_vertical_line(self, row: int, col: int, height: int, char: str = "\u2502") -> None: + """ + Draw a vertical line. + + Args: + row: Starting row (1-indexed) + col: Column (1-indexed) + height: Line height in characters + char: Character to use for the line + """ + for i in range(height): + self.write_at(row + i, col, char) + + +# Convenience function for quick terminal operations +def get_terminal() -> Terminal: + """ + Get a Terminal instance. + + Returns: + Terminal controller instance + """ + return Terminal() diff --git a/src/tide/console/worker_reporter.py b/src/tide/console/worker_reporter.py new file mode 100644 index 0000000..a82414e --- /dev/null +++ b/src/tide/console/worker_reporter.py @@ -0,0 +1,318 @@ +""" +Worker Reporter Module +====================== + +Wraps the grid point processing function to add status reporting. +This module provides the bridge between the worker execution and the console UI. +""" + +import logging +import os +import sys +from multiprocessing import Queue +from pathlib import Path +from typing import Optional + +from .ipc import StatusReporter, WorkerPhase, create_status_reporter + + +class WorkerLoggingHandler(logging.Handler): + """ + Custom logging handler that captures log messages for the console UI. + + This handler sends log messages to the status reporter for display + in focus mode, and optionally writes to a per-worker log file. + """ + + def __init__( + self, + reporter: StatusReporter, + file_path: Optional[Path] = None, + level: int = logging.DEBUG, + ): + """ + Initialize the worker logging handler. + + Args: + reporter: StatusReporter to send log messages through + file_path: Optional path for log file output + level: Minimum log level to capture + """ + super().__init__(level) + self._reporter = reporter + self._file_handler: Optional[logging.FileHandler] = None + + if file_path: + file_path.parent.mkdir(parents=True, exist_ok=True) + self._file_handler = logging.FileHandler(file_path, mode="w") + self._file_handler.setLevel(logging.DEBUG) + self._file_handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(levelname)s - %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + + def emit(self, record: logging.LogRecord) -> None: + """ + Emit a log record. + + Sends to status reporter for focus mode display and + writes to file if configured. + """ + try: + msg = self.format(record) + + # Send to reporter for focus mode + self._reporter.log(record.levelname, msg) + + # Write to file + if self._file_handler: + self._file_handler.emit(record) + + except Exception: + self.handleError(record) + + def close(self) -> None: + """Close the handler and any file handlers.""" + if self._file_handler: + self._file_handler.close() + super().close() + + +def setup_worker_logging( + reporter: StatusReporter, + log_dir: Optional[Path] = None, + point_label: str = "", + worker_id: int = 0, +) -> logging.Handler: + """ + Configure logging for a worker process. + + This redirects log output to the status reporter (for UI) and + optionally to a per-worker log file. + + Args: + reporter: StatusReporter for sending log messages + log_dir: Directory for worker log files (optional) + point_label: Current grid point label for log filename + worker_id: Worker ID for log filename + + Returns: + The configured logging handler (for cleanup) + """ + # Determine log file path + log_file = None + if log_dir: + log_file = log_dir / f"worker_{worker_id}_{point_label}.log" + + # Create and configure handler + handler = WorkerLoggingHandler(reporter, log_file) + handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", datefmt="%H:%M:%S") + ) + + # Get root logger and configure + root = logging.getLogger() + + # Remove console handlers (we're redirecting output) + handlers_to_remove = [ + h + for h in root.handlers + if isinstance(h, logging.StreamHandler) and h.stream in (sys.stdout, sys.stderr) + ] + for h in handlers_to_remove: + root.removeHandler(h) + + # Add our handler + root.addHandler(handler) + + return handler + + +class _ConsoleGridPointReporter: + """ + Forward grid-point stage transitions to the console ``StatusReporter``. + + The physics lives once in ``grid_search.process_grid_point``; this adapter + maps its stage hooks onto the live UI phases (audit C-003), so the console + path does not re-implement the numeric pipeline. + """ + + def __init__(self, reporter: StatusReporter): + self._reporter = reporter + + def optimization(self) -> None: + self._reporter.phase(WorkerPhase.OPTIMIZATION, 0) + + def simulation(self) -> None: + self._reporter.phase(WorkerPhase.FEM_SIMULATION, 0) + + def sampling(self) -> None: + self._reporter.phase(WorkerPhase.EFIELD_SAMPLING, 0) + + def activating_function(self) -> None: + self._reporter.phase(WorkerPhase.ACTIVATING_FUNCTION, 0) + + def bundle_analysis(self) -> None: + self._reporter.phase(WorkerPhase.BUNDLE_ANALYSIS, 0) + + def saving_results(self) -> None: + self._reporter.phase(WorkerPhase.SAVING_RESULTS, 0) + + def progress(self, pct: int) -> None: + self._reporter.progress(pct) + + +def process_grid_point_with_reporting( + task, # GridPointTask - imported inside to avoid circular deps + status_queue: Optional[Queue], + worker_id: int, + log_dir: Optional[Path] = None, +): + """ + Process a grid point with status reporting to the console UI. + + This wraps the original process_grid_point function, adding: + 1. Status updates via queue to main process + 2. Log redirection to per-worker files + 3. Phase tracking for UI display + + Args: + task: GridPointTask containing all processing parameters + status_queue: Queue for sending status updates (or None) + worker_id: This worker's ID (0-indexed) + log_dir: Directory for worker log files (optional) + + Returns: + GridPointResult with processing results + """ + # Import here to avoid issues with spawn context and circular imports + from tide.workflows.grid_search import ( + GridPointResult, + _configure_worker_environment, + process_grid_point, + ) + + # CRITICAL: Configure environment before any heavy imports + _configure_worker_environment() + + # Use persistent worker ID if provided by the process environment/initializer + actual_worker_id = worker_id + if worker_id == -1: + # Try to get from global (set by initializer) + import tide.workflows.grid_search as gs + + actual_worker_id = getattr(gs, "_process_worker_id", 0) + if actual_worker_id is None: + actual_worker_id = 0 + + # Create status reporter + reporter = create_status_reporter(status_queue, actual_worker_id) + reporter.started(task.point_label) + + saved_stdout_fd = None + saved_stderr_fd = None + try: + if log_dir: + log_dir.mkdir(parents=True, exist_ok=True) + stdout_path = log_dir / f"worker_{worker_id}_{task.point_label}_stdout.log" + stderr_path = log_dir / f"worker_{worker_id}_{task.point_label}_stderr.log" + stdout_fd = os.open(str(stdout_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644) + stderr_fd = os.open(str(stderr_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644) + else: + stdout_fd = os.open(os.devnull, os.O_WRONLY) + stderr_fd = os.open(os.devnull, os.O_WRONLY) + + saved_stdout_fd = os.dup(1) + saved_stderr_fd = os.dup(2) + + os.dup2(stdout_fd, 1) + os.dup2(stderr_fd, 2) + + os.close(stdout_fd) + os.close(stderr_fd) + + sys.stdout = open(1, mode="w", buffering=1, closefd=False) + sys.stderr = open(2, mode="w", buffering=1, closefd=False) + except Exception: + if saved_stdout_fd is not None: + try: + os.close(saved_stdout_fd) + except Exception: + pass + saved_stdout_fd = None + if saved_stderr_fd is not None: + try: + os.close(saved_stderr_fd) + except Exception: + pass + saved_stderr_fd = None + + # Setup logging + log_handler = None + if log_dir: + log_handler = setup_worker_logging(reporter, log_dir, task.point_label, worker_id) + + # process_grid_point owns the physics; this adapter forwards UI stages. + log = logging.getLogger(__name__) + grid_reporter = _ConsoleGridPointReporter(reporter) + + try: + result = process_grid_point(task, reporter=grid_reporter) + + if result.success: + reporter.completed( + { + "weighted_mso": result.weighted_mso, + "unweighted_mso": result.unweighted_mso, + "success": True, + } + ) + else: + reporter.failed(result.error_message or "processing failed") + + return result + + except Exception as e: + log.error(f"Processing failed for {task.point_label}: {e}") + reporter.failed(str(e)) + + return GridPointResult( + index=task.index, + point_label=task.point_label, + success=False, + weighted_mso=999.9, + unweighted_mso=999.9, + cortex_coord=task.cortex_coord, + opt_scalp_coords=None, + opt_matrix=None, + error_message=str(e), + ) + + finally: + # Cleanup logging handler + if log_handler: + root = logging.getLogger() + root.removeHandler(log_handler) + log_handler.close() + + if saved_stdout_fd is not None: + try: + os.dup2(saved_stdout_fd, 1) + except Exception: + pass + try: + os.close(saved_stdout_fd) + except Exception: + pass + + if saved_stderr_fd is not None: + try: + os.dup2(saved_stderr_fd, 2) + except Exception: + pass + try: + os.close(saved_stderr_fd) + except Exception: + pass diff --git a/src/tide/core/_reporting.py b/src/tide/core/_reporting.py new file mode 100644 index 0000000..806bbb8 --- /dev/null +++ b/src/tide/core/_reporting.py @@ -0,0 +1,659 @@ +from html import escape +from pathlib import Path +from typing import Any, Dict, List, Optional + + +def _parse_report_sections(lines: List[str]) -> List[Dict[str, Any]]: + sections = [] + current = {"title": "Preamble", "lines": []} + + for line in lines: + stripped = line.strip() + if stripped.startswith("--- ") and stripped.endswith(" ---"): + if current["lines"] or current["title"] != "Preamble": + sections.append(current) + current = {"title": stripped.strip("- ").strip(), "lines": []} + elif stripped and set(stripped) <= {"="}: + continue + else: + current["lines"].append(line) + + if current["lines"] or current["title"] != "Preamble": + sections.append(current) + + for section in sections: + fields = {} + for line in section["lines"]: + stripped = line.strip() + if ":" in stripped and "|" not in stripped: + key, val = stripped.split(":", 1) + fields[key.strip()] = val.strip() + if fields: + section["fields"] = fields + + return sections + + +def _collect_report_fields(sections: List[Dict[str, Any]]) -> Dict[str, str]: + fields: Dict[str, str] = {} + for section in sections: + for key, value in section.get("fields", {}).items(): + fields.setdefault(key, value) + return fields + + +def _discover_report_images(base_dir: Path, limit: int = 12) -> List[Path]: + candidates: List[Path] = [] + for pattern in ("*.png", "*.jpg", "*.jpeg", "*.webp"): + candidates.extend(base_dir.glob(pattern)) + for folder_name in ("visualizations", "visualization"): + folder = base_dir / folder_name + if folder.exists(): + for pattern in ("*.png", "*.jpg", "*.jpeg", "*.webp"): + candidates.extend(folder.glob(pattern)) + + def rank(path: Path) -> tuple: + name = path.name.lower() + priority = 5 + for idx, token in enumerate( + ("composite", "depth_analysis", "roi_side", "roi_front", "roi_top", "oblique") + ): + if token in name: + priority = idx + break + return (priority, name) + + unique = sorted({path.resolve(): path for path in candidates}.values(), key=rank) + return unique[:limit] + + +def _relative_path(path: Optional[Path], start: Path) -> str: + if path is None: + return "N/A" + try: + return path.resolve().relative_to(start.resolve()).as_posix() + except ValueError: + return path.as_posix() + + +def _human_report_type(report_type: str) -> str: + return report_type.replace("_", " ").strip().title() + + +def _report_overview(report_type: str, fields: Dict[str, str], data: Dict[str, Any]) -> str: + subject = fields.get("Subject") or data.get("subject_id") or "N/A" + target = data.get("target_label") or fields.get("Target Tractogram") or fields.get("Prefix") + workflow = data.get("workflow") or _human_report_type(report_type) + if target: + return ( + f"{workflow} report for subject {subject}, focused on {target}. " + "This HTML sidecar summarizes the existing TXT and JSON outputs and links " + "available visualization files without embedding large binary data." + ) + return ( + f"{workflow} report for subject {subject}. This HTML sidecar summarizes " + "the existing TXT and JSON outputs and links available visualization files " + "without embedding large binary data." + ) + + +_STATUS_OK = {"PASS", "WITHIN_RANGE", "OK", "VALID"} +_STATUS_WARN = { + "WARN", + "WARNING", + "CLAMPED", + "CLAMPED_LOW", + "CLAMPED_HIGH", + "DEVICE_LIMITED", +} +_STATUS_BAD = {"FAIL", "FAILED", "ERROR", "ESTIMATION_FAILED", "INVALID"} + + +def _status_class(token: str) -> Optional[str]: + t = token.strip().upper() + if t in _STATUS_OK: + return "ok" + if t in _STATUS_WARN: + return "warn" + if t in _STATUS_BAD: + return "bad" + return None + + +def _decorate_cell(escaped: str) -> str: + """Wrap a leading QC/flag status token in a coloured badge (input pre-escaped).""" + if not escaped: + return escaped + head = escaped.split(" ", 1)[0] + cls = _status_class(head) + if cls is None: + return escaped + return f'{head}{escaped[len(head):]}' + + +def _slug(text: str) -> str: + keep = [c.lower() if (c.isalnum()) else "-" for c in str(text)] + slug = "".join(keep).strip("-") + while "--" in slug: + slug = slug.replace("--", "-") + return slug or "section" + + +def _render_kv_table(items: List[tuple]) -> str: + rows = [] + for key, value in items: + if value in (None, "", []): + continue + val = _decorate_cell(escape(str(value))) + rows.append("" f"{escape(str(key))}" f"{val}" "") + if not rows: + return "" + return '' + "".join(rows) + "
" + + +def _is_separator_line(line: str) -> bool: + stripped = line.strip() + return bool(stripped) and set(stripped) <= {"-", "|", " "} + + +def _render_pipe_table(lines: List[str]) -> str: + rows = [] + for line in lines: + if _is_separator_line(line): + continue + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if any(cells): + rows.append(cells) + + if not rows: + return "" + + header, *body = rows + thead = "" + "".join(f"{escape(cell)}" for cell in header) + "" + tbody_rows = [] + for row in body: + tbody_rows.append( + "" + "".join(f"{_decorate_cell(escape(cell))}" for cell in row) + "" + ) + tbody = "" + "".join(tbody_rows) + "" + return f'
{thead}{tbody}
' + + +def _render_section(section: Dict[str, Any]) -> str: + title = escape(str(section["title"])) + fields = section.get("fields", {}) + field_lines = {f"{key}: {value}" for key, value in fields.items()} + blocks = [] + pending_text = [] + pending_table = [] + + def flush_text() -> None: + if pending_text: + text = "\n".join(pending_text).strip() + if text: + blocks.append(f"
{escape(text)}
") + pending_text.clear() + + def flush_table() -> None: + if pending_table: + table = _render_pipe_table(pending_table) + if table: + blocks.append(table) + pending_table.clear() + + if fields: + blocks.append(_render_kv_table(list(fields.items()))) + + for line in section.get("lines", []): + stripped = line.strip() + if not stripped or stripped in field_lines: + continue + if "|" in line or (pending_table and _is_separator_line(line)): + flush_text() + pending_table.append(line) + else: + flush_table() + pending_text.append(line) + + flush_table() + flush_text() + + if not blocks: + return "" + sec_id = _slug(section["title"]) + return f'

{title}

{"".join(blocks)}
' + + +_ICON_EXTERNAL = ( + '' +) + +_REPORT_CSS = """ +:root{ + --bg:#f5f6f8; --panel:#ffffff; --ink:#161b22; --muted:#5c6773; + --line:#e3e7ec; --line-2:#cdd4dd; --accent:#245c73; --accent-2:#2f7c99; + --accent-soft:#e9f1f4; --band:#eef4f6; + --mono:"SFMono-Regular",Consolas,"Liberation Mono",Menlo,monospace; + --ok-bg:#e6f4ec; --ok-fg:#1c7a49; --warn-bg:#fbf0d9; --warn-fg:#8a5a00; + --bad-bg:#fbe7e6; --bad-fg:#a4302c; +} +*{box-sizing:border-box} +html{scroll-behavior:smooth} +body{margin:0;background:var(--bg);color:var(--ink);line-height:1.55;font-size:15px; + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + -webkit-font-smoothing:antialiased} +main{max-width:1120px;margin:0 auto;padding:34px 22px 24px} +a{color:var(--accent-2);text-decoration:none} +a:hover{text-decoration:underline} +header.rep{border-left:4px solid var(--accent);padding:2px 0 16px 18px} +header.rep h1{margin:0 0 6px;font-size:27px;line-height:1.2;letter-spacing:-.01em;font-weight:700} +header.rep .lead{color:var(--muted);max-width:82ch;margin:0 0 15px;font-size:14px} +.chips{display:flex;flex-wrap:wrap;gap:8px} +.chip{font-size:12.5px;color:var(--ink);background:var(--panel);border:1px solid var(--line); + border-radius:999px;padding:4px 12px} +.chip b{color:var(--muted);font-weight:600;margin-right:5px;font-size:11px; + text-transform:uppercase;letter-spacing:.05em} +nav.toc{position:sticky;top:0;z-index:5;display:flex;flex-wrap:wrap;gap:2px;margin:10px 0 2px; + background:rgba(245,246,248,.93);backdrop-filter:blur(6px); + border-bottom:1px solid var(--line);padding:8px 0} +nav.toc a{font-size:12.5px;color:var(--muted);padding:5px 11px;border-radius:6px} +nav.toc a:hover{color:var(--ink);background:var(--accent-soft);text-decoration:none} +section{background:var(--panel);border:1px solid var(--line);border-radius:10px; + padding:18px 20px;margin:14px 0} +section.band{background:var(--band);border-color:#cfe0e6} +h2{margin:0 0 13px;font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.06em; + color:var(--accent);border-bottom:1px solid var(--line);padding-bottom:9px} +section.band h2{border-bottom-color:#cfe0e6} +h3{margin:16px 0 9px;font-size:13px;font-weight:600;color:var(--muted)} +h3:first-of-type{margin-top:2px} +p{color:var(--muted);max-width:84ch;margin:0 0 12px;font-size:13.5px} +.grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(330px,1fr));gap:14px} +.grid-2 section{margin:0} +table{width:100%;border-collapse:collapse} +th,td{border-bottom:1px solid var(--line);padding:7px 10px;text-align:left; + vertical-align:top;font-size:13.5px} +tbody tr:last-child th,tbody tr:last-child td{border-bottom:0} +thead th{color:var(--muted);font-weight:700;font-size:11.5px;text-transform:uppercase; + letter-spacing:.04em;border-bottom:1.5px solid var(--line-2)} +.kv-table th{width:36%;color:var(--muted);font-weight:600} +.kv-table td{font-variant-numeric:tabular-nums} +.kv-table td a{word-break:break-all} +.table-wrap{overflow-x:auto} +.table-wrap tbody tr:hover{background:#fafbfc} +pre{white-space:pre-wrap;word-break:break-word;overflow-x:auto;margin:0;color:var(--ink); + font-family:var(--mono);font-size:12.5px;background:#f8f9fb;border:1px solid var(--line); + border-radius:8px;padding:11px 13px} +.badge{display:inline-block;font-family:var(--mono);font-size:11px;font-weight:600; + letter-spacing:.02em;padding:2px 8px;border-radius:5px;line-height:1.5} +.badge-ok{background:var(--ok-bg);color:var(--ok-fg)} +.badge-warn{background:var(--warn-bg);color:var(--warn-fg)} +.badge-bad{background:var(--bad-bg);color:var(--bad-fg)} +.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px} +.stat{background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--accent); + border-radius:8px;padding:13px 15px} +.stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted); + font-weight:600} +.stat-value{font-size:25px;font-weight:700;letter-spacing:-.01em;margin:5px 0 2px; + font-variant-numeric:tabular-nums} +.stat-unit{font-size:12px;color:var(--muted);display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.btn-row{display:flex;flex-wrap:wrap;gap:10px} +.btn{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;font-weight:600;color:#fff; + background:var(--accent);border:1px solid var(--accent);border-radius:8px;padding:9px 15px} +.btn:hover{background:#1d4d60;text-decoration:none} +.btn .ext{opacity:.85} +.shot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px} +.shot{display:block;border:1px solid var(--line);border-radius:9px;overflow:hidden;background:#fff} +.shot:hover{border-color:var(--line-2);text-decoration:none} +.shot img{display:block;width:100%;height:auto} +.shot-cap{display:block;color:var(--muted);font-size:12px;padding:8px 11px; + border-top:1px solid var(--line)} +footer{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;color:var(--muted); + font-size:12px;padding:16px 4px 6px;margin-top:6px;border-top:1px solid var(--line)} +@media (max-width:640px){main{padding:22px 14px}header.rep h1{font-size:23px}nav.toc{display:none}} +@media print{ + html{scroll-behavior:auto} + body{background:#fff} + main{max-width:none;padding:0} + nav.toc,.btn-row{display:none} + section{break-inside:avoid;border:1px solid #ccc} + .shot{break-inside:avoid} +} +""" + + +def _humanize_view(stem: str) -> str: + low = stem.lower() + if "cst" in low: + side = "CST / M1" + elif "optimized" in low or "target" in low: + side = "Target" + else: + side = "" + views = [ + ("depth_analysis", "Depth analysis"), + ("composite", "Composite (multi-view)"), + ("roi_side", "ROI (sagittal)"), + ("roi_front", "ROI (coronal)"), + ("roi_top", "ROI (axial)"), + ("lateral_left", "Lateral (left)"), + ("lateral_right", "Lateral (right)"), + ("anterior", "Anterior view"), + ("posterior", "Posterior view"), + ("superior", "Superior view"), + ("oblique", "Oblique view"), + ] + view = next((label for token, label in views if token in low), stem.replace("_", " ")) + return f"{side} · {view}" if side else view + + +def _image_group(name: str) -> str: + low = name.lower() + if "cst" in low: + return "CST / M1" + if "optimized" in low or "target" in low: + return "Target" + return "Other" + + +def _discover_report_renders(base_dir: Path) -> List[tuple]: + """Locate interactive 3D HTML viewers (bundle previews + grid map) near the report.""" + found: List[Path] = [] + seen = set() + for folder in (base_dir, base_dir / "visualizations", base_dir / "visualization"): + if not folder.exists(): + continue + for pattern in ("*_interactive.html", "grid_interactive.html"): + for path in sorted(folder.glob(pattern)): + resolved = path.resolve() + if resolved in seen: + continue + seen.add(resolved) + found.append(path) + + def classify(path: Path) -> tuple: + name = path.name.lower() + if name == "grid_interactive.html": + return (2, "Interactive grid map (3D)") + if "cst" in name: + return (0, "CST / M1 bundle (3D)") + if "optimized" in name or "target" in name: + return (1, "Target bundle (3D)") + return (1, "Bundle preview (3D)") + + items = [] + for path in found: + order, label = classify(path) + items.append((order, label, _relative_path(path, base_dir))) + items.sort(key=lambda item: (item[0], item[2])) + return [(label, src) for _, label, src in items] + + +def _render_link(value: Any, base_dir: Path) -> Optional[str]: + if value in (None, "", []): + return None + text = str(value) + if isinstance(value, Path) or ("/" in text or "\\" in text): + path = Path(text) + rel = _relative_path(path, base_dir) + if rel not in ("N/A", ".", "") and not rel.startswith("/") and not rel.startswith(".."): + return f'{escape(path.name)}' + return escape(text) + return _decorate_cell(escape(text)) + + +def _render_paths_table(paths: List[tuple], base_dir: Path) -> str: + rows = [] + for key, value in paths: + cell = _render_link(value, base_dir) + if cell is None: + continue + rows.append(f"{escape(str(key))}{cell}") + if not rows: + return "" + return '' + "".join(rows) + "
" + + +def _parse_results_table(sections: List[Dict[str, Any]]) -> tuple: + for section in sections: + rows = [] + for line in section.get("lines", []): + if "|" in line and not _is_separator_line(line): + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if any(cells): + rows.append(cells) + if len(rows) >= 2 and rows[0] and rows[0][0].strip().lower() == "metric": + header = [cell.strip().lower() for cell in rows[0]] + table = {row[0]: row for row in rows[1:] if row and row[0]} + return header, table + return None, None + + +def _weighted_index(header: List[str]) -> int: + for idx, name in enumerate(header): + if "weighted" in name and "un" not in name: + return idx + return len(header) - 1 + + +def _unweighted_index(header: List[str]) -> int: + for idx, name in enumerate(header): + if "unweighted" in name: + return idx + return 1 if len(header) > 1 else 0 + + +def _key_result_tiles( + sections: List[Dict[str, Any]], fields: Dict[str, str] +) -> List[Dict[str, str]]: + tiles: List[Dict[str, str]] = [] + header, table = _parse_results_table(sections) + if table: + widx = _weighted_index(header) + uidx = _unweighted_index(header) + + def cell(idx: int, *needles: str) -> Optional[str]: + for name, row in table.items(): + low = name.lower() + if all(needle in low for needle in needles) and 0 <= idx < len(row): + return row[idx] + return None + + est_w = cell(widx, "estimated", "raw") + if est_w is not None: + tiles.append( + { + "label": "Estimated intensity (weighted)", + "value": est_w, + "unit": "% max output", + "badge": cell(widx, "flag") or "", + } + ) + est_u = cell(uidx, "estimated", "raw") + if est_u is not None: + tiles.append( + { + "label": "Estimated intensity (unweighted)", + "value": est_u, + "unit": "% max output", + "badge": cell(uidx, "flag") or "", + } + ) + return tiles + + +def _build_report_html( + *, + txt_path: Path, + html_path: Path, + json_path: Optional[Path], + report_type: str, + generated_at: str, + fields: Dict[str, str], + data: Dict[str, Any], + sections: List[Dict[str, Any]], + images: List[Path], +) -> str: + try: + from tide import __version__ as version + except Exception: + version = "" + + title = f"TIDE Report - {_human_report_type(report_type)}" + overview = _report_overview(report_type, fields, data) + base_dir = html_path.parent + subject = fields.get("Subject") or data.get("subject_id") + workflow = data.get("workflow") or _human_report_type(report_type) + target = data.get("target_label") or fields.get("Prefix") + date = fields.get("Date") or generated_at + + paths = [ + ("Generated at", generated_at), + ("Source TXT", txt_path), + ("Source JSON", json_path or txt_path.with_suffix(".json")), + ("Report HTML", html_path), + ("Output folder", fields.get("Output Folder") or data.get("output_dir") or base_dir), + ] + key_metrics = [ + ("Subject", subject), + ("Date", date), + ("Workflow", workflow), + ("Target", target), + ("Spatial mode", fields.get("Spatial Mode")), + ("Weight source", fields.get("Weight Source")), + ("ROI size", fields.get("ROI Size")), + ("Activation length", fields.get("Activation Length")), + ] + + # Header meta chips + chips = [] + for label, value in ( + ("Subject", subject), + ("Workflow", workflow), + ("Date", date), + ("Target", target), + ): + if value: + chips.append(f'{escape(label)}{escape(str(value))}') + chips_html = f'
{"".join(chips)}
' if chips else "" + + # Key-results highlight band + tiles = _key_result_tiles(sections, fields) + band_html = "" + if tiles: + stat_cards = [] + for tile in tiles: + badge = "" + if tile.get("badge"): + cls = _status_class(tile["badge"]) or "warn" + badge = f'{escape(tile["badge"])}' + stat_cards.append( + '
' + f'
{escape(tile["label"])}
' + f'
{escape(str(tile["value"]))}
' + f'
{escape(tile["unit"])}{badge}
' + "
" + ) + band_html = ( + '

Estimated intensity (recommended dose)

' + f'
{"".join(stat_cards)}
' + ) + + # Interactive 3D viewers + renders = _discover_report_renders(base_dir) + render_html = "" + if renders: + buttons = "".join( + f'' + f"{escape(label)}{_ICON_EXTERNAL}" + for label, src in renders + ) + render_html = ( + '

Interactive 3D views

' + "

Open the WebGL bundle and grid viewers in a new browser tab. " + "Full-resolution geometry stays in the TRK, NIfTI and CSV outputs.

" + f'
{buttons}
' + ) + + # Visualization gallery (clickable to full resolution), grouped by bundle + groups: Dict[str, List[str]] = {"CST / M1": [], "Target": [], "Other": []} + for image in images: + src = _relative_path(image, base_dir) + caption = _humanize_view(image.stem) + card = ( + f'' + f'{escape(caption)}' + f'{escape(caption)}' + ) + groups[_image_group(image.name)].append(card) + gallery = "" + for group_name in ("CST / M1", "Target", "Other"): + cards = groups[group_name] + if not cards: + continue + gallery += f"

{escape(group_name)}

" f'
{"".join(cards)}
' + image_html = "" + if gallery: + image_html = ( + '

Visualizations

' + "

Click any panel to open the full-resolution image in a new tab.

" + f"{gallery}
" + ) + + # Detailed parsed sections + table of contents + toc = [] + if renders: + toc.append(("interactive-3d", "3D views")) + if tiles: + toc.append(("results-highlight", "Key results")) + if gallery: + toc.append(("visualizations", "Visualizations")) + rendered_sections = [] + for section in sections: + section_html = _render_section(section) + if not section_html: + continue + rendered_sections.append(section_html) + toc.append(("sec-" + _slug(section["title"]), section["title"])) + sections_html = "".join(rendered_sections) + toc_html = "" + if toc: + links = "".join(f'{escape(label)}' for anchor, label in toc) + toc_html = f'' + + footer = ( + f"
Generated {escape(generated_at)}" + f"TIDE {escape(version)} reporting-only sidecar; " + "numerics live in the TXT, JSON, CSV, TRK and NIfTI outputs
" + ) + + return f""" + + + + +{escape(title)} + + + +
+
+

{escape(title)}

+

{escape(overview)}

+ {chips_html} +
+ {toc_html} + {render_html} + {band_html} +
+

Run summary

{_render_kv_table(key_metrics)}
+

Files and paths

{_render_paths_table(paths, base_dir)}
+
+ {image_html} + {sections_html} + {footer} +
+ + +""" diff --git a/src/tide/core/geometry.py b/src/tide/core/geometry.py new file mode 100644 index 0000000..8f65a31 --- /dev/null +++ b/src/tide/core/geometry.py @@ -0,0 +1,614 @@ +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import numpy as np + +# SimNIBS imports for mesh reading +try: + from simnibs.msh import read_msh +except ImportError: + try: + from simnibs import read_msh + except ImportError: + read_msh = None + +log = logging.getLogger(__name__) + +# GM node coordinates cached per resolved .msh path (read_msh is expensive). +_GM_NODE_CACHE: Dict[str, np.ndarray] = {} + + +@dataclass(frozen=True) +class CoilPoseQC: + """Coil-pose accessibility diagnostic.""" + + status: str + reasons: Tuple[str, ...] = () + scalp_outward_dot: Optional[float] = None + scalp_normal_z: Optional[float] = None + coil_normal_z: Optional[float] = None + scalp_z_percentile: Optional[float] = None + nearest_scalp_distance_mm: Optional[float] = None + + def as_dict(self) -> Dict[str, object]: + return { + "status": self.status, + "reasons": list(self.reasons), + "scalp_outward_dot": self.scalp_outward_dot, + "scalp_normal_z": self.scalp_normal_z, + "coil_normal_z": self.coil_normal_z, + "scalp_z_percentile": self.scalp_z_percentile, + "nearest_scalp_distance_mm": self.nearest_scalp_distance_mm, + } + + +def format_coil_pose_qc(qc: Optional[CoilPoseQC]) -> str: + """Return a compact single-line representation for reports/logs.""" + if qc is None: + return "N/A" + + parts = [qc.status] + if qc.reasons: + parts.append(f"({', '.join(qc.reasons)})") + + details = [] + if qc.scalp_outward_dot is not None: + details.append(f"normal_dot={qc.scalp_outward_dot:.3f}") + if qc.scalp_normal_z is not None: + details.append(f"scalp_normal_z={qc.scalp_normal_z:.3f}") + if qc.coil_normal_z is not None: + details.append(f"coil_normal_z={qc.coil_normal_z:.3f}") + if qc.scalp_z_percentile is not None: + details.append(f"scalp_z_pct={qc.scalp_z_percentile:.1f}") + if qc.nearest_scalp_distance_mm is not None: + details.append(f"nearest_scalp_mm={qc.nearest_scalp_distance_mm:.1f}") + if details: + parts.append("[" + ", ".join(details) + "]") + + return " ".join(parts) + + +def validate_coil_pose_for_dose(qc: CoilPoseQC, *, explicit_matrix: bool) -> None: + if qc.status != "WARN" or explicit_matrix: + return + + reasons = ", ".join(qc.reasons) if qc.reasons else "unspecified pose QC warning" + raise ValueError( + f"Automatically optimized coil pose is not dose-eligible ({reasons}). " + "Supply a verified 4x4 matsimnibs orientation for a specialist override." + ) + + +def _mesh_path(mesh_path: Path) -> Optional[Path]: + msh = Path(mesh_path) + if msh.is_dir(): + meshes = sorted(msh.glob("*.msh")) + if not meshes: + return None + if len(meshes) > 1: + raise ValueError(f"Head-model directory contains multiple .msh files: {msh}") + msh = meshes[0] + return msh + + +def _gm_nodes(mesh_path: Path) -> Optional[np.ndarray]: + """Return grey-matter node coordinates for a head mesh, cached per file.""" + if read_msh is None: + return None + + msh = _mesh_path(mesh_path) + if msh is None: + return None + + key = str(msh) + cached = _GM_NODE_CACHE.get(key) + if cached is not None: + return cached + + mesh = read_msh(key) + elm_tags = mesh.elm.tag1 + gm_tag = 1002 if 1002 in elm_tags else 2 + gm_node_indices = np.unique(mesh.elm.node_number_list[elm_tags == gm_tag]) + coords = mesh.nodes[:][gm_node_indices] + _GM_NODE_CACHE[key] = coords + return coords + + +def _mesh_scalp_nodes_and_center(mesh_path: Path) -> Tuple[np.ndarray, np.ndarray]: + if read_msh is None: + raise ImportError("SimNIBS not installed. Cannot read mesh.") + + msh = _mesh_path(mesh_path) + if msh is None: + raise FileNotFoundError(f"No .msh file found in {mesh_path}") + + mesh = read_msh(str(msh)) + all_nodes = mesh.nodes[:] + elm_tags = mesh.elm.tag1 + + if 1002 in elm_tags or 1005 in elm_tags: + gm_tag, scalp_tag = 1002, 1005 + else: + gm_tag, scalp_tag = 2, 5 + + scalp_mask = elm_tags == scalp_tag + elm_types = getattr(mesh.elm, "elm_type", None) + if elm_types is not None and len(elm_types) == len(elm_tags): + tri_mask = scalp_mask & (elm_types == 2) + if np.any(tri_mask): + scalp_mask = tri_mask + + scalp_elems = np.asarray(mesh.elm.node_number_list[scalp_mask]) + if scalp_elems.size == 0: + raise ValueError(f"No scalp elements found with tag {scalp_tag}") + scalp_node_indices = np.unique(scalp_elems[:, :3]) + scalp_nodes = all_nodes[scalp_node_indices] + + gm_elems = np.asarray(mesh.elm.node_number_list[elm_tags == gm_tag]) + if gm_elems.size == 0: + raise ValueError(f"No grey matter elements found with tag {gm_tag}") + gm_node_indices = np.unique(gm_elems[:, :3]) + brain_center = np.mean(all_nodes[gm_node_indices], axis=0) + + return scalp_nodes, brain_center + + +def _local_scalp_normal( + scalp_nodes: np.ndarray, + brain_center: np.ndarray, + scalp_coords: np.ndarray, + n_neighbors: int, +) -> Tuple[np.ndarray, float, float]: + distances = np.linalg.norm(scalp_nodes - scalp_coords, axis=1) + nearest_count = min(max(n_neighbors, 3), len(scalp_nodes)) + nearest_indices = np.argsort(distances)[:nearest_count] + neighbors = scalp_nodes[nearest_indices] + + centered = neighbors - neighbors.mean(axis=0) + _, _, vh = np.linalg.svd(centered, full_matrices=False) + scalp_normal = vh[-1] + + if np.dot(scalp_normal, scalp_coords - brain_center) < 0: + scalp_normal = -scalp_normal + + norm = np.linalg.norm(scalp_normal) + if norm < 1e-9: + raise ValueError("Could not estimate a stable local scalp normal") + + z_percentile = float(np.mean(scalp_nodes[:, 2] <= scalp_coords[2]) * 100.0) + return scalp_normal / norm, float(distances[nearest_indices[0]]), z_percentile + + +def evaluate_coil_pose_qc( + mesh_path: Path, + matrix: np.ndarray, + scalp_coords: Optional[np.ndarray] = None, + *, + n_neighbors: int = 30, + inward_dot_threshold: float = -0.20, + inferior_percentile: float = 10.0, + inferior_normal_z: float = 0.25, + upward_coil_z: float = 0.25, + max_scalp_distance_mm: float = 15.0, +) -> CoilPoseQC: + """ + Evaluate coil-pose accessibility without relocating or modifying the pose. + + The SimNIBS matsimnibs third column is treated as the inward coil normal. + A valid scalp-side pose should therefore have a negative dot product with + the local outward scalp normal. Inferior-surface checks catch the documented + skull-base/upward-firing failure mode. Callers decide whether WARN is + report-only or a dose-eligibility gate. + """ + if read_msh is None: + return CoilPoseQC(status="UNAVAILABLE", reasons=("simnibs_unavailable",)) + + matrix_arr = np.asarray(matrix, dtype=float) + if matrix_arr.shape != (4, 4) or not np.isfinite(matrix_arr).all(): + return CoilPoseQC(status="UNAVAILABLE", reasons=("invalid_matrix",)) + + scalp_arr = ( + np.asarray(scalp_coords, dtype=float) + if scalp_coords is not None + else np.asarray(matrix_arr[:3, 3], dtype=float) + ) + if scalp_arr.shape != (3,) or not np.isfinite(scalp_arr).all(): + return CoilPoseQC(status="UNAVAILABLE", reasons=("invalid_scalp_coords",)) + + coil_normal = np.asarray(matrix_arr[:3, 2], dtype=float) + coil_norm = np.linalg.norm(coil_normal) + if coil_norm < 1e-9: + return CoilPoseQC(status="UNAVAILABLE", reasons=("invalid_coil_normal",)) + coil_normal = coil_normal / coil_norm + + try: + scalp_nodes, brain_center = _mesh_scalp_nodes_and_center(mesh_path) + inferior_z_cut = float(np.percentile(scalp_nodes[:, 2], inferior_percentile)) + scalp_normal, nearest_distance, z_percentile = _local_scalp_normal( + scalp_nodes, + brain_center, + scalp_arr, + n_neighbors, + ) + except Exception as exc: + return CoilPoseQC(status="UNAVAILABLE", reasons=(f"mesh_unavailable:{exc}",)) + + outward_dot = float(np.dot(coil_normal, scalp_normal)) + reasons = [] + if outward_dot >= inward_dot_threshold: + reasons.append("coil_normal_not_inward") + if nearest_distance > max_scalp_distance_mm: + reasons.append("coil_center_far_from_scalp") + is_inferior = scalp_arr[2] <= inferior_z_cut + 1e-6 + if is_inferior and scalp_normal[2] < -inferior_normal_z: + reasons.append("inferior_scalp_surface") + if is_inferior and coil_normal[2] > upward_coil_z: + reasons.append("upward_firing_low_inferior_pose") + + return CoilPoseQC( + status="WARN" if reasons else "PASS", + reasons=tuple(reasons), + scalp_outward_dot=outward_dot, + scalp_normal_z=float(scalp_normal[2]), + coil_normal_z=float(coil_normal[2]), + scalp_z_percentile=z_percentile, + nearest_scalp_distance_mm=nearest_distance, + ) + + +def coords_inside_brain(mesh_path: Path, coords: np.ndarray) -> Optional[bool]: + """ + Heuristic test for whether a point lies within the grey-matter extent. + + Compares the point's distance from the brain centre against the GM extent + along the point's own radial direction. True means the point looks like a + cortical coordinate (inside GM) rather than a scalp coordinate. Returns + None when the mesh cannot be read. + """ + gm = _gm_nodes(mesh_path) + if gm is None or len(gm) == 0: + return None + + coords = np.asarray(coords, dtype=float) + center = gm.mean(axis=0) + offset = coords - center + dist = float(np.linalg.norm(offset)) + if dist < 1e-6: + return True + + direction = offset / dist + gm_extent = float(((gm - center) @ direction).max()) + return dist < gm_extent + + +def project_target_to_scalp(mesh_path: Path, target_coords: np.ndarray) -> np.ndarray: + """ + Projects a cortical target coordinate to the outermost scalp surface using + Ray-Triangle Intersection (Ray Casting). + + This offers sub-millimeter precision compared to node-based cone search. + + Args: + mesh_path: Path to the SimNIBS .msh file. + target_coords: Numpy array [x, y, z] of the cortical target. + + Returns: + Numpy array [x, y, z] of the exact intersection point on the scalp. + + Raises: + ImportError: If SimNIBS is not installed. + FileNotFoundError: If mesh file doesn't exist. + ValueError: If target coordinates are invalid. + """ + if read_msh is None: + raise ImportError("SimNIBS not installed. Cannot read mesh.") + + # Input validation + mesh_path = Path(mesh_path) + if not mesh_path.exists(): + raise FileNotFoundError(f"Mesh file not found: {mesh_path}") + + target_coords = np.asarray(target_coords, dtype=float) + if target_coords.shape != (3,): + raise ValueError(f"target_coords must be shape (3,), got {target_coords.shape}") + if not np.isfinite(target_coords).all(): + raise ValueError(f"target_coords contains non-finite values: {target_coords}") + + try: + # --- Load Mesh --- + log.debug(f"Loading mesh: {mesh_path}") + mesh = read_msh(str(mesh_path)) + all_nodes = mesh.nodes[:] + elm_tags = mesh.elm.tag1 + + # Determine Tags (SimNIBS v3 vs v4 compatibility) + if 1002 in elm_tags: + gm_tag, scalp_tag = 1002, 1005 + else: + gm_tag, scalp_tag = 2, 5 + + # --- 1. Get Brain Center (Origin of Ray) --- + gm_elm_mask = elm_tags == gm_tag + gm_node_indices = np.unique(mesh.elm.node_number_list[gm_elm_mask]) + + if len(gm_node_indices) == 0: + raise ValueError(f"No grey matter elements found with tag {gm_tag}") + + brain_center = np.mean(all_nodes[gm_node_indices], axis=0) + + # --- 2. Define Ray --- + ray_origin = brain_center + target_vec = target_coords - brain_center + target_dist = np.linalg.norm(target_vec) + if target_dist < 1e-6: + raise ValueError( + f"Target ({target_coords}) is too close to brain center ({brain_center}). " + "Cannot define projection ray." + ) + ray_direction = target_vec / target_dist + + # --- 3. Extract Scalp Triangles --- + scalp_elm_mask = elm_tags == scalp_tag + scalp_tris_indices = mesh.elm.node_number_list[scalp_elm_mask] + + # Ensure we are looking at triangles (3 columns) + if scalp_tris_indices.shape[1] != 3: + tri_types = mesh.elm.elm_type[scalp_elm_mask] == 2 + scalp_tris_indices = scalp_tris_indices[tri_types] + + # Get triangle vertices (SimNIBS nodes array has dummy row at index 0) + vert0 = all_nodes[scalp_tris_indices[:, 0]] + vert1 = all_nodes[scalp_tris_indices[:, 1]] + vert2 = all_nodes[scalp_tris_indices[:, 2]] + + # --- 4. Vectorized Möller–Trumbore Intersection --- + edge1 = vert1 - vert0 + edge2 = vert2 - vert0 + + h = np.cross(ray_direction, edge2) + a = np.einsum("ij,ij->i", edge1, h) + + # Handle parallel triangles + epsilon = 1e-7 + valid_a = np.abs(a) > epsilon + + f = np.zeros_like(a) + f[valid_a] = 1.0 / a[valid_a] + + s = ray_origin - vert0 + u = f * np.einsum("ij,ij->i", s, h) + + q = np.cross(s, edge1) + v = f * np.einsum("j,ij->i", ray_direction, q) + t = f * np.einsum("ij,ij->i", edge2, q) + + # Intersection validity conditions + valid_mask = valid_a & (u >= 0.0) & (u <= 1.0) & (v >= 0.0) & (u + v <= 1.0) & (t > epsilon) + + if not np.any(valid_mask): + log.warning("Ray casting found no intersection. Falling back to nearest scalp node.") + scalp_node_indices = np.unique(scalp_tris_indices) + scalp_nodes = all_nodes[scalp_node_indices] + dists = np.linalg.norm(scalp_nodes - target_coords, axis=1) + return scalp_nodes[np.argmin(dists)] + + # --- 5. Select Outermost Intersection (max t) --- + valid_t = t[valid_mask] + best_t = np.max(valid_t) + + intersection_point = ray_origin + ray_direction * best_t + + return intersection_point + + except Exception as e: + log.error(f"Error during geometric projection: {e}") + raise + + +def compute_default_coil_orientation(mesh_path: Path, scalp_coords: np.ndarray) -> List[float]: + """ + Computes a default coil handle orientation (pos_ydir) for TMS optimization. + + The handle is oriented 45° from ANTERIOR toward the MEDIAL direction + (contralateral hemisphere), constrained to the scalp tangent plane. + + - Left hemisphere (X < 0): Handle points anterior-RIGHT (45° toward midline) + - Right hemisphere (X > 0): Handle points anterior-LEFT (45° toward midline) + + Args: + mesh_path: Path to SimNIBS .msh head mesh + scalp_coords: [x, y, z] coil center position on scalp + + Returns: + [x, y, z] reference point defining handle direction (for SimNIBS pos_ydir) + """ + if read_msh is None: + raise ImportError("SimNIBS not installed. Cannot read mesh.") + + scalp_coords = np.asarray(scalp_coords, dtype=float) + + # --- Load mesh and extract scalp surface --- + mesh = read_msh(str(mesh_path)) + all_nodes = mesh.nodes[:] + elm_tags = mesh.elm.tag1 + + # SimNIBS v3/v4 tag compatibility + scalp_tag = 1005 if 1002 in elm_tags else 5 + gm_tag = 1002 if 1002 in elm_tags else 2 + + # Get scalp nodes + scalp_elm_mask = elm_tags == scalp_tag + scalp_node_indices = np.unique(mesh.elm.node_number_list[scalp_elm_mask]) + scalp_nodes = all_nodes[scalp_node_indices] + + # Get brain center for outward direction reference + gm_elm_mask = elm_tags == gm_tag + gm_node_indices = np.unique(mesh.elm.node_number_list[gm_elm_mask]) + brain_center = np.mean(all_nodes[gm_node_indices], axis=0) + + # --- Compute local scalp normal at coil position --- + distances = np.linalg.norm(scalp_nodes - scalp_coords, axis=1) + nearest_indices = np.argsort(distances)[:30] + neighbors = scalp_nodes[nearest_indices] + + # PCA: smallest eigenvector = surface normal + centered = neighbors - neighbors.mean(axis=0) + _, _, vh = np.linalg.svd(centered) + scalp_normal = vh[2] + + # Ensure normal points OUTWARD (away from brain center) + if np.dot(scalp_normal, scalp_coords - brain_center) < 0: + scalp_normal = -scalp_normal + + # --- Compute handle direction in tangent plane --- + # 1. Start with ANTERIOR direction (+Y in RAS/MNI coordinates) + anterior = np.array([0.0, 1.0, 0.0]) + + # 2. Project anterior onto scalp tangent plane + anterior_tangent = anterior - np.dot(anterior, scalp_normal) * scalp_normal + norm = np.linalg.norm(anterior_tangent) + + if norm < 1e-6: + # Edge case: scalp normal is nearly vertical (top of head) + # Use -X as fallback anterior reference + anterior_tangent = np.array([-1.0, 0.0, 0.0]) + anterior_tangent = anterior_tangent - np.dot(anterior_tangent, scalp_normal) * scalp_normal + norm = np.linalg.norm(anterior_tangent) + + anterior_tangent = anterior_tangent / norm + + # 3. Compute lateral direction (perpendicular to anterior, in tangent plane) + # Cross product: normal × anterior gives lateral direction + lateral = np.cross(scalp_normal, anterior_tangent) + lateral = lateral / np.linalg.norm(lateral) + + # 4. Rotate 45° from anterior toward MEDIAL (contralateral) side + angle_rad = np.radians(45.0) + + # Determine hemisphere and medial direction: + # Left hemisphere (X < 0): medial is toward +X (right) + # Right hemisphere (X > 0): medial is toward -X (left) + if scalp_coords[0] < 0: + # Left hemisphere: medial is +X direction + medial_sign = 1.0 if lateral[0] > 0 else -1.0 + else: + # Right hemisphere: medial is -X direction + medial_sign = 1.0 if lateral[0] < 0 else -1.0 + + # Combine anterior + medial rotation + handle_direction = ( + np.cos(angle_rad) * anterior_tangent + np.sin(angle_rad) * medial_sign * lateral + ) + + handle_direction = handle_direction / np.linalg.norm(handle_direction) + + # --- Create reference point for SimNIBS --- + # pos_ydir expects a point that the handle "looks at" + orientation_point = scalp_coords + handle_direction * 100.0 + + hemisphere = "Left" if scalp_coords[0] < 0 else "Right" + log.info( + f"Auto-orientation ({hemisphere} hemisphere): " + f"45° anterior-medial, handle vector = {handle_direction.round(3)}" + ) + + return orientation_point.tolist() + + +def calculate_alignment_and_depth( + streamlines: list, + e_vectors: list, + roi_masks: list, + mesh_path: Path, + roi_center: list, +): + """ + Calculates geometric bias metrics (Alignment and Depth) for a bundle. + + Args: + streamlines: List of streamline coordinates + e_vectors: List of E-field vectors per streamline + roi_masks: List of boolean masks for ROI + mesh_path: Path to SimNIBS mesh + roi_center: ROI center coordinates + + Returns: + Tuple of (mean_alignment, depth_mm) + """ + valid_alignments = [] + + for sl, e, mask in zip(streamlines, e_vectors, roi_masks): + if len(sl) < 2 or len(sl) != len(e) or len(mask) != len(sl): + continue + + tangents = np.gradient(sl, axis=0) + norm = np.linalg.norm(tangents, axis=1)[:, None] + tangents = tangents / (norm + 1e-9) + + e_mag = np.linalg.norm(e, axis=1)[:, None] + e_norm = e / (e_mag + 1e-9) + + alignment = np.abs(np.sum(e_norm * tangents, axis=1)) + roi_align = alignment[mask] + + if len(roi_align) > 0: + roi_e = e_mag[mask].flatten() + e_thresh = np.max(roi_e) * 0.1 + significant_mask = roi_e > e_thresh + if np.any(significant_mask): + valid_alignments.extend(roi_align[significant_mask]) + + mean_alignment = float(np.mean(valid_alignments)) if valid_alignments else 0.0 + + try: + scalp_point = project_target_to_scalp(mesh_path, np.array(roi_center)) + depth = float(np.linalg.norm(scalp_point - np.array(roi_center))) + except Exception: + depth = 0.0 + + return mean_alignment, depth + + +def calculate_alignment_corrected( + streamlines: List[np.ndarray], + e_vectors: List[np.ndarray], + roi_masks: List[np.ndarray], +) -> float: + """Calculate alignment on midpoint streamlines using midpoint E vectors.""" + valid_alignments: List[float] = [] + + for sl, e, mask in zip(streamlines, e_vectors, roi_masks): + sl_arr = np.asarray(sl, dtype=float) + e_arr = np.asarray(e, dtype=float) + mask_arr = np.asarray(mask, dtype=bool) + + if len(sl_arr) < 2 or len(mask_arr) != len(sl_arr): + continue + + if len(e_arr) == len(sl_arr) + 1: + e_eval = 0.5 * (e_arr[:-1] + e_arr[1:]) + elif len(e_arr) == len(sl_arr): + e_eval = e_arr + else: + continue + + tangents = np.gradient(sl_arr, axis=0) + tangent_norm = np.linalg.norm(tangents, axis=1)[:, None] + tangents = tangents / (tangent_norm + 1e-9) + + e_mag = np.linalg.norm(e_eval, axis=1)[:, None] + e_norm = e_eval / (e_mag + 1e-9) + + alignment = np.abs(np.sum(e_norm * tangents, axis=1)) + roi_align = alignment[mask_arr] + + if len(roi_align) > 0: + roi_e = e_mag[mask_arr].flatten() + e_thresh = np.max(roi_e) * 0.1 + significant_mask = roi_e > e_thresh + if np.any(significant_mask): + valid_alignments.extend(roi_align[significant_mask]) + + return float(np.mean(valid_alignments)) if valid_alignments else 0.0 diff --git a/src/tide/core/io.py b/src/tide/core/io.py new file mode 100644 index 0000000..dc27150 --- /dev/null +++ b/src/tide/core/io.py @@ -0,0 +1,632 @@ +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import matplotlib.pyplot as plt +import nibabel as nib +import numpy as np +from dipy.io.stateful_tractogram import StatefulTractogram +from dipy.io.streamline import save_tractogram + +from tide.core import _reporting +from tide.core.physics import AGGREGATOR_KEYS, AGGREGATOR_LABELS, PRIMARY_AGGREGATOR + +log = logging.getLogger(__name__) + + +def _json_safe(value: Any) -> Any: + if value is None or isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + return value if np.isfinite(value) else None + if isinstance(value, np.generic): + return _json_safe(value.item()) + if isinstance(value, np.ndarray): + return _json_safe(value.tolist()) + if isinstance(value, Path): + return str(value) + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + return str(value) + + +def save_report_json( + txt_path: Path, + report_type: str, + data: Optional[Dict[str, Any]] = None, + text_lines: Optional[List[str]] = None, +) -> Path: + txt_path = Path(txt_path) + json_path = txt_path.with_suffix(".json") + + try: + if text_lines is None: + text_content = txt_path.read_text(encoding="utf-8") if txt_path.exists() else "" + lines = text_content.splitlines() + else: + lines = list(text_lines) + text_content = "\n".join(lines) + + payload = { + "schema_version": "1.0", + "report_type": report_type, + "source_txt": str(txt_path), + "generated_at": datetime.now().isoformat(timespec="seconds"), + "text": { + "content": text_content, + "lines": lines, + }, + "sections": _reporting._parse_report_sections(lines), + } + if data is not None: + payload["data"] = _json_safe(data) + + with open(json_path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, ensure_ascii=False, allow_nan=False) + + save_report_html( + txt_path, + report_type, + data=data, + text_lines=lines, + json_path=json_path, + generated_at=payload["generated_at"], + ) + log.info(f"Saved JSON report to: {json_path}") + return json_path + except Exception as e: + log.error(f"Failed to save JSON report {json_path}: {e}") + raise + + +def save_report_html( + txt_path: Path, + report_type: str, + data: Optional[Dict[str, Any]] = None, + text_lines: Optional[List[str]] = None, + json_path: Optional[Path] = None, + generated_at: Optional[str] = None, +) -> Path: + txt_path = Path(txt_path) + html_path = txt_path.with_suffix(".html") + + try: + if text_lines is None: + text_content = txt_path.read_text(encoding="utf-8") if txt_path.exists() else "" + lines = text_content.splitlines() + else: + lines = list(text_lines) + + sections = _reporting._parse_report_sections(lines) + report_fields = _reporting._collect_report_fields(sections) + generated = generated_at or datetime.now().isoformat(timespec="seconds") + safe_data = _json_safe(data or {}) + images = _reporting._discover_report_images(txt_path.parent, limit=32) + + html = _reporting._build_report_html( + txt_path=txt_path, + html_path=html_path, + json_path=json_path, + report_type=report_type, + generated_at=generated, + fields=report_fields, + data=safe_data, + sections=sections, + images=images, + ) + html_path.write_text(html, encoding="utf-8") + log.info(f"Saved HTML report to: {html_path}") + return html_path + except Exception as e: + log.error(f"Failed to save HTML report {html_path}: {e}") + raise + + +def save_tract_with_data( + reference_sft: StatefulTractogram, + new_streamlines: List[np.ndarray], + output_path: Path, + scalar_name: str, + scalar_values: List[np.ndarray], + segment_lengths: List[np.ndarray], +): + """ + Saves a tractogram (.trk) using the NEW streamlines (midpoints) and scalar data. + """ + formatted_scalars = [s.reshape(-1, 1) for s in scalar_values] + formatted_lengths = [lengths.reshape(-1, 1) for lengths in segment_lengths] + + data_per_point = { + scalar_name: formatted_scalars, + "segment_length": formatted_lengths, + } + + sft_out = StatefulTractogram.from_sft( + new_streamlines, reference_sft, data_per_point=data_per_point + ) + + save_tractogram(sft_out, str(output_path), bbox_valid_check=False) + log.info(f"Successfully saved .trk file: {output_path}") + + +def save_gmsh_pos(points: np.ndarray, scalars: np.ndarray, output_path: Path, view_name: str): + try: + with open(output_path, "w") as f: + f.write(f'View "{view_name}" {{\n') + for (x, y, z), val in zip(points, scalars): + f.write(f" SP({x},{y},{z}){{{val}}};\n") + f.write("};\n") + log.info(f"Successfully saved .pos file: {output_path}") + except IOError as e: + log.error(f"Failed to save .pos file: {e}") + + +def save_points_as_nifti( + points: np.ndarray, + ref_img_path: Path, + output_path: Path, + values: Optional[np.ndarray] = None, +): + """ + Saves a set of RASMM points as a NIfTI mask using a reference image for affine/header. + If 'values' is provided, the voxels are filled with these values (float32). + Otherwise, voxels are set to 1 (uint8 binary mask). + """ + if nib is None: + raise RuntimeError("nibabel not installed. Cannot save NIfTI points.") + + if not ref_img_path.exists(): + raise FileNotFoundError(f"Reference image not found: {ref_img_path}") + + try: + ref_img = nib.load(str(ref_img_path)) + affine = ref_img.affine + inv_affine = np.linalg.inv(affine) + + # Ensure points are Nx3 + points_arr = np.atleast_2d(points) + if points_arr.shape[1] != 3: + raise ValueError(f"Points array has invalid shape {points_arr.shape}, expected (N, 3)") + + # Convert RASMM points to Voxel Coordinates + M = inv_affine[:3, :3] + abc = inv_affine[:3, 3] + voxel_coords = points_arr @ M.T + abc + + voxel_indices = np.rint(voxel_coords).astype(int) + + # Initialize empty volume + if values is not None: + if len(values) != len(points_arr): + raise ValueError( + f"Shape mismatch: {len(values)} values for {len(points_arr)} points." + ) + data = np.zeros(ref_img.shape, dtype=np.float32) + else: + data = np.zeros(ref_img.shape, dtype=np.uint8) + + # Filter out-of-bounds indices + valid_mask = ( + (voxel_indices[:, 0] >= 0) + & (voxel_indices[:, 0] < data.shape[0]) + & (voxel_indices[:, 1] >= 0) + & (voxel_indices[:, 1] < data.shape[1]) + & (voxel_indices[:, 2] >= 0) + & (voxel_indices[:, 2] < data.shape[2]) + ) + + valid_indices = voxel_indices[valid_mask] + + # Set voxels + if values is not None: + valid_values = values[valid_mask] + # Note: If multiple points map to the same voxel, the last one writes. + data[valid_indices[:, 0], valid_indices[:, 1], valid_indices[:, 2]] = valid_values + else: + data[valid_indices[:, 0], valid_indices[:, 1], valid_indices[:, 2]] = 1 + + # Save + new_img = nib.Nifti1Image(data, affine, ref_img.header) + nib.save(new_img, str(output_path)) + log.info(f"Saved NIfTI map: {output_path}") + + except Exception as e: + log.error(f"Failed to save grid points NIfTI: {e}") + raise + + +def plot_activation_depth( + activated_lengths: List[float], + output_path: Path, + scalar_name: str, + threshold_val: float, + threshold_pct: float, +): + try: + plt.figure(figsize=(10, 6)) + max_len = np.max(activated_lengths) if activated_lengths else 0 + bins = min(50, int(max_len) + 1) if max_len > 0 else 1 + + plt.hist(activated_lengths, bins=bins, edgecolor="black", color="#007ACC") + plt.title(f"Activated Length ({scalar_name})", fontsize=14) + plt.xlabel("Length > Threshold (mm)", fontsize=12) + plt.ylabel("Count", fontsize=12) + plt.grid(axis="y", linestyle="--", alpha=0.7) + + stats_text = ( + f"Total Streamlines: {len(activated_lengths)}\n" + f"Threshold: {threshold_pct}% ({threshold_val:.4f})" + ) + plt.text( + 0.95, + 0.95, + stats_text, + transform=plt.gca().transAxes, + ha="right", + va="top", + bbox=dict(facecolor="white", alpha=0.8), + ) + + plt.savefig(output_path, dpi=300, bbox_inches="tight") + plt.close() + log.info(f"Saved histogram: {output_path}") + except Exception as e: + log.warning(f"Failed to plot histogram: {e}") + + +def write_summary(output_path: Path, info: Dict[str, Any]): + """Generic summary writer (key-value).""" + try: + with open(output_path, "w") as f: + f.write("--- Analysis Summary ---\n") + for key, val in info.items(): + f.write(f"{key}: {val}\n") + save_report_json(output_path, "analysis_summary", data=info) + log.info(f"Successfully saved summary to: {output_path}") + except IOError as e: + log.error(f"Failed to save summary: {e}") + raise + + +def _format_pose_qc(pose_qc: Optional[Dict[str, Any]]) -> str: + if not pose_qc: + return "N/A" + + status = str(pose_qc.get("status", "N/A")) + reasons = pose_qc.get("reasons") or [] + parts = [status] + if reasons: + parts.append(f"({', '.join(str(reason) for reason in reasons)})") + + details = [] + for key, label in ( + ("scalp_outward_dot", "normal_dot"), + ("scalp_normal_z", "scalp_normal_z"), + ("coil_normal_z", "coil_normal_z"), + ("scalp_z_percentile", "scalp_z_pct"), + ("nearest_scalp_distance_mm", "nearest_scalp_mm"), + ): + val = pose_qc.get(key) + if val is not None: + details.append(f"{label}={float(val):.3f}") + if details: + parts.append("[" + ", ".join(details) + "]") + + return " ".join(parts) + + +def format_pose_qc(pose_qc: Optional[Dict[str, Any]]) -> str: + return _format_pose_qc(pose_qc) + + +def _format_optional_float(value: Optional[float], precision: int) -> str: + return "N/A" if value is None else f"{value:.{precision}f}" + + +def _format_optional_depth(value: Optional[float]) -> str: + return "N/A" if value is None else f"{value:.1f} mm" + + +def _format_alignment_qc( + alignment_qc: Optional[Dict[str, Any]], + label: str = "", +) -> List[str]: + prefix = f"{label} " if label else "" + if not alignment_qc: + return [ + f"{prefix}Alignment: N/A", + f"{prefix}Alignment Corrected: N/A", + f"{prefix}Depth: N/A", + ] + + return [ + f"{prefix}Alignment: {_format_optional_float(alignment_qc.get('alignment'), 4)}", + ( + f"{prefix}Alignment Corrected: " + f"{_format_optional_float(alignment_qc.get('alignment_corrected'), 4)}" + ), + f"{prefix}Depth: {_format_optional_depth(alignment_qc.get('depth_mm'))}", + ] + + +def save_mapping_summary(output_path: Path, info: Dict[str, Any]): + """ + Saves the E-field mapping summary in the specific requested format. + """ + try: + with open(output_path, "w") as f: + f.write("--- E-Field to Bundle Mapping Summary ---\n") + f.write(f"Timestamp: {info['Timestamp']}\n") + f.write(f"Prefix: {info['Prefix']}\n\n") + + f.write("--- INPUTS ---\n") + f.write(f"Mesh: {info['Mesh']}\n") + f.write(f"Bundle: {info['Bundle']}\n") + f.write(f"Anatomy: {info['Anatomy']}\n\n") + + f.write("--- PARAMETERS ---\n") + f.write(f"Mode (Scalar): {info['Mode']}\n") + threshold = info["Threshold_Percent"] + threshold_suffix = "" if str(threshold).upper() == "N/A" else "%" + f.write(f"Activation Threshold: {threshold}{threshold_suffix}\n\n") + + f.write("--- RESULTS ---\n") + f.write(f"Total Streamlines Processed: {info['Total_Streamlines']}\n") + f.write(f"Max AF Value: {info['Max_Value']}\n") + f.write(f"Min AF Value: {info['Min_Value']}\n\n") + + metrics = info.get("Metrics") + if metrics: + f.write("--- ROBUST METRICS ---\n") + for key, val in metrics.items(): + f.write(f"{key}: {val}\n") + f.write("\n") + + qc = info.get("QC") + if qc: + f.write("--- QC ---\n") + f.write(f"Target Coil Pose QC: {_format_pose_qc(qc.get('pose_qc'))}\n") + for line in _format_alignment_qc(qc.get("alignment_qc"), label="Target"): + f.write(f"{line}\n") + f.write("\n") + + f.write("--- OUTPUT FILES (in outdir) ---\n") + for desc, fname in info["Output_Files"].items(): + f.write(f"{desc}: {fname}\n") + + save_report_json(output_path, "mapping_summary", data=info) + log.info(f"Successfully saved summary to: {output_path}") + except IOError as e: + log.error(f"Failed to save summary: {e}") + raise + + +def save_optimization_result_txt( + output_path: Path, + matrix: np.ndarray, + scalp_coords: np.ndarray, + setup_info: List[str] = None, + pose_qc: Optional[Dict[str, Any]] = None, + alignment_qc: Optional[Dict[str, Any]] = None, +): + try: + row_strings = [] + for r in range(3): + row_strings.append( + f"[{matrix[r, 0]:.8f}, {matrix[r, 1]:.8f}, {matrix[r, 2]:.8f}, {matrix[r, 3]:.8f}]" + ) + row_strings.append("[0, 0, 0, 1]") + single_line_matrix = f"[{', '.join(row_strings)}]" + rot = matrix[0:3, 0:3] + + content = [] + if setup_info: + content.extend(setup_info) + content.append("") + content.append("=" * 60) + content.append("--- OPTIMIZATION RESULTS ---") + content.append("=" * 60) + content.append("") + + content.append("Optimized Scalp Position (x, y, z):") + content.append(f"[{scalp_coords[0]:.8f}, {scalp_coords[1]:.8f}, {scalp_coords[2]:.8f}]") + content.append("") + content.append("Optimal 3x3 Orientation Matrix (m):") + content.append(f"[[{rot[0, 0]:.8f}, {rot[0, 1]:.8f}, {rot[0, 2]:.8f}],") + content.append(f" [{rot[1, 0]:.8f}, {rot[1, 1]:.8f}, {rot[1, 2]:.8f}],") + content.append(f" [{rot[2, 0]:.8f}, {rot[2, 1]:.8f}, {rot[2, 2]:.8f}]]") + content.append("") + content.append("Full 4x4 Transformation Matrix:") + content.append(np.array2string(matrix, precision=8, suppress_small=True)) + content.append("") + content.append("--- COIL POSE QC ---") + content.append(f"Pose QC: {_format_pose_qc(pose_qc)}") + content.append("") + content.append("--- ALIGNMENT QC ---") + content.extend(_format_alignment_qc(alignment_qc)) + content.append("") + content.append("--- FOR CONFIG FILE (copy/paste) ---") + content.append(f"orientation: {single_line_matrix}") + + with open(output_path, "w") as f: + f.write("\n".join(content)) + + save_report_json( + output_path, + "optimization_result", + data={ + "optimized_scalp_position": scalp_coords, + "orientation_matrix_3x3": rot, + "transformation_matrix_4x4": matrix, + "config_orientation": single_line_matrix, + "pose_qc": pose_qc, + "alignment_qc": alignment_qc, + "setup_info": setup_info or [], + }, + text_lines=content, + ) + log.info(f"Saved optimization results to: {output_path}") + + except Exception as e: + log.error(f"Failed to write optimization result file: {e}") + raise + + +def build_estimation_summary_lines( + *, + subject_id: str, + timestamp_str: str, + out_dir: Path, + num_workers: int, + t1w_path: Path, + cst_bundle_path: Path, + target_bundle_path: Path, + spatial_mode: str, + weight_source: str, + roi_size_mm: float, + activation_length_mm: float, + calibration_label: str, + measured_rmt_mso: float, + m1_matrix_str: str, + af_cst_w: float, + af_cst_u: float, + intensity_rmt: float, + biological_threshold: float, + target_label: str, + target_coords: List[float], + opt_scalp_str: str, + tgt_matrix_str: str, + af_tgt_w: float, + af_tgt_u: float, + cst_align: float, + tgt_align: float, + cst_depth: float, + tgt_depth: float, + optimization_gain: float, + ratio_at_m1: float, + intensity_from_m1_position: float, + intensity_raw_w: float, + intensity_raw_u: float, + intensity_clamped_w: float, + intensity_clamped_u: float, + intensity_flag_w: str, + intensity_flag_u: str, + mso_floor_ratio: float, + sei_w: float, + sei_u: float, + multiplier_w: float, + multiplier_u: float, + cst_align_corrected: Optional[float] = None, + tgt_align_corrected: Optional[float] = None, + calibration_pose_qc: Optional[Dict[str, Any]] = None, + target_pose_qc: Optional[Dict[str, Any]] = None, + aggregator_sensitivity: Optional[Dict[str, Dict[str, float]]] = None, +) -> List[str]: + """Build the TIDE_Results_.txt summary lines.""" + lines = [ + "===========================================", + "--- TIDE Estimation Pipeline Summary ---", + "===========================================", + "", + f" Subject: {subject_id}", + f" Date: {timestamp_str}", + f" Output Folder: {out_dir}", + f" Parallel Workers: {num_workers}", + "", + "--- Configuration ---", + f" Input T1w: {t1w_path}", + f" CST Tractogram: {cst_bundle_path}", + f" Target Tractogram: {target_bundle_path}", + f" Spatial Mode: {spatial_mode}", + f" Weight Source: {weight_source}", + f" ROI Size: {roi_size_mm} mm", + f" Activation Length: {activation_length_mm} mm", + "", + f"--- M1 Calibration ({calibration_label}) ---", + f" Measured RMT: {measured_rmt_mso} %MSO", + f" Optimized Matrix: {m1_matrix_str}", + f" M1 Coil Pose QC: {_format_pose_qc(calibration_pose_qc)}", + f" CST Efficiency (Weighted): {af_cst_w:.4f} V/m^2", + f" CST Efficiency (Unweighted): {af_cst_u:.4f} V/m^2", + f" RMT Intensity (dI/dt): {intensity_rmt / 1e6:.2f} A/us", + f" Biological Threshold: {biological_threshold:.2f} V/m^2", + "", + f"--- Target Estimation ({target_label}) ---", + f" Target Coords (Cortex): {target_coords}", + f" Optimized Scalp Position: {opt_scalp_str}", + f" Optimized Matrix: {tgt_matrix_str}", + f" Target Coil Pose QC: {_format_pose_qc(target_pose_qc)}", + f" Target Efficiency (Weighted): {af_tgt_w:.4f} V/m^2", + f" Target Efficiency (Unweighted): {af_tgt_u:.4f} V/m^2", + "", + "--- Geometric Analysis ---", + f" CST Alignment: {cst_align:.4f}", + f" Target Alignment: {tgt_align:.4f}", + f" CST Alignment Corrected: {_format_optional_float(cst_align_corrected, 4)}", + f" Target Alignment Corrected: {_format_optional_float(tgt_align_corrected, 4)}", + f" CST Depth: {cst_depth:.1f} mm", + f" Target Depth: {tgt_depth:.1f} mm", + "", + "--- Validation Metrics ---", + f" Optimization Gain: {optimization_gain:.2f}x", + f" Geometry Factor (at M1): {ratio_at_m1:.3f}", + f" I from M1 Position: {intensity_from_m1_position:.1f}%", + "", + "===========================================", + "--- RESULTS ---", + "===========================================", + f"{'Metric':<30} | {'Unweighted':<15} | {'Weighted':<15}", + "-" * 65, + f"{'Target Efficiency (V/m^2)':<30} | {af_tgt_u:<15.4f} | {af_tgt_w:<15.4f}", + f"{'Estimated I - Raw (%)':<30} | {intensity_raw_u:<15.1f} | {intensity_raw_w:<15.1f}", + f"{'Estimated I - Clamped (%)':<30} | {intensity_clamped_u:<15.1f} | {intensity_clamped_w:<15.1f}", + f"{'I Flag':<30} | {intensity_flag_u:<15} | {intensity_flag_w:<15}", + f"{'MSO Floor Ratio':<30} | {mso_floor_ratio:<15} | {mso_floor_ratio:<15}", + f"{'SEI (AF_target/AF_CST)':<30} | {sei_u:<15.4f} | {sei_w:<15.4f}", + f"{'Multiplier (M_CST/M_target)':<30} | {multiplier_u:<15.4f} | {multiplier_w:<15.4f}", + "=" * 65, + ] + lines.extend(build_aggregator_sensitivity_lines(aggregator_sensitivity)) + return lines + + +def build_aggregator_sensitivity_lines( + aggregator_sensitivity: Optional[Dict[str, Dict[str, float]]], +) -> List[str]: + """ + Build the additive aggregator-sensitivity block appended to the summary. + + Returns an empty list when no sensitivity data is supplied, so the report is + byte-identical to its previous form for callers that do not pass it. + """ + if not aggregator_sensitivity: + return [] + + lines = [ + "", + "--- Aggregator Sensitivity ---", + f" Reported dose uses '{AGGREGATOR_LABELS[PRIMARY_AGGREGATOR]}'; " + "rows below are diagnostic.", + "", + f"{'Aggregator':<20} | {'AF_CST (U)':<12} | {'AF_tgt (U)':<12} | {'SEI (U)':<9} | " + f"{'I Raw (U)':<9} | {'AF_CST (W)':<12} | {'AF_tgt (W)':<12} | {'SEI (W)':<9} | " + f"{'I Raw (W)':<9}", + "-" * 122, + ] + for key in AGGREGATOR_KEYS: + row = aggregator_sensitivity.get(key) + if row is None: + continue + lines.append( + f"{AGGREGATOR_LABELS[key]:<20} | " + f"{row['af_cst_unweighted']:<12.4f} | {row['af_target_unweighted']:<12.4f} | " + f"{row['sei_unweighted']:<9.4f} | {row['intensity_raw_unweighted']:<9.1f} | " + f"{row['af_cst_weighted']:<12.4f} | {row['af_target_weighted']:<12.4f} | " + f"{row['sei_weighted']:<9.4f} | {row['intensity_raw_weighted']:<9.1f}" + ) + lines.append("=" * 122) + return lines diff --git a/src/tide/core/physics.py b/src/tide/core/physics.py new file mode 100644 index 0000000..ff1dca9 --- /dev/null +++ b/src/tide/core/physics.py @@ -0,0 +1,664 @@ +""" +Physics Module for TIDE Pipeline +================================= +Implements activating function calculation and threshold estimation. +""" + +import logging +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +from dipy.tracking.metrics import frenet_serret +from scipy.ndimage import gaussian_filter1d + +log = logging.getLogger(__name__) + +# Constants +UNIT_DIDT = 1e6 # 1 A/µs = 1e6 A/s +AF_RESAMPLE_STEP_MM = 0.5 +AF_BOUNDARY_MODE = "nearest" + +# Cross-streamline aggregators of the per-streamline threshold distribution. +# "median_top5" is the committed TIDE statistic; the rest are diagnostic +# alternatives reported alongside it (see cross_streamline_aggregates). +AGGREGATOR_KEYS: Tuple[str, ...] = ( + "median_top5", + "median_top1", + "q95", + "q90", + "median", + "mean", +) +AGGREGATOR_LABELS: Dict[str, str] = { + "median_top5": "Median of Top 5%", + "median_top1": "Median of Top 1%", + "q95": "Q0.95", + "q90": "Q0.90", + "median": "Median", + "mean": "Mean", +} +PRIMARY_AGGREGATOR = "median_top5" + + +def calculate_scalar_map( + streamlines: List[np.ndarray], + e_field_vectors: List[np.ndarray], + mode: str = "af", + smooth_sigma: Optional[float] = None, + target_smooth_length_mm: float = 2.5, + signed: bool = True, + indices: Optional[np.ndarray] = None, +) -> Union[ + Tuple[List[np.ndarray], List[np.ndarray], List[np.ndarray]], + Tuple[List[np.ndarray], List[np.ndarray], List[np.ndarray], np.ndarray], +]: + """ + Calculates scalar values along streamlines. + + If mode='af', computes the Activating Function (gradient term): + AF = d(E·T)/ds + + AF is evaluated after 0.5 mm uniform arc-length resampling and physical + Gaussian smoothing. E_parallel retains point-based projection with an + adaptive smoothing scale for the streamline geometry. + + Args: + streamlines: List of streamline coordinates + e_field_vectors: List of E-field vectors per streamline + mode: 'af' for activating function, 'e_parallel' for parallel E-field + smooth_sigma: Gaussian smoothing sigma in points. If provided, applied + uniformly to every streamline (override for testing/debug). If None, + the physical smoothing length is converted to the sampling support. + target_smooth_length_mm: Target physical smoothing length in mm, + used to derive sigma when ``smooth_sigma`` is None. + signed: If True (default), return signed scalars preserving polarity + (depolarising vs hyperpolarising for AF; field direction for E_parallel). + If False, return absolute magnitudes. Consumers that require magnitude + (e.g., thresholding) should apply ``np.abs`` explicitly. + indices: Optional identifier array parallel to ``streamlines`` (e.g. + original streamline ids). When provided, the identifiers of the + surviving streamlines are returned as a 4th element so callers can + keep per-streamline weights aligned after drops (audit C-002). + + Returns: + ``(midpoint_streamlines, scalar_values, segment_lengths)``, or, when + ``indices`` is provided, ``(..., surviving_indices)``. + """ + if mode == "af": + return _calculate_af_map( + streamlines, + e_field_vectors, + smooth_sigma=smooth_sigma, + target_smooth_length_mm=target_smooth_length_mm, + signed=signed, + indices=indices, + ) + + track_indices = indices is not None + if track_indices: + indices = np.asarray(indices) + # Input validation - accept list or any array-like sequence (e.g., DIPY's ArraySequence) + try: + len(streamlines) + len(e_field_vectors) + except TypeError: + raise TypeError("streamlines and e_field_vectors must be iterable sequences") + if len(streamlines) != len(e_field_vectors): + raise ValueError( + f"Mismatch: {len(streamlines)} streamlines but {len(e_field_vectors)} E-field vectors" + ) + if mode != "e_parallel": + raise ValueError(f"Invalid mode '{mode}'. Must be 'af' or 'e_parallel'.") + + if len(streamlines) == 0: + log.warning("Empty streamlines list provided") + if track_indices: + return [], [], [], np.array([], dtype=int) + return [], [], [] + + log.debug(f"Computing {mode.upper()} for {len(streamlines)} streamlines") + + fallback_sigma = 3.0 # Used when a streamline step size cannot be estimated. + + final_streamlines_list = [] + final_scalars_list = [] + final_distances_list = [] + final_indices_list = [] + + skipped_short = 0 + skipped_frenet = 0 + skipped_mismatch = 0 + + for i, s_points in enumerate(streamlines): + if len(s_points) < 4: + skipped_short += 1 + continue + + # Per-streamline adaptive sigma: derive from this streamline's own + # mean step size so the physical smoothing length stays uniform + # across streamlines with heterogeneous resolution. + if smooth_sigma is None: + steps = np.linalg.norm(np.diff(s_points, axis=0), axis=1) + mean_step_mm = float(np.mean(steps)) if steps.size > 0 else 0.0 + sigma_i = target_smooth_length_mm / mean_step_mm if mean_step_mm > 0 else fallback_sigma + else: + sigma_i = smooth_sigma + + # Smooth geometry for stable tangent estimation. + s_smooth = gaussian_filter1d(s_points, sigma=sigma_i, axis=0, mode="nearest") + + try: + # Frenet frame from smoothed points; its tangent defines the projection. + T, _, _, _, _ = frenet_serret(s_smooth) + except Exception as e: + skipped_frenet += 1 + log.debug(f"Frenet-Serret failed for streamline {i}: {e}") + continue + + E = e_field_vectors[i] + + if len(E) != len(T): + skipped_mismatch += 1 + log.debug(f"Length mismatch for streamline {i}: E={len(E)}, T={len(T)}") + continue + + # E_parallel: project E onto tangent + s_e_parallel = np.sum(E * T, axis=1) + + # Segment lengths + distances_mm = np.linalg.norm(np.diff(s_smooth, axis=0), axis=1) + + s_e_parallel_mid = (s_e_parallel[:-1] + s_e_parallel[1:]) / 2 + val_to_store = s_e_parallel_mid if signed else np.abs(s_e_parallel_mid) + + # Store midpoints for visualization + s_mid_points = (s_points[:-1] + s_points[1:]) / 2 + + final_scalars_list.append(val_to_store) + final_distances_list.append(distances_mm) + final_streamlines_list.append(s_mid_points) + if track_indices: + final_indices_list.append(indices[i]) + + # Log summary of skipped streamlines + total_skipped = skipped_short + skipped_frenet + skipped_mismatch + if total_skipped > 0: + log.debug( + f"Skipped {total_skipped} streamlines: " + f"{skipped_short} too short, {skipped_frenet} Frenet errors, {skipped_mismatch} length mismatch" + ) + log.debug(f"Processed {len(final_scalars_list)}/{len(streamlines)} streamlines successfully") + + if track_indices: + surviving_indices = np.array(final_indices_list, dtype=int) + return final_streamlines_list, final_scalars_list, final_distances_list, surviving_indices + + return final_streamlines_list, final_scalars_list, final_distances_list + + +def _calculate_af_map( + streamlines: List[np.ndarray], + e_field_vectors: List[np.ndarray], + smooth_sigma: Optional[float], + target_smooth_length_mm: float, + signed: bool, + indices: Optional[np.ndarray], +) -> Union[ + Tuple[List[np.ndarray], List[np.ndarray], List[np.ndarray]], + Tuple[List[np.ndarray], List[np.ndarray], List[np.ndarray], np.ndarray], +]: + track_indices = indices is not None + if track_indices: + indices = np.asarray(indices) + + try: + len(streamlines) + len(e_field_vectors) + except TypeError: + raise TypeError("streamlines and e_field_vectors must be iterable sequences") + if len(streamlines) != len(e_field_vectors): + raise ValueError( + f"Mismatch: {len(streamlines)} streamlines but " + f"{len(e_field_vectors)} E-field vectors" + ) + + if len(streamlines) == 0: + log.warning("Empty streamlines list provided") + if track_indices: + return [], [], [], np.array([], dtype=int) + return [], [], [] + + log.debug(f"Computing AF for {len(streamlines)} streamlines on arc-length support") + + final_streamlines_list = [] + final_scalars_list = [] + final_distances_list = [] + final_indices_list = [] + + skipped_short = 0 + skipped_degenerate = 0 + skipped_frenet = 0 + skipped_mismatch = 0 + + for i, s_points in enumerate(streamlines): + if len(s_points) < 4: + skipped_short += 1 + continue + + points = np.asarray(s_points) + e_vectors = np.asarray(e_field_vectors[i]) + if len(e_vectors) != len(points): + skipped_mismatch += 1 + log.debug( + "Length mismatch for streamline %d: E=%d, points=%d", + i, + len(e_vectors), + len(points), + ) + continue + + original_distances_mm = np.linalg.norm(np.diff(points, axis=0), axis=1) + original_s = np.concatenate(([0.0], np.cumsum(original_distances_mm))) + unique = np.concatenate(([True], np.diff(original_s) > 0.0)) + unique_s = original_s[unique] + if len(unique_s) < 4: + skipped_degenerate += 1 + continue + + total_length_mm = unique_s[-1] + interval_count = max(3, int(np.ceil(total_length_mm / AF_RESAMPLE_STEP_MM))) + uniform_s = np.linspace(0.0, total_length_mm, interval_count + 1) + uniform_step_mm = uniform_s[1] - uniform_s[0] + + unique_points = points[unique] + unique_e_vectors = e_vectors[unique] + resampled_points = np.column_stack( + [np.interp(uniform_s, unique_s, unique_points[:, axis]) for axis in range(3)] + ) + resampled_e_vectors = np.column_stack( + [np.interp(uniform_s, unique_s, unique_e_vectors[:, axis]) for axis in range(3)] + ) + + sigma_i = ( + target_smooth_length_mm / uniform_step_mm if smooth_sigma is None else smooth_sigma + ) + smooth_points = gaussian_filter1d( + resampled_points, + sigma=sigma_i, + axis=0, + mode=AF_BOUNDARY_MODE, + ) + smooth_e_vectors = gaussian_filter1d( + resampled_e_vectors, + sigma=sigma_i, + axis=0, + mode=AF_BOUNDARY_MODE, + ) + + try: + tangents, _, _, _, _ = frenet_serret(smooth_points) + except Exception as e: + skipped_frenet += 1 + log.debug(f"Frenet-Serret failed for streamline {i}: {e}") + continue + + e_parallel = np.sum(smooth_e_vectors * tangents, axis=1) + smooth_distances_m = np.linalg.norm(np.diff(smooth_points, axis=0), axis=1) / 1000.0 + uniform_af = np.divide( + np.diff(e_parallel), + smooth_distances_m, + out=np.zeros_like(smooth_distances_m), + where=smooth_distances_m > np.finfo(float).eps, + ) + + uniform_mid_s = (uniform_s[:-1] + uniform_s[1:]) / 2.0 + original_mid_s = (original_s[:-1] + original_s[1:]) / 2.0 + mapped_af = np.interp(original_mid_s, uniform_mid_s, uniform_af) + value_to_store = mapped_af if signed else np.abs(mapped_af) + + final_streamlines_list.append((points[:-1] + points[1:]) / 2.0) + final_scalars_list.append(value_to_store) + final_distances_list.append(original_distances_mm) + if track_indices: + final_indices_list.append(indices[i]) + + total_skipped = skipped_short + skipped_degenerate + skipped_frenet + skipped_mismatch + if total_skipped > 0: + log.debug( + f"Skipped {total_skipped} streamlines: " + f"{skipped_short} too short, " + f"{skipped_degenerate} fewer than four distinct arc-length positions, " + f"{skipped_frenet} Frenet errors, " + f"{skipped_mismatch} length mismatch" + ) + log.debug(f"Processed {len(final_scalars_list)}/{len(streamlines)} streamlines successfully") + + if track_indices: + surviving_indices = np.array(final_indices_list, dtype=int) + return final_streamlines_list, final_scalars_list, final_distances_list, surviving_indices + + return final_streamlines_list, final_scalars_list, final_distances_list + + +def get_max_contiguous_threshold( + values: np.ndarray, lengths: np.ndarray, target_length: float +) -> float: + """ + Finds the highest threshold T such that there exists at least one + contiguous segment of 'target_length' where all values >= T. + + Args: + values: Array of scalar values (e.g., AF) + lengths: Array of corresponding segment lengths (mm) + target_length: Required contiguous length (mm) + + Returns: + Maximum threshold value + """ + if len(values) == 0 or np.sum(lengths) < target_length: + return 0.0 + + cum_len = np.cumsum(lengths) + total_len = cum_len[-1] + + if total_len < target_length: + return 0.0 + + max_thresh_found = 0.0 + + # Sliding window approach + start_idx = 0 + for end_idx in range(len(lengths)): + len_start = cum_len[start_idx - 1] if start_idx > 0 else 0.0 + current_window_len = cum_len[end_idx] - len_start + + while current_window_len >= target_length: + window_min = np.min(values[start_idx : end_idx + 1]) + + if window_min > max_thresh_found: + max_thresh_found = window_min + + start_idx += 1 + if start_idx > end_idx: + break + len_start = cum_len[start_idx - 1] if start_idx > 0 else 0.0 + current_window_len = cum_len[end_idx] - len_start + + return max_thresh_found + + +def weighted_percentile(values: np.ndarray, weights: np.ndarray, percentile: float) -> float: + """ + Weighted percentile of ``values``. + + Args: + values: Sample values. + weights: Non-negative weights, one per value. + percentile: Percentile in [0, 100]. + + Returns: + The value at the requested weighted percentile, or 0.0 if the input is + empty or all weights are zero. + """ + if len(values) == 0: + return 0.0 + + w_sum = np.sum(weights) + if w_sum == 0: + return 0.0 + + order = np.argsort(values) + sorted_vals = values[order] + cum_weights = np.cumsum(weights[order]) / w_sum + + cutoff_idx = np.searchsorted(cum_weights, percentile / 100.0) + if cutoff_idx >= len(sorted_vals): + return sorted_vals[-1] + return sorted_vals[cutoff_idx] + + +def median_of_top_percentile(values: np.ndarray, pct: float = 95.0) -> float: + """ + Median of the values at or above the ``pct`` percentile (top-tail aggregator). + + Single source of the unweighted "median of the top 5%" statistic used for the + per-streamline threshold distribution in both bundle analysis and M1 + validation. Returns 0.0 for an empty input or an empty top tail. + """ + if values.size == 0: + return 0.0 + cutoff = np.percentile(values, pct) + top = values[values >= cutoff] + return float(np.median(top)) if top.size else 0.0 + + +def weighted_median_of_top_percentile( + values: np.ndarray, weights: np.ndarray, pct: float = 95.0 +) -> float: + """ + Weighted counterpart of :func:`median_of_top_percentile`. + + The cutoff and the median of the surviving tail are both taken with + :func:`weighted_percentile`, matching the weighted "median of the top 5%" + used for the bundle metric. Returns 0.0 for an empty input or top tail. + """ + values = np.asarray(values, dtype=float) + if values.size == 0: + return 0.0 + cutoff = weighted_percentile(values, weights, pct) + top_mask = values >= cutoff + if not np.any(top_mask): + return 0.0 + return float(weighted_percentile(values[top_mask], np.asarray(weights)[top_mask], 50.0)) + + +def weighted_mean(values: np.ndarray, weights: np.ndarray) -> float: + """Weighted arithmetic mean; 0.0 for an empty input or zero total weight.""" + values = np.asarray(values, dtype=float) + weights = np.asarray(weights, dtype=float) + if values.size == 0: + return 0.0 + w_sum = float(np.sum(weights)) + if w_sum == 0.0: + return 0.0 + return float(np.dot(values, weights) / w_sum) + + +def cross_streamline_aggregates( + values: np.ndarray, weights: Optional[np.ndarray] = None +) -> Dict[str, float]: + """ + Every cross-streamline aggregate of a per-streamline threshold distribution. + + ``median_top5`` is the aggregator TIDE commits to and the only one that + feeds the calibration ratio and the reported dose. The remaining entries are + diagnostic alternatives evaluated on the same distribution, so the + sensitivity of AF_CST/AF_target to the aggregation rule is readable without + re-running the field solve. + + With ``weights=None`` the unweighted (NumPy quantile) definitions are used; + with weights, every entry uses its weighted counterpart. Keys are ordered as + in :data:`AGGREGATOR_KEYS`. + """ + values = np.asarray(values, dtype=float) + if values.size == 0: + return {key: 0.0 for key in AGGREGATOR_KEYS} + + if weights is None: + return { + "median_top5": median_of_top_percentile(values, 95.0), + "median_top1": median_of_top_percentile(values, 99.0), + "q95": float(np.percentile(values, 95.0)), + "q90": float(np.percentile(values, 90.0)), + "median": float(np.median(values)), + "mean": float(np.mean(values)), + } + + weights = np.asarray(weights, dtype=float) + return { + "median_top5": weighted_median_of_top_percentile(values, weights, 95.0), + "median_top1": weighted_median_of_top_percentile(values, weights, 99.0), + "q95": float(weighted_percentile(values, weights, 95.0)), + "q90": float(weighted_percentile(values, weights, 90.0)), + "median": float(weighted_percentile(values, weights, 50.0)), + "mean": weighted_mean(values, weights), + } + + +def get_robust_threshold( + values: np.ndarray, lengths: np.ndarray, min_activation_length_mm: float = 3.0 +) -> float: + """ + Calculates a robust threshold value V such that 'min_activation_length_mm' + of the bundle has a value >= V. + + Args: + values: Array of scalar values (e.g., AF) + lengths: Array of corresponding segment lengths (mm) + min_activation_length_mm: Target length of tissue to activate + + Returns: + The scalar value threshold + """ + if len(values) == 0 or len(lengths) == 0: + return 0.0 + + # Sort values descending + sort_idx = np.argsort(values)[::-1] + sorted_vals = values[sort_idx] + sorted_lens = lengths[sort_idx] + + cum_len = np.cumsum(sorted_lens) + + if cum_len[-1] < min_activation_length_mm: + return sorted_vals[-1] + + idx = np.searchsorted(cum_len, min_activation_length_mm) + + if idx < len(sorted_vals): + return sorted_vals[idx] + else: + return sorted_vals[-1] + + +def estimate_rmt_threshold( + cst_af_roi: np.ndarray, + cst_len_roi: np.ndarray, + target_af_roi: np.ndarray, + target_len_roi: np.ndarray, + measured_rmt_mso: float, + didt_max: float, + activation_length_mm: float = 3.0, +) -> Dict[str, float]: + """ + Estimates the target intensity using Length-Based Estimation. + + Args: + cst_af_roi: AF values in CST ROI (1 A/µs) + cst_len_roi: Segment lengths in CST ROI (mm) + target_af_roi: AF values in Target ROI (1 A/µs) + target_len_roi: Segment lengths in Target ROI (mm) + measured_rmt_mso: Patient RMT percentage + didt_max: Device max dI/dt + activation_length_mm: Required activation length (mm) + + Returns: + Dictionary with estimation results + """ + if len(cst_af_roi) == 0: + raise ValueError("No AF values in CST ROI.") + if len(target_af_roi) == 0: + raise ValueError("No AF values in Target ROI.") + + # Magnitude aggregation: AF polarity is preserved upstream; thresholding + # requires absolute activation strength. + af_cst_robust = get_robust_threshold(np.abs(cst_af_roi), cst_len_roi, activation_length_mm) + af_target_robust = get_robust_threshold( + np.abs(target_af_roi), target_len_roi, activation_length_mm + ) + + # Calculate absolute intensity at RMT + intensity_rmt = didt_max * (measured_rmt_mso / 100.0) + + # Calculate biological threshold + biological_thresh = intensity_rmt * (af_cst_robust / UNIT_DIDT) + + # Calculate target efficiency + eff_target = af_target_robust / UNIT_DIDT + + if eff_target <= 1e-12: + raise ValueError("Target efficiency is zero. Cannot stimulate this target.") + + # Calculate required target intensity + intensity_target = biological_thresh / eff_target + + # Convert to intensity (% MSO) + estimated_intensity = (intensity_target / didt_max) * 100.0 + + return { + "estimated_intensity": estimated_intensity, + "biological_thresh": biological_thresh, + "intensity_rmt": intensity_rmt, + "intensity_target": intensity_target, + "af_cst_99": af_cst_robust, + "af_target_99": af_target_robust, + "cst_efficiency": af_cst_robust / UNIT_DIDT, + "target_efficiency": eff_target, + } + + +def estimate_rmt_threshold_contiguous( + target_af_list: list, + target_len_list: list, + roi_masks: list, + measured_rmt_mso: float, + didt_max: float, + activation_length_mm: float = 4.0, + percentile_streamlines: float = 10.0, +): + """ + Estimates intensity required to activate a continuous segment on X% of streamlines. + + Args: + target_af_list: List of AF arrays per streamline + target_len_list: List of length arrays per streamline + roi_masks: List of boolean masks from tractography.get_roi_masks + measured_rmt_mso: Measured RMT percentage + didt_max: Device max dI/dt + activation_length_mm: Required contiguous length (mm) + percentile_streamlines: Percentage of fibers to activate + + Returns: + Target AF threshold value + """ + streamline_thresholds = [] + + for af, length, mask in zip(target_af_list, target_len_list, roi_masks): + # Apply ROI mask + if len(mask) == len(af): + af_roi = af[mask] + len_roi = length[mask] + elif len(mask) == len(af) + 1: + af_roi = af[mask[:-1]] + len_roi = length[mask[:-1]] + else: + continue + + if len(af_roi) == 0: + streamline_thresholds.append(0.0) + continue + + # AF is signed upstream; thresholding operates on magnitude. + s_thresh = get_max_contiguous_threshold(np.abs(af_roi), len_roi, activation_length_mm) + streamline_thresholds.append(s_thresh) + + streamline_thresholds = np.array(streamline_thresholds) + + # Determine population threshold + target_percentile = 100.0 - percentile_streamlines + if len(streamline_thresholds) > 0: + af_target_robust = np.percentile(streamline_thresholds, target_percentile) + else: + af_target_robust = 0.0 + + return af_target_robust diff --git a/src/tide/core/tractography.py b/src/tide/core/tractography.py new file mode 100644 index 0000000..b69e400 --- /dev/null +++ b/src/tide/core/tractography.py @@ -0,0 +1,477 @@ +import logging +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union + +import numpy as np +from scipy.spatial.distance import cdist + +try: + import nibabel as nib # noqa: F401 + from dipy.io.stateful_tractogram import Space, StatefulTractogram + from dipy.io.streamline import load_tractogram, save_tractogram # noqa: F401 + from dipy.tracking.metrics import frenet_serret + from sklearn.cluster import KMeans +except ImportError: + raise ImportError("Missing dependencies: nibabel, dipy, or sklearn.") + +log = logging.getLogger(__name__) + + +def load_tract(trk_path: Path, anat_ref: Path) -> StatefulTractogram: + """ + Loads a tractogram in RASMM space. + + Args: + trk_path: Path to the tractography file (.trk) + anat_ref: Path to the reference anatomy NIfTI file + + Returns: + StatefulTractogram with streamlines in RASMM space + """ + if not trk_path.exists(): + raise FileNotFoundError(f"Tractogram not found: {trk_path}") + if not anat_ref.exists(): + raise FileNotFoundError(f"Anatomy reference not found: {anat_ref}") + + try: + sft = load_tractogram(str(trk_path), str(anat_ref), to_space=Space.RASMM) + return sft + except Exception as e: + raise RuntimeError(f"Failed to load tractogram {trk_path}: {e}") + + +def get_roi_masks( + streamlines: List[np.ndarray], + roi_size_mm: float, + target_coords: Optional[Union[List[float], np.ndarray]] = None, +) -> Tuple[List[np.ndarray], List[np.ndarray]]: + """ + Generates boolean masks for Points and Segments indicating which parts + of the streamline are within the ROI. + + Args: + streamlines: List of streamline coordinate arrays (Nx3) + roi_size_mm: Radius of spherical ROI in mm + target_coords: Optional ROI center coordinates [x, y, z] + + Returns: + Tuple of (point_masks, segment_masks) + + Raises: + TypeError: If streamlines is not a list + ValueError: If roi_size_mm is invalid + """ + # Input validation - accept list or any array-like sequence + try: + len(streamlines) + except TypeError: + raise TypeError( + f"streamlines must be an iterable sequence, got {type(streamlines).__name__}" + ) + if roi_size_mm <= 0: + raise ValueError(f"roi_size_mm must be positive, got {roi_size_mm}") + + if len(streamlines) == 0: + log.debug("Empty streamlines list provided to get_roi_masks") + return [], [] + + point_masks = [] + segment_masks = [] + + target_arr = np.asarray(target_coords, dtype=float) if target_coords is not None else None + + if target_arr is not None and target_arr.shape != (3,): + raise ValueError(f"target_coords must be shape (3,), got {target_arr.shape}") + + for s_points in streamlines: + if len(s_points) < 1: + point_masks.append(np.array([], dtype=bool)) + segment_masks.append(np.array([], dtype=bool)) + continue + + if target_arr is not None: + # Distance to specific target coordinate + dists_to_target = np.linalg.norm(s_points - target_arr, axis=1) + p_mask = dists_to_target < roi_size_mm + s_mask = ( + dists_to_target[:-1] < roi_size_mm + if len(s_points) > 1 + else np.array([], dtype=bool) + ) + else: + # Distance to tips + dist_to_start_tip = np.linalg.norm(s_points - s_points[0], axis=1) + dist_to_end_tip = np.linalg.norm(s_points - s_points[-1], axis=1) + + p_mask = np.logical_or(dist_to_start_tip < roi_size_mm, dist_to_end_tip < roi_size_mm) + + if len(s_points) > 1: + mask_s_start = dist_to_start_tip[:-1] < roi_size_mm + mask_s_end = dist_to_end_tip[:-1] < roi_size_mm + s_mask = np.logical_or(mask_s_start, mask_s_end) + else: + s_mask = np.array([], dtype=bool) + + point_masks.append(p_mask) + segment_masks.append(s_mask) + + return point_masks, segment_masks + + +def get_data_in_roi( + streamlines: List[np.ndarray], + values: List[np.ndarray], + roi_size_mm: float, + target_coords: Optional[Union[List[float], np.ndarray]] = None, + lengths: Optional[List[np.ndarray]] = None, +) -> Union[np.ndarray, Tuple[np.ndarray, np.ndarray]]: + """ + Extracts specific scalar values (and optionally lengths) for points/segments within the ROI. + + Args: + streamlines: List of streamline coordinates. + values: List of scalar arrays (e.g. AF) per streamline. + roi_size_mm: Radius of the ROI. + target_coords: Optional coordinate center for the ROI. + lengths: Optional List of length arrays (segment lengths) per streamline. + + Returns: + If lengths is None: flattened np.array of values. + If lengths is provided: Tuple (flattened_values, flattened_lengths) + """ + point_masks, segment_masks = get_roi_masks(streamlines, roi_size_mm, target_coords) + all_values_in_roi = [] + all_lengths_in_roi = [] + + for i, (p_mask, s_mask) in enumerate(zip(point_masks, segment_masks)): + current_vals = values[i].flatten() + + # Determine if values correspond to points or segments + if len(current_vals) == len(p_mask): + mask_to_use = p_mask + elif len(current_vals) == len(s_mask): + mask_to_use = s_mask + else: + continue + + all_values_in_roi.extend(current_vals[mask_to_use]) + + if lengths is not None: + current_lens = lengths[i].flatten() + # Lengths always correspond to segments + if len(current_lens) == len(s_mask): + # If we are using a point mask for values, we might have a mismatch + # (N points vs N-1 lengths). Usually AF is calculated on segments or midpoints (N-1). + # We assume here values and lengths are aligned (both N-1). + if len(current_vals) == len(current_lens): + all_lengths_in_roi.extend(current_lens[mask_to_use]) + + if lengths is not None: + return np.array(all_values_in_roi), np.array(all_lengths_in_roi) + + return np.array(all_values_in_roi) + + +def validate_curvature(trk_path: Path, anat_path: Path, roi_size_mm: float) -> Dict[str, float]: + """Calculates curvature statistics for the tractogram endpoints.""" + sft = load_tract(trk_path, anat_path) + streamlines = sft.streamlines + + all_curvatures = [] + for s in streamlines: + if len(s) < 3: + continue + try: + curv_values = frenet_serret(s)[3] # k + all_curvatures.append(curv_values) + except Exception: + continue + + # Note: Validate Curvature is usually a generic check, so we typically + # check *both* ends (target_coords=None) to ensure the whole bundle is healthy. + roi_curvatures = get_data_in_roi(streamlines, all_curvatures, roi_size_mm, target_coords=None) + + valid_curv = roi_curvatures[np.isfinite(roi_curvatures)] + + if len(valid_curv) == 0: + return {"mean": 0.0, "p95": 0.0, "max": 0.0} + + stats = { + "mean": float(np.mean(valid_curv)), + "median": float(np.median(valid_curv)), + "std": float(np.std(valid_curv)), + "p95": float(np.percentile(valid_curv, 95)), + "max": float(np.max(valid_curv)), + } + return stats + + +def extract_grid_endpoints( + trk_path: Path, + anat_path: Path, + step_mm: float, + cortex_thickness_mm: float, + target_center: Optional[List[float]] = None, + search_radius: float = 20.0, +) -> List[List[float]]: + """ + Extracts cortical endpoints for Grid Search. + + Loads tractogram in RASMM space and extracts streamline endpoints + from the cortical region for grid-based targeting. + + Args: + trk_path: Path to tractography file + anat_path: Path to reference T1w anatomy + step_mm: Grid spacing in mm + cortex_thickness_mm: Depth of cortical layer to sample + target_center: Optional center point for focused search + search_radius: Radius around target_center to search (if provided) + + Returns: + List of [x, y, z] coordinates in RASMM space + """ + log.info(f"Extracting grid endpoints from {trk_path.name}") + log.debug(f"Using reference anatomy: {anat_path.name}") + + sft = load_tract(trk_path, anat_path) + streamlines = sft.streamlines + if not streamlines: + log.warning("No streamlines found in tractogram") + return [] + + start_pts = np.array([s[0] for s in streamlines]) + end_pts = np.array([s[-1] for s in streamlines]) + all_pts = np.vstack((start_pts, end_pts)) + + log.debug(f"Extracted {len(start_pts)} start points and {len(end_pts)} end points") + log.debug( + f"Coordinate range: X=[{all_pts[:, 0].min():.1f}, {all_pts[:, 0].max():.1f}], " + f"Y=[{all_pts[:, 1].min():.1f}, {all_pts[:, 1].max():.1f}], " + f"Z=[{all_pts[:, 2].min():.1f}, {all_pts[:, 2].max():.1f}]" + ) + + if target_center: + center = np.array(target_center) + dists = np.linalg.norm(all_pts - center, axis=1) + mask_prox = dists <= search_radius + candidates = all_pts[mask_prox] + else: + if len(all_pts) < 2: + candidates = all_pts + else: + kmeans = KMeans(n_clusters=2, random_state=0, n_init=10).fit(all_pts) + centers = kmeans.cluster_centers_ + target_label = 0 if centers[0][2] > centers[1][2] else 1 + candidates = all_pts[kmeans.labels_ == target_label] + + if len(candidates) == 0: + log.warning("No candidate points found after filtering") + return [] + + # Filter to cortical depth + z_coords = candidates[:, 2] + top_peak = np.percentile(z_coords, 98) + z_cutoff = top_peak - cortex_thickness_mm + filtered = candidates[z_coords >= z_cutoff] + + log.debug(f"Filtered to {len(filtered)} points within cortex depth") + log.debug(f"Z-range after filtering: [{filtered[:, 2].min():.1f}, {filtered[:, 2].max():.1f}]") + + # Round to grid and get unique points + rounded = np.round(filtered / step_mm) * step_mm + unique_targets = np.unique(rounded, axis=0) + + log.info(f"Generated {len(unique_targets)} unique grid points") + if len(unique_targets) > 0: + # Log first few points and centroid for verification + centroid = unique_targets.mean(axis=0) + log.debug(f"Grid centroid (RAS): [{centroid[0]:.1f}, {centroid[1]:.1f}, {centroid[2]:.1f}]") + log.debug("Sample grid points (first 3):") + for i, pt in enumerate(unique_targets[:3]): + log.debug(f" Point {i}: [{pt[0]:.1f}, {pt[1]:.1f}, {pt[2]:.1f}]") + + return unique_targets.tolist() + + +def get_bundle_cortical_medoid( + trk_path: Path, + anat_path: Path, + cortex_thickness_mm: float = 4.0, + percentile_depth: float = 98.0, + reference_coord: Optional[List[float]] = None, +) -> np.ndarray: + """Calculates the 'Cortical Medoid' of a bundle.""" + log.info(f"--- Processing Medoid for {trk_path.name} ---") + sft = load_tract(trk_path, anat_path) + streamlines = sft.streamlines + if not streamlines: + raise ValueError("Tractogram is empty.") + + start_pts = np.array([s[0] for s in streamlines]) + end_pts = np.array([s[-1] for s in streamlines]) + all_pts = np.vstack((start_pts, end_pts)) + + if len(all_pts) < 2: + return all_pts[0] + + log.info("Clustering endpoints (K=2) to separate bundle ends...") + kmeans = KMeans(n_clusters=2, random_state=0, n_init=10).fit(all_pts) + centers = kmeans.cluster_centers_ + + if reference_coord is not None: + ref = np.array(reference_coord) + dist0 = np.linalg.norm(centers[0] - ref) + dist1 = np.linalg.norm(centers[1] - ref) + target_label = 0 if dist0 < dist1 else 1 + log.info(f"Using Spatial Hint: {reference_coord}") + else: + target_label = 0 if centers[0][2] > centers[1][2] else 1 + log.info("No reference coord provided. Auto-selecting superior (highest Z) end.") + + cortical_candidates = all_pts[kmeans.labels_ == target_label] + if len(cortical_candidates) == 0: + cortical_candidates = all_pts + + z_coords = cortical_candidates[:, 2] + top_peak = np.percentile(z_coords, percentile_depth) + z_cutoff = top_peak - cortex_thickness_mm + filtered_pts = cortical_candidates[z_coords >= z_cutoff] + + if len(filtered_pts) == 0: + filtered_pts = cortical_candidates + + if len(filtered_pts) <= 2: + best_medoid = filtered_pts[0] + else: + dists = cdist(filtered_pts, filtered_pts, metric="euclidean") + sum_dists = dists.sum(axis=1) + medoid_idx = np.argmin(sum_dists) + best_medoid = filtered_pts[medoid_idx] + + return best_medoid + + +def filter_by_angular_deviation( + streamlines: List[np.ndarray], + e_field_vectors: Optional[List[np.ndarray]] = None, + max_angle_deg: float = 80.0, + roi_center: Optional[Union[List[float], np.ndarray]] = None, + roi_radius: float = 40.0, + indices: Optional[np.ndarray] = None, +) -> Union[ + Tuple[List[np.ndarray], int], + Tuple[List[np.ndarray], List[np.ndarray], int], + Tuple[List[np.ndarray], List[np.ndarray], int, np.ndarray], +]: + """ + Filters out streamlines with angular deviations exceeding a threshold. + + Checks the maximum angle between consecutive tangent vectors within the + ROI region. Streamlines where any consecutive pair of tangent vectors + deviates by more than max_angle_deg are excluded (likely tractography + artifacts, e.g. streamlines curling back at cortical endpoints). + + Args: + streamlines: List of streamline coordinate arrays (Nx3). + e_field_vectors: Optional parallel list of E-field vector arrays. + If provided, filtered in sync with streamlines. + max_angle_deg: Maximum allowed angular deviation in degrees. + Streamlines with any consecutive tangent angle > this are removed. + roi_center: If provided, only check angular deviation within this + spherical ROI. If None, check entire streamline. + roi_radius: Radius of the ROI sphere in mm. + indices: Optional identifier array parallel to ``streamlines`` (e.g. + original streamline ids). When provided, it is filtered in sync and + returned as a trailing element so per-streamline weights stay + aligned after removals (audit C-002). Requires ``e_field_vectors``. + + Returns: + If e_field_vectors is None: + (filtered_streamlines, n_removed) + If e_field_vectors is provided: + (filtered_streamlines, filtered_e_vectors, n_removed) + If indices is also provided: + (filtered_streamlines, filtered_e_vectors, n_removed, filtered_indices) + """ + track_indices = indices is not None + if track_indices: + indices = np.asarray(indices) + + if max_angle_deg <= 0 or max_angle_deg >= 180: + log.debug(f"Angular filter disabled (max_angle_deg={max_angle_deg})") + if track_indices: + return streamlines, e_field_vectors, 0, indices + if e_field_vectors is not None: + return streamlines, e_field_vectors, 0 + return streamlines, 0 + + cos_threshold = np.cos(np.radians(max_angle_deg)) + roi_arr = np.asarray(roi_center, dtype=float) if roi_center is not None else None + + filtered_sl = [] + filtered_ev = [] if e_field_vectors is not None else None + filtered_idx = [] if track_indices else None + n_removed = 0 + + for i, sl in enumerate(streamlines): + if len(sl) < 4: + # Streamlines with fewer than 4 points cannot contribute a valid + # AF estimate (matches the physics cutoff in calculate_scalar_map) + # and are typically tractography stubs. Drop them rather than + # bypass the quality filter. + n_removed += 1 + continue + + # Compute tangent vectors (unnormalized differences) + diffs = np.diff(sl, axis=0) + norms = np.linalg.norm(diffs, axis=1, keepdims=True) + norms = np.maximum(norms, 1e-12) + tangents = diffs / norms + + # Cosine of angle between consecutive tangents + cos_angles = np.sum(tangents[:-1] * tangents[1:], axis=1) + + if roi_arr is not None: + # Only check within ROI: use midpoints of consecutive tangent pairs + # The midpoint between point[j] and point[j+2] (where cos_angles[j] is defined) + midpoints = (sl[:-2] + sl[2:]) / 2.0 + dists = np.linalg.norm(midpoints - roi_arr, axis=1) + in_roi = dists <= roi_radius + + if not np.any(in_roi): + # No points in ROI — keep streamline (no evidence of artifact) + filtered_sl.append(sl) + if filtered_ev is not None: + filtered_ev.append(e_field_vectors[i]) + if filtered_idx is not None: + filtered_idx.append(indices[i]) + continue + + cos_in_roi = cos_angles[in_roi] + else: + cos_in_roi = cos_angles + + # Check if any angle exceeds threshold + # cos(angle) < cos(threshold) means angle > threshold (cosine is decreasing) + if np.any(cos_in_roi < cos_threshold): + n_removed += 1 + continue + + filtered_sl.append(sl) + if filtered_ev is not None: + filtered_ev.append(e_field_vectors[i]) + if filtered_idx is not None: + filtered_idx.append(indices[i]) + + if n_removed > 0: + log.info( + f"Angular deviation filter: removed {n_removed}/{len(streamlines)} " + f"streamlines (threshold: {max_angle_deg}°)" + ) + + if track_indices: + return filtered_sl, filtered_ev, n_removed, np.array(filtered_idx, dtype=int) + if filtered_ev is not None: + return filtered_sl, filtered_ev, n_removed + return filtered_sl, n_removed diff --git a/src/tide/interfaces/grid_visualization.py b/src/tide/interfaces/grid_visualization.py new file mode 100644 index 0000000..eade87b --- /dev/null +++ b/src/tide/interfaces/grid_visualization.py @@ -0,0 +1,861 @@ +""" +Grid Search Visualization (in-package) +====================================== + +Library port of ``scripts/visualize_grid.py``. Produces: + + 1. Scalar NIfTI — intensity-valued voxels (% MSO) overlayable in FSLeyes / MRIcroGL, + written as a raw map, a clamped map, and a categorical clamp-regime map + 2. Interactive 3D — Self-contained HTML with tractography + clickable points + +The raw map carries the unbounded output of the calibration inversion and is the +quantity to use for any quantitative or cross-subject comparison. The clamped map +carries the programmable intensity and saturates at the floor and ceiling bounds, +so uniform patches in it may reflect the clamp rather than uniform anatomy; the +clamp-regime map identifies exactly which points saturated. + +Public entry point: :func:`run_grid_visualization`. Called in-process from +``workflows.grid_search`` Step 8. +""" + +from __future__ import annotations + +import ast +import csv +import json +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np + +log = logging.getLogger(__name__) + +# Voxel codes of the clamp-regime map. 0 marks background and any unlabelled point. +CLAMP_FLAG_CODES: Dict[str, int] = { + "WITHIN_RANGE": 1, + "CLAMPED_LOW": 2, + "CLAMPED_HIGH": 3, + "DEVICE_LIMITED": 4, +} + +# Colour-scale modes of the interactive viewer, mapped to their record field. +VIEW_MODES: Dict[str, str] = { + "raw": "weighted_mso_raw", + "clamped": "weighted_mso_clamped", +} + +VIEW_MODE_TITLES: Dict[str, str] = { + "raw": "Weighted I, raw (%)", + "clamped": "Weighted I, clamped (%)", +} + +DEFAULT_VIEW_MODE = "raw" + + +# ============================================================================= +# CSV PARSING +# ============================================================================= + + +def parse_grid_csv(csv_path: Path) -> List[Dict[str, Any]]: + """Parse the TIDE grid results CSV into a list of structured records.""" + if not csv_path.exists(): + raise FileNotFoundError(f"CSV file not found: {csv_path}") + + records: List[Dict[str, Any]] = [] + + with open(csv_path, newline="") as fh: + reader = csv.DictReader(fh) + + expected_cols = {"grid_point_labels", "grid_point_coords"} + if not expected_cols.issubset(set(reader.fieldnames or [])): + raise ValueError( + f"Unexpected CSV header. Expected at least {expected_cols}, " + f"got {reader.fieldnames}" + ) + + for row in reader: + try: + record = { + "label": row["grid_point_labels"], + "coords": ast.literal_eval(row["grid_point_coords"]), + "scalp_coords": _safe_literal_eval(row.get("fixed_scalp_start_coords", "")), + "opt_scalp_coords": _safe_literal_eval( + row.get("optimized_scalp_point_coords", "") + ), + "matrix4x4": _parse_matrix(row.get("matrix4x4", "")), + "measured_m1_mso": float(row.get("measured_m1_mso", 0)), + "unweighted_mso_raw": float(row.get("unweighted_mso_raw", 0)), + "weighted_mso_raw": float(row.get("weighted_mso_raw", 0)), + "unweighted_mso_clamped": float(row.get("unweighted_mso_clamped", 0)), + "weighted_mso_clamped": float(row.get("weighted_mso_clamped", 0)), + "unweighted_mso_flag": row.get("unweighted_mso_flag", "N/A"), + "weighted_mso_flag": row.get("weighted_mso_flag", "N/A"), + "sei_weighted": float(row.get("sei_weighted", 0)), + "sei_unweighted": float(row.get("sei_unweighted", 0)), + "sei_rank_pct": float(row.get("sei_rank_pct", 0)), + } + intensity_values = ( + record["unweighted_mso_raw"], + record["weighted_mso_raw"], + record["unweighted_mso_clamped"], + record["weighted_mso_clamped"], + ) + if ( + "ESTIMATION_FAILED" + in (record["unweighted_mso_flag"], record["weighted_mso_flag"]) + or not np.isfinite(intensity_values).all() + ): + log.warning("Skipping failed grid point '%s'", record["label"]) + continue + records.append(record) + except (ValueError, SyntaxError) as exc: + log.warning( + "Skipping malformed row '%s': %s", + row.get("grid_point_labels", "?"), + exc, + ) + + log.info("Parsed %d grid point records from %s", len(records), csv_path) + return records + + +def _safe_literal_eval(value: str) -> Optional[list]: + if not value or value.strip() in ("", "None"): + return None + try: + return ast.literal_eval(value.strip()) + except (ValueError, SyntaxError): + return None + + +def _parse_matrix(value: str) -> Optional[np.ndarray]: + if not value or value.strip() in ("", "None"): + return None + try: + parsed = ast.literal_eval(value.strip()) + arr = np.array(parsed, dtype=float) + if arr.shape == (4, 4): + return arr + if arr.size == 16: + return arr.reshape(4, 4) + log.warning("Matrix has unexpected shape %s", arr.shape) + return None + except (ValueError, SyntaxError): + return None + + +# ============================================================================= +# SCALAR NIFTI MAP +# ============================================================================= + + +def _scatter_to_volume( + points: np.ndarray, + values: np.ndarray, + shape: tuple, + inv_affine: np.ndarray, +) -> np.ndarray: + """Paint per-point scalar values into a volume of the reference shape.""" + data = np.zeros(shape, dtype=np.float32) + + voxel_coords = (points @ inv_affine[:3, :3].T) + inv_affine[:3, 3] + voxel_indices = np.rint(voxel_coords).astype(int) + + valid = ( + (voxel_indices[:, 0] >= 0) + & (voxel_indices[:, 0] < data.shape[0]) + & (voxel_indices[:, 1] >= 0) + & (voxel_indices[:, 1] < data.shape[1]) + & (voxel_indices[:, 2] >= 0) + & (voxel_indices[:, 2] < data.shape[2]) + ) + + vi = voxel_indices[valid] + data[vi[:, 0], vi[:, 1], vi[:, 2]] = values[valid] + return data + + +def generate_scalar_nifti( + records: List[Dict[str, Any]], + t1w_path: Path, + output_dir: Path, +) -> None: + """Write the clamped, raw, and clamp-regime NIfTI maps + a TSV label sidecar.""" + import nibabel as nib + + ref_img = nib.load(str(t1w_path)) + affine = ref_img.affine + inv_affine = np.linalg.inv(affine) + shape = ref_img.shape[:3] + + points = np.array([r["coords"] for r in records]) + + volumes = { + "grid_mso_map.nii.gz": [r["weighted_mso_clamped"] for r in records], + "grid_mso_raw_map.nii.gz": [r["weighted_mso_raw"] for r in records], + "grid_mso_flag_map.nii.gz": [ + CLAMP_FLAG_CODES.get(r["weighted_mso_flag"], 0) for r in records + ], + } + + for filename, values in volumes.items(): + data = _scatter_to_volume(points, np.array(values, dtype=np.float32), shape, inv_affine) + nifti_path = output_dir / filename + nib.save(nib.Nifti1Image(data, affine, ref_img.header), str(nifti_path)) + log.info("Scalar NIfTI saved: %s", nifti_path) + + tsv_path = output_dir / "grid_mso_labels.tsv" + with open(tsv_path, "w") as fh: + fh.write( + "label\tx_ras\ty_ras\tz_ras\t" + "weighted_mso_clamped\tweighted_mso_raw\t" + "unweighted_mso_clamped\tsei_weighted\t" + "unweighted_mso_raw\tweighted_mso_flag\tunweighted_mso_flag\n" + ) + for r in records: + fh.write( + f"{r['label']}\t" + f"{r['coords'][0]:.2f}\t{r['coords'][1]:.2f}\t" + f"{r['coords'][2]:.2f}\t" + f"{r['weighted_mso_clamped']:.2f}\t" + f"{r['weighted_mso_raw']:.2f}\t" + f"{r['unweighted_mso_clamped']:.2f}\t" + f"{r['sei_weighted']:.4f}\t" + f"{r['unweighted_mso_raw']:.2f}\t" + f"{r['weighted_mso_flag']}\t" + f"{r['unweighted_mso_flag']}\n" + ) + log.info("Label sidecar saved: %s", tsv_path) + + +# ============================================================================= +# INTERACTIVE 3D HTML +# ============================================================================= + + +def _mso_to_hex(value: float, vmin: float, vmax: float) -> str: + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + cmap = plt.cm.RdYlGn_r + t = 0.0 if vmax == vmin else (value - vmin) / (vmax - vmin) + t = max(0.0, min(1.0, t)) + r, g, b, _ = cmap(t) + return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" + + +def _serialize_streamlines_for_html( + streamlines: List[np.ndarray], + max_streamlines: int = 1500, + max_points_per_streamline: int = 80, + decimals: int = 2, +) -> List[List[List[float]]]: + total = len(streamlines) + if total == 0: + return [] + + stride = max(1, int(np.ceil(total / max_streamlines))) + selected = list(streamlines[::stride])[:max_streamlines] + + serialized: List[List[List[float]]] = [] + for sl in selected: + arr = np.asarray(sl, dtype=float) + if arr.ndim != 2 or arr.shape[1] != 3 or len(arr) < 2: + continue + if len(arr) > max_points_per_streamline: + indices = np.linspace(0, len(arr) - 1, max_points_per_streamline, dtype=int) + arr = arr[indices] + serialized.append(np.round(arr, decimals).tolist()) + return serialized + + +def generate_interactive_html( + records: List[Dict[str, Any]], + trk_path: Path, + output_dir: Path, + streamline_subsample: int = 3, +) -> None: + """Generate a self-contained interactive 3D HTML viewer.""" + import nibabel as nib + + log.info("Loading tractogram for 3D viewer: %s", trk_path.name) + trk_file = nib.streamlines.load(str(trk_path)) + streamlines = trk_file.streamlines + + total_sl = len(streamlines) + step = max(1, streamline_subsample) + subsampled = list(streamlines[::step]) + sl_data = _serialize_streamlines_for_html(subsampled) + log.info( + "Using %d / %d streamlines in HTML preview (subsample=%d)", + len(sl_data), + total_sl, + step, + ) + + scales = {} + for mode, field in VIEW_MODES.items(): + values = [r[field] for r in records] + scales[mode] = {"vmin": min(values), "vmax": max(values)} + + points_js = [] + for r in records: + colors = { + f"color_{mode}": _mso_to_hex(r[field], scales[mode]["vmin"], scales[mode]["vmax"]) + for mode, field in VIEW_MODES.items() + } + points_js.append( + { + "label": r["label"], + "coords": [round(c, 2) for c in r["coords"]], + **colors, + "weighted_mso_clamped": round(r["weighted_mso_clamped"], 2), + "weighted_mso_raw": round(r["weighted_mso_raw"], 2), + "unweighted_mso_clamped": round(r["unweighted_mso_clamped"], 2), + "unweighted_mso_raw": round(r["unweighted_mso_raw"], 2), + "weighted_mso_flag": r["weighted_mso_flag"], + "unweighted_mso_flag": r["unweighted_mso_flag"], + "sei_weighted": round(r["sei_weighted"], 4), + "sei_unweighted": round(r["sei_unweighted"], 4), + "sei_rank_pct": round(r["sei_rank_pct"], 1), + "matrix4x4": (r["matrix4x4"].tolist() if r["matrix4x4"] is not None else None), + } + ) + + all_coords = np.array([r["coords"] for r in records]) + centroid = all_coords.mean(axis=0).tolist() + + n_stops = 8 + for mode, scale in scales.items(): + vmin, vmax = scale["vmin"], scale["vmax"] + scale["stops"] = [ + { + "value": round(vmin + (i / n_stops) * (vmax - vmin), 1), + "color": _mso_to_hex(vmin + (i / n_stops) * (vmax - vmin), vmin, vmax), + } + for i in range(n_stops + 1) + ] + scale["vmin"] = round(vmin, 1) + scale["vmax"] = round(vmax, 1) + scale["title"] = VIEW_MODE_TITLES[mode] + + html_content = _build_html_template( + streamlines_json=json.dumps(sl_data), + points_json=json.dumps(points_js), + centroid_json=json.dumps(centroid), + scales_json=json.dumps(scales), + default_mode=DEFAULT_VIEW_MODE, + num_points=len(records), + num_streamlines=len(sl_data), + ) + + out_path = output_dir / "grid_interactive.html" + out_path.write_text(html_content, encoding="utf-8") + log.info("Interactive HTML saved: %s", out_path) + + +def _build_html_template( + *, + streamlines_json: str, + points_json: str, + centroid_json: str, + scales_json: str, + default_mode: str, + num_points: int, + num_streamlines: int, +) -> str: + """Return the complete self-contained HTML string for the 3D viewer.""" + + return f""" + + + + +TIDE Grid Search - Interactive 3D Viewer + + + + + + +
+ +
+
+
+
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+ Click a sphere to inspect  |  + Drag to rotate  |  + Scroll to zoom +
+ + + + + +""" + + +# ============================================================================= +# PUBLIC ENTRY POINT +# ============================================================================= + + +def run_grid_visualization( + csv_path: Path, + t1w_path: Path, + trk_path: Path, + output_dir: Path, + streamline_subsample: int = 3, + generate_interactive: bool = True, +) -> None: + """Run all grid-search visualizations in-process. + + Equivalent to invoking ``scripts/visualize_grid.py`` but without spawning + a subprocess. Produces ``grid_mso_map.nii.gz``, ``grid_mso_raw_map.nii.gz``, + ``grid_mso_flag_map.nii.gz``, ``grid_mso_labels.tsv``, and, when requested, + ``grid_interactive.html`` in *output_dir*. + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + records = parse_grid_csv(Path(csv_path)) + if not records: + log.error("No valid grid points found in CSV: %s", csv_path) + return + + generate_scalar_nifti(records, Path(t1w_path), output_dir) + if generate_interactive: + generate_interactive_html( + records, + Path(trk_path), + output_dir, + streamline_subsample=streamline_subsample, + ) + + log.info("Grid visualizations written to: %s", output_dir) diff --git a/src/tide/interfaces/sampling.py b/src/tide/interfaces/sampling.py new file mode 100644 index 0000000..ddeb803 --- /dev/null +++ b/src/tide/interfaces/sampling.py @@ -0,0 +1,206 @@ +""" +E-field Sampling Module +======================= +Interpolates E-field values at streamline coordinates using SimNIBS CLI. +""" + +import logging +import subprocess +from pathlib import Path +from typing import List, Optional + +import numpy as np +import pandas as pd + +try: + from scipy.spatial import cKDTree as KDTree +except ImportError: + from scipy.spatial import KDTree + +from tide.utils import simnibs_env +from tide.utils.artifacts import capture_artifacts, fresh_artifacts, record_artifact + +log = logging.getLogger(__name__) + + +def sample_field_at_coordinates( + mesh_path: Path, + coordinates: np.ndarray, + field_name: str = "E", + output_dir: Optional[Path] = None, + file_prefix: str = "bundle", +) -> np.ndarray: + """ + Interpolates E-field using SimNIBS CLI tool (get_fields_at_coordinates). + + Args: + mesh_path: Path to SimNIBS mesh file + coordinates: Nx3 array of coordinates to sample + field_name: Field to sample ('E' for vector E-field) + output_dir: Output directory for intermediate files + file_prefix: Prefix for output files + + Returns: + Nx3 array of E-field vectors at coordinates + """ + # Find the CLI command (handle Windows .cmd/.exe extensions) + cli_cmd = simnibs_env.find_get_fields_at_coordinates() + if cli_cmd is None: + raise RuntimeError( + "Command 'get_fields_at_coordinates' not found in PATH. " + "Ensure SimNIBS is properly installed and in your PATH." + ) + + work_dir = output_dir if output_dir else mesh_path.parent + coords_csv = work_dir / f"{file_prefix}_coords.csv" + + if Path(cli_cmd).suffix.lower() in {".bat", ".cmd"}: + batch_metacharacters = '&|<>()^%!"\r\n' + batch_args = (cli_cmd, str(mesh_path), str(coords_csv)) + if any(char in arg for arg in batch_args for char in batch_metacharacters): + raise ValueError("Windows batch command arguments cannot contain shell metacharacters") + + np.savetxt(coords_csv, coordinates, delimiter=",", comments="") + + def output_candidates() -> List[Path]: + return [path for path in work_dir.glob(f"{file_prefix}_coords_*.csv") if path != coords_csv] + + before = capture_artifacts(output_candidates(), hash_contents=True) + + log.debug(f"Sampling E-field at {len(coordinates)} points using CLI: {cli_cmd}") + + cmd = [ + cli_cmd, + "--mesh", + str(mesh_path), + "--csv", + str(coords_csv), + ] + + try: + result = subprocess.run( + cmd, + check=True, + cwd=str(work_dir), + shell=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.stdout: + log.debug(f"CLI stdout: {result.stdout[:200]}") + except subprocess.CalledProcessError as e: + log.error(f"CLI stderr: {e.stderr}") + raise RuntimeError(f"CLI interpolation failed: {e}") + + expected_out = work_dir / f"{file_prefix}_coords_{field_name}.csv" + candidates = fresh_artifacts(before, output_candidates(), hash_contents=True) + if expected_out in candidates: + selected_candidates = [expected_out] + else: + selected_candidates = candidates + + if not selected_candidates: + raise FileNotFoundError( + f"Sampling finished but no fresh output CSV was found in {work_dir}" + ) + if len(selected_candidates) > 1: + names = ", ".join(path.name for path in selected_candidates) + raise RuntimeError(f"Ambiguous sampling output: {names}") + + expected_out = selected_candidates[0] + record_artifact( + work_dir, + f"sampling:{file_prefix}:{field_name}", + expected_out, + candidates, + ) + + try: + df = pd.read_csv(expected_out, header=None, comment="#") + raw_data = df.values + except Exception as e: + log.error(f"Failed to read output CSV: {e}") + raise + + # Parse output format + if raw_data.shape[1] == 3: + out_coords = coordinates + out_vals = raw_data + elif raw_data.shape[1] >= 6: + out_coords = raw_data[:, :3] + out_vals = raw_data[:, 3:6] + else: + if raw_data.shape[1] == 4: + out_coords = raw_data[:, :3] + out_vals = raw_data[:, 3].reshape(-1, 1) + else: + raise ValueError(f"Unexpected CSV column count: {raw_data.shape[1]}") + + log.debug(f"Loaded E-field vectors: shape={out_vals.shape}") + + # Clean up temp file + try: + coords_csv.unlink() + except Exception: + pass + + if out_coords is coordinates: + if len(out_vals) != len(coordinates): + raise RuntimeError( + "E-field sampling output omitted coordinates and changed the row count; " + "realignment is not possible." + ) + return out_vals + + if len(out_vals) == len(coordinates) and np.array_equal(out_coords, coordinates): + return out_vals + + log.debug("Verifying and re-aligning sampled coordinates via nearest neighbor") + return _realign_sampled_field(coordinates, out_coords, out_vals) + + +def _realign_sampled_field( + coordinates: np.ndarray, + out_coords: np.ndarray, + out_vals: np.ndarray, + tol_mm: float = 0.1, +) -> np.ndarray: + """ + Map CLI-returned field values back onto the requested coordinates. + + The ``tol_mm`` match tolerance sits well below the streamline step size and + well above the float round-trip error of the coordinate CSV. Points with no + returned value are filled with NaN (not 0.0) so the absence is detectable + downstream instead of silently deflating the AF. Raises if more than 1% of + points are unmatched, which signals a coordinate/ordering mismatch. + """ + n_components = out_vals.shape[1] + tree = KDTree(out_coords) + _, indices = tree.query(coordinates, k=1, distance_upper_bound=tol_mm) + matched = indices < len(out_coords) + + matched_indices = indices[matched] + if len(np.unique(matched_indices)) != len(matched_indices): + raise RuntimeError( + "E-field sampling produced duplicate nearest-neighbour assignments; " + "returned coordinates are not one-to-one." + ) + + full_vectors = np.full((len(coordinates), n_components), np.nan, dtype=float) + full_vectors[matched] = out_vals[indices[matched]] + + n_missing = int((~matched).sum()) + if n_missing: + missing_frac = n_missing / len(coordinates) + log.warning( + f"E-field sampling: {n_missing}/{len(coordinates)} points " + f"({missing_frac:.1%}) unmatched within {tol_mm} mm; filled with NaN." + ) + if missing_frac > 0.01: + raise RuntimeError( + f"E-field sampling dropped {missing_frac:.1%} of points " + "(> 1% threshold); coordinate or ordering mismatch likely." + ) + + return full_vectors diff --git a/src/tide/interfaces/simnibs_interface.py b/src/tide/interfaces/simnibs_interface.py new file mode 100644 index 0000000..eb0c791 --- /dev/null +++ b/src/tide/interfaces/simnibs_interface.py @@ -0,0 +1,487 @@ +""" +SimNIBS Interface Module +======================== +Wrapper around SimNIBS functions for simulation and optimization. +""" + +import logging +import os +import platform +import re +import shutil +from datetime import datetime +from pathlib import Path +from typing import List, Optional, Tuple, Union + +import numpy as np + +# Core imports from SimNIBS +try: + import simnibs + from simnibs import opt_struct, run_simnibs, sim_struct +except ImportError: + raise ImportError( + "SimNIBS is not installed. This pipeline requires the SimNIBS python environment." + ) + +from tide.core.geometry import ( + compute_default_coil_orientation, + coords_inside_brain, + project_target_to_scalp, +) +from tide.utils.artifacts import ( + capture_artifacts, + fixed_pose_cache_enabled, + fixed_pose_cache_key, + fresh_artifacts, + record_artifact, + restore_fixed_pose_artifacts, + store_fixed_pose_artifacts, +) + +log = logging.getLogger(__name__) + + +class SimNIBSInterface: + """ + Wrapper around SimNIBS functions to handle Simulation and Optimization. + Isolates the pipeline from direct calls to simnibs libraries. + """ + + @staticmethod + def run_simulation( + mesh_path: Path, + output_dir: Path, + coil_path: Path, + didt: float, + coords: Optional[List[float]] = None, + orientation: Optional[Union[str, List[float], List[List[float]]]] = None, + distance_mm: float = 4.0, + fields: str = "E", + ) -> Path: + """ + Runs a standard isotropic FEM simulation. + + Args: + mesh_path: Path to m2m directory or .msh file + output_dir: Output directory for results + coil_path: Path to coil model file + didt: Stimulation intensity (A/s) + coords: Scalp coordinates [x, y, z] + orientation: Coil orientation (vector, 4x4 matrix, or EEG label) + distance_mm: Coil-scalp distance + fields: Which fields to compute (default 'E') + + Returns: + Path to the generated mesh file (.msh) + """ + log.debug(f"Setting up simulation: mesh={mesh_path}, output={output_dir}") + + s = sim_struct.SESSION() + s.subpath = str(mesh_path) + s.pathfem = str(output_dir) + s.open_in_gmsh = False + s.fields = fields + + tms = s.add_tmslist() + tms.fnamecoil = str(coil_path) + tms.anisotropy_type = "scalar" + + # Add position + pos = tms.add_position() + pos.didt = didt + pos.distance = distance_mm + + is_matrix = ( + isinstance(orientation, list) + and len(orientation) == 4 + and isinstance(orientation[0], list) + ) + + if is_matrix: + log.debug("Using 4x4 transformation matrix") + pos.matsimnibs = orientation + pos.centre = None + else: + if not coords: + raise ValueError("Coordinates required when orientation is not a 4x4 matrix.") + pos.centre = coords + pos.pos_ydir = orientation if orientation else "F8" + # Log the pos_ydir being used for simulation + log.info(f"[SIMULATION] pos_ydir (y_dir) = {pos.pos_ydir}") + + # Sanity check: pos.centre is expected to be a scalp coordinate. + try: + if coords_inside_brain(mesh_path, np.asarray(coords, dtype=float)): + log.warning( + f"[SIMULATION] Coil centre {list(coords)} lies inside the " + "grey-matter extent; expected a scalp coordinate. " + "Check the configuration." + ) + except Exception: + pass + + # Archive existing simulation results to prevent OSError + _archive_existing_results(output_dir) + + before = capture_artifacts(_simulation_mesh_candidates(output_dir)) + cache_key = None + if is_matrix and fixed_pose_cache_enabled(): + try: + cache_key = fixed_pose_cache_key( + mesh_path=mesh_path, + coil_path=coil_path, + orientation=orientation, + didt=didt, + distance_mm=distance_mm, + fields=fields, + runtime_signature={ + "simnibs": str(getattr(simnibs, "__version__", "unknown")), + "numpy": np.__version__, + "python": platform.python_version(), + "platform": platform.platform(), + "OMP_NUM_THREADS": os.environ.get("OMP_NUM_THREADS", ""), + "MKL_NUM_THREADS": os.environ.get("MKL_NUM_THREADS", ""), + "MKL_DOMAIN_NUM_THREADS": os.environ.get("MKL_DOMAIN_NUM_THREADS", ""), + "OPENBLAS_NUM_THREADS": os.environ.get("OPENBLAS_NUM_THREADS", ""), + "NUMEXPR_NUM_THREADS": os.environ.get("NUMEXPR_NUM_THREADS", ""), + }, + ) + restored = restore_fixed_pose_artifacts(cache_key, output_dir) + except (OSError, ValueError) as error: + log.warning(f"Fixed-pose cache lookup failed; running SimNIBS: {error}") + restored = [] + + if restored: + fresh = fresh_artifacts(before, _simulation_mesh_candidates(output_dir)) + result = _select_simulation_mesh(fresh, output_dir) + tms.postprocess = fields + _write_cached_simulation_metadata(s, output_dir, cache_key) + record_artifact( + output_dir, + "simulation_mesh", + result, + fresh, + details={"cache": {"status": "hit", "key": cache_key}}, + ) + log.info(f"Fixed-pose cache hit: {cache_key}") + return result + + log.debug("Starting SimNIBS simulation...") + run_simnibs(s) + + fresh = fresh_artifacts(before, _simulation_mesh_candidates(output_dir)) + result = _select_simulation_mesh(fresh, output_dir) + details = None + if cache_key is not None: + stored = False + try: + stored = store_fixed_pose_artifacts( + cache_key, + _simulation_cache_artifacts(result), + ) + except (OSError, ValueError) as error: + log.warning(f"Fixed-pose cache storage failed: {error}") + details = { + "cache": { + "status": "miss", + "key": cache_key, + "stored": stored, + } + } + record_artifact( + output_dir, + "simulation_mesh", + result, + fresh, + details=details, + ) + + log.debug(f"Simulation complete: {result}") + return result + + @staticmethod + def run_optimization( + mesh_path: Path, + output_dir: Path, + coil_path: Path, + target_coords: List[float], + scalp_centre: Optional[List[float]] = None, + orientation_ref: Optional[Union[str, List[float], List[List[float]]]] = None, + didt: float = 1e6, + distance_mm: float = 4.0, + search_radius_mm: float = 10.0, + spatial_resolution: float = 5.0, + angle_resolution: float = 30.0, + search_angle: float = 30.0, + use_adm: bool = True, + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Runs isotropic TMS Optimization. + + Args: + mesh_path: Path to .msh head mesh file + output_dir: Output directory + coil_path: Path to coil model + target_coords: Cortical target coordinates [x, y, z] + scalp_centre: Initial scalp position (auto-projected if None) + orientation_ref: Handle direction reference point + didt: Stimulation intensity + distance_mm: Coil-scalp distance + search_radius_mm: Search radius on scalp + spatial_resolution: Spatial search step (mm) + angle_resolution: Angular search step (degrees) + search_angle: Total angular search range + use_adm: Use ADM method (faster) + + Returns: + Tuple of (best_4x4_matrix, best_scalp_coords) + """ + # Log the incoming orientation_ref for debugging + log.info( + f"[OPTIMIZATION] Received orientation_ref = {orientation_ref} (type: {type(orientation_ref).__name__})" + ) + + # Auto-projection if needed + if not scalp_centre: + log.debug("Projecting cortical target to scalp...") + scalp_centre = project_target_to_scalp(mesh_path, np.array(target_coords)) + scalp_centre = scalp_centre.tolist() + + # Auto-orientation if needed + if not orientation_ref: + log.debug("Computing default coil orientation (no orientation_ref provided)...") + orientation_ref = compute_default_coil_orientation(mesh_path, np.array(scalp_centre)) + log.info(f"[OPTIMIZATION] Auto-computed pos_ydir (y_dir) = {orientation_ref}") + + log.debug(f"Optimization setup: target={target_coords}, scalp={scalp_centre}") + + # Setup optimization + opt = opt_struct.TMSoptimize() + opt.open_in_gmsh = False + opt.fnamehead = str(mesh_path) + opt.pathfem = str(output_dir) + opt.fnamecoil = str(coil_path) + opt.target = target_coords + opt.centre = scalp_centre + opt.distance = distance_mm + opt.search_radius = search_radius_mm + opt.spatial_resolution = spatial_resolution + opt.angle_resolution = angle_resolution + opt.search_angle = search_angle + opt.solver_options = "pardiso" + opt.didt = didt + opt.method = "ADM" if use_adm else "direct" + + # NOTE: ADM method may handle orientation constraints differently + if use_adm: + log.info("[OPTIMIZATION] Using ADM method (faster but may have orientation quirks)") + else: + log.info("[OPTIMIZATION] Using DIRECT method (slower but more reliable orientation)") + + if orientation_ref: + is_matrix = ( + isinstance(orientation_ref, list) + and len(orientation_ref) == 4 + and isinstance(orientation_ref[0], list) + ) + + if is_matrix: + raise ValueError( + "4x4 Matrix orientation is not supported for TMS Optimization. " + "Use cortex coordinates [x, y, z] or EEG label (e.g., 'F8')." + ) + + opt.pos_ydir = orientation_ref + # pos_ydir_is_position=True tells SimNIBS to compute the handle direction + # as (pos_ydir - coil_centre). This is only valid for coordinate lists; + # EEG label strings (e.g. "F7") are looked up internally by SimNIBS and + # must NOT have this flag set, otherwise numpy tries to subtract a string. + if isinstance(orientation_ref, list): + opt.pos_ydir_is_position = True + # Log the final pos_ydir being sent to SimNIBS optimizer + log.info(f"[OPTIMIZATION] Final pos_ydir (y_dir) sent to SimNIBS = {opt.pos_ydir}") + log.info( + f"[OPTIMIZATION] pos_ydir_is_position = {getattr(opt, 'pos_ydir_is_position', False)}" + ) + else: + log.warning( + "[OPTIMIZATION] No orientation_ref provided - SimNIBS will use default orientation!" + ) + + # Log all optimization parameters being sent to SimNIBS + log.info("[OPTIMIZATION] === SimNIBS TMSoptimize Parameters ===") + log.info(f"[OPTIMIZATION] opt.target = {opt.target}") + log.info(f"[OPTIMIZATION] opt.centre = {opt.centre}") + log.info(f"[OPTIMIZATION] opt.pos_ydir = {getattr(opt, 'pos_ydir', 'NOT SET')}") + log.info(f"[OPTIMIZATION] opt.search_radius = {opt.search_radius}") + log.info(f"[OPTIMIZATION] opt.spatial_resolution = {opt.spatial_resolution}") + log.info(f"[OPTIMIZATION] opt.angle_resolution = {opt.angle_resolution}") + log.info(f"[OPTIMIZATION] opt.search_angle = {opt.search_angle}") + log.info(f"[OPTIMIZATION] opt.method = {opt.method}") + + # Archive existing results + _archive_existing_results(output_dir) + + log.debug("Starting TMS optimization...") + # CRITICAL: opt.run() returns the optimal matsimnibs matrix directly! + opt_matrix = opt.run() + + # Extract results from the returned matrix + try: + # opt.run() returns a 4x4 numpy array (or can be squeezed from higher dims) + opt_matrix = np.atleast_2d(np.squeeze(opt_matrix)) + + if opt_matrix.shape != (4, 4): + raise ValueError(f"Unexpected matrix shape: {opt_matrix.shape}, expected (4, 4)") + + # Extract scalp coordinates from the transformation matrix (translation column) + scalp_coords = opt_matrix[0:3, 3] + + # Extract and log the ACTUAL Y-direction from the result matrix + # In SimNIBS matsimnibs format: column 0 = X-axis, column 1 = Y-axis, column 2 = Z-axis (normal) + result_y_direction = opt_matrix[0:3, 1] + result_z_direction = opt_matrix[0:3, 2] # Coil normal (should point into head) + + log.info("=" * 60) + log.info("[OPTIMIZATION] === RESULT ANALYSIS ===") + log.info(f"[OPTIMIZATION] Result coil position: {scalp_coords.tolist()}") + log.info(f"[OPTIMIZATION] Result Y-direction (handle): {result_y_direction.tolist()}") + log.info(f"[OPTIMIZATION] Result Z-direction (normal): {result_z_direction.tolist()}") + log.info("=" * 60) + + log.debug(f"Optimization complete: scalp_coords={scalp_coords}") + return opt_matrix, scalp_coords + + except Exception as e: + log.error(f"Failed to process optimization results: {e}") + raise + + @staticmethod + def _parse_optimization_log(log_file: Path) -> Tuple[np.ndarray, np.ndarray]: + """ + Parse SimNIBS optimization log to extract the best 4x4 matrix. + + DEPRECATED: This method is kept for backwards compatibility but is no longer + the primary way to get optimization results. The opt.run() method returns + the matrix directly, which is more reliable across SimNIBS versions. + """ + matrix_lines = [] + found_header = False + header_regex = re.compile(r"Best coil position") + matrix_line_regex = re.compile(r"^\s*\[") + + with open(log_file, "r") as f: + for line in f: + if not found_header and header_regex.search(line): + found_header = True + continue + + if found_header: + clean_line = line.strip() + if matrix_line_regex.search(clean_line): + matrix_lines.append(clean_line) + if len(matrix_lines) == 4: + break + + if len(matrix_lines) != 4: + raise ValueError(f"Could not parse 4x4 matrix from log: {log_file}") + + # Clean and convert to numpy + matrix_string = " ".join(matrix_lines) + matrix_string = re.sub(r"[\[\]]", "", matrix_string) + matrix_string = re.sub(r"\s+", ",", matrix_string) + matrix_string = matrix_string.strip(",") + matrix_string = re.sub(r",,", ",", matrix_string) + + flat_arr = np.fromstring(matrix_string, sep=",") + if flat_arr.size != 16: + raise ValueError("Parsed matrix does not have 16 elements.") + + matrix_4x4 = flat_arr.reshape((4, 4)) + coords = matrix_4x4[0:3, 3] + + log.debug(f"Parsed optimization result: scalp_coords={coords}") + return matrix_4x4, coords + + +def _archive_existing_results(output_dir: Path): + """Archive existing SimNIBS results to prevent conflicts.""" + if not output_dir.exists(): + return + + existing_mats = sorted(output_dir.glob("simnibs_simulation*.mat")) + + if existing_mats: + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + archive_dir = output_dir / f"archive_{timestamp}" + + try: + archive_dir.mkdir(parents=True, exist_ok=True) + log.debug(f"Archiving {len(existing_mats)} existing result files") + + for mat_file in existing_mats: + shutil.move(str(mat_file), str(archive_dir / mat_file.name)) + except Exception as e: + log.warning(f"Failed to archive existing files: {e}") + + +def _simulation_mesh_candidates(output_dir: Path) -> List[Path]: + return sorted( + path + for path in output_dir.glob("*.msh") + if path.name != "target.msh" and "optimize" not in path.name.lower() + ) + + +def _simulation_cache_artifacts(mesh_path: Path) -> List[Path]: + artifacts = [mesh_path] + options_path = Path(f"{mesh_path}.opt") + if options_path.is_file(): + artifacts.append(options_path) + + summary_path = mesh_path.parent / "fields_summary.txt" + if summary_path.is_file(): + artifacts.append(summary_path) + + for suffix in ("_scalar.msh", "_E.msh"): + if mesh_path.name.endswith(suffix): + prefix = mesh_path.name[: -len(suffix)] + geometry_path = mesh_path.parent / f"{prefix}_coil_pos.geo" + if geometry_path.is_file(): + artifacts.append(geometry_path) + break + return artifacts + + +def _write_cached_simulation_metadata(session: object, output_dir: Path, cache_key: str) -> None: + time_str = getattr(session, "time_str", None) + save_struct = getattr(sim_struct, "save_matlab_sim_struct", None) + if not time_str or not callable(save_struct): + return + + try: + save_struct(session, str(output_dir / f"simnibs_simulation_{time_str}.mat")) + (output_dir / f"simnibs_simulation_{time_str}.log").write_text( + f"Fixed-pose cache hit: {cache_key}\n", + encoding="utf-8", + ) + except (OSError, TypeError, ValueError) as error: + log.warning(f"Could not write cached SimNIBS session metadata: {error}") + + +def _select_simulation_mesh(candidates: List[Path], output_dir: Path) -> Path: + priority_groups = ( + [path for path in candidates if path.name.endswith("_scalar.msh")], + [path for path in candidates if path.name.endswith("_E.msh")], + candidates, + ) + for group in priority_groups: + if not group: + continue + if len(group) > 1: + names = ", ".join(path.name for path in group) + raise RuntimeError(f"Ambiguous simulation output: {names}") + return group[0] + + raise FileNotFoundError(f"Simulation finished but no fresh .msh file was found in {output_dir}") diff --git a/src/tide/interfaces/stmpx.py b/src/tide/interfaces/stmpx.py new file mode 100644 index 0000000..a91d2de --- /dev/null +++ b/src/tide/interfaces/stmpx.py @@ -0,0 +1,190 @@ +"""Softaxic STMPX export for completed TIDE estimations.""" + +import ast +import re +import time +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Optional + +import numpy as np +from defusedxml import ElementTree as DefusedET +from defusedxml.common import DefusedXmlException + +COIL_CODE = "8700449" + +FP_ATTR_ORDER = [ + "m00", + "m10", + "y", + "m21", + "m02", + "m22", + "m01", + "m12", + "ts", + "z", + "id", + "x", + "m20", + "m11", +] + + +def _parse_matrix_string(matrix_str: str) -> np.ndarray: + try: + return np.array(ast.literal_eval(matrix_str)) + except (ValueError, SyntaxError) as exc: + raise ValueError(f"Failed to parse Target Estimation matrix: {exc}") from exc + + +def _parse_coords_string(coords_str: str) -> list[float]: + try: + return [float(coordinate) for coordinate in ast.literal_eval(coords_str)] + except (ValueError, SyntaxError) as exc: + raise ValueError(f"Failed to parse Target Estimation coordinates: {exc}") from exc + + +def _extract_target_data(results_path: Path) -> dict[str, Any]: + content = results_path.read_text() + target_match = re.search( + r"--- Target Estimation \((.*?)\) ---(.*?)--- Geometric Analysis", + content, + re.DOTALL, + ) + if not target_match: + raise ValueError("Could not find Target Estimation section in TIDE results.") + + target_text = target_match.group(2) + data: dict[str, Any] = {} + + cortex_match = re.search(r"Target Coords \(Cortex\):\s*(\[.*?\])", target_text) + if cortex_match: + data["cortex_coords"] = _parse_coords_string(cortex_match.group(1)) + + scalp_match = re.search(r"Optimized Scalp Position:\s*(\[.*?\])", target_text) + if scalp_match: + data["scalp_coords"] = _parse_coords_string(scalp_match.group(1)) + + matrix_match = re.search(r"Optimized Matrix:\s*(\[\[.*?\]\])", target_text, re.DOTALL) + if not matrix_match: + raise ValueError("Could not find Target Estimation matrix in TIDE results.") + data["matrix"] = _parse_matrix_string(matrix_match.group(1)) + + return data + + +def simnibs_to_softaxic_rotation(matrix: np.ndarray) -> dict[str, float]: + """Apply the laboratory-verified SimNIBS-to-Softaxic rotation mapping.""" + return { + "m00": float(matrix[0, 1]), + "m01": float(matrix[0, 0]), + "m02": float(-matrix[0, 2]), + "m10": float(matrix[1, 1]), + "m11": float(matrix[1, 0]), + "m12": float(-matrix[1, 2]), + "m20": float(matrix[2, 1]), + "m21": float(matrix[2, 0]), + "m22": float(-matrix[2, 2]), + } + + +def _create_fp_element(matrix: np.ndarray, point_id: str) -> ET.Element: + rotation = simnibs_to_softaxic_rotation(matrix) + fp = ET.Element("fp") + attributes = { + "m00": f"{rotation['m00']:.6f}", + "m10": f"{rotation['m10']:.6f}", + "y": f"{float(matrix[1, 3]):.4f}", + "m21": f"{rotation['m21']:.6f}", + "m02": f"{rotation['m02']:.6f}", + "m22": f"{rotation['m22']:.6f}", + "m01": f"{rotation['m01']:.6f}", + "m12": f"{rotation['m12']:.6f}", + "ts": str(int(time.time() * 1000)), + "z": f"{float(matrix[2, 3]):.4f}", + "id": point_id, + "x": f"{float(matrix[0, 3]):.4f}", + "m20": f"{rotation['m20']:.6f}", + "m11": f"{rotation['m11']:.6f}", + } + for attribute_name in FP_ATTR_ORDER: + fp.set(attribute_name, attributes[attribute_name]) + return fp + + +def _create_target_element(data: dict[str, Any]) -> ET.Element: + target = ET.Element("fmp") + target.set("global", "0") + target.set("id", "Target_Estimation") + + fp = _create_fp_element(data["matrix"], point_id=COIL_CODE) + cortex_coords = data.get("cortex_coords") + if cortex_coords: + cortex = ET.SubElement(fp, "b") + cortex.set("x", f"{cortex_coords[0]:.4f}") + cortex.set("y", f"{cortex_coords[1]:.4f}") + cortex.set("z", f"{cortex_coords[2]:.4f}") + + scalp_coords = data.get("scalp_coords") + if scalp_coords: + scalp = ET.SubElement(fp, "f") + scalp.set("x", f"{scalp_coords[0]:.4f}") + scalp.set("y", f"{scalp_coords[1]:.4f}") + scalp.set("z", f"{scalp_coords[2]:.4f}") + + target.append(fp) + return target + + +def _indent_xml(element: ET.Element, level: int = 0) -> None: + spacer = "\n" + level * " " + if len(element): + if not element.text or not element.text.strip(): + element.text = spacer + " " + if not element.tail or not element.tail.strip(): + element.tail = spacer + for child in element: + _indent_xml(child, level + 1) + if not child.tail or not child.tail.strip(): + child.tail = spacer + elif level and (not element.tail or not element.tail.strip()): + element.tail = spacer + + +def _load_stmpx(stmpx_path: Path) -> tuple[ET.ElementTree, ET.Element]: + if not stmpx_path.is_file(): + raise FileNotFoundError(f"STMPX file not found: {stmpx_path}") + try: + tree = DefusedET.parse(stmpx_path) + except (ET.ParseError, DefusedXmlException) as exc: + raise ValueError(f"Invalid STMPX XML ({stmpx_path}): {exc}") from exc + fmpm = tree.getroot().find("fmpm") + if fmpm is None: + raise ValueError(f"STMPX file has no element: {stmpx_path}") + return tree, fmpm + + +def validate_stmpx_input(stmpx_path: Path) -> None: + """Validate the STMPX template before TIDE creates derivative output.""" + _load_stmpx(stmpx_path) + + +def export_target_to_stmpx( + stmpx_path: Path, + results_path: Path, + dataset_name: Optional[str] = None, +) -> Path: + """Append the TIDE Target Estimation pose and write ``*_updated.stmpx``.""" + target_data = _extract_target_data(results_path) + tree, fmpm = _load_stmpx(stmpx_path) + if dataset_name is not None: + fmpm.set("dataset", dataset_name) + fmpm.append(_create_target_element(target_data)) + + output_path = stmpx_path.parent / f"{stmpx_path.stem}_updated{stmpx_path.suffix}" + _indent_xml(tree.getroot()) + with open(output_path, "wb") as file_handle: + file_handle.write(b"\n") + tree.write(file_handle, encoding="utf-8", xml_declaration=False) + return output_path diff --git a/src/tide/interfaces/unified_estimation.py b/src/tide/interfaces/unified_estimation.py new file mode 100644 index 0000000..fe7f43e --- /dev/null +++ b/src/tide/interfaces/unified_estimation.py @@ -0,0 +1,677 @@ +""" +Unified TMS Target Intensity Estimation Module +============================================== +Estimates target intensity using the Contiguous Activating Function method. + +Supports 4 modes: +1. Baseline: Spherical ROI only +2. Surface-Constrained (GWI): Restricted to grey-white interface +3. Weighted: Uses SIFT2/TOM streamline weights +4. Hybrid: Combines GWI and weights +""" + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import nibabel as nib +import numpy as np +from nibabel.streamlines.trk import TrkFile +from scipy.spatial import cKDTree + +from tide.core.physics import ( + AGGREGATOR_KEYS, + cross_streamline_aggregates, + get_max_contiguous_threshold, + median_of_top_percentile, + weighted_percentile, +) + +log = logging.getLogger(__name__) + +# Definition of the %MSO scale: no stimulator can be programmed above its own +# maximum output, whatever the configured safety ratio allows. +MAX_STIMULATOR_OUTPUT_PCT = 100.0 + + +@dataclass +class AnalysisConfig: + """Configuration for bundle analysis.""" + + cst_trk: str + target_trk: str + rmt: float + cst_coords: np.ndarray + target_coords: np.ndarray + surf_path: Optional[str] = None + gwi_threshold: float = 3.0 + roi_radius: float = 20.0 + activation_len: float = 4.0 + min_seg_len: float = 10.0 + cst_weights: Optional[str] = None + target_weights: Optional[str] = None + out_dir: str = "viz_output" + + +@dataclass +class BundleResult: + """Results from bundle analysis.""" + + name: str + metric_weighted: float + metric_unweighted: float + n_total: int + n_analyzed: int + weight_source: str + roi_center: np.ndarray = field(default_factory=lambda: np.zeros(3)) + roi_radius: float = 0.0 + aggregates_weighted: Dict[str, float] = field(default_factory=dict) + aggregates_unweighted: Dict[str, float] = field(default_factory=dict) + + +def load_surface_tree(surface_path: str) -> cKDTree: + """Load FreeSurfer surface and build KDTree for distance queries.""" + log.debug(f"Loading surface: {surface_path}") + try: + coords, _ = nib.freesurfer.read_geometry(surface_path) + return cKDTree(coords) + except Exception as e: + log.error(f"Failed to load surface: {e}") + raise + + +def _validate_weights(weights: np.ndarray, source: str) -> np.ndarray: + """Validate SIFT2 streamline weights (audit S-004). + + Weights are per-streamline cross-sectional multipliers, so an explicitly + requested weight set must be usable: fail loudly rather than silently + fall back to a fabricated uniform weighting. + """ + weights = np.asarray(weights, dtype=float).ravel() + if weights.size == 0: + raise ValueError(f"Weight source '{source}' is empty.") + if not np.all(np.isfinite(weights)): + raise ValueError(f"Weight source '{source}' contains non-finite values.") + if np.any(weights < 0): + raise ValueError(f"Weight source '{source}' contains negative values.") + if weights.sum() <= 0: + raise ValueError(f"Weight source '{source}' has zero total mass.") + return weights + + +def load_weights(weight_path: str, streamlines: List[np.ndarray]) -> np.ndarray: + """Load and validate streamline weights from a text file or NIfTI volume. + + A weight file is only loaded when it was explicitly configured; per audit + S-004 a load or validation failure raises rather than silently substituting + uniform weights. + """ + if weight_path.endswith(".txt"): + log.debug(f"Loading text weights: {weight_path}") + try: + w = np.loadtxt(weight_path) + except Exception as e: + raise ValueError(f"Failed to load weight file '{weight_path}': {e}") + return _validate_weights(w, weight_path) + + elif weight_path.endswith((".nii", ".nii.gz")): + log.debug(f"Sampling volume weights: {weight_path}") + try: + img = nib.load(weight_path) + data = img.get_fdata() + inv_affine = np.linalg.inv(img.affine) + if data.ndim == 4: + data = np.linalg.norm(data, axis=3) + + weights = [] + for sl in streamlines: + vox_idx = nib.affines.apply_affine(inv_affine, sl).astype(int) + x = np.clip(vox_idx[:, 0], 0, data.shape[0] - 1) + y = np.clip(vox_idx[:, 1], 0, data.shape[1] - 1) + z = np.clip(vox_idx[:, 2], 0, data.shape[2] - 1) + vals = data[x, y, z] + weights.append(np.mean(vals)) + except Exception as e: + raise ValueError(f"Failed to sample weight volume '{weight_path}': {e}") + return _validate_weights(np.array(weights), weight_path) + + raise ValueError(f"Unsupported weight file format: {weight_path}") + + +def apply_intensity_bounds( + raw_intensity: float, + rmt: float, + floor_ratio: float = 0.70, + ceiling_ratio: float = 1.40, +) -> dict: + """ + Apply physiological bounds to a raw intensity estimate. + + Reports both the raw model value and a clamped "best estimate" constrained + within [floor, ceiling]. The ceiling is the safety ratio limited by the + stimulator maximum, and the floor may not exceed that ceiling, so the + clamped value is always a programmable stimulator intensity. + + The two ceiling mechanisms carry distinct flags: CLAMPED_HIGH means the + safety ratio bound the estimate, DEVICE_LIMITED means the estimate exceeds + the stimulator maximum and cannot be delivered at any setting. + + Args: + raw_intensity: Raw model-estimated intensity (% MSO). + rmt: Measured RMT (% MSO). + floor_ratio: Min intensity as fraction of RMT (default 0.70). + ceiling_ratio: Max intensity as fraction of RMT (default 1.40). + + Returns: + Dict with keys: model_raw, best_estimate, flag, deviation_pct. + """ + # A non-finite raw value means the estimate failed (e.g. zero target + # metric); report it as such rather than clamping it into a plausible range. + if not np.isfinite(raw_intensity): + return { + "model_raw": raw_intensity, + "best_estimate": float("nan"), + "flag": "ESTIMATION_FAILED", + "deviation_pct": float("nan"), + } + + safety_ceil = rmt * ceiling_ratio + intensity_ceil = min(safety_ceil, MAX_STIMULATOR_OUTPUT_PCT) + # A floor ratio above 100/RMT would otherwise yield an unprogrammable floor. + intensity_floor = min(rmt * floor_ratio, intensity_ceil) + + if raw_intensity < intensity_floor: + best_estimate = intensity_floor + flag = "CLAMPED_LOW" + elif raw_intensity > intensity_ceil: + best_estimate = intensity_ceil + flag = "DEVICE_LIMITED" if safety_ceil > MAX_STIMULATOR_OUTPUT_PCT else "CLAMPED_HIGH" + else: + best_estimate = raw_intensity + flag = "WITHIN_RANGE" + + deviation_pct = abs(raw_intensity - rmt) / rmt * 100.0 if rmt > 0 else 0.0 + + return { + "model_raw": raw_intensity, + "best_estimate": best_estimate, + "flag": flag, + "deviation_pct": deviation_pct, + } + + +def validate_calibration_metrics(metric_weighted: float, metric_unweighted: float) -> None: + """Require usable weighted and unweighted CST calibration metrics.""" + invalid = [] + if not np.isfinite(metric_weighted) or metric_weighted <= 0: + invalid.append(f"weighted={metric_weighted}") + if not np.isfinite(metric_unweighted) or metric_unweighted <= 0: + invalid.append(f"unweighted={metric_unweighted}") + if invalid: + values = ", ".join(invalid) + raise ValueError( + "CST calibration is unusable; weighted and unweighted metrics must be " + f"finite and greater than zero ({values})." + ) + + +def format_weight_sources(cst_source: str, target_source: str) -> str: + """Preserve the legacy value when sources match, otherwise report both.""" + if cst_source == target_source: + return cst_source + return f"CST: {cst_source}; Target: {target_source}" + + +def analyze_bundle( + name: str, + trk_path: str, + roi_center: np.ndarray, + config: AnalysisConfig, + surface_tree: Optional[cKDTree] = None, + weight_path: Optional[str] = None, + orig_indices: Optional[np.ndarray] = None, +) -> BundleResult: + """ + Analyze a bundle to compute robust activation metrics. + + Args: + name: Bundle name for logging + trk_path: Path to TRK file with AF data + roi_center: ROI center coordinates + config: Analysis configuration + surface_tree: Optional surface KDTree for GWI filtering + weight_path: Optional path to weight file + orig_indices: Optional original streamline ids for the TRK streamlines, + in TRK order (audit C-002). When provided, the weight file (indexed + by original bundle id) is re-aligned to TRK order before use so the + SIFT2 weight of each surviving streamline stays attached to it after + upstream drops. When None, weights are indexed positionally + (legacy/standalone behaviour). + + Returns: + BundleResult with metrics + """ + log.debug(f"Analyzing bundle: {name}") + + try: + trk = TrkFile.load(trk_path) + streamlines = list(trk.tractogram.streamlines) + + # Get AF scalar data + if "AF" in trk.tractogram.data_per_point: + af_data = trk.tractogram.data_per_point["AF"] + elif "af" in trk.tractogram.data_per_point: + af_data = trk.tractogram.data_per_point["af"] + else: + raise ValueError(f"Scalar 'AF' not found in {trk_path}") + + af_values = [np.array(a).flatten() for a in af_data] + log.debug(f"Loaded {len(streamlines)} streamlines") + except Exception as e: + log.error(f"Failed to load TRK: {e}") + raise + + # Load weights if available + all_weights = None + if weight_path: + if not Path(weight_path).exists(): + raise FileNotFoundError(f"Configured weight file does not exist: {weight_path}") + all_weights = load_weights(weight_path, streamlines) + weight_source = f"External ({Path(weight_path).name})" + + # Re-align the weight vector (indexed by original bundle id) to the TRK + # streamline order, so each surviving streamline keeps its own SIFT2 + # weight after upstream angular/AF drops (audit C-002). Only text weight + # files are indexed by original streamline id; volume-sampled (NIfTI) + # weights are read at the TRK streamline positions and are already + # TRK-aligned, so they are left untouched. + if orig_indices is not None and weight_path.endswith(".txt"): + orig_indices = np.asarray(orig_indices, dtype=int) + if len(orig_indices) != len(streamlines): + raise ValueError( + f"{name}: orig_indices length ({len(orig_indices)}) does not " + f"match TRK streamlines ({len(streamlines)})." + ) + if all_weights.size <= int(orig_indices.max(initial=-1)): + raise ValueError( + f"{name}: weight file '{Path(weight_path).name}' has " + f"{all_weights.size} weights but the bundle references index " + f"{int(orig_indices.max())} (tractogram/weight provenance mismatch)." + ) + all_weights = all_weights[orig_indices] + else: + weight_source = "Uniform" + + thresholds = [] + valid_indices = [] + n_nonfinite = 0 + + for idx, (sl, af) in enumerate(zip(streamlines, af_values)): + # ROI filter + dists = np.linalg.norm(sl - roi_center, axis=1) + sphere_mask = dists <= config.roi_radius + + if not np.any(sphere_mask): + continue + + points_roi = sl[sphere_mask] + af_roi = af[sphere_mask] + + # Surface filter (GWI) + if surface_tree: + surf_dists, _ = surface_tree.query(points_roi, k=1) + gwi_mask = surf_dists <= config.gwi_threshold + if not np.any(gwi_mask): + continue + points_roi = points_roi[gwi_mask] + af_roi = af_roi[gwi_mask] + + if len(points_roi) < 2: + continue + + # Skip streamlines whose ROI carries a non-finite AF (e.g. an + # unmatched E-field sample); they would poison the aggregation. + if not np.all(np.isfinite(af_roi)): + n_nonfinite += 1 + continue + + # Contiguous threshold calculation + steps = np.linalg.norm(np.diff(points_roi, axis=0), axis=1) + if np.sum(steps) < config.min_seg_len: + continue + + af_abs = np.abs(af_roi) + af_seg = (af_abs[:-1] + af_abs[1:]) / 2.0 + + thresh = get_max_contiguous_threshold(af_seg, steps, config.activation_len) + thresholds.append(thresh) + valid_indices.append(idx) + + n_valid = len(thresholds) + log.debug(f"Valid streamlines: {n_valid}/{len(streamlines)}") + if n_nonfinite: + log.warning(f"{name}: skipped {n_nonfinite} streamlines with non-finite AF") + + thresholds = np.array(thresholds) + + if n_valid == 0: + empty_aggregates = cross_streamline_aggregates(thresholds) + return BundleResult( + name, + 0.0, + 0.0, + len(streamlines), + 0, + weight_source, + aggregates_weighted=dict(empty_aggregates), + aggregates_unweighted=dict(empty_aggregates), + ) + + if all_weights is not None: + final_weights = all_weights[valid_indices] + else: + final_weights = np.ones(n_valid) + + # Weighted metric: median of top 5% + p95_w = weighted_percentile(thresholds, final_weights, 95.0) + top_mask_w = thresholds >= p95_w + metric_w = 0.0 + if np.any(top_mask_w): + metric_w = weighted_percentile(thresholds[top_mask_w], final_weights[top_mask_w], 50.0) + + # Unweighted metric: median of top 5% + metric_u = median_of_top_percentile(thresholds, 95.0) + + log.debug(f"{name}: Weighted={metric_w:.2f}, Unweighted={metric_u:.2f} V/m²") + + # Diagnostic alternatives on the same threshold distribution. The primary + # entry is overwritten with the reported metric so the sensitivity table can + # never drift from the value that drives the dose. + aggregates_weighted = cross_streamline_aggregates(thresholds, final_weights) + aggregates_unweighted = cross_streamline_aggregates(thresholds) + aggregates_weighted["median_top5"] = metric_w + aggregates_unweighted["median_top5"] = metric_u + + return BundleResult( + name=name, + metric_weighted=metric_w, + metric_unweighted=metric_u, + n_total=len(streamlines), + n_analyzed=n_valid, + weight_source=weight_source, + roi_center=roi_center, + roi_radius=config.roi_radius, + aggregates_weighted=aggregates_weighted, + aggregates_unweighted=aggregates_unweighted, + ) + + +def build_aggregator_sensitivity( + *, + cst_weighted: Dict[str, float], + cst_unweighted: Dict[str, float], + target_weighted: Dict[str, float], + target_unweighted: Dict[str, float], + rmt: float, +) -> Dict[str, Dict[str, float]]: + """ + Calibration ratio and raw intensity under each cross-streamline aggregator. + + Diagnostic only: the reported dose always uses the primary aggregator + (median of the top 5%). Every entry is derived from the per-streamline + threshold distributions already computed by :func:`analyze_bundle`, so no + additional field solve or AF pass is involved. + + Args: + cst_weighted: Weighted aggregates of the calibration bundle. + cst_unweighted: Unweighted aggregates of the calibration bundle. + target_weighted: Weighted aggregates of the target bundle. + target_unweighted: Unweighted aggregates of the target bundle. + rmt: Measured RMT percentage. + + Returns: + Mapping of aggregator key to AF_CST, AF_target, SEI and raw intensity, + weighted and unweighted. + """ + sensitivity: Dict[str, Dict[str, float]] = {} + for key in AGGREGATOR_KEYS: + row: Dict[str, float] = {} + for suffix, cst_agg, tgt_agg in ( + ("weighted", cst_weighted, target_weighted), + ("unweighted", cst_unweighted, target_unweighted), + ): + af_cst = float(cst_agg.get(key, 0.0)) + af_tgt = float(tgt_agg.get(key, 0.0)) + row[f"af_cst_{suffix}"] = af_cst + row[f"af_target_{suffix}"] = af_tgt + row[f"sei_{suffix}"] = af_tgt / af_cst if af_cst > 0 else 0.0 + row[f"intensity_raw_{suffix}"] = rmt * (af_cst / af_tgt) if af_tgt > 0 else float("nan") + sensitivity[key] = row + return sensitivity + + +def run_unified_estimation( + cst_trk: Path, + target_trk: Path, + cst_coords: List[float], + target_coords: List[float], + rmt: float, + weights_cst: Optional[Path] = None, + weights_target: Optional[Path] = None, + surface_path: Optional[Path] = None, + gwi_threshold: float = 3.0, + roi_radius: float = 20.0, + activation_len: float = 4.0, + mso_floor_ratio: float = 0.70, + mso_ceiling_ratio: float = 1.40, + cst_orig_indices: Optional[np.ndarray] = None, + target_orig_indices: Optional[np.ndarray] = None, +) -> Dict[str, Any]: + """ + Main entry point for unified intensity estimation. + + Args: + cst_trk: Path to CST TRK with AF + target_trk: Path to target TRK with AF + cst_coords: CST ROI center coordinates + target_coords: Target ROI center coordinates + rmt: Measured RMT percentage + weights_cst: Optional path to CST weights + weights_target: Optional path to target weights + surface_path: Optional path to FreeSurfer surface + gwi_threshold: Max distance from the GWI surface (mm); used only when + surface_path is supplied + roi_radius: ROI radius (mm) + activation_len: Required contiguous length (mm) + mso_floor_ratio: Min MSO as fraction of RMT (default 0.70) + mso_ceiling_ratio: Max MSO as fraction of RMT (default 1.40) + cst_orig_indices: Original streamline ids for the CST TRK streamlines + (audit C-002); keeps SIFT2 weights aligned after drops. + target_orig_indices: Original streamline ids for the target TRK streamlines. + + Returns: + Dictionary with estimation results + """ + config = AnalysisConfig( + cst_trk=str(cst_trk), + target_trk=str(target_trk), + rmt=rmt, + cst_coords=np.array(cst_coords), + target_coords=np.array(target_coords), + surf_path=str(surface_path) if surface_path and surface_path.exists() else None, + gwi_threshold=gwi_threshold, + cst_weights=str(weights_cst) if weights_cst is not None else None, + target_weights=str(weights_target) if weights_target is not None else None, + roi_radius=roi_radius, + activation_len=activation_len, + ) + + # Determine mode + mode = "Baseline" + if config.surf_path: + mode = "Surface-Constrained (GWI)" + if config.cst_weights or config.target_weights: + mode += " + Weighted" + + log.debug(f"Estimation mode: {mode}") + + # Load surface if available + surf_tree = None + if config.surf_path: + surf_tree = load_surface_tree(config.surf_path) + + # Analyze bundles + cst_res = analyze_bundle( + "CST", + config.cst_trk, + config.cst_coords, + config, + surf_tree, + config.cst_weights, + orig_indices=cst_orig_indices, + ) + validate_calibration_metrics(cst_res.metric_weighted, cst_res.metric_unweighted) + + tgt_res = analyze_bundle( + "Target", + config.target_trk, + config.target_coords, + config, + surf_tree, + config.target_weights, + orig_indices=target_orig_indices, + ) + + cst_metric = cst_res.metric_weighted + tgt_metric = tgt_res.metric_weighted + + # Calculate intensity + if tgt_metric > 0: + intensity_est = rmt * (cst_metric / tgt_metric) + else: + intensity_est = float("nan") + log.error("Target metric is zero. Intensity cannot be estimated.") + + # Unweighted fallback + if tgt_res.metric_unweighted > 0: + intensity_est_u = rmt * (cst_res.metric_unweighted / tgt_res.metric_unweighted) + else: + intensity_est_u = float("nan") + + sei_weighted = tgt_metric / cst_metric if cst_metric > 0 else 0.0 + sei_unweighted = ( + tgt_res.metric_unweighted / cst_res.metric_unweighted + if cst_res.metric_unweighted > 0 + else 0.0 + ) + + # Multiplier k = M_CST / M_target (intensity-invariant geometric ratio). + # I_raw = RMT * k → user can rescale dose by multiplying k by any RMT + # without rerunning the simulation. + multiplier_weighted = cst_metric / tgt_metric if tgt_metric > 0 else 0.0 + multiplier_unweighted = ( + cst_res.metric_unweighted / tgt_res.metric_unweighted + if tgt_res.metric_unweighted > 0 + else 0.0 + ) + + # Apply physiological bounds + bounded_w = apply_intensity_bounds( + intensity_est, + rmt, + floor_ratio=mso_floor_ratio, + ceiling_ratio=mso_ceiling_ratio, + ) + bounded_u = apply_intensity_bounds( + intensity_est_u, + rmt, + floor_ratio=mso_floor_ratio, + ceiling_ratio=mso_ceiling_ratio, + ) + + # Log summary + log.debug(f"CST AF: {cst_metric:.2f} V/m², Target AF: {tgt_metric:.2f} V/m²") + log.debug(f"Raw intensity: {intensity_est:.1f}% (Unweighted: {intensity_est_u:.1f}%)") + log.debug( + f"Clamped intensity: {bounded_w['best_estimate']:.1f}% (Unweighted: {bounded_u['best_estimate']:.1f}%)" + ) + if bounded_w["flag"] != "WITHIN_RANGE": + log.debug(f"Intensity flag (weighted): {bounded_w['flag']}") + if bounded_u["flag"] != "WITHIN_RANGE": + log.debug(f"Intensity flag (unweighted): {bounded_u['flag']}") + + return { + "cst_metric": cst_metric, + "tgt_metric": tgt_metric, + # Weighted intensity + "intensity_est": bounded_w["best_estimate"], + "intensity_est_raw": bounded_w["model_raw"], + "intensity_est_clamped": bounded_w["best_estimate"], + "intensity_est_flag": bounded_w["flag"], + # Unweighted intensity + "intensity_est_u": bounded_u["best_estimate"], + "intensity_est_u_raw": bounded_u["model_raw"], + "intensity_est_u_clamped": bounded_u["best_estimate"], + "intensity_est_u_flag": bounded_u["flag"], + # SEI — Stimulation Efficiency Index: AF_target / AF_CST = RMT / I_raw + # SEI > 1: target more efficient than CST (less stimulation needed) + # SEI = 1: same efficiency as CST (M1 identity case) + # SEI < 1: target less efficient than CST (more stimulation needed) + "sei_weighted": sei_weighted, + "sei_unweighted": sei_unweighted, + # Multiplier k = M_CST / M_target (intensity-invariant; I_raw = RMT * k) + "multiplier_weighted": multiplier_weighted, + "multiplier_unweighted": multiplier_unweighted, + "mode": mode, + "weight_source": cst_res.weight_source, + "weight_source_cst": cst_res.weight_source, + "weight_source_target": tgt_res.weight_source, + "cst_unweighted": cst_res.metric_unweighted, + "tgt_unweighted": tgt_res.metric_unweighted, + "mso_floor_ratio": mso_floor_ratio, + "mso_ceiling_ratio": mso_ceiling_ratio, + "cst_aggregates_weighted": cst_res.aggregates_weighted, + "cst_aggregates_unweighted": cst_res.aggregates_unweighted, + "target_aggregates_weighted": tgt_res.aggregates_weighted, + "target_aggregates_unweighted": tgt_res.aggregates_unweighted, + "aggregator_sensitivity": build_aggregator_sensitivity( + cst_weighted=cst_res.aggregates_weighted, + cst_unweighted=cst_res.aggregates_unweighted, + target_weighted=tgt_res.aggregates_weighted, + target_unweighted=tgt_res.aggregates_unweighted, + rmt=rmt, + ), + } + + +if __name__ == "__main__": + import argparse + + logging.basicConfig(level=logging.INFO) + + parser = argparse.ArgumentParser(description="Unified TMS Target Intensity Estimation") + parser.add_argument("--cst", required=True, help="CST TRK file") + parser.add_argument("--target", required=True, help="Target TRK file") + parser.add_argument("--rmt", required=True, type=float, help="Measured RMT (%)") + parser.add_argument("--cst_coords", type=float, nargs=3, required=True, help="CST ROI center") + parser.add_argument( + "--target_coords", type=float, nargs=3, required=True, help="Target ROI center" + ) + parser.add_argument("--surf", help="FreeSurfer surface file") + parser.add_argument("--cst_weights", help="CST weights file") + parser.add_argument("--target_weights", help="Target weights file") + args = parser.parse_args() + + results = run_unified_estimation( + Path(args.cst), + Path(args.target), + args.cst_coords, + args.target_coords, + args.rmt, + Path(args.cst_weights) if args.cst_weights else None, + Path(args.target_weights) if args.target_weights else None, + Path(args.surf) if args.surf else None, + ) + + print(f"\nEstimated Target I: {results['intensity_est']:.1f}% MSO") diff --git a/src/tide/interfaces/visualization.py b/src/tide/interfaces/visualization.py new file mode 100644 index 0000000..bca52bf --- /dev/null +++ b/src/tide/interfaces/visualization.py @@ -0,0 +1,120 @@ +""" +Basic Visualization Module (FURY/DIPY) +====================================== +Provides basic streamline visualization using FURY/DIPY. +For advanced 3D visualization, use visualization_3d module. +""" + +import logging +from pathlib import Path + +import numpy as np + +log = logging.getLogger(__name__) + +# Check for visualization libraries +try: + from dipy.viz import actor, colormap, window + + FURY_AVAILABLE = True +except ImportError: + try: + from fury import actor, colormap, window + + FURY_AVAILABLE = True + except ImportError: + FURY_AVAILABLE = False + window = None + actor = None + colormap = None + + +def save_af_visualization( + streamlines: list, + values: list, + roi_center: list, + roi_radius: float, + output_dir: Path, + prefix: str, +): + """ + Generates basic PNG snapshots of the bundle colored by AF values. + + Args: + streamlines: List of streamline coordinates + values: List of AF values per streamline + roi_center: ROI center coordinates + roi_radius: ROI radius + output_dir: Output directory + prefix: Filename prefix + """ + if not FURY_AVAILABLE: + log.debug("FURY/DIPY visualization not available. Skipping basic visualization.") + return + + if not streamlines or not values: + log.debug(f"No streamlines to visualize for {prefix}") + return + + try: + # AF is signed upstream (polarity preserved); colour scaling uses + # magnitude so peak activation of either polarity maps to the top. + abs_values = [np.abs(v) for v in values] + all_vals = np.concatenate(abs_values) + _ = np.max(all_vals) if len(all_vals) > 0 else 1.0 + + # Create streamline actor + lut = colormap.create_colormap(np.linspace(0, 1, 256), name="jet") + streamlines_obj = np.array(streamlines, dtype=object) + values_obj = np.array(abs_values, dtype=object) + streamline_actor = actor.line( + streamlines_obj, values_obj, linewidth=0.5, lookup_colormap=lut + ) + + # Create ROI sphere + sphere_actor = None + if roi_center is not None: + sphere_actor = actor.sphere( + centers=np.array([roi_center]), + radii=roi_radius, + colors=np.array([1, 1, 1]), + opacity=0.3, + ) + + # Setup scene + scene = window.Scene() + scene.add(streamline_actor) + if sphere_actor: + scene.add(sphere_actor) + scene.add(actor.scalar_bar(lut, title="AF (V/m²)")) + + # Center camera + center = ( + np.array(roi_center) + if roi_center is not None + else np.mean(np.concatenate(streamlines), axis=0) + ) + + views = { + "axial": (0, 0, 1), + "coronal": (0, 1, 0), + "sagittal": (1, 0, 0), + "oblique": (1, 1, 1), + } + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + for view_name, cam_vec in views.items(): + scene.set_camera(position=None, focal_point=center, view_up=(0, 0, 1)) + cam_pos = center + np.array(cam_vec) * 200 + scene.set_camera(position=cam_pos, focal_point=center, view_up=(0, 0, 1)) + scene.zoom(1.0) + + out_path = output_dir / f"{prefix}_view_{view_name}.png" + window.record(scene, out_path=str(out_path), size=(1024, 768)) + + log.debug(f"Saved basic visualizations to {output_dir}") + + except Exception as e: + log.debug(f"Basic visualization failed: {e}") diff --git a/src/tide/interfaces/visualization_3d.py b/src/tide/interfaces/visualization_3d.py new file mode 100644 index 0000000..b4419b7 --- /dev/null +++ b/src/tide/interfaces/visualization_3d.py @@ -0,0 +1,1378 @@ +#!/usr/bin/env python3 +""" +Professional 3D Visualization Module for TIDE Pipeline +======================================================== +Creates publication-quality 3D visualizations of E-field and AF along bundles +with brain anatomical context using PyVista. + +Features: +- Brain surface rendering with E-field colormap +- Bundle tubes colored by AF values +- ROI boundary visualization +- Multiple camera angles +- Interactive HTML export +- Multi-view composite figures +- Depth analysis visualizations + +Author: TIDE Pipeline +""" + +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np + +log = logging.getLogger(__name__) + + +def _drop_dead_display() -> bool: + """Unset ``DISPLAY`` when the X server is unreachable. + + Stale ``DISPLAY`` env vars (e.g. from a closed SSH X11 forward) make + VTK abort with ``bad X server connection`` before Python can catch + the failure. Returns ``True`` if a usable DISPLAY remains afterwards. + """ + import os + import subprocess + + display = os.environ.get("DISPLAY") + if not display: + return False + try: + result = subprocess.run( + ["xset", "q"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=2, + ) + if result.returncode == 0: + return True + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + pass + log.debug( + "Dropping unreachable DISPLAY=%s; will use Xvfb if available.", + display, + ) + os.environ.pop("DISPLAY", None) + return False + + +def _ensure_offscreen_display() -> None: + """Provide a working virtual display for VTK when none is available. + + SimNIBS-bundled VTK on Linux requires an X RenderWindow even with + ``OFF_SCREEN=True``. When no usable DISPLAY exists, spawn Xvfb so + rendering proceeds without aborting the pipeline. + """ + import os + import shutil + + if os.environ.get("DISPLAY"): + return + if shutil.which("Xvfb") is None: + log.warning( + "No DISPLAY and Xvfb not installed; 3D visualizations will be " + "skipped. Install xvfb (Debian/Ubuntu: 'sudo apt install xvfb') " + "or run on a workstation with a working display." + ) + return + try: + import pyvista as _pv + + _pv.start_xvfb() + log.debug("Started Xvfb at DISPLAY=%s", os.environ.get("DISPLAY")) + except Exception as exc: + log.warning("Failed to start Xvfb (3D viz disabled): %s", exc) + + +# Force offscreen rendering before importing pyvista; VTK reads DISPLAY +# at first RenderWindow creation, so the env must be set up first. +import os as _os # noqa: E402 + +_os.environ.setdefault("PYVISTA_OFF_SCREEN", "true") +_drop_dead_display() +_ensure_offscreen_display() + +# Check for PyVista +try: + import pyvista as pv + + pv.OFF_SCREEN = True + import vtk + + vtk.vtkObject.GlobalWarningDisplayOff() + PYVISTA_AVAILABLE = True +except ImportError: + PYVISTA_AVAILABLE = False + log.debug("PyVista not available. 3D visualization disabled.") + +# Check for matplotlib +try: + import matplotlib.image as mpimg + import matplotlib.pyplot as plt + from matplotlib.colors import LinearSegmentedColormap # noqa: F401 + + MATPLOTLIB_AVAILABLE = True +except ImportError: + MATPLOTLIB_AVAILABLE = False + +# SimNIBS mesh reading +try: + from simnibs.mesh_tools import mesh_io + + read_msh = mesh_io.read_msh +except ImportError: + try: + from simnibs import read_msh + except ImportError: + read_msh = None + + +@dataclass +class VisualizationConfig: + """Configuration for 3D visualizations.""" + + # E-field colormap + efield_cmap: str = "YlOrRd" + efield_vmin: float = 0.0 + efield_vmax: float = 100.0 + + # AF colormap + af_cmap: str = "plasma" + af_vmin: float = 0.0 + af_vmax: float = None # Auto-scale if None + + # ROI visualization + roi_color: str = "lime" + roi_line_width: float = 4.0 + roi_opacity: float = 0.3 + + # Bundle visualization + bundle_tube_radius: float = 0.3 + bundle_tube_sides: int = 8 + bundle_opacity: float = 0.9 + + # Brain surface + brain_opacity: float = 0.4 + brain_color: str = "lightgray" + + # Output settings + window_size: Tuple[int, int] = (1920, 1080) + dpi: int = 300 + background_color: str = "white" + interactive_max_streamlines: int = 1500 + interactive_max_points_per_streamline: int = 80 + interactive_decimals: int = 2 + + +class Visualization3D: + """ + Professional 3D visualization class for TIDE pipeline. + + Handles brain surface extraction, bundle rendering, and multi-view exports. + """ + + def __init__(self, config: Optional[VisualizationConfig] = None): + """Initialize visualization with optional config.""" + self.config = config or VisualizationConfig() + self._check_dependencies() + + def _check_dependencies(self) -> bool: + """Check if required dependencies are available.""" + if not PYVISTA_AVAILABLE: + log.warning("PyVista not installed. 3D visualization disabled.") + return False + return True + + # ========================================================================= + # BRAIN SURFACE EXTRACTION + # ========================================================================= + + def extract_brain_surface( + self, mesh_path: Path, surface_tag: int = 1002, extract_efield: bool = True + ) -> Optional[Tuple[pv.PolyData, Optional[np.ndarray]]]: + """ + Extract brain (GM) surface from SimNIBS mesh. + + Args: + mesh_path: Path to SimNIBS .msh file + surface_tag: Tag for GM surface (1002 in SimNIBS v4) + extract_efield: Whether to extract E-field values + + Returns: + Tuple of (PyVista surface, E-field values) or None if failed + """ + if not PYVISTA_AVAILABLE or read_msh is None: + return None + + try: + mesh = read_msh(str(mesh_path)) + vertices = mesh.nodes.node_coord + elm_types = mesh.elm.elm_type + elm_tags = mesh.elm.tag1 + + # Find surface triangles + surface_mask = (elm_types == 2) & (elm_tags == surface_tag) + surface_indices = np.where(surface_mask)[0] + + if len(surface_indices) == 0: + # Try alternative tags + for alt_tag in [1002, 2, 1001, 1]: + surface_mask = (elm_types == 2) & (elm_tags == alt_tag) + surface_indices = np.where(surface_mask)[0] + if len(surface_indices) > 0: + break + + if len(surface_indices) == 0: + log.warning("No brain surface found in mesh") + return None + + triangles = mesh.elm.node_number_list[surface_indices, :3] - 1 + unique_nodes = np.unique(triangles) + + # Node mapping + node_mapping = np.zeros(len(vertices), dtype=int) - 1 + for new_idx, old_idx in enumerate(unique_nodes): + node_mapping[old_idx] = new_idx + + surface_vertices = vertices[unique_nodes] + surface_faces = node_mapping[triangles] + + # Create PyVista mesh + faces_pv = np.column_stack([np.full(len(surface_faces), 3), surface_faces]).ravel() + surface = pv.PolyData(surface_vertices, faces_pv) + + # Extract E-field if available (vectorized element->vertex average) + efield_values = None + if extract_efield and hasattr(mesh, "elmdata") and mesh.elmdata: + for ed in mesh.elmdata: + if "magne" in ed.field_name.lower(): + elm_efield = np.asarray(ed.value) + + valid_mask = surface_indices < len(elm_efield) + elm_vals = elm_efield[surface_indices[valid_mask]] + tri_local = surface_faces[valid_mask] # (N, 3) → 0..M-1 or -1 + + # Replicate per-element scalar across its 3 vertices + tri_flat = tri_local.ravel() + val_flat = np.repeat(elm_vals, 3) + node_mask = tri_flat >= 0 + + node_efield = np.zeros(len(unique_nodes), dtype=np.float64) + node_counts = np.zeros(len(unique_nodes), dtype=np.float64) + np.add.at(node_efield, tri_flat[node_mask], val_flat[node_mask]) + np.add.at(node_counts, tri_flat[node_mask], 1.0) + + node_counts[node_counts == 0] = 1 + efield_values = node_efield / node_counts + surface.point_data["E-field"] = efield_values + break + + return surface, efield_values + + except Exception as e: + log.warning(f"Failed to extract brain surface: {e}") + return None + + # ========================================================================= + # BUNDLE VISUALIZATION + # ========================================================================= + + def create_bundle_tubes( + self, + streamlines: List[np.ndarray], + scalar_values: Optional[List[np.ndarray]] = None, + scalar_name: str = "AF", + radius: Optional[float] = None, + subsample: int = 1, + ) -> Optional[pv.PolyData]: + """ + Create tube mesh from streamlines with optional scalar coloring. + + Args: + streamlines: List of streamline coordinates + scalar_values: Optional list of scalar values per streamline + scalar_name: Name for the scalar data + radius: Tube radius (uses config default if None) + subsample: Use every Nth streamline + + Returns: + Combined PyVista tube mesh or None + """ + if not PYVISTA_AVAILABLE: + return None + + radius = radius or self.config.bundle_tube_radius + n_sides = self.config.bundle_tube_sides + + tubes = [] + n_streamlines = len(streamlines) + + for i in range(0, n_streamlines, subsample): + sl = streamlines[i] + if len(sl) < 2: + continue + + try: + spline = pv.Spline(sl, n_points=len(sl)) + + if scalar_values is not None and i < len(scalar_values): + sv = scalar_values[i] + if len(sv) == len(sl): + spline.point_data[scalar_name] = sv + elif len(sv) == len(sl) - 1: + # Interpolate segment values to points + sv_interp = np.concatenate([[sv[0]], (sv[:-1] + sv[1:]) / 2, [sv[-1]]]) + if len(sv_interp) == len(sl): + spline.point_data[scalar_name] = sv_interp + + tube = spline.tube(radius=radius, n_sides=n_sides) + tubes.append(tube) + + except Exception: + continue + + if not tubes: + return None + + # Batch merge for efficiency + combined = self._batch_merge(tubes) + return combined + + def _batch_merge(self, meshes: List[pv.PolyData], batch_size: int = 100) -> pv.PolyData: + """Efficiently merge multiple meshes using batch processing.""" + if len(meshes) == 0: + return None + if len(meshes) == 1: + return meshes[0] + + current = meshes + while len(current) > 1: + merged = [] + for i in range(0, len(current), batch_size): + batch = current[i : i + batch_size] + if len(batch) == 1: + merged.append(batch[0]) + else: + m = batch[0] + for mesh in batch[1:]: + m = m.merge(mesh) + merged.append(m) + current = merged + + return current[0] + + # ========================================================================= + # ROI VISUALIZATION + # ========================================================================= + + def create_roi_sphere( + self, + center: np.ndarray, + radius: float, + color: Optional[str] = None, + opacity: Optional[float] = None, + ) -> pv.PolyData: + """Create a sphere mesh for ROI visualization.""" + if not PYVISTA_AVAILABLE: + return None + + sphere = pv.Sphere(radius=radius, center=center, theta_resolution=30, phi_resolution=30) + return sphere + + def find_roi_boundary_on_surface( + self, surface: pv.PolyData, roi_center: np.ndarray, roi_radius: float + ) -> Optional[pv.PolyData]: + """ + Find the ROI boundary on the brain surface. + + Returns edges where surface crosses ROI boundary. + """ + if not PYVISTA_AVAILABLE: + return None + + try: + # Calculate distance from ROI center + points = surface.points + distances = np.linalg.norm(points - roi_center, axis=1) + + # Find vertices inside ROI + roi_mask = distances <= roi_radius + + # Get faces + faces = surface.faces.reshape(-1, 4)[:, 1:4] + + # Find boundary edges + boundary_edges = [] + for face in faces: + in_roi = [roi_mask[v] for v in face] + edges = [(face[0], face[1]), (face[1], face[2]), (face[2], face[0])] + for i, (va, vb) in enumerate(edges): + if in_roi[i % 3] != in_roi[(i + 1) % 3]: + boundary_edges.append((va, vb)) + + if not boundary_edges: + return None + + # Create line mesh + edge_points = [] + lines = [] + for i, (v0, v1) in enumerate(boundary_edges): + edge_points.append(points[v0]) + edge_points.append(points[v1]) + lines.extend([2, 2 * i, 2 * i + 1]) + + edge_points = np.array(edge_points) + lines = np.array(lines) + + boundary_mesh = pv.PolyData(edge_points, lines=lines) + return boundary_mesh + + except Exception as e: + log.debug(f"Failed to extract ROI boundary: {e}") + return None + + # ========================================================================= + # MULTI-VIEW RENDERING + # ========================================================================= + + def render_multiview( + self, + output_dir: Path, + prefix: str, + brain_surface: Optional[pv.PolyData] = None, + bundle_mesh: Optional[pv.PolyData] = None, + roi_center: Optional[np.ndarray] = None, + roi_radius: Optional[float] = None, + roi_boundary: Optional[pv.PolyData] = None, + scalar_name: str = "AF", + scalar_range: Optional[Tuple[float, float]] = None, + create_composite: bool = True, + create_html: bool = True, + interactive_streamlines: Optional[List[np.ndarray]] = None, + interactive_scalars: Optional[List[np.ndarray]] = None, + ) -> Dict[str, Path]: + """ + Render multiple views and create outputs. + + Args: + output_dir: Directory for output files + prefix: Filename prefix + brain_surface: Brain surface mesh + bundle_mesh: Bundle tube mesh + roi_center: ROI center coordinates + roi_radius: ROI radius + roi_boundary: ROI boundary lines on surface + scalar_name: Name of scalar data to visualize + scalar_range: (min, max) for scalar colormap + create_composite: Create multi-view composite image + create_html: Create interactive HTML file + interactive_streamlines: Streamlines for the lightweight HTML preview + interactive_scalars: Scalar arrays for the lightweight HTML preview + + Returns: + Dictionary of output file paths + """ + if not PYVISTA_AVAILABLE: + log.warning("PyVista not available for rendering") + return {} + + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + outputs = {} + + # Determine center and camera distance + if bundle_mesh is not None: + center = bundle_mesh.center + bounds = bundle_mesh.bounds + elif brain_surface is not None: + center = brain_surface.center + bounds = brain_surface.bounds + else: + return outputs + + max_extent = max(bounds[1] - bounds[0], bounds[3] - bounds[2], bounds[5] - bounds[4]) + cam_distance = max_extent * 1.5 + + # Auto-scale scalar range + if scalar_range is None and bundle_mesh is not None: + if scalar_name in bundle_mesh.point_data: + data = bundle_mesh.point_data[scalar_name] + scalar_range = (0, np.percentile(data, 99)) + + # Define views + views = { + "lateral_left": { + "position": (center[0] - cam_distance, center[1], center[2]), + "focal": center, + "up": (0, 0, 1), + "title": "Left Lateral", + }, + "lateral_right": { + "position": (center[0] + cam_distance, center[1], center[2]), + "focal": center, + "up": (0, 0, 1), + "title": "Right Lateral", + }, + "anterior": { + "position": (center[0], center[1] + cam_distance, center[2]), + "focal": center, + "up": (0, 0, 1), + "title": "Anterior", + }, + "posterior": { + "position": (center[0], center[1] - cam_distance, center[2]), + "focal": center, + "up": (0, 0, 1), + "title": "Posterior", + }, + "superior": { + "position": (center[0], center[1], center[2] + cam_distance), + "focal": center, + "up": (0, 1, 0), + "title": "Superior", + }, + "oblique": { + "position": ( + center[0] - cam_distance * 0.7, + center[1] + cam_distance * 0.5, + center[2] + cam_distance * 0.5, + ), + "focal": center, + "up": (0, 0, 1), + "title": "Oblique", + }, + } + + view_files = [] + + for view_name, view_params in views.items(): + try: + plotter = pv.Plotter(off_screen=True, window_size=self.config.window_size) + plotter.set_background(self.config.background_color) + + # Add brain surface + if brain_surface is not None: + if "E-field" in brain_surface.point_data: + plotter.add_mesh( + brain_surface, + scalars="E-field", + cmap=self.config.efield_cmap, + clim=[self.config.efield_vmin, self.config.efield_vmax], + opacity=self.config.brain_opacity, + show_scalar_bar=False, + ) + else: + plotter.add_mesh( + brain_surface, + color=self.config.brain_color, + opacity=self.config.brain_opacity, + ) + + # Add bundle + if bundle_mesh is not None: + if scalar_name in bundle_mesh.point_data: + plotter.add_mesh( + bundle_mesh, + scalars=scalar_name, + cmap=self.config.af_cmap, + clim=scalar_range, + opacity=self.config.bundle_opacity, + scalar_bar_args={ + "title": f"{scalar_name} (V/m²)", + "title_font_size": 14, + "label_font_size": 12, + "vertical": True, + "position_x": 0.85, + "position_y": 0.2, + "width": 0.08, + "height": 0.5, + }, + ) + else: + plotter.add_mesh( + bundle_mesh, color="steelblue", opacity=self.config.bundle_opacity + ) + + # Add ROI boundary + if roi_boundary is not None: + plotter.add_mesh( + roi_boundary, + color=self.config.roi_color, + line_width=self.config.roi_line_width, + render_lines_as_tubes=True, + ) + + # Add ROI sphere (semi-transparent) + if roi_center is not None and roi_radius is not None: + sphere = self.create_roi_sphere(roi_center, roi_radius) + if sphere is not None: + plotter.add_mesh( + sphere, + color=self.config.roi_color, + opacity=self.config.roi_opacity, + style="wireframe", + line_width=1, + ) + + # Set camera + plotter.camera.position = view_params["position"] + plotter.camera.focal_point = view_params["focal"] + plotter.camera.up = view_params["up"] + + # Add title + plotter.add_text( + view_params["title"], position="upper_left", font_size=12, color="black" + ) + + # Save + out_path = output_dir / f"{prefix}_{view_name}.png" + plotter.screenshot(str(out_path), scale=2) + view_files.append(out_path) + outputs[view_name] = out_path + + plotter.close() + + except Exception as e: + log.debug(f"Failed to render {view_name}: {e}") + + # Create composite + if create_composite and MATPLOTLIB_AVAILABLE and len(view_files) >= 4: + try: + composite_path = output_dir / f"{prefix}_composite.png" + self._create_composite_figure(view_files[:6], composite_path) + outputs["composite"] = composite_path + except Exception as e: + log.debug(f"Failed to create composite: {e}") + + # Create interactive HTML + if create_html: + try: + html_path = output_dir / f"{prefix}_interactive.html" + self._create_interactive_html( + html_path, + brain_surface, + bundle_mesh, + roi_center, + roi_radius, + scalar_name, + scalar_range, + interactive_streamlines, + interactive_scalars, + ) + outputs["html"] = html_path + except Exception as e: + log.warning(f"Interactive HTML export failed: {e}") + + return outputs + + def _create_composite_figure(self, image_paths: List[Path], output_path: Path): + """Create a multi-view composite figure.""" + n_images = min(6, len(image_paths)) + + fig, axes = plt.subplots(2, 3, figsize=(18, 12)) + axes = axes.flatten() + + for i, img_path in enumerate(image_paths[:n_images]): + if img_path.exists(): + img = mpimg.imread(str(img_path)) + axes[i].imshow(img) + axes[i].axis("off") + + for i in range(n_images, 6): + axes[i].axis("off") + + plt.tight_layout() + plt.savefig(str(output_path), dpi=self.config.dpi, bbox_inches="tight", facecolor="white") + plt.close() + + def _interactive_payload( + self, + streamlines: Optional[List[np.ndarray]], + scalar_values: Optional[List[np.ndarray]], + ) -> List[Dict[str, Any]]: + if streamlines is None or len(streamlines) == 0: + return [] + + total = len(streamlines) + stride = max(1, int(np.ceil(total / self.config.interactive_max_streamlines))) + payload: List[Dict[str, Any]] = [] + + for original_idx in list(range(0, total, stride))[ + : self.config.interactive_max_streamlines + ]: + sl = np.asarray(streamlines[original_idx], dtype=float) + if sl.ndim != 2 or sl.shape[1] != 3 or len(sl) < 2: + continue + + values = None + if scalar_values is not None and original_idx < len(scalar_values): + values = np.asarray(scalar_values[original_idx], dtype=float) + if len(values) != len(sl): + values = None + + if len(sl) > self.config.interactive_max_points_per_streamline: + indices = np.linspace( + 0, + len(sl) - 1, + self.config.interactive_max_points_per_streamline, + dtype=int, + ) + sl = sl[indices] + if values is not None: + values = values[indices] + + if values is None: + values = np.zeros(len(sl), dtype=float) + + values = np.nan_to_num(np.abs(values), nan=0.0, posinf=0.0, neginf=0.0) + payload.append( + { + "pts": np.round(sl, self.config.interactive_decimals).tolist(), + "values": np.round(values, self.config.interactive_decimals).tolist(), + } + ) + + return payload + + def _create_interactive_html( + self, + output_path: Path, + brain_surface: Optional[pv.PolyData], + bundle_mesh: Optional[pv.PolyData], + roi_center: Optional[np.ndarray], + roi_radius: Optional[float], + scalar_name: str, + scalar_range: Optional[Tuple[float, float]], + interactive_streamlines: Optional[List[np.ndarray]] = None, + interactive_scalars: Optional[List[np.ndarray]] = None, + ): + payload = self._interactive_payload(interactive_streamlines, interactive_scalars) + if bundle_mesh is not None: + center = [round(float(v), 2) for v in bundle_mesh.center] + bounds = [round(float(v), 2) for v in bundle_mesh.bounds] + elif roi_center is not None: + center = [round(float(v), 2) for v in roi_center] + bounds = [ + center[0] - 40, + center[0] + 40, + center[1] - 40, + center[1] + 40, + center[2] - 40, + center[2] + 40, + ] + else: + center = [0.0, 0.0, 0.0] + bounds = [-50, 50, -50, 50, -50, 50] + + vmax = scalar_range[1] if scalar_range is not None else 1.0 + if not np.isfinite(vmax) or vmax <= 0: + vmax = 1.0 + + roi = { + "center": [round(float(v), 2) for v in roi_center] if roi_center is not None else None, + "radius": float(roi_radius) if roi_radius is not None else None, + } + html = _build_lightweight_bundle_html( + streamlines_json=json.dumps(payload, allow_nan=False), + center_json=json.dumps(center), + bounds_json=json.dumps(bounds), + roi_json=json.dumps(roi), + scalar_name=scalar_name, + scalar_vmax=round(float(vmax), 2), + sampled_streamlines=len(payload), + full_streamlines=( + len(interactive_streamlines) if interactive_streamlines is not None else 0 + ), + has_brain_surface=brain_surface is not None, + ) + output_path.write_text(html, encoding="utf-8") + + # ========================================================================= + # DEPTH ANALYSIS VISUALIZATION + # ========================================================================= + + def render_depth_analysis( + self, + output_dir: Path, + prefix: str, + streamlines: List[np.ndarray], + scalar_values: List[np.ndarray], + scalp_point: np.ndarray, + scalar_name: str = "AF", + ) -> Dict[str, Path]: + """ + Create depth analysis visualizations. + + Args: + output_dir: Output directory + prefix: Filename prefix + streamlines: List of streamline coordinates + scalar_values: List of scalar values per streamline + scalp_point: Reference scalp point for depth calculation + scalar_name: Name of scalar metric + + Returns: + Dictionary of output file paths + """ + if not MATPLOTLIB_AVAILABLE: + return {} + + output_dir = Path(output_dir) + outputs = {} + + try: + # Compute depth for all points + all_points = [] + all_values = [] + all_depths = [] + + for sl, sv in zip(streamlines, scalar_values): + # Align lengths + n = min(len(sl), len(sv)) + if n < 2: + continue + + depths = np.linalg.norm(sl[:n] - scalp_point, axis=1) + all_points.extend(sl[:n]) + all_values.extend(sv[:n]) + all_depths.extend(depths) + + if not all_depths: + return outputs + + all_values = np.array(all_values) + all_depths = np.array(all_depths) + + # Create figure + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + + # 1. Scatter plot + ax = axes[0, 0] + scatter = ax.scatter( + all_depths, all_values, c=all_values, cmap="plasma", alpha=0.3, s=1 + ) + ax.set_xlabel("Depth from scalp (mm)", fontsize=11) + ax.set_ylabel(f"{scalar_name} (V/m²)", fontsize=11) + ax.set_title(f"{scalar_name} vs Depth", fontsize=12, fontweight="bold") + plt.colorbar(scatter, ax=ax, label=scalar_name) + ax.grid(True, alpha=0.3) + + # 2. Binned statistics + ax = axes[0, 1] + depth_bins = np.linspace(np.min(all_depths), np.max(all_depths), 20) + bin_centers = (depth_bins[:-1] + depth_bins[1:]) / 2 + bin_indices = np.digitize(all_depths, depth_bins) + + means = [] + stds = [] + maxs = [] + for i in range(1, len(depth_bins)): + mask = bin_indices == i + if np.any(mask): + means.append(np.mean(all_values[mask])) + stds.append(np.std(all_values[mask])) + maxs.append(np.max(all_values[mask])) + else: + means.append(0) + stds.append(0) + maxs.append(0) + + means = np.array(means) + stds = np.array(stds) + + ax.fill_between(bin_centers, means - stds, means + stds, alpha=0.3, color="steelblue") + ax.plot(bin_centers, means, "b-", linewidth=2, label="Mean ± SD") + ax.plot(bin_centers, maxs, "r--", linewidth=1.5, label="Max") + ax.set_xlabel("Depth from scalp (mm)", fontsize=11) + ax.set_ylabel(f"{scalar_name} (V/m²)", fontsize=11) + ax.set_title("Depth Profile", fontsize=12, fontweight="bold") + ax.legend() + ax.grid(True, alpha=0.3) + + # 3. Histogram + ax = axes[1, 0] + ax.hist(all_values, bins=50, color="steelblue", edgecolor="white", alpha=0.8) + ax.axvline( + np.mean(all_values), + color="red", + linestyle="--", + linewidth=2, + label=f"Mean: {np.mean(all_values):.1f}", + ) + ax.axvline( + np.median(all_values), + color="orange", + linestyle="--", + linewidth=2, + label=f"Median: {np.median(all_values):.1f}", + ) + ax.set_xlabel(f"{scalar_name} (V/m²)", fontsize=11) + ax.set_ylabel("Count", fontsize=11) + ax.set_title(f"{scalar_name} Distribution", fontsize=12, fontweight="bold") + ax.legend() + ax.grid(True, alpha=0.3, axis="y") + + # 4. Box plot by depth bands + ax = axes[1, 1] + depth_bands = [(0, 20), (20, 40), (40, 60), (60, 80), (80, 100)] + band_data = [] + band_labels = [] + + for d_min, d_max in depth_bands: + mask = (all_depths >= d_min) & (all_depths < d_max) + if np.any(mask): + band_data.append(all_values[mask]) + band_labels.append(f"{d_min}-{d_max}") + + if band_data: + bp = ax.boxplot(band_data, labels=band_labels, patch_artist=True) + for patch in bp["boxes"]: + patch.set_facecolor("steelblue") + patch.set_alpha(0.6) + + ax.set_xlabel("Depth band (mm)", fontsize=11) + ax.set_ylabel(f"{scalar_name} (V/m²)", fontsize=11) + ax.set_title("Distribution by Depth", fontsize=12, fontweight="bold") + ax.grid(True, alpha=0.3, axis="y") + + plt.suptitle(f"{scalar_name} Depth Analysis", fontsize=14, fontweight="bold") + plt.tight_layout() + + out_path = output_dir / f"{prefix}_depth_analysis.png" + plt.savefig(str(out_path), dpi=self.config.dpi, bbox_inches="tight", facecolor="white") + plt.close() + + outputs["depth_analysis"] = out_path + + except Exception as e: + log.debug(f"Failed to create depth analysis: {e}") + + return outputs + + # ========================================================================= + # ROI-FOCUSED VISUALIZATION + # ========================================================================= + + def render_roi_focused( + self, + output_dir: Path, + prefix: str, + bundle_mesh: pv.PolyData, + roi_center: np.ndarray, + roi_radius: float, + scalar_name: str = "AF", + scalar_range: Optional[Tuple[float, float]] = None, + stats: Optional[Dict[str, float]] = None, + ) -> Dict[str, Path]: + """ + Create ROI-focused close-up visualizations. + """ + if not PYVISTA_AVAILABLE: + return {} + + output_dir = Path(output_dir) + outputs = {} + + cam_distance = roi_radius * 3 + + views = { + "roi_front": ( + (roi_center[0], roi_center[1] + cam_distance, roi_center[2]), + roi_center, + (0, 0, 1), + ), + "roi_side": ( + (roi_center[0] - cam_distance, roi_center[1], roi_center[2]), + roi_center, + (0, 0, 1), + ), + "roi_top": ( + (roi_center[0], roi_center[1], roi_center[2] + cam_distance), + roi_center, + (0, 1, 0), + ), + } + + for view_name, (position, focal, up) in views.items(): + try: + plotter = pv.Plotter(off_screen=True, window_size=self.config.window_size) + plotter.set_background(self.config.background_color) + + # Add bundle + if scalar_name in bundle_mesh.point_data: + plotter.add_mesh( + bundle_mesh, + scalars=scalar_name, + cmap=self.config.af_cmap, + clim=scalar_range, + opacity=1.0, + scalar_bar_args={ + "title": f"{scalar_name} (V/m²)", + "vertical": True, + "position_x": 0.85, + }, + ) + else: + plotter.add_mesh(bundle_mesh, color="steelblue") + + # Add ROI sphere + sphere = self.create_roi_sphere(roi_center, roi_radius) + if sphere is not None: + plotter.add_mesh( + sphere, color=self.config.roi_color, opacity=0.15, style="wireframe" + ) + + # Set camera + plotter.camera.position = position + plotter.camera.focal_point = focal + plotter.camera.up = up + + # Add stats annotation + if stats: + stats_text = "\n".join([f"{k}: {v:.1f}" for k, v in stats.items()]) + plotter.add_text( + stats_text, position="lower_right", font_size=10, color="black" + ) + + out_path = output_dir / f"{prefix}_{view_name}.png" + plotter.screenshot(str(out_path), scale=2) + outputs[view_name] = out_path + plotter.close() + + except Exception as e: + log.debug(f"Failed to render {view_name}: {e}") + + return outputs + + +def _build_lightweight_bundle_html( + *, + streamlines_json: str, + center_json: str, + bounds_json: str, + roi_json: str, + scalar_name: str, + scalar_vmax: float, + sampled_streamlines: int, + full_streamlines: int, + has_brain_surface: bool, +) -> str: + surface_note = ( + "Static PNGs include brain-surface context. " + if has_brain_surface + else "Brain-surface context was not available. " + ) + return f""" + + + + +TIDE Bundle Preview + + + + +
+
+ {scalar_name} magnitude +
+
0 to {scalar_vmax}
+
+
{surface_note}This preview is sampled for browser performance. Full data are in the TRK, NIfTI, TXT and JSON outputs.
+ + + + +""" + + +# ============================================================================= +# CONVENIENCE FUNCTIONS +# ============================================================================= + + +def generate_bundle_visualization( + mesh_path: Path, + streamlines: List[np.ndarray], + af_values: List[np.ndarray], + roi_center: np.ndarray, + roi_radius: float, + output_dir: Path, + prefix: str, + config: Optional[VisualizationConfig] = None, + scalp_point: Optional[np.ndarray] = None, +) -> Dict[str, Path]: + """ + High-level function to generate all visualizations for a bundle. + + Args: + mesh_path: Path to SimNIBS mesh + streamlines: List of streamline coordinates + af_values: List of AF values per streamline + roi_center: ROI center coordinates + roi_radius: ROI radius + output_dir: Output directory + prefix: Filename prefix + config: Optional visualization config + scalp_point: Optional scalp point for depth analysis + + Returns: + Dictionary of all output file paths + """ + if not PYVISTA_AVAILABLE: + log.warning("PyVista not available. Skipping 3D visualization.") + return {} + + viz = Visualization3D(config) + all_outputs = {} + + # Extract brain surface + result = viz.extract_brain_surface(mesh_path) + brain_surface = result[0] if result else None + + # Create bundle tubes + bundle_mesh = viz.create_bundle_tubes(streamlines, af_values, scalar_name="AF") + + if bundle_mesh is None: + log.warning("Failed to create bundle mesh") + return {} + + # Get ROI boundary + roi_boundary = None + if brain_surface is not None: + roi_boundary = viz.find_roi_boundary_on_surface(brain_surface, roi_center, roi_radius) + + # Determine scalar range. AF is signed (polarity preserved); the colour bar + # uses activation magnitude so peaks of either polarity map to the upper end. + nonempty = [v for v in af_values if len(v) > 0] if af_values else [] + if nonempty: + all_af_signed = np.concatenate(nonempty) + all_af = np.abs(all_af_signed) + scalar_range = (0.0, float(np.percentile(all_af, 99))) + else: + all_af_signed = None + all_af = None + scalar_range = None + + # Render multi-view + outputs = viz.render_multiview( + output_dir, + prefix, + brain_surface=brain_surface, + bundle_mesh=bundle_mesh, + roi_center=roi_center, + roi_radius=roi_radius, + roi_boundary=roi_boundary, + scalar_name="AF", + scalar_range=scalar_range, + interactive_streamlines=streamlines, + interactive_scalars=af_values, + ) + all_outputs.update(outputs) + + # ROI-focused views — summary statistics use |AF| magnitude + stats = { + "Mean AF": float(np.mean(all_af)) if all_af is not None else 0, + "Max AF": float(np.max(all_af)) if all_af is not None else 0, + "Median AF": float(np.median(all_af)) if all_af is not None else 0, + } + + roi_outputs = viz.render_roi_focused( + output_dir, + prefix, + bundle_mesh, + roi_center, + roi_radius, + scalar_name="AF", + scalar_range=scalar_range, + stats=stats, + ) + all_outputs.update(roi_outputs) + + # Depth analysis + if scalp_point is not None: + depth_outputs = viz.render_depth_analysis( + output_dir, prefix, streamlines, af_values, scalp_point, scalar_name="AF" + ) + all_outputs.update(depth_outputs) + + return all_outputs diff --git a/src/tide/utils/artifacts.py b/src/tide/utils/artifacts.py new file mode 100644 index 0000000..596802d --- /dev/null +++ b/src/tide/utils/artifacts.py @@ -0,0 +1,395 @@ +import hashlib +import json +import os +import shutil +import tempfile +from functools import lru_cache +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +ArtifactState = Tuple[int, int, int, Optional[str]] +FIXED_POSE_CACHE_SCHEMA = 1 + +# Tokens that disable the fixed-pose cache, whether given via the +# TIDE_FIXED_POSE_CACHE env var or a `subject.cache_dir` config field. +CACHE_DISABLE_TOKENS = frozenset({"0", "false", "no", "off"}) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file_obj: + for chunk in iter(lambda: file_obj.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +@lru_cache(maxsize=64) +def _sha256_for_state(path: str, size: int, mtime_ns: int, ctime_ns: int) -> str: + return _sha256(Path(path)) + + +def _input_sha256(path: Path) -> str: + resolved = path.resolve() + stat = resolved.stat() + return _sha256_for_state( + str(resolved), + stat.st_size, + stat.st_mtime_ns, + stat.st_ctime_ns, + ) + + +def _copy_with_sha256(source: Path, destination: Path) -> str: + digest = hashlib.sha256() + with source.open("rb") as source_obj, destination.open("wb") as destination_obj: + for chunk in iter(lambda: source_obj.read(1024 * 1024), b""): + destination_obj.write(chunk) + digest.update(chunk) + return digest.hexdigest() + + +def _atomic_json_write(path: Path, payload: Dict[str, object]) -> None: + file_descriptor, temporary_name = tempfile.mkstemp( + dir=path.parent, + prefix=f".{path.name}.", + suffix=".tmp", + ) + os.close(file_descriptor) + temporary_path = Path(temporary_name) + try: + temporary_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + temporary_path.replace(path) + finally: + temporary_path.unlink(missing_ok=True) + + +def fixed_pose_cache_enabled() -> bool: + value = os.environ.get("TIDE_FIXED_POSE_CACHE", "1").strip().lower() + return value not in CACHE_DISABLE_TOKENS + + +def fixed_pose_cache_root() -> Path: + configured = os.environ.get("TIDE_CACHE_DIR") + if configured: + base = Path(configured).expanduser() + else: + base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "tide" + return base / "fixed_pose" + + +def resolve_cache_max_bytes(config_gb: Optional[float] = None) -> Optional[int]: + """Return the fixed-pose cache size cap in bytes, or None (unlimited). + + Config value wins over the ``TIDE_CACHE_MAX_GB`` env fallback. ``0``, a + negative value, or an unparseable value all mean unlimited. GB is binary + GiB (``1024**3``). + """ + value = config_gb + if value is None: + env_value = os.environ.get("TIDE_CACHE_MAX_GB") + if env_value is not None: + try: + value = float(env_value) + except ValueError: + value = None + if value is None or value <= 0: + return None + return int(value * 1024**3) + + +def iter_cache_entries(root: Path) -> List[Path]: + """Return fixed-pose cache entry dirs (``root/*/*`` with a metadata.json).""" + if not root.is_dir(): + return [] + return [entry for entry in root.glob("*/*") if (entry / "metadata.json").is_file()] + + +def entry_size(entry: Path) -> int: + """Return the total size in bytes of all files in a cache entry dir.""" + total = 0 + for path in entry.rglob("*"): + if path.is_file(): + try: + total += path.stat().st_size + except OSError: + pass + return total + + +def cache_total_size(root: Path) -> int: + """Return the total size in bytes of all fixed-pose cache entries.""" + return sum(entry_size(entry) for entry in iter_cache_entries(root)) + + +def enforce_cache_limit(root: Path, max_bytes: int) -> Tuple[int, int]: + """Evict least-recently-used entries until the store is under ``max_bytes``. + + Recency is the entry dir mtime (bumped on every cache hit). Returns + ``(evicted_count, freed_bytes)``. Best-effort: an entry removed concurrently + is treated as already gone, not an error. + """ + entries = iter_cache_entries(root) + sized = [] + for entry in entries: + try: + mtime = entry.stat().st_mtime + except OSError: + continue + sized.append((mtime, entry_size(entry), entry)) + total = sum(size for _, size, _ in sized) + if total <= max_bytes: + return 0, 0 + + sized.sort(key=lambda item: item[0]) # oldest first (LRU) + evicted = 0 + freed = 0 + for _, size, entry in sized: + if total <= max_bytes: + break + try: + shutil.rmtree(entry) + except OSError: + continue + evicted += 1 + freed += size + total -= size + return evicted, freed + + +def clear_cache(root: Path) -> Tuple[int, int]: + """Remove all fixed-pose cache entries. Returns ``(removed_count, freed_bytes)``.""" + removed = 0 + freed = 0 + for entry in iter_cache_entries(root): + size = entry_size(entry) + try: + shutil.rmtree(entry) + except OSError: + continue + removed += 1 + freed += size + return removed, freed + + +def fixed_pose_cache_key( + mesh_path: Path, + coil_path: Path, + orientation: Sequence[Sequence[float]], + didt: float, + distance_mm: float, + fields: str, + runtime_signature: Dict[str, str], +) -> str: + matrix = [list(row) for row in orientation] + if len(matrix) != 4 or any(len(row) != 4 for row in matrix): + raise ValueError("Fixed-pose cache requires a 4x4 orientation matrix") + + if mesh_path.is_file(): + mesh_files = [mesh_path] + elif mesh_path.is_dir(): + mesh_files = sorted( + (path for path in mesh_path.glob("*.msh") if path.is_file()), + key=lambda path: path.name, + ) + else: + raise FileNotFoundError(f"Head mesh input not found: {mesh_path}") + if not mesh_files: + raise FileNotFoundError(f"No head mesh found in: {mesh_path}") + if not coil_path.is_file(): + raise FileNotFoundError(f"Coil model not found: {coil_path}") + + payload = { + "schema_version": FIXED_POSE_CACHE_SCHEMA, + "head_meshes": [{"name": path.name, "sha256": _input_sha256(path)} for path in mesh_files], + "coil": {"name": coil_path.name, "sha256": _input_sha256(coil_path)}, + "orientation": [[float(value).hex() for value in row] for row in matrix], + "didt": float(didt).hex(), + "distance_mm": float(distance_mm).hex(), + "fields": fields, + "anisotropy_type": "scalar", + "solver_options": None, + "runtime": dict(sorted(runtime_signature.items())), + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _fixed_pose_entry(cache_key: str, cache_root: Optional[Path]) -> Path: + root = cache_root if cache_root is not None else fixed_pose_cache_root() + return root / cache_key[:2] / cache_key + + +def store_fixed_pose_artifacts( + cache_key: str, + artifacts: Iterable[Path], + cache_root: Optional[Path] = None, +) -> bool: + source_paths = list(artifacts) + if not source_paths: + return False + output_names = [path.name for path in source_paths] + if len(set(output_names)) != len(output_names): + raise ValueError("Fixed-pose cache artifact names must be unique") + + entry_dir = _fixed_pose_entry(cache_key, cache_root) + entry_dir.mkdir(parents=True, exist_ok=True) + records: List[Dict[str, object]] = [] + + for index, source in enumerate(source_paths): + if not source.is_file(): + raise FileNotFoundError(f"Simulation artifact not found: {source}") + file_descriptor, temporary_name = tempfile.mkstemp( + dir=entry_dir, + prefix=".artifact.", + suffix=".tmp", + ) + os.close(file_descriptor) + temporary_path = Path(temporary_name) + try: + digest = _copy_with_sha256(source, temporary_path) + cached_name = f"{index:02d}-{digest}" + temporary_path.replace(entry_dir / cached_name) + finally: + temporary_path.unlink(missing_ok=True) + records.append( + { + "output_name": source.name, + "cached_name": cached_name, + "sha256": digest, + "size": source.stat().st_size, + } + ) + + _atomic_json_write( + entry_dir / "metadata.json", + { + "schema_version": FIXED_POSE_CACHE_SCHEMA, + "cache_key": cache_key, + "artifacts": records, + }, + ) + return True + + +def restore_fixed_pose_artifacts( + cache_key: str, + output_dir: Path, + cache_root: Optional[Path] = None, +) -> List[Path]: + entry_dir = _fixed_pose_entry(cache_key, cache_root) + metadata_path = entry_dir / "metadata.json" + if not metadata_path.is_file(): + return [] + + try: + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + if ( + metadata["schema_version"] != FIXED_POSE_CACHE_SCHEMA + or metadata["cache_key"] != cache_key + ): + return [] + records = metadata["artifacts"] + if not isinstance(records, list) or not records: + return [] + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return [] + + output_dir.mkdir(parents=True, exist_ok=True) + pending: List[Tuple[Path, Path]] = [] + try: + for record in records: + output_name = record["output_name"] + cached_name = record["cached_name"] + expected_digest = record["sha256"] + expected_size = record["size"] + if ( + not isinstance(output_name, str) + or Path(output_name).name != output_name + or not isinstance(cached_name, str) + or Path(cached_name).name != cached_name + or not isinstance(expected_digest, str) + or not isinstance(expected_size, int) + ): + return [] + + cached_path = entry_dir / cached_name + if not cached_path.is_file() or cached_path.stat().st_size != expected_size: + return [] + file_descriptor, temporary_name = tempfile.mkstemp( + dir=output_dir, + prefix=f".{output_name}.", + suffix=".tmp", + ) + os.close(file_descriptor) + temporary_path = Path(temporary_name) + digest = _copy_with_sha256(cached_path, temporary_path) + pending.append((temporary_path, output_dir / output_name)) + if digest != expected_digest or temporary_path.stat().st_size != expected_size: + return [] + + for temporary_path, destination in pending: + temporary_path.replace(destination) + # Bump entry mtime so LRU eviction treats a cache hit as recent use. + try: + os.utime(entry_dir, None) + except OSError: + pass + return [destination for _, destination in pending] + except (KeyError, TypeError): + return [] + finally: + for temporary_path, _ in pending: + temporary_path.unlink(missing_ok=True) + + +def capture_artifacts( + paths: Iterable[Path], hash_contents: bool = False +) -> Dict[Path, ArtifactState]: + states = {} + for path in sorted(set(paths), key=str): + if not path.is_file(): + continue + stat = path.stat() + digest = _sha256(path) if hash_contents else None + states[path] = (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns, digest) + return states + + +def fresh_artifacts( + before: Dict[Path, ArtifactState], + paths: Iterable[Path], + hash_contents: bool = False, +) -> List[Path]: + after = capture_artifacts(paths, hash_contents=hash_contents) + return sorted((path for path, state in after.items() if before.get(path) != state), key=str) + + +def record_artifact( + output_dir: Path, + key: str, + selected: Path, + candidates: Iterable[Path], + details: Optional[Dict[str, object]] = None, +) -> None: + manifest_path = output_dir / ".tide_run_manifest.json" + if manifest_path.exists(): + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + else: + payload = {"schema_version": 1, "artifacts": {}} + + artifact: Dict[str, object] = { + "selected": str(selected.relative_to(output_dir)), + "candidates": [str(path.relative_to(output_dir)) for path in sorted(candidates, key=str)], + } + if details: + artifact.update(details) + payload["artifacts"][key] = artifact + + temporary_path = output_dir / ".tide_run_manifest.json.tmp" + temporary_path.write_text( + json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + temporary_path.replace(manifest_path) diff --git a/src/tide/utils/config.py b/src/tide/utils/config.py new file mode 100644 index 0000000..2621da7 --- /dev/null +++ b/src/tide/utils/config.py @@ -0,0 +1,1042 @@ +""" +Configuration Module for TIDE Pipeline +====================================== +Handles YAML configuration loading and validation. +""" + +import ast +import logging +import math +from dataclasses import dataclass +from numbers import Real +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import yaml + +from tide.utils import simnibs_env +from tide.utils.artifacts import CACHE_DISABLE_TOKENS + +log = logging.getLogger(__name__) + + +def orientation_is_matrix(orientation: Any) -> bool: + """True if an orientation value is a full 4x4 matsimnibs matrix.""" + return ( + isinstance(orientation, list) + and len(orientation) == 4 + and all(isinstance(row, list) and len(row) == 4 for row in orientation) + and all( + isinstance(value, Real) and not isinstance(value, bool) and math.isfinite(float(value)) + for row in orientation + for value in row + ) + ) + + +@dataclass +class SubjectConfig: + """Subject-specific paths and identifiers.""" + + id: str + derivatives_path: Path + m2m_path: Path + t1w_path: Path + weights_cst_path: Optional[Path] = None + weights_target_path: Optional[Path] = None + surface_path: Optional[Path] = None + # Fixed-pose cache base directory; None -> default (~/.cache/tide). + cache_dir: Optional[Path] = None + # Optional fixed-pose cache size cap in GB (LRU eviction); None -> unlimited. + cache_max_size_gb: Optional[float] = None + # True when `cache_dir: no` disables the fixed-pose cache (same as --no-cache). + cache_disabled: bool = False + + @property + def mesh_path(self) -> Path: + """Return the path to the .msh file inside the m2m folder.""" + msh_files = sorted(self.m2m_path.glob("*.msh")) + if len(msh_files) == 1: + return msh_files[0] + if len(msh_files) > 1: + raise ValueError(f"Head-model directory contains multiple .msh files: {self.m2m_path}") + return self.m2m_path / f"{self.id}.msh" + + +@dataclass +class CoilConfig: + """TMS coil configuration.""" + + coil_model: str + coil_path: Path + coil_distance_mm: float + device_didt_max: float + + +@dataclass +class TargetConfig: + """Target/calibration region configuration.""" + + label: str + bundle_path: Path + coords: Optional[List[float]] = None + scalp_coords: Optional[List[float]] = None + orientation: Optional[Union[str, List[float], List[List[float]]]] = None + medoid_endpoint: bool = False + measured_rmt_mso: Optional[float] = None + didt: Optional[float] = None + mso: Optional[float] = None + + +@dataclass +class OptionsConfig: + """Processing options and parameters.""" + + roi_size_mm: float + activation_length_mm: float + field_mode: str + adm_optimization: bool + opt_spatial_resolution: float + opt_angle_resolution: float + opt_search_angle: float + opt_search_radius: float + # Visualization options + generate_visualizations: bool = True # Generate output images (2D plots, NIfTI masks) + generate_3d_visualization: bool = True # Generate 3D PyVista visualization + visualization_dpi: int = 300 + # Streamline quality filter + max_angular_deviation_deg: float = ( + 0.0 # Max angle between consecutive tangents (degrees). 0 = disabled. + ) + # Surface-constrained (GWI) filter; applies only when subject.files.surface is set + gwi_threshold_mm: float = 3.0 # Max distance from the GWI surface (mm) + # Intensity bounds + mso_floor_ratio: float = 0.70 # Min intensity as fraction of RMT (0.70 = 70%) + mso_ceiling_ratio: float = 1.40 # Max intensity as fraction of RMT (1.40 = 140%) + # Parallelization options (grid search) + max_workers: Optional[int] = None # Max parallel processes, None = auto + no_parallel: bool = False # Force sequential processing + stmpx_dataset_name: Optional[str] = None + + +@dataclass +class GridConfig: + """Grid search configuration.""" + + coords: List[float] + search_radius_mm: float + step_size_mm: float + cortex_depth_mm: float + scalp_coords: Optional[List[float]] = None + orientation: Optional[Union[str, List[float], List[List[float]]]] = None + + +@dataclass +class SimNIBSConfig: + """Main configuration container.""" + + subject: SubjectConfig + coil: CoilConfig + calibration: TargetConfig + target: TargetConfig + options: OptionsConfig + grid: GridConfig + workflow: Optional[str] = None + + def get_orientation(self) -> Optional[Union[str, List[float], List[List[float]]]]: + """Returns orientation or None if empty/not specified.""" + orientation = self.target.orientation + + if orientation is None: + return None + if isinstance(orientation, str) and orientation.strip() == "": + return None + if isinstance(orientation, list) and len(orientation) == 0: + return None + + return orientation + + @classmethod + def from_yaml(cls, config_path: Union[str, Path]) -> "SimNIBSConfig": + """Load configuration from YAML file.""" + config_path = Path(config_path) + if not config_path.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with open(config_path, "r") as f: + raw = yaml.safe_load(f) + if not isinstance(raw, dict): + raise ValueError("Configuration root must be a YAML mapping.") + + # Top-level workflow selection (blank/whitespace treated as unset). + wf_raw = raw.get("workflow") + workflow = wf_raw.strip() if isinstance(wf_raw, str) and wf_raw.strip() else None + metadata = raw.get("_metadata") + if workflow is None and isinstance(metadata, dict): + metadata_workflow_raw = metadata.get("workflow") + metadata_workflow = ( + metadata_workflow_raw.strip() + if isinstance(metadata_workflow_raw, str) and metadata_workflow_raw.strip() + else None + ) + if ( + metadata_workflow == "grid_search" + and metadata.get("source") == "grid_point_reproducibility" + ): + workflow = "estimation" + elif metadata_workflow == "grid_search": + workflow = "grid" + else: + workflow = metadata_workflow + + def resolve_path(base: Path, p: str) -> Optional[Path]: + if not p: + return None + path_obj = Path(p) + return path_obj if path_obj.is_absolute() else base / path_obj + + # --- 1. Subject --- + s_data = raw.get("subject", {}) + der_path = Path(s_data["derivatives_path"]) + cache_raw = s_data.get("cache_dir") + cache_disabled = ( + isinstance(cache_raw, str) and cache_raw.strip().lower() in CACHE_DISABLE_TOKENS + ) + cache_dir = None if cache_disabled or not cache_raw else Path(cache_raw).expanduser() + cache_max_raw = s_data.get("cache_max_size_gb") + cache_max_size_gb = float(cache_max_raw) if cache_max_raw else None + files = s_data.get("files", {}) + + # Resolve m2m_path - required input + m2m_raw = s_data.get("m2m_path") + if not m2m_raw: + raise ValueError("m2m_path is required in the configuration.") + m2m_path = Path(m2m_raw) if Path(m2m_raw).is_absolute() else der_path / m2m_raw + + subject_conf = SubjectConfig( + id=s_data.get("id", der_path.name), + derivatives_path=der_path, + m2m_path=m2m_path, + t1w_path=resolve_path(der_path, files["t1w"]), + weights_cst_path=resolve_path(der_path, files.get("weights_cst")), + weights_target_path=resolve_path(der_path, files.get("weights_target")), + surface_path=resolve_path(der_path, files.get("surface")), + cache_dir=cache_dir, + cache_max_size_gb=cache_max_size_gb, + cache_disabled=cache_disabled, + ) + + # --- 2. Coil --- + c_data = raw.get("coil", {}) + raw_coil_path = c_data.get("coil_path") + coil_model = c_data["coil_model"] + if raw_coil_path: + configured_coil_path = Path(raw_coil_path) + if configured_coil_path.suffix.lower() == ".ccd": + coil_path = configured_coil_path + coil_model = configured_coil_path.name + else: + coil_path = configured_coil_path / coil_model + else: + log.debug("Coil path not set. Auto-detecting...") + coil_dir = _detect_simnibs_coil_path() + if not coil_dir: + raise ValueError("Could not auto-detect SimNIBS coil path.") + coil_path = coil_dir / coil_model + + coil_conf = CoilConfig( + coil_model=coil_model, + coil_path=coil_path, + coil_distance_mm=float(c_data["coil_distance_mm"]), + device_didt_max=float(c_data["device_didt_max"]), + ) + + # --- 3. Experiment --- + exp_data = raw.get("experiment", {}) + + def parse_val(val): + """Parse string representations of lists.""" + if isinstance(val, str) and ("[" in val): + try: + return ast.literal_eval(val) + except Exception: + return val + return val + + # Calibration (M1/CST) + cal_data = exp_data.get("calibration", {}) + for section_name, section_data in ( + ("calibration", cal_data), + ("target", exp_data.get("target", {})), + ): + if section_data.get("stmpx_file") not in (None, ""): + raise ValueError( + f"experiment.{section_name}.stmpx_file is not supported; convert the " + "pose to a SimNIBS orientation matrix before running TIDE." + ) + if cal_data.get("didt") not in (None, ""): + raise ValueError( + "experiment.calibration.didt is not supported; dose calibration uses a " + "unit field scaled by measured_rmt_mso." + ) + calibration_conf = TargetConfig( + label=cal_data.get("label", "M1"), + bundle_path=resolve_path(der_path, cal_data.get("bundle_path")), + measured_rmt_mso=float(cal_data.get("measured_rmt_mso", 0)), + coords=parse_val(cal_data.get("coords")), + scalp_coords=parse_val(cal_data.get("scalp_coords")), + orientation=parse_val(cal_data.get("orientation")), + ) + + # Target + tgt_data = exp_data.get("target", {}) + target_conf = TargetConfig( + label=tgt_data.get("label", "Target"), + bundle_path=resolve_path(der_path, tgt_data.get("bundle_path")), + coords=parse_val(tgt_data.get("coords")), + scalp_coords=parse_val(tgt_data.get("scalp_coords")), + orientation=parse_val(tgt_data.get("orientation")), + medoid_endpoint=bool(tgt_data.get("cortical_medoid", False)), + didt=float(tgt_data.get("didt")) if tgt_data.get("didt") else None, + mso=float(tgt_data.get("mso")) if tgt_data.get("mso") else None, + ) + + # Options + opt_data = raw.get("options", {}) + stmpx_dataset_name = opt_data.get("stmpx_dataset_name") + if stmpx_dataset_name is not None and not isinstance(stmpx_dataset_name, str): + raise ValueError("options.stmpx_dataset_name must be a string.") + if stmpx_dataset_name == "": + stmpx_dataset_name = None + options_conf = OptionsConfig( + roi_size_mm=float(opt_data.get("roi_size_mm", 30.0)), + activation_length_mm=float(opt_data.get("activation_length_mm", 4.0)), + field_mode=opt_data.get("field_mode", "af"), + adm_optimization=opt_data.get("adm_optimization", True), + opt_spatial_resolution=float(opt_data.get("opt_spatial_resolution", 2.0)), + opt_angle_resolution=float(opt_data.get("opt_angle_resolution", 10.0)), + opt_search_angle=float(opt_data.get("opt_search_angle", 30.0)), + opt_search_radius=float(opt_data.get("opt_search_radius", 10.0)), + generate_visualizations=( + opt_data["generate_visualizations"] + if "generate_visualizations" in opt_data + else opt_data.get("generate_visualization", True) + ), + generate_3d_visualization=opt_data.get("generate_3d_visualization", True), + visualization_dpi=int(opt_data.get("visualization_dpi", 300)), + max_workers=( + int(opt_data["max_workers"]) if opt_data.get("max_workers") is not None else None + ), + no_parallel=opt_data.get("no_parallel", False), + max_angular_deviation_deg=float(opt_data.get("max_angular_deviation_deg", 0.0)), + gwi_threshold_mm=float(opt_data.get("gwi_threshold_mm", 3.0)), + mso_floor_ratio=float(opt_data.get("mso_floor_ratio", 0.70)), + mso_ceiling_ratio=float(opt_data.get("mso_ceiling_ratio", 1.40)), + stmpx_dataset_name=stmpx_dataset_name, + ) + + # Grid — geometry is nested under the target block. The grid search reuses + # the target's coords / scalp_coords / orientation as its center and + # per-point seed; only the search geometry (radius / step / depth) is + # grid-specific. A legacy top-level experiment.grid block, which carries + # its own coords / scalp_coords / orientation, is still accepted. + nested_grid = tgt_data.get("grid") + if isinstance(nested_grid, dict): + grid_conf = GridConfig( + coords=target_conf.coords, + scalp_coords=target_conf.scalp_coords, + orientation=target_conf.orientation, + search_radius_mm=float(nested_grid.get("search_radius_mm", 20.0)), + step_size_mm=float(nested_grid.get("step_size_mm", 4.0)), + cortex_depth_mm=float(nested_grid.get("cortex_depth_mm", 2.0)), + ) + else: + g_data = exp_data.get("grid", {}) + grid_conf = GridConfig( + coords=parse_val(g_data.get("coords")), + scalp_coords=parse_val(g_data.get("scalp_coords")), + orientation=parse_val(g_data.get("orientation")), + search_radius_mm=float(g_data.get("search_radius_mm", 20.0)), + step_size_mm=float(g_data.get("step_size_mm", 4.0)), + cortex_depth_mm=float(g_data.get("cortex_depth_mm", 2.0)), + ) + + return cls( + subject=subject_conf, + coil=coil_conf, + calibration=calibration_conf, + target=target_conf, + options=options_conf, + grid=grid_conf, + workflow=workflow, + ) + + +def _validate_number( + value: Any, + key: str, + *, + minimum: Optional[float] = None, + maximum: Optional[float] = None, + minimum_inclusive: bool = True, +) -> None: + if not isinstance(value, Real) or isinstance(value, bool) or not math.isfinite(float(value)): + raise ValueError(f"{key} must be a finite number.") + if minimum is not None: + below_minimum = value < minimum if minimum_inclusive else value <= minimum + if below_minimum: + comparator = ">=" if minimum_inclusive else ">" + raise ValueError(f"{key} must be {comparator} {minimum}.") + if maximum is not None and value > maximum: + raise ValueError(f"{key} must be <= {maximum}.") + + +def _validate_vector(value: Any, key: str, *, required: bool = False) -> None: + if value is None: + if required: + raise ValueError(f"{key} is required.") + return + if not isinstance(value, list) or len(value) != 3: + raise ValueError(f"{key} must contain exactly three coordinates.") + for coordinate in value: + if ( + not isinstance(coordinate, Real) + or isinstance(coordinate, bool) + or not math.isfinite(float(coordinate)) + ): + raise ValueError(f"{key} must contain only finite numbers.") + + +def _validate_orientation(value: Any, key: str) -> None: + if value is None: + return + if isinstance(value, str): + if not value.strip(): + raise ValueError(f"{key} cannot be blank.") + return + if orientation_is_matrix(value): + matrix = [[float(element) for element in row] for row in value] + if any( + not math.isclose(matrix[3][index], expected, abs_tol=1e-6) + for index, expected in enumerate((0.0, 0.0, 0.0, 1.0)) + ): + raise ValueError(f"{key} matrix must end with [0, 0, 0, 1].") + + rotation = [row[:3] for row in matrix[:3]] + for row_index in range(3): + for column_index in range(3): + dot_product = sum( + rotation[axis][row_index] * rotation[axis][column_index] for axis in range(3) + ) + expected = 1.0 if row_index == column_index else 0.0 + if not math.isclose(dot_product, expected, abs_tol=1e-4): + raise ValueError(f"{key} rotation block must be orthonormal.") + determinant = ( + rotation[0][0] * (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1]) + - rotation[0][1] * (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0]) + + rotation[0][2] * (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0]) + ) + if not math.isclose(determinant, 1.0, abs_tol=1e-4): + raise ValueError(f"{key} rotation block must have determinant +1.") + return + if isinstance(value, list) and not any(isinstance(item, list) for item in value): + _validate_vector(value, key) + return + raise ValueError(f"{key} must be an EEG label, a three-vector, or a rigid 4x4 matrix.") + + +def _validate_label(value: Any, key: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key} must be a non-empty string.") + if value in {".", ".."} or "/" in value or "\\" in value or "\x00" in value: + raise ValueError(f"{key} cannot contain path separators or traversal components.") + + +def _require_file(path: Optional[Path], key: str) -> None: + if path is None or not path.is_file(): + raise FileNotFoundError(f"Required file does not exist ({key}): {path}") + + +def validate_workflow_config(config: SimNIBSConfig, workflow: str) -> None: + """Validate workflow-specific inputs before any output is created.""" + if workflow in {"estimation", "grid"} and config.options.field_mode != "af": + raise ValueError( + f"options.field_mode '{config.options.field_mode}' is not supported by the " + f"{workflow} dose workflow; use 'af'. The 'e_parallel' mode is available " + "only for standard simulation mapping." + ) + + weight_paths = [] + if workflow in {"estimation", "grid"}: + weight_paths.extend( + [ + ("subject.files.weights_cst", config.subject.weights_cst_path), + ("subject.files.weights_target", config.subject.weights_target_path), + ] + ) + elif workflow == "simulation": + weight_paths.append(("subject.files.weights_target", config.subject.weights_target_path)) + + for config_key, weight_path in weight_paths: + if weight_path is not None and not weight_path.exists(): + raise FileNotFoundError( + f"Configured weight file does not exist ({config_key}): {weight_path}" + ) + + if config.options.field_mode not in {"af", "e_parallel"}: + raise ValueError("options.field_mode must be 'af' or 'e_parallel'.") + + _validate_label(config.subject.id, "subject.id") + _validate_label(config.calibration.label, "experiment.calibration.label") + _validate_label(config.target.label, "experiment.target.label") + + _validate_vector( + config.calibration.coords, + "experiment.calibration.coords", + required=workflow in {"estimation", "grid"}, + ) + _validate_vector(config.calibration.scalp_coords, "experiment.calibration.scalp_coords") + _validate_vector( + config.target.coords, + "experiment.target.coords", + required=workflow in {"estimation", "grid", "optimization"} + or (workflow == "simulation" and config.target.bundle_path is not None), + ) + _validate_vector(config.target.scalp_coords, "experiment.target.scalp_coords") + _validate_orientation(config.calibration.orientation, "experiment.calibration.orientation") + _validate_orientation(config.target.orientation, "experiment.target.orientation") + _validate_vector( + config.grid.coords, "experiment.target.grid.coords", required=workflow == "grid" + ) + _validate_vector(config.grid.scalp_coords, "experiment.target.grid.scalp_coords") + _validate_orientation(config.grid.orientation, "experiment.target.grid.orientation") + if workflow == "grid" and orientation_is_matrix(config.grid.orientation): + raise ValueError("experiment.target.orientation cannot be a 4x4 matrix for grid search.") + + _validate_number( + config.coil.coil_distance_mm, + "coil.coil_distance_mm", + minimum=0.0, + ) + _validate_number( + config.coil.device_didt_max, + "coil.device_didt_max", + minimum=0.0, + minimum_inclusive=False, + ) + _validate_number( + config.options.roi_size_mm, + "options.roi_size_mm", + minimum=0.0, + minimum_inclusive=False, + ) + _validate_number( + config.options.activation_length_mm, + "options.activation_length_mm", + minimum=0.0, + minimum_inclusive=False, + ) + _validate_number( + config.options.gwi_threshold_mm, + "options.gwi_threshold_mm", + minimum=0.0, + minimum_inclusive=False, + ) + for key, value in ( + ("options.opt_spatial_resolution", config.options.opt_spatial_resolution), + ("options.opt_angle_resolution", config.options.opt_angle_resolution), + ): + _validate_number(value, key, minimum=0.0, minimum_inclusive=False) + _validate_number( + config.options.opt_search_radius, + "options.opt_search_radius", + minimum=0.0, + ) + _validate_number( + config.options.opt_search_angle, + "options.opt_search_angle", + minimum=0.0, + maximum=360.0, + ) + _validate_number( + config.options.max_angular_deviation_deg, + "options.max_angular_deviation_deg", + minimum=0.0, + maximum=180.0, + ) + _validate_number(config.options.mso_floor_ratio, "options.mso_floor_ratio", minimum=0.0) + _validate_number( + config.options.mso_ceiling_ratio, + "options.mso_ceiling_ratio", + minimum=0.0, + minimum_inclusive=False, + ) + if config.options.mso_floor_ratio > config.options.mso_ceiling_ratio: + raise ValueError("options.mso_floor_ratio must be <= options.mso_ceiling_ratio.") + _validate_number( + config.options.visualization_dpi, + "options.visualization_dpi", + minimum=1.0, + ) + if config.options.max_workers is not None: + _validate_number(config.options.max_workers, "options.max_workers", minimum=1.0) + for key, value in ( + ("options.adm_optimization", config.options.adm_optimization), + ("options.generate_visualizations", config.options.generate_visualizations), + ("options.generate_3d_visualization", config.options.generate_3d_visualization), + ("options.no_parallel", config.options.no_parallel), + ): + if not isinstance(value, bool): + raise ValueError(f"{key} must be true or false.") + + if workflow in {"estimation", "grid"}: + _validate_number( + config.calibration.measured_rmt_mso, + "experiment.calibration.measured_rmt_mso", + minimum=0.0, + maximum=100.0, + minimum_inclusive=False, + ) + if config.target.didt is not None: + _validate_number( + config.target.didt, + "experiment.target.didt", + minimum=0.0, + minimum_inclusive=False, + ) + if config.target.mso is not None: + _validate_number( + config.target.mso, + "experiment.target.mso", + minimum=0.0, + maximum=100.0, + minimum_inclusive=False, + ) + + if workflow == "grid": + _validate_number( + config.grid.search_radius_mm, + "experiment.target.grid.search_radius_mm", + minimum=0.0, + ) + _validate_number( + config.grid.step_size_mm, + "experiment.target.grid.step_size_mm", + minimum=0.0, + minimum_inclusive=False, + ) + _validate_number( + config.grid.cortex_depth_mm, + "experiment.target.grid.cortex_depth_mm", + minimum=0.0, + ) + + _require_file(config.subject.t1w_path, "subject.files.t1w") + if not config.subject.m2m_path.is_dir(): + raise FileNotFoundError( + f"Head-model directory does not exist (subject.m2m_path): {config.subject.m2m_path}" + ) + _require_file(config.subject.mesh_path, "subject.m2m_path/*.msh") + _require_file(config.coil.coil_path, "coil.coil_path") + + if workflow in {"estimation", "grid"}: + _require_file(config.calibration.bundle_path, "experiment.calibration.bundle_path") + _require_file(config.target.bundle_path, "experiment.target.bundle_path") + elif config.target.bundle_path is not None: + _require_file(config.target.bundle_path, "experiment.target.bundle_path") + + if workflow in {"estimation", "grid", "simulation"} and config.subject.surface_path: + _require_file(config.subject.surface_path, "subject.files.surface") + + +def _detect_simnibs_coil_path() -> Optional[Path]: + """Auto-detect SimNIBS coil models directory.""" + return simnibs_env.find_coil_models_dir() + + +class FlowStyleDumper(yaml.SafeDumper): + """ + Custom YAML dumper that uses flow style for lists. + + Makes coordinates and matrices display horizontally for better readability: + coords: [-13.28, -26.71, 63.0] + """ + + pass + + +def _represent_list(dumper: FlowStyleDumper, data: list) -> yaml.Node: + """Represent lists in flow style (horizontal).""" + return dumper.represent_sequence("tag:yaml.org,2002:seq", data, flow_style=True) + + +def _represent_none(dumper: FlowStyleDumper, data: None) -> yaml.Node: + """Represent None as empty string for cleaner output.""" + return dumper.represent_scalar("tag:yaml.org,2002:null", "") + + +# Register custom representers +FlowStyleDumper.add_representer(list, _represent_list) +FlowStyleDumper.add_representer(type(None), _represent_none) + + +def _path_to_str(p: Optional[Path]) -> Optional[str]: + """Convert Path to string, or return None.""" + return str(p) if p else None + + +def _subject_output(config: SimNIBSConfig) -> Dict[str, Any]: + return { + "id": config.subject.id, + "derivatives_path": _path_to_str(config.subject.derivatives_path), + "cache_dir": ( + "no" if config.subject.cache_disabled else _path_to_str(config.subject.cache_dir) + ), + "cache_max_size_gb": config.subject.cache_max_size_gb, + "m2m_path": _path_to_str(config.subject.m2m_path), + "files": { + "t1w": _path_to_str(config.subject.t1w_path), + "weights_cst": _path_to_str(config.subject.weights_cst_path), + "weights_target": _path_to_str(config.subject.weights_target_path), + "surface": _path_to_str(config.subject.surface_path), + }, + } + + +def _coil_output(config: SimNIBSConfig) -> Dict[str, Any]: + return { + "coil_model": config.coil.coil_model, + "coil_path": _path_to_str(config.coil.coil_path.parent) if config.coil.coil_path else None, + "coil_distance_mm": config.coil.coil_distance_mm, + "device_didt_max": config.coil.device_didt_max, + } + + +def _options_output(config: SimNIBSConfig) -> Dict[str, Any]: + options = { + "roi_size_mm": config.options.roi_size_mm, + "activation_length_mm": config.options.activation_length_mm, + "field_mode": config.options.field_mode, + "adm_optimization": config.options.adm_optimization, + "opt_search_radius": config.options.opt_search_radius, + "opt_search_angle": config.options.opt_search_angle, + "opt_angle_resolution": config.options.opt_angle_resolution, + "opt_spatial_resolution": config.options.opt_spatial_resolution, + "generate_visualizations": config.options.generate_visualizations, + "generate_3d_visualization": config.options.generate_3d_visualization, + "visualization_dpi": config.options.visualization_dpi, + "max_angular_deviation_deg": config.options.max_angular_deviation_deg, + "gwi_threshold_mm": config.options.gwi_threshold_mm, + "mso_floor_ratio": config.options.mso_floor_ratio, + "mso_ceiling_ratio": config.options.mso_ceiling_ratio, + "max_workers": config.options.max_workers, + "no_parallel": config.options.no_parallel, + } + if config.options.stmpx_dataset_name is not None: + options["stmpx_dataset_name"] = config.options.stmpx_dataset_name + return options + + +def _grid_output(config: SimNIBSConfig) -> Dict[str, float]: + return { + "search_radius_mm": config.grid.search_radius_mm, + "step_size_mm": config.grid.step_size_mm, + "cortex_depth_mm": config.grid.cortex_depth_mm, + } + + +def _write_config_output( + config_dict: Dict[str, Any], + config_path: Path, + success_message: str, + failure_message: str, +) -> None: + try: + with open(config_path, "w") as f: + yaml.dump( + config_dict, + f, + Dumper=FlowStyleDumper, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + width=1000, + ) + log.info(f"{success_message}: {config_path}") + except Exception as e: + log.warning(f"{failure_message}: {e}") + raise + + +def save_config_to_output( + config: SimNIBSConfig, + output_dir: Path, + workflow: str, + generated_calibration_matrix: Optional[List[List[float]]] = None, + generated_target_matrix: Optional[List[List[float]]] = None, + generated_calibration_scalp_coords: Optional[List[float]] = None, + generated_target_scalp_coords: Optional[List[float]] = None, + medoid_resolved: bool = False, +) -> Path: + """ + Save a copy of the configuration to the output directory. + + Creates a YAML file in the output directory containing all configuration + parameters used for the workflow run. This provides reproducibility and + documentation of the exact parameters used. + + When an orientation matrix is generated during optimization (from a 3D vector + input), the output config will: + - Store the generated 4x4 matrix as the 'orientation' value + - Preserve the original user input in '_original_orientation_input' field + + For the 'optimization' workflow, generated matrices are NOT included since + the purpose of that workflow is to generate them. + + Args: + config: The SimNIBSConfig object to save. + output_dir: The output directory for the workflow. + workflow: Name of the workflow (e.g., 'simulation', 'optimization', + 'estimation', 'grid_search'). + generated_calibration_matrix: Optional 4x4 matrix generated during + calibration/M1 optimization. + generated_target_matrix: Optional 4x4 matrix generated during target + optimization. + + Returns: + Path to the saved configuration file. + """ + from datetime import datetime + + # Create output directory if it doesn't exist + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename with timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + config_filename = f"config_{workflow}_{timestamp}.yml" + config_path = output_dir / config_filename + + # Helper to build orientation field with optional generated matrix + def _build_orientation_field( + original_orientation: Optional[Union[str, List[float], List[List[float]]]], + generated_matrix: Optional[List[List[float]]], + ) -> Dict[str, Any]: + """ + Build orientation field dict. + + If a matrix was generated, returns both the matrix (as 'orientation') + and the original input (as '_original_orientation_input'). + Otherwise, returns just the original orientation. + """ + result = {} + + if generated_matrix is not None: + # Use generated matrix as the orientation value + result["orientation"] = generated_matrix + # Preserve original input for reference + if original_orientation is not None: + result["_original_orientation_input"] = original_orientation + else: + # No matrix generated, use original orientation + result["orientation"] = original_orientation + + return result + + # Build calibration orientation + cal_orientation_data = _build_orientation_field( + config.calibration.orientation, + generated_calibration_matrix, + ) + + # Build target orientation + tgt_orientation_data = _build_orientation_field( + config.target.orientation, + generated_target_matrix, + ) + + # Resolve scalp coordinates: prefer optimization outputs so the saved + # config is fully re-runnable without re-triggering coil optimization. + cal_scalp_coords_final = ( + generated_calibration_scalp_coords + if generated_calibration_scalp_coords is not None + else config.calibration.scalp_coords + ) + tgt_scalp_coords_final = ( + generated_target_scalp_coords + if generated_target_scalp_coords is not None + else config.target.scalp_coords + ) + + # If medoid was applied upstream, `config.target.coords` already holds the + # resolved coordinates. Flip the flag off so replay uses them directly + # rather than recomputing the medoid. + cortical_medoid_final = False if medoid_resolved else config.target.medoid_endpoint + + # Build configuration dictionary + config_dict = { + "subject": _subject_output(config), + "coil": _coil_output(config), + "options": _options_output(config), + "experiment": { + "calibration": { + "label": config.calibration.label, + "bundle_path": _path_to_str(config.calibration.bundle_path), + **cal_orientation_data, + "coords": config.calibration.coords, + "scalp_coords": cal_scalp_coords_final, + "measured_rmt_mso": config.calibration.measured_rmt_mso, + }, + "target": { + "label": config.target.label, + "bundle_path": _path_to_str(config.target.bundle_path), + **tgt_orientation_data, + "coords": config.target.coords, + "scalp_coords": tgt_scalp_coords_final, + "cortical_medoid": cortical_medoid_final, + "didt": config.target.didt, + "mso": config.target.mso, + "grid": _grid_output(config), + }, + }, + "_metadata": { + "workflow": workflow, + "generated_at": datetime.now().isoformat(), + "output_directory": str(output_dir), + }, + } + + _write_config_output( + config_dict, + config_path, + "Configuration saved to", + "Failed to save configuration", + ) + + return config_path + + +def save_grid_point_config( + config: SimNIBSConfig, + output_dir: Path, + point_label: str, + cortex_coords: List[float], + scalp_coords: List[float], + orientation_matrix: List[List[float]], + fixed_scalp_coords: List[float], + grid_orientation_ref: List[float], + calibration_orientation: Optional[Union[str, List[float], List[List[float]]]] = None, +) -> Path: + """ + Save a fully self-contained configuration for a specific grid point. + + The emitted YAML mirrors the structure produced by + :func:`save_config_to_output` so it can be fed directly to + ``main.py --workflow estimation`` to reproduce this grid point's + simulation without any manual editing. The ``target`` section is + pre-populated with the optimized 4x4 coil matrix (skipping target + optimization on re-run), and the ``calibration`` section carries the + finalized M1 orientation from the originating grid run (4x4 matrix when + available) so that M1 optimization is also skipped. + + Args: + config: The base SimNIBSConfig object used for the grid run. + output_dir: The grid point output directory. + point_label: Label for this grid point (e.g., ``"grid_P00"``). + cortex_coords: Target cortex coordinates for this point. + scalp_coords: Optimized scalp coordinates for this point. + orientation_matrix: 4x4 coil orientation matrix from optimization. + fixed_scalp_coords: Fixed scalp center used across all grid points. + grid_orientation_ref: Orientation reference (``pos_ydir``) used for + optimization. + calibration_orientation: Final calibration orientation from the grid + run (4x4 matrix when M1 optimization was performed, otherwise the + original user input). If ``None``, falls back to + ``config.calibration.orientation``. + + Returns: + Path to the saved configuration file. + """ + from datetime import datetime + + output_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + config_filename = f"config_{point_label}_{timestamp}.yml" + config_path = output_dir / config_filename + + target_orientation = [list(row) for row in orientation_matrix] + + if calibration_orientation is not None: + if ( + isinstance(calibration_orientation, list) + and len(calibration_orientation) == 4 + and calibration_orientation + and isinstance(calibration_orientation[0], list) + ): + cal_orientation_final: Union[str, List[float], List[List[float]]] = [ + list(row) for row in calibration_orientation + ] + else: + cal_orientation_final = calibration_orientation + else: + cal_orientation_final = config.calibration.orientation + + config_dict = { + "subject": _subject_output(config), + "coil": _coil_output(config), + "options": _options_output(config), + "experiment": { + "calibration": { + "label": config.calibration.label, + "bundle_path": _path_to_str(config.calibration.bundle_path), + "orientation": cal_orientation_final, + "coords": config.calibration.coords, + "scalp_coords": config.calibration.scalp_coords, + "measured_rmt_mso": config.calibration.measured_rmt_mso, + }, + "target": { + "label": config.target.label, + "bundle_path": _path_to_str(config.target.bundle_path), + "orientation": target_orientation, + "coords": list(cortex_coords), + "scalp_coords": list(scalp_coords), + # Medoid must be disabled so the grid-point coords are used + # as-is when the config is replayed via `--workflow estimation`. + "cortical_medoid": False, + "didt": config.target.didt, + "mso": config.target.mso, + "grid": _grid_output(config), + }, + }, + "grid_point": { + "label": point_label, + "cortex_coords": list(cortex_coords), + "optimized_scalp_coords": list(scalp_coords), + "orientation_matrix": target_orientation, + "fixed_scalp_center": list(fixed_scalp_coords), + "orientation_reference": ( + list(grid_orientation_ref) + if hasattr(grid_orientation_ref, "__iter__") + and not isinstance(grid_orientation_ref, str) + else grid_orientation_ref + ), + }, + "_metadata": { + "workflow": "grid_search", + "source": "grid_point_reproducibility", + "generated_at": datetime.now().isoformat(), + "output_directory": str(output_dir), + "reproduce_with": ( + "python main.py --no-gui --config " f"{config_filename} --workflow estimation" + ), + }, + } + + _write_config_output( + config_dict, + config_path, + "Grid point configuration saved to", + "Failed to save grid point configuration", + ) + + return config_path diff --git a/src/tide/utils/logging.py b/src/tide/utils/logging.py new file mode 100644 index 0000000..7ca7e9b --- /dev/null +++ b/src/tide/utils/logging.py @@ -0,0 +1,151 @@ +""" +Logging Configuration for TIDE Pipeline +======================================== +Provides colored console output and file logging with configurable verbosity. +""" + +import logging +import sys +from pathlib import Path +from typing import Optional + +# Define standard levels +LOG_FORMAT_VERBOSE = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" +LOG_FORMAT_STANDARD = "%(asctime)s - %(levelname)s - %(message)s" +LOG_FORMAT_MINIMAL = "%(message)s" +DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# --- Custom HIGHLIGHT Level Definition --- +HIGHLIGHT_LEVEL = 25 +logging.addLevelName(HIGHLIGHT_LEVEL, "HIGHLIGHT") + + +def highlight(self, message, *args, **kwargs): + if self.isEnabledFor(HIGHLIGHT_LEVEL): + self._log(HIGHLIGHT_LEVEL, message, args, **kwargs) + + +# Monkey-patch the Logger class to add the .highlight() method +logging.Logger.highlight = highlight + + +class ColoredFormatter(logging.Formatter): + """Custom formatter to add ANSI colors to console output.""" + + GREEN = "\033[0;32m" + YELLOW = "\033[0;33m" + RED = "\033[0;31m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + BOLD = "\033[1m" + NC = "\033[0m" # No Color + + def __init__(self, fmt: str, datefmt: Optional[str] = None): + super().__init__(fmt, datefmt=datefmt) + + # Create a specific format for HIGHLIGHT that removes the levelname + highlight_fmt = fmt.replace("%(levelname)s - ", "").replace("%(levelname)s", "") + + self.formatters = { + logging.DEBUG: logging.Formatter(f"{self.CYAN}{fmt}{self.NC}", datefmt=datefmt), + logging.INFO: logging.Formatter(f"{self.NC}{fmt}{self.NC}", datefmt=datefmt), + HIGHLIGHT_LEVEL: logging.Formatter( + f"{self.GREEN}{self.BOLD}{highlight_fmt}{self.NC}", datefmt=datefmt + ), + logging.WARNING: logging.Formatter(f"{self.YELLOW}{fmt}{self.NC}", datefmt=datefmt), + logging.ERROR: logging.Formatter(f"{self.RED}{fmt}{self.NC}", datefmt=datefmt), + logging.CRITICAL: logging.Formatter( + f"{self.RED}{self.BOLD}{fmt}{self.NC}", datefmt=datefmt + ), + } + self.default_formatter = logging.Formatter(fmt, datefmt) + + def format(self, record: logging.LogRecord) -> str: + formatter = self.formatters.get(record.levelno, self.default_formatter) + return formatter.format(record) + + +class QuietFilter(logging.Filter): + """Filter to suppress verbose messages from specific modules.""" + + QUIET_MODULES = [ + "simnibs", + "nibabel", + "dipy", + "scipy", + "numpy", + "matplotlib", + "pyvista", + "vtk", + ] + + def filter(self, record: logging.LogRecord) -> bool: + # Allow all HIGHLIGHT and above + if record.levelno >= HIGHLIGHT_LEVEL: + return True + + # Filter out DEBUG from external modules + if record.levelno <= logging.DEBUG: + for module in self.QUIET_MODULES: + if record.name.startswith(module): + return False + + return True + + +def setup_logging(output_dir: Path, subject_id: str, verbosity: str = "standard"): + """ + Configures the root logger. + + Args: + output_dir: Directory for log file + subject_id: Subject ID for log filename + verbosity: 'quiet', 'standard', or 'verbose' + - quiet: Only HIGHLIGHT, WARNING, ERROR (minimal output) + - standard: INFO and above (default) + - verbose: DEBUG and above (full output) + """ + log = logging.getLogger() + + # Set base level based on verbosity + level_map = {"quiet": HIGHLIGHT_LEVEL, "standard": logging.INFO, "verbose": logging.DEBUG} + log.setLevel(level_map.get(verbosity, logging.INFO)) + + # Clear existing handlers to prevent duplicates during re-runs + if log.hasHandlers(): + log.handlers.clear() + + # Select format based on verbosity + if verbosity == "quiet": + fmt = LOG_FORMAT_MINIMAL + elif verbosity == "verbose": + fmt = LOG_FORMAT_VERBOSE + else: + fmt = LOG_FORMAT_STANDARD + + # 1. Console Handler (Colored) + console = logging.StreamHandler(sys.stdout) + console.setFormatter(ColoredFormatter(fmt, DATE_FORMAT)) + console.addFilter(QuietFilter()) + log.addHandler(console) + + # 2. File Handler (Always verbose, no color) + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + log_file = output_dir / f"{subject_id}_simNIBS_tide.log" + + file_handler = logging.FileHandler(log_file, mode="a") + file_handler.setLevel(logging.DEBUG) # Always capture everything to file + file_handler.setFormatter(logging.Formatter(LOG_FORMAT_VERBOSE, DATE_FORMAT)) + log.addHandler(file_handler) + + # Suppress verbose output from third-party libraries + for lib in ["simnibs", "nibabel", "dipy", "pyvista", "vtk", "matplotlib"]: + logging.getLogger(lib).setLevel(logging.WARNING) + + log.debug(f"Logging initialized. File: {log_file}, Verbosity: {verbosity}") + + +def get_logger(name: str) -> logging.Logger: + """Get a logger with the given name.""" + return logging.getLogger(name) diff --git a/src/tide/utils/simnibs_env.py b/src/tide/utils/simnibs_env.py new file mode 100644 index 0000000..e6a0a07 --- /dev/null +++ b/src/tide/utils/simnibs_env.py @@ -0,0 +1,121 @@ +""" +SimNIBS Installation Descriptor +=============================== +Single, standard-library-only source of truth for locating a SimNIBS +installation: the bundled Python interpreter and its pip, the +``get_fields_at_coordinates`` CLI launcher, and the bundled coil-models +directory. + +No NumPy or SimNIBS import happens here, so the module stays importable under +any interpreter (source checkout, foreign venv, or the SimNIBS environment +itself) and can run before the environment relaunch. The resolver functions are +pure: they return ``None`` (or an empty list) when a component is absent and +never call ``sys.exit`` or print. Callers own their error messages and exit +behaviour, preserving the existing CLI, configuration, and sampling contracts. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path +from typing import List, Optional + + +def find_simnibs_launcher() -> Optional[str]: + """Return the ``simnibs`` launcher path on PATH, or ``None``.""" + return shutil.which("simnibs") + + +def simnibs_root() -> Optional[Path]: + """Return the SimNIBS installation root inferred from the launcher.""" + launcher = find_simnibs_launcher() + if not launcher: + return None + return Path(launcher).resolve().parent.parent + + +def python_candidates(root: Path) -> List[Path]: + """Return the ordered SimNIBS python interpreter candidates for ``root``.""" + if sys.platform == "win32": + return [ + root / "simnibs_env" / "Scripts" / "python.exe", + root / "simnibs_env" / "python.exe", + root / "Scripts" / "python.exe", + root / "python.exe", + ] + return [ + root / "simnibs_env" / "bin" / "python3", + root / "simnibs_env" / "bin" / "python", + root / "bin" / "python3", + root / "bin" / "python", + ] + + +def select_python(candidates: List[Path]) -> Optional[Path]: + """Return the first existing, executable python candidate, or ``None``.""" + for candidate in candidates: + if candidate.exists() and (sys.platform == "win32" or os.access(candidate, os.X_OK)): + return candidate + return None + + +def pip_candidates(python: Path) -> List[Path]: + """Return the ordered pip candidates next to ``python``.""" + bin_dir = python.parent + if sys.platform == "win32": + return [bin_dir / "pip.exe", bin_dir / "pip3.exe"] + return [bin_dir / "pip", bin_dir / "pip3"] + + +def select_pip(candidates: List[Path]) -> Optional[Path]: + """Return the first existing pip candidate, or ``None``.""" + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def find_get_fields_at_coordinates() -> Optional[str]: + """Return the ``get_fields_at_coordinates`` launcher path, or ``None``. + + Handles the Windows ``.cmd``/``.exe`` suffixes. + """ + for name in ( + "get_fields_at_coordinates", + "get_fields_at_coordinates.cmd", + "get_fields_at_coordinates.exe", + ): + found = shutil.which(name) + if found is not None: + return found + return None + + +def find_coil_models_dir() -> Optional[Path]: + """Return the bundled Drakaki coil-models directory, or ``None``. + + Falls back to the parent ``coil_models`` directory when the specific + ``Drakaki_BrainStim_2022`` subdirectory is absent. + """ + root = simnibs_root() + if root is None: + return None + + lib_dir = root / "simnibs_env" / "lib" + if not lib_dir.exists(): + return None + + site_packages = list(lib_dir.glob("*/site-packages")) + if not site_packages: + return None + + coil_path = ( + site_packages[0] / "simnibs" / "resources" / "coil_models" / "Drakaki_BrainStim_2022" + ) + if coil_path.exists(): + return coil_path + if coil_path.parent.exists(): + return coil_path.parent + return None diff --git a/src/tide/workflows/_grid_reporting.py b/src/tide/workflows/_grid_reporting.py new file mode 100644 index 0000000..b9e20c9 --- /dev/null +++ b/src/tide/workflows/_grid_reporting.py @@ -0,0 +1,823 @@ +from __future__ import annotations + +import csv +import logging +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence + +import numpy as np + +from tide.core import io +from tide.core.physics import AGGREGATOR_KEYS +from tide.interfaces.unified_estimation import build_aggregator_sensitivity, format_weight_sources +from tide.utils.config import SimNIBSConfig, save_grid_point_config +from tide.workflows._shared import calculate_target_in_field_metric + +if TYPE_CHECKING: + from tide.workflows.grid_search import GridPointResult + +log = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class GridReportingContext: + config: SimNIBSConfig + out_dir: Path + sims_dir: Path + results_csv: Path + fixed_scalp_coords: Sequence[float] + grid_orientation_ref: Any + calibration_orientation: Any + target_streamlines_full: List[np.ndarray] + target_vectors_in_m1: List[np.ndarray] + cst_result: Any + af_cst_calibration: float + cst_align: float + cst_align_corrected: float + cst_depth: float + intensity_rmt: float + biological_threshold: float + m1_matrix_str: str + spatial_mode: str + num_workers: int + calibration_pose_qc: Optional[Dict[str, object]] + start_time: float + worker_memory_model: Optional[Dict[str, object]] = None + + +@dataclass(frozen=True) +class GridSummaryResult: + summary_path: Path + elapsed_time: float + weighted_statistics: Dict[str, float] + unweighted_statistics: Dict[str, float] + weighted_raw_statistics: Dict[str, float] + unweighted_raw_statistics: Dict[str, float] + weighted_multiplier_statistics: Dict[str, float] + unweighted_multiplier_statistics: Dict[str, float] + status_counts: Dict[str, Any] + + +def _aggregator_csv_columns() -> List[str]: + """Additive per-aggregator raw-intensity column names, appended to the grid CSV.""" + columns: List[str] = [] + for key in AGGREGATOR_KEYS: + columns.append(f"intensity_raw_{key}_unweighted") + columns.append(f"intensity_raw_{key}_weighted") + return columns + + +def initialize_grid_results_csv(results_csv: Path) -> None: + with open(results_csv, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + "grid_point_labels", + "grid_point_coords", + "fixed_scalp_start_coords", + "optimized_scalp_point_coords", + "matrix4x4", + "measured_m1_mso", + "unweighted_mso_raw", + "weighted_mso_raw", + "unweighted_mso_clamped", + "weighted_mso_clamped", + "unweighted_mso_flag", + "weighted_mso_flag", + "sei_weighted", + "sei_unweighted", + "sei_rank_pct", + "multiplier_weighted", + "multiplier_unweighted", + ] + + _aggregator_csv_columns() + ) + + +def _write_point_summary_txt( + *, + config: SimNIBSConfig, + point_dir: Path, + result: "GridPointResult", + tgt_streamlines_full: List[np.ndarray], + e_vecs_list_tgt_in_m1_full: List[np.ndarray], + cst_res: Any, + af_cst_calibration: float, + cst_align: float, + cst_align_corrected: float, + cst_depth: float, + intensity_rmt: float, + biological_threshold: float, + m1_matrix_str: str, + spatial_mode: str, + num_workers: int, + out_dir: Path, + calibration_pose_qc: Optional[Dict[str, object]] = None, + aggregator_sensitivity: Optional[Dict[str, Dict[str, float]]] = None, +) -> None: + """Write per-point TIDE_Results_.txt mirroring the estimation workflow.""" + cortex_coord = result.cortex_coord + target_weight_source = getattr(result, "target_weight_source", None) or ( + f"External ({config.subject.weights_target_path.name})" + if config.subject.weights_target_path is not None + else "Uniform" + ) + + af_target_m1_calibration = calculate_target_in_field_metric( + tgt_streamlines_full, + e_vecs_list_tgt_in_m1_full, + roi_center=cortex_coord, + roi_size_mm=config.options.roi_size_mm, + activation_length_mm=config.options.activation_length_mm, + max_angular_deviation_deg=config.options.max_angular_deviation_deg, + ) + + af_target_optimized = result.target_metric_weighted + optimization_gain = ( + af_target_optimized / af_target_m1_calibration if af_target_m1_calibration > 0 else 0.0 + ) + ratio_at_m1 = af_target_m1_calibration / af_cst_calibration if af_cst_calibration > 0 else 0.0 + intensity_from_m1_position = ( + config.calibration.measured_rmt_mso * (af_cst_calibration / af_target_m1_calibration) + if af_target_m1_calibration > 0 + else 0.0 + ) + + tgt_matrix_str = ( + str(result.opt_matrix).replace("\n", "") if result.opt_matrix is not None else "N/A" + ) + if result.opt_scalp_coords is not None: + tgt_scalp_str = ( + f"[{result.opt_scalp_coords[0]:.2f}, " + f"{result.opt_scalp_coords[1]:.2f}, " + f"{result.opt_scalp_coords[2]:.2f}]" + ) + else: + tgt_scalp_str = "N/A" + + summary_lines = io.build_estimation_summary_lines( + subject_id=config.subject.id, + timestamp_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + out_dir=out_dir, + num_workers=num_workers, + t1w_path=config.subject.t1w_path, + cst_bundle_path=config.calibration.bundle_path, + target_bundle_path=config.target.bundle_path, + spatial_mode=spatial_mode, + weight_source=format_weight_sources( + cst_res.weight_source, + target_weight_source, + ), + roi_size_mm=config.options.roi_size_mm, + activation_length_mm=config.options.activation_length_mm, + calibration_label=config.calibration.label, + measured_rmt_mso=config.calibration.measured_rmt_mso, + m1_matrix_str=m1_matrix_str, + af_cst_w=af_cst_calibration, + af_cst_u=cst_res.metric_unweighted, + intensity_rmt=intensity_rmt, + biological_threshold=biological_threshold, + target_label=config.target.label, + target_coords=cortex_coord, + opt_scalp_str=tgt_scalp_str, + tgt_matrix_str=tgt_matrix_str, + af_tgt_w=af_target_optimized, + af_tgt_u=result.target_metric_unweighted, + cst_align=cst_align, + tgt_align=result.tgt_align, + cst_depth=cst_depth, + tgt_depth=result.tgt_depth, + optimization_gain=optimization_gain, + ratio_at_m1=ratio_at_m1, + intensity_from_m1_position=intensity_from_m1_position, + intensity_raw_w=result.weighted_mso_raw, + intensity_raw_u=result.unweighted_mso_raw, + intensity_clamped_w=result.weighted_mso, + intensity_clamped_u=result.unweighted_mso, + intensity_flag_w=result.weighted_mso_flag, + intensity_flag_u=result.unweighted_mso_flag, + mso_floor_ratio=config.options.mso_floor_ratio, + sei_w=result.sei_weighted, + sei_u=result.sei_unweighted, + multiplier_w=result.multiplier_weighted, + multiplier_u=result.multiplier_unweighted, + cst_align_corrected=cst_align_corrected, + tgt_align_corrected=result.tgt_align_corrected, + calibration_pose_qc=calibration_pose_qc, + target_pose_qc=result.pose_qc, + aggregator_sensitivity=aggregator_sensitivity, + ) + + summary_path = point_dir / f"TIDE_Results_{config.target.label}.txt" + try: + with open(summary_path, "w") as f: + f.write("\n".join(summary_lines)) + io.save_report_json( + summary_path, + "grid_point_estimation_summary", + data={ + "workflow": "grid", + "point_label": result.point_label, + "subject_id": config.subject.id, + "target_label": config.target.label, + "output_dir": point_dir, + "weight_source_cst": cst_res.weight_source, + "weight_source_target": target_weight_source, + "aggregator_sensitivity": aggregator_sensitivity, + "cst_aggregates_weighted": cst_res.aggregates_weighted, + "cst_aggregates_unweighted": cst_res.aggregates_unweighted, + "target_aggregates_weighted": result.target_aggregates_weighted, + "target_aggregates_unweighted": result.target_aggregates_unweighted, + }, + text_lines=summary_lines, + ) + log.debug(f"Saved per-point summary: {summary_path}") + except Exception as e: + log.error(f"Failed to save per-point summary {summary_path}: {e}") + raise + + +def _calculate_statistics(values: List[float]) -> Dict[str, float]: + """ + Calculate statistical summary for a list of values. + + Computes: + - Mean + - Median + - Standard Deviation + - Mean without outliers (exceeding 2 std dev) + + Args: + values: List of numerical values. + + Returns: + Dictionary with statistical metrics. + """ + if not values: + return { + "mean": 0.0, + "median": 0.0, + "std": 0.0, + "mean_no_outliers": 0.0, + "outlier_count": 0, + } + + arr = np.array(values) + mean_val = np.mean(arr) + median_val = np.median(arr) + std_val = np.std(arr) + + # Outlier detection (outside +/- 2 std dev) + lower_bound = mean_val - 2 * std_val + upper_bound = mean_val + 2 * std_val + + # Filter valid points (inclusive) + valid_mask = (arr >= lower_bound) & (arr <= upper_bound) + valid_points = arr[valid_mask] + + mean_no_outliers = np.mean(valid_points) if len(valid_points) > 0 else mean_val + outlier_count = len(arr) - len(valid_points) + + return { + "mean": float(mean_val), + "median": float(median_val), + "std": float(std_val), + "mean_no_outliers": float(mean_no_outliers), + "outlier_count": outlier_count, + } + + +def _valid_result_values( + results: Sequence[GridPointResult], + value_field: str, + flag_field: str, + exclude_zero: bool = False, +) -> List[float]: + values = [] + for result in results: + value = getattr(result, value_field, None) + if ( + result.success + and getattr(result, flag_field, "ESTIMATION_FAILED") != "ESTIMATION_FAILED" + and value != 999.9 + and value is not None + and np.isfinite(value) + and (not exclude_zero or value != 0.0) + ): + values.append(float(value)) + return values + + +def _grid_status_counts(results: Sequence[GridPointResult]) -> Dict[str, Any]: + counts: Dict[str, Any] = { + "total_points": len(results), + "processing_failed": sum(not result.success for result in results), + } + for prefix in ("weighted", "unweighted"): + flag_field = f"{prefix}_mso_flag" + raw_field = f"{prefix}_mso_raw" + clamped_field = f"{prefix}_mso" + status = { + "included": 0, + "within_range": 0, + "clamped_low": 0, + "clamped_high": 0, + "estimation_failed": 0, + } + for result in results: + if not result.success: + continue + flag = getattr(result, flag_field, "ESTIMATION_FAILED") + raw_value = getattr(result, raw_field, None) + clamped_value = getattr(result, clamped_field, None) + finite = ( + raw_value is not None + and clamped_value is not None + and np.isfinite(raw_value) + and np.isfinite(clamped_value) + ) + if flag == "ESTIMATION_FAILED" or not finite: + status["estimation_failed"] += 1 + continue + status["included"] += 1 + status[flag.lower()] = status.get(flag.lower(), 0) + 1 + counts[prefix] = status + return counts + + +def write_grid_results( + results: Sequence[GridPointResult], + context: GridReportingContext, +) -> List[Dict[str, Any]]: + config = context.config + out_dir = context.out_dir + sims_dir = context.sims_dir + results_csv = context.results_csv + fixed_scalp_coords = context.fixed_scalp_coords + grid_orientation_ref = context.grid_orientation_ref + cal_orientation = context.calibration_orientation + tgt_streamlines_full = context.target_streamlines_full + e_vecs_list_tgt_in_m1_full = context.target_vectors_in_m1 + cst_res = context.cst_result + af_cst_calibration = context.af_cst_calibration + cst_align = context.cst_align + cst_align_corrected = context.cst_align_corrected + cst_depth = context.cst_depth + intensity_rmt = context.intensity_rmt + biological_threshold = context.biological_threshold + m1_matrix_str = context.m1_matrix_str + spatial_mode = context.spatial_mode + actual_workers = context.num_workers + calibration_pose_qc = context.calibration_pose_qc + final_grid_results = [] + + # Pre-compute SEI percentile ranks across successful grid points + _sei_vals = np.array([r.sei_weighted for r in results if r.success and r.sei_weighted > 0]) + + def _sei_rank_pct(sei_val: float) -> float: + """Percentile rank of sei_val within the distribution of successful SEI values (0–100).""" + if len(_sei_vals) == 0 or sei_val <= 0: + return 0.0 + return float(np.sum(_sei_vals <= sei_val) / len(_sei_vals) * 100.0) + + for result in results: + if result.success: + # Write to CSV + matrix_str = str(result.opt_matrix).replace("\n", "") + cortex_str = ( + f"[{result.cortex_coord[0]:.2f}, " + f"{result.cortex_coord[1]:.2f}, " + f"{result.cortex_coord[2]:.2f}]" + ) + scalp_opt_str = ( + f"[{result.opt_scalp_coords[0]:.2f}, " + f"{result.opt_scalp_coords[1]:.2f}, " + f"{result.opt_scalp_coords[2]:.2f}]" + ) + + sei_rank = _sei_rank_pct(result.sei_weighted) + aggregator_sensitivity = build_aggregator_sensitivity( + cst_weighted=cst_res.aggregates_weighted, + cst_unweighted=cst_res.aggregates_unweighted, + target_weighted=result.target_aggregates_weighted, + target_unweighted=result.target_aggregates_unweighted, + rmt=config.calibration.measured_rmt_mso, + ) + aggregator_cells: List[str] = [] + for key in AGGREGATOR_KEYS: + row = aggregator_sensitivity[key] + aggregator_cells.append(f"{row['intensity_raw_unweighted']:.2f}") + aggregator_cells.append(f"{row['intensity_raw_weighted']:.2f}") + + with open(results_csv, "a", newline="") as f: + writer = csv.writer(f) + writer.writerow( + [ + result.point_label, + cortex_str, + str(fixed_scalp_coords), + scalp_opt_str, + matrix_str, + config.calibration.measured_rmt_mso, + f"{result.unweighted_mso_raw:.2f}", + f"{result.weighted_mso_raw:.2f}", + f"{result.unweighted_mso:.2f}", + f"{result.weighted_mso:.2f}", + result.unweighted_mso_flag, + result.weighted_mso_flag, + f"{result.sei_weighted:.4f}", + f"{result.sei_unweighted:.4f}", + f"{sei_rank:.1f}", + f"{result.multiplier_weighted:.4f}", + f"{result.multiplier_unweighted:.4f}", + ] + + aggregator_cells + ) + + # Save grid point configuration + point_dir = sims_dir / result.point_label + save_grid_point_config( + config=config, + output_dir=point_dir, + point_label=result.point_label, + cortex_coords=result.cortex_coord, + scalp_coords=result.opt_scalp_coords, + orientation_matrix=result.opt_matrix, + fixed_scalp_coords=( + fixed_scalp_coords.tolist() + if hasattr(fixed_scalp_coords, "tolist") + else list(fixed_scalp_coords) + ), + grid_orientation_ref=grid_orientation_ref, + calibration_orientation=cal_orientation, + ) + + _write_point_summary_txt( + config=config, + point_dir=point_dir, + result=result, + tgt_streamlines_full=tgt_streamlines_full, + e_vecs_list_tgt_in_m1_full=e_vecs_list_tgt_in_m1_full, + cst_res=cst_res, + af_cst_calibration=af_cst_calibration, + cst_align=cst_align, + cst_align_corrected=cst_align_corrected, + cst_depth=cst_depth, + intensity_rmt=intensity_rmt, + biological_threshold=biological_threshold, + m1_matrix_str=m1_matrix_str, + spatial_mode=spatial_mode, + num_workers=actual_workers, + out_dir=out_dir, + calibration_pose_qc=calibration_pose_qc, + aggregator_sensitivity=aggregator_sensitivity, + ) + + final_grid_results.append( + { + "label": result.point_label, + "weighted_mso": result.weighted_mso, + "unweighted_mso": result.unweighted_mso, + "weighted_mso_raw": result.weighted_mso_raw, + "unweighted_mso_raw": result.unweighted_mso_raw, + "weighted_mso_flag": result.weighted_mso_flag, + "unweighted_mso_flag": result.unweighted_mso_flag, + "sei_weighted": result.sei_weighted, + "sei_unweighted": result.sei_unweighted, + "sei_rank_pct": sei_rank, + "multiplier_weighted": result.multiplier_weighted, + "multiplier_unweighted": result.multiplier_unweighted, + "target_pose_qc": result.pose_qc, + "target_align": result.tgt_align, + "target_align_corrected": result.tgt_align_corrected, + "target_depth": result.tgt_depth, + } + ) + else: + # Record failed point with N/A values + final_grid_results.append( + { + "label": result.point_label, + "weighted_mso": 999.9, + "unweighted_mso": 999.9, + "weighted_mso_raw": 999.9, + "unweighted_mso_raw": 999.9, + "weighted_mso_flag": "N/A", + "unweighted_mso_flag": "N/A", + "sei_weighted": None, + "sei_unweighted": None, + "sei_rank_pct": None, + "multiplier_weighted": None, + "multiplier_unweighted": None, + "target_pose_qc": None, + "target_align": None, + "target_align_corrected": None, + "target_depth": None, + } + ) + + return final_grid_results + + +def write_grid_summary( + results: Sequence[GridPointResult], + final_grid_results: Sequence[Dict[str, Any]], + context: GridReportingContext, +) -> GridSummaryResult: + config = context.config + out_dir = context.out_dir + results_csv = context.results_csv + start_time = context.start_time + actual_workers = context.num_workers + spatial_mode = context.spatial_mode + cal_orientation = context.calibration_orientation + calibration_pose_qc = context.calibration_pose_qc + af_cst_calibration = context.af_cst_calibration + intensity_rmt = context.intensity_rmt + biological_threshold = context.biological_threshold + cst_align = context.cst_align + cst_align_corrected = context.cst_align_corrected + cst_depth = context.cst_depth + grid_orientation_ref = context.grid_orientation_ref + elapsed_time = time.time() - start_time + elapsed_min = int(elapsed_time // 60) + elapsed_sec = elapsed_time % 60 + worker_memory_model = context.worker_memory_model or {} + worker_memory_lines = [] + if worker_memory_model: + available_memory = worker_memory_model.get("available_memory_gb") + available_memory_text = ( + f"{available_memory:.1f} GB" if available_memory is not None else "Unknown" + ) + memory_reserve_gb = float(worker_memory_model.get("memory_reserve_gb", 0.0)) + memory_per_worker_gb = float(worker_memory_model.get("memory_per_worker_gb", 0.0)) + worker_memory_lines = [ + "", + "--- Parallel Resource Plan ---", + f" Solver: {worker_memory_model.get('solver', 'PARDISO')}", + f" Available Memory: {available_memory_text}", + f" Parent/OS Reserve: {memory_reserve_gb:.1f} GB", + f" Estimated Memory per Worker: {memory_per_worker_gb:.1f} GB", + f" Memory Worker Limit: {worker_memory_model.get('memory_worker_limit', 1)}", + f" Forced Worker Override: {worker_memory_model.get('forced', False)}", + ] + + # Calculate Statistics + valid_weighted = _valid_result_values( + results, + "weighted_mso", + "weighted_mso_flag", + ) + valid_unweighted = _valid_result_values( + results, + "unweighted_mso", + "unweighted_mso_flag", + ) + valid_raw_weighted = _valid_result_values( + results, + "weighted_mso_raw", + "weighted_mso_flag", + ) + valid_raw_unweighted = _valid_result_values( + results, + "unweighted_mso_raw", + "unweighted_mso_flag", + ) + + stats_weighted = _calculate_statistics(valid_weighted) + stats_unweighted = _calculate_statistics(valid_unweighted) + stats_raw_weighted = _calculate_statistics(valid_raw_weighted) + stats_raw_unweighted = _calculate_statistics(valid_raw_unweighted) + status_counts = _grid_status_counts(results) + + # Multiplier statistics (M_CST/M_target). I_raw = RMT * multiplier, so + # surfacing mean/median multipliers next to intensity lets the user rescale the + # predicted dose to any RMT without rerunning the simulation. + valid_mult_weighted = _valid_result_values( + results, + "multiplier_weighted", + "weighted_mso_flag", + exclude_zero=True, + ) + valid_mult_unweighted = _valid_result_values( + results, + "multiplier_unweighted", + "unweighted_mso_flag", + exclude_zero=True, + ) + stats_mult_weighted = _calculate_statistics(valid_mult_weighted) + stats_mult_unweighted = _calculate_statistics(valid_mult_unweighted) + + summary_lines = [ + "===========================================", + "--- TIDE Grid Search Pipeline Summary ---", + "===========================================", + "", + f" Subject: {config.subject.id}", + f" Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + f" Output Folder: {out_dir}", + f" Elapsed Time: {elapsed_min}m {elapsed_sec:.1f}s", + f" Workers Used: {actual_workers}", + *worker_memory_lines, + "", + "--- Configuration ---", + f" Input T1w: {config.subject.t1w_path}", + f" Target Tractogram: {config.target.bundle_path}", + f" Spatial Mode: {spatial_mode}", + f" ROI Size: {config.options.roi_size_mm} mm", + f" Visualization Artifacts: {'Enabled' if config.options.generate_visualizations else 'Disabled'}", + f" Interactive 3D: {'Enabled' if config.options.generate_3d_visualization else 'Disabled'}", + "", + "--- Optimization Settings ---", + f" Search Radius: {config.options.opt_search_radius} mm", + f" Spatial Resolution: {config.options.opt_spatial_resolution} mm", + f" Angle Resolution: {config.options.opt_angle_resolution} deg", + f" Search Angle: {config.options.opt_search_angle} deg (±{config.options.opt_search_angle / 2} deg)", + f" ADM Optimization: {config.options.adm_optimization}", + "", + "--- Intensity Floor ---", + f" I Floor Ratio: {config.options.mso_floor_ratio}", + f" I Floor Value: {config.calibration.measured_rmt_mso * config.options.mso_floor_ratio:.1f} % max output", + "", + f"--- M1 Calibration ({config.calibration.label}) ---", + f" Measured RMT: {config.calibration.measured_rmt_mso} % max output", + f" Optimized Matrix: {cal_orientation}", + f" M1 Coil Pose QC: {io.format_pose_qc(calibration_pose_qc)}", + f" CST Efficiency (Weighted): {af_cst_calibration:.4f} V/m^2", + f" RMT Intensity (dI/dt): {intensity_rmt / 1e6:.2f} A/us", + f" Biological Threshold: {biological_threshold:.2f} V/m^2", + f" CST Alignment: {cst_align:.4f}", + f" CST Alignment Corrected: {cst_align_corrected:.4f}", + f" CST Depth: {cst_depth:.1f} mm", + "", + "--- Grid Settings ---", + f" Grid Center (Cortex): {config.grid.coords}", + f" Grid Orientation Ref (pos_ydir): {grid_orientation_ref}", + f" Search Radius: {config.grid.search_radius_mm} mm", + f" Step Size: {config.grid.step_size_mm} mm", + "", + "===========================================", + "--- Statistical Summary ---", + "===========================================", + "Metric | Unweighted I | Weighted I", + "-----------------------|----------------------|---------------------", + f"Mean | {stats_unweighted['mean']:<20.2f} | {stats_weighted['mean']:<20.2f}", + f"Median | {stats_unweighted['median']:<20.2f} | {stats_weighted['median']:<20.2f}", + f"Std Dev | {stats_unweighted['std']:<20.2f} | {stats_weighted['std']:<20.2f}", + f"Mean (w/o outliers) * | {stats_unweighted['mean_no_outliers']:<20.2f} | {stats_weighted['mean_no_outliers']:<20.2f}", + f"Outliers (>2 SD) | {stats_unweighted['outlier_count']:<20} | {stats_weighted['outlier_count']:<20}", + "", + "* Mean calculated excluding values outside [mean +/- 2 * std]", + "", + "===========================================", + "--- Raw Statistical Summary ---", + "===========================================", + "Metric | Unweighted Raw I | Weighted Raw I", + "-----------------------|----------------------|---------------------", + f"Mean | {stats_raw_unweighted['mean']:<20.2f} | {stats_raw_weighted['mean']:<20.2f}", + f"Median | {stats_raw_unweighted['median']:<20.2f} | {stats_raw_weighted['median']:<20.2f}", + f"Std Dev | {stats_raw_unweighted['std']:<20.2f} | {stats_raw_weighted['std']:<20.2f}", + f"Mean (w/o outliers) * | {stats_raw_unweighted['mean_no_outliers']:<20.2f} | {stats_raw_weighted['mean_no_outliers']:<20.2f}", + f"Outliers (>2 SD) | {stats_raw_unweighted['outlier_count']:<20} | {stats_raw_weighted['outlier_count']:<20}", + "", + "===========================================", + "--- Grid Result Status Counts ---", + "===========================================", + f"Total Points: {status_counts['total_points']}", + f"Processing Failures: {status_counts['processing_failed']}", + "Status | Unweighted | Weighted", + "------------------------|----------------------|---------------------", + f"Included | {status_counts['unweighted']['included']:<20} | {status_counts['weighted']['included']:<20}", + f"Within Range | {status_counts['unweighted']['within_range']:<20} | {status_counts['weighted']['within_range']:<20}", + f"Clamped Low | {status_counts['unweighted']['clamped_low']:<20} | {status_counts['weighted']['clamped_low']:<20}", + f"Clamped High | {status_counts['unweighted']['clamped_high']:<20} | {status_counts['weighted']['clamped_high']:<20}", + f"Estimation Failed | {status_counts['unweighted']['estimation_failed']:<20} | {status_counts['weighted']['estimation_failed']:<20}", + "", + "===========================================", + "--- Grid Point Results ---", + "===========================================", + f"{'Label':<15} | {'Unweighted Raw':<15} | {'Weighted Raw':<14} | {'Unweighted Clamp':<16} | {'Weighted Clamp':<14} | {'U-Flag':<14} | {'W-Flag':<14} | {'SEI (W)':<10} | {'SEI Rank':<10} | {'Mult (W)':<10}", + "-" * 153, + ] + + def _format_intensity(value: Any) -> str: + if value is None or value == 999.9 or not np.isfinite(value): + return "N/A" + return f"{value:.1f}" + + for res in final_grid_results: + w_val = _format_intensity(res["weighted_mso"]) + u_val = _format_intensity(res["unweighted_mso"]) + w_raw = _format_intensity(res["weighted_mso_raw"]) + u_raw = _format_intensity(res["unweighted_mso_raw"]) + w_flag = res.get("weighted_mso_flag", "N/A") + u_flag = res.get("unweighted_mso_flag", "N/A") + sei_w_str = f"{res['sei_weighted']:.4f}" if res.get("sei_weighted") is not None else "N/A" + sei_rank_str = ( + f"{res['sei_rank_pct']:.1f}%" if res.get("sei_rank_pct") is not None else "N/A" + ) + mult_w_str = ( + f"{res['multiplier_weighted']:.4f}" + if res.get("multiplier_weighted") is not None + else "N/A" + ) + summary_lines.append( + f"{res['label']:<15} | {u_raw:<15} | {w_raw:<14} | {u_val:<16} | {w_val:<14}" + f" | {u_flag:<14} | {w_flag:<14} | {sei_w_str:<10} | {sei_rank_str:<10} | {mult_w_str:<10}" + ) + + summary_lines.append("=" * 153) + summary_lines.extend( + [ + "", + "===========================================", + "--- Grid Point QC ---", + "===========================================", + f"{'Label':<15} | {'Target Coil Pose QC':<22} | {'Target Alignment':<16} | {'Target Alignment Corrected':<26} | {'Target Depth':<12}", + "-" * 102, + ] + ) + + for res in final_grid_results: + pose_qc = res.get("target_pose_qc") or {} + pose_status = str(pose_qc.get("status", "N/A")) if pose_qc else "N/A" + reasons = pose_qc.get("reasons") or [] if pose_qc else [] + if reasons: + pose_status = f"{pose_status} ({', '.join(str(reason) for reason in reasons)})" + target_align = ( + f"{res['target_align']:.4f}" if res.get("target_align") is not None else "N/A" + ) + target_align_corrected = ( + f"{res['target_align_corrected']:.4f}" + if res.get("target_align_corrected") is not None + else "N/A" + ) + target_depth = ( + f"{res['target_depth']:.1f} mm" if res.get("target_depth") is not None else "N/A" + ) + summary_lines.append( + f"{res['label']:<15} | {pose_status:<22} | {target_align:<16} | " + f"{target_align_corrected:<26} | {target_depth:<12}" + ) + + summary_lines.append("=" * 102) + + try: + summary_path = out_dir / f"TIDE_Grid_Summary_{config.target.label}.txt" + with open(summary_path, "w") as f_txt: + f_txt.write("\n".join(summary_lines)) + io.save_report_json( + summary_path, + "grid_summary", + data={ + "workflow": "grid", + "subject_id": config.subject.id, + "target_label": config.target.label, + "output_dir": out_dir, + "results_csv": results_csv, + "num_grid_points": len(final_grid_results), + "statistics": { + "weighted_clamped": stats_weighted, + "unweighted_clamped": stats_unweighted, + "weighted_raw": stats_raw_weighted, + "unweighted_raw": stats_raw_unweighted, + "weighted_multiplier": stats_mult_weighted, + "unweighted_multiplier": stats_mult_unweighted, + }, + "status_counts": status_counts, + "worker_memory_model": context.worker_memory_model, + "visualization_artifacts": { + "generate_visualizations": config.options.generate_visualizations, + "generate_3d_visualization": config.options.generate_3d_visualization, + }, + "weight_source_cst": context.cst_result.weight_source, + "weight_source_target": ( + f"External ({config.subject.weights_target_path.name})" + if config.subject.weights_target_path is not None + else "Uniform" + ), + }, + text_lines=summary_lines, + ) + log.info(f"Summary report saved to: {summary_path}") + except Exception as e: + log.error(f"Failed to save summary txt: {e}") + raise + + log.info(f"Grid Search Complete. Results: {results_csv}") + log.info(f"Total time: {elapsed_min}m {elapsed_sec:.1f}s") + + return GridSummaryResult( + summary_path=summary_path, + elapsed_time=elapsed_time, + weighted_statistics=stats_weighted, + unweighted_statistics=stats_unweighted, + weighted_raw_statistics=stats_raw_weighted, + unweighted_raw_statistics=stats_raw_unweighted, + weighted_multiplier_statistics=stats_mult_weighted, + unweighted_multiplier_statistics=stats_mult_unweighted, + status_counts=status_counts, + ) diff --git a/src/tide/workflows/_shared.py b/src/tide/workflows/_shared.py new file mode 100644 index 0000000..25b6e28 --- /dev/null +++ b/src/tide/workflows/_shared.py @@ -0,0 +1,100 @@ +import os +from contextlib import contextmanager +from typing import Iterator, Sequence + +import numpy as np + +from tide.core import physics, tractography + +SINGLE_THREAD_ENV = { + "OMP_NUM_THREADS": "1", + "MKL_NUM_THREADS": "1", + "OPENBLAS_NUM_THREADS": "1", + "NUMEXPR_NUM_THREADS": "1", +} + + +class WorkflowError(RuntimeError): + pass + + +@contextmanager +def single_thread_child_environment() -> Iterator[None]: + saved = {key: os.environ.get(key) for key in SINGLE_THREAD_ENV} + os.environ.update(SINGLE_THREAD_ENV) + try: + yield + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + +def configure_worker_environment() -> None: + os.environ.update(SINGLE_THREAD_ENV) + + +def split_vectors_by_streamline( + vectors: np.ndarray, + streamlines: Sequence[np.ndarray], +) -> list[np.ndarray]: + result = [] + offset = 0 + for streamline in streamlines: + point_count = len(streamline) + result.append(vectors[offset : offset + point_count]) + offset += point_count + return result + + +def calculate_target_in_field_metric( + streamlines: Sequence[np.ndarray], + e_field_vectors: Sequence[np.ndarray], + *, + roi_center: Sequence[float], + roi_size_mm: float, + activation_length_mm: float, + max_angular_deviation_deg: float, +) -> float: + streamlines_for_validation = list(streamlines) + vectors_for_validation = list(e_field_vectors) + if max_angular_deviation_deg > 0: + ( + streamlines_for_validation, + vectors_for_validation, + _, + ) = tractography.filter_by_angular_deviation( + streamlines_for_validation, + e_field_vectors=vectors_for_validation, + max_angle_deg=max_angular_deviation_deg, + roi_center=roi_center, + roi_radius=roi_size_mm, + ) + + midpoint_streamlines, af_values, segment_lengths = physics.calculate_scalar_map( + streamlines_for_validation, + vectors_for_validation, + mode="af", + ) + roi_masks, _ = tractography.get_roi_masks( + midpoint_streamlines, + roi_size_mm, + roi_center, + ) + + scores = [] + for af_value, lengths, mask in zip(af_values, segment_lengths, roi_masks): + af_in_roi = af_value[mask] + if not np.all(np.isfinite(af_in_roi)): + continue + scores.append( + physics.get_max_contiguous_threshold( + np.abs(af_in_roi), + lengths[mask], + activation_length_mm, + ) + ) + + return physics.median_of_top_percentile(np.array(scores), 95.0) diff --git a/src/tide/workflows/estimation.py b/src/tide/workflows/estimation.py new file mode 100644 index 0000000..71a045c --- /dev/null +++ b/src/tide/workflows/estimation.py @@ -0,0 +1,1338 @@ +""" +TIDE Estimation Workflow +======================== +Main workflow for estimating target intensity using the Unified Estimation Method. +Includes 3D visualization generation. + +Parallelization Strategy (2-Worker System) +------------------------------------------ +The workflow uses a 2-worker parallel processing system to accelerate the +computationally intensive optimization and simulation phases: + + ┌─────────────────────────────────────────────────────────────┐ + │ Worker 1 (M1/CST Pipeline) │ Worker 2 (Target Pipeline) │ + ├─────────────────────────────────────────────────────────────┤ + │ M1 Optimization │ Target Optimization │ + │ ↓ │ ↓ │ + │ M1 Simulation │ Target Simulation │ + │ ↓ │ ↓ │ + │ CST E-field Sampling │ Target E-field Sampling │ + │ ↓ │ ↓ │ + │ CST AF Calculation │ Target AF Calculation │ + └─────────────────────────────────────────────────────────────┘ + ↓ + [Synchronization Point] + ↓ + Validation (needs both M1 mesh + Target data) + ↓ + Unified Estimation + ↓ + Visualizations + +This provides up to 2x speedup for the optimization and simulation phases, +which are the most computationally intensive parts of the workflow. + +Mesh Caching Strategy +-------------------- +SimNIBS mesh objects contain internal solver state and are not picklable, +preventing direct sharing between processes. To optimize mesh loading: + +1. The main process pre-loads the mesh file before spawning workers +2. This "warms up" the OS disk cache with the ~500MB mesh data +3. Workers benefit from cached disk I/O when loading the same mesh file +4. Each worker still creates its own mesh object, but I/O is near-instant + +This approach provides implicit parallelization benefits without requiring +modifications to SimNIBS internals or complex shared memory setups. +""" + +import logging +import multiprocessing as mp +import sys +import time +from concurrent.futures import ProcessPoolExecutor, wait +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np + +from tide.core import geometry, io, physics, tractography +from tide.core.geometry import ( + calculate_alignment_and_depth, + calculate_alignment_corrected, + evaluate_coil_pose_qc, + validate_coil_pose_for_dose, +) +from tide.interfaces.sampling import sample_field_at_coordinates +from tide.interfaces.simnibs_interface import SimNIBSInterface +from tide.interfaces.unified_estimation import format_weight_sources, run_unified_estimation +from tide.interfaces.visualization_3d import ( + PYVISTA_AVAILABLE, + VisualizationConfig, + generate_bundle_visualization, +) +from tide.utils.config import ( + SimNIBSConfig, + orientation_is_matrix, + save_config_to_output, + validate_workflow_config, +) +from tide.workflows._shared import WorkflowError, calculate_target_in_field_metric +from tide.workflows._shared import configure_worker_environment as _configure_worker_environment +from tide.workflows._shared import single_thread_child_environment as _single_thread_child_env +from tide.workflows._shared import split_vectors_by_streamline as _split_vectors_by_streamline + +log = logging.getLogger(__name__) + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +NUM_ESTIMATION_WORKERS = 2 + +ESTIMATION_STEPS = { + 1: "Pre-processing", + 2: "Parallel Estimation", + 3: "Validation (Target in M1)", + 4: "Unified Estimation", + 5: "Visualizations", + 6: "Summary Report", +} + + +# ============================================================================= +# DATACLASSES FOR PARALLEL TASKS +# ============================================================================= + + +@dataclass +class PipelineTask: + """ + Encapsulates all data needed to run a complete site pipeline + (optimization + simulation + E-field sampling + AF calculation). + + This dataclass is designed to be picklable for inter-process communication. + All Path objects are converted to strings for serialization. + """ + + task_type: str # 'm1' or 'target' + label: str # Site label (e.g., 'M1' or target name) + + # Mesh paths + mesh_path: str # .msh file for optimization + m2m_path: str # m2m directory for simulation + + # Output + output_dir: str + + # Coil configuration + coil_path: str + coil_distance_mm: float + + # Optimization parameters + target_coords: List[float] # Cortical target + scalp_coords: Optional[List[float]] # Initial scalp position + orientation_ref: Optional[Any] # Orientation reference (list or EEG label) + needs_optimization: bool # Whether to run optimization + opt_didt: float + opt_search_radius: float + opt_spatial_resolution: float + opt_angle_resolution: float + opt_search_angle: float + use_adm: bool + + # Simulation parameters + sim_didt: float + sim_coords: Optional[List[float]] # Only used if not optimizing + sim_orientation: Optional[Any] # Only used if not optimizing (can be matrix) + + # Tractography and analysis + bundle_path: str + t1w_path: str + roi_coords: List[float] + roi_size_mm: float + field_mode: str + max_angular_deviation_deg: float = 0.0 + + +@dataclass +class PipelineResult: + """ + Results from a complete site pipeline execution. + """ + + task_type: str + label: str + success: bool + + # Optimization results + opt_matrix: Optional[List[List[float]]] + opt_scalp_coords: Optional[List[float]] + + # Simulation results + mesh_path: Optional[str] + trk_path: Optional[str] + + # Computed data (for downstream analysis) + streamlines: Optional[List[np.ndarray]] + af_values: Optional[List[np.ndarray]] + len_values: Optional[List[np.ndarray]] + e_vecs_list: Optional[List[np.ndarray]] + roi_masks: Optional[List[np.ndarray]] + roi_segments: Optional[List[np.ndarray]] + + # Original bundle ids of the surviving TRK streamlines, in TRK order + # (audit C-002; keeps SIFT2 weights aligned in run_unified_estimation). + orig_indices: Optional[np.ndarray] = None + pose_qc: Optional[Dict[str, Any]] = None + + error_message: Optional[str] = None + + +# ============================================================================= +# WORKER FUNCTIONS +# ============================================================================= + + +def _run_pipeline_task(task: PipelineTask) -> PipelineResult: + """ + Worker function to run the complete site pipeline. + + This function is executed in a separate process and performs: + 1. Coil position optimization (if needed) + 2. FEM E-field simulation + 3. Tractogram loading + 4. E-field sampling on streamlines + 5. Activating function calculation + 6. TRK file saving + + Args: + task: PipelineTask with all necessary parameters. + + Returns: + PipelineResult with computed data or error information. + """ + # Configure environment before heavy imports + _configure_worker_environment() + + # Import modules inside worker to ensure environment is set + from pathlib import Path + + import numpy as np + + from tide.core import io, tractography + from tide.interfaces.sampling import sample_field_at_coordinates + + # Register highlight method for spawned process + from tide.utils.logging import highlight + + logging.Logger.highlight = highlight + + log = logging.getLogger(__name__) + + output_dir = Path(task.output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + + opt_matrix = None + opt_scalp_coords = None + pose_qc = None + sim_coords = task.sim_coords + sim_orientation = task.sim_orientation + + try: + # ===================================================================== + # Step 1: Optimization (if needed) + # ===================================================================== + if task.needs_optimization: + log.info(f"[{task.label}] Running coil position optimization...") + + opt_matrix_np, opt_scalp_np = SimNIBSInterface.run_optimization( + mesh_path=Path(task.mesh_path), + output_dir=output_dir, + coil_path=Path(task.coil_path), + target_coords=task.target_coords, + scalp_centre=task.scalp_coords, + orientation_ref=task.orientation_ref, + didt=task.opt_didt, + use_adm=task.use_adm, + spatial_resolution=task.opt_spatial_resolution, + angle_resolution=task.opt_angle_resolution, + search_angle=task.opt_search_angle, + search_radius_mm=task.opt_search_radius, + ) + + opt_matrix = opt_matrix_np.tolist() + opt_scalp_coords = opt_scalp_np.tolist() + sim_orientation = opt_matrix + sim_coords = None + qc = evaluate_coil_pose_qc(Path(task.mesh_path), opt_matrix_np, opt_scalp_np) + pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"[{task.label}] Coil pose QC warning: {qc.reasons}") + + # Save optimization result + opt_filename = f"{task.label}_opt_result.txt" + io.save_optimization_result_txt( + output_dir / opt_filename, + opt_matrix_np, + opt_scalp_np, + pose_qc=pose_qc, + ) + validate_coil_pose_for_dose(qc, explicit_matrix=False) + + log.info(f"[{task.label}] Optimization complete.") + elif ( + isinstance(sim_orientation, list) + and len(sim_orientation) == 4 + and isinstance(sim_orientation[0], list) + ): + qc = evaluate_coil_pose_qc(Path(task.mesh_path), np.asarray(sim_orientation)) + pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"[{task.label}] Coil pose QC warning: {qc.reasons}") + validate_coil_pose_for_dose(qc, explicit_matrix=True) + + # ===================================================================== + # Step 2: Simulation + # ===================================================================== + log.info(f"[{task.label}] Running FEM E-field simulation...") + + mesh_result = SimNIBSInterface.run_simulation( + mesh_path=Path(task.m2m_path), + output_dir=output_dir, + coil_path=Path(task.coil_path), + didt=task.sim_didt, + coords=sim_coords, + orientation=sim_orientation, + distance_mm=task.coil_distance_mm, + ) + + log.info(f"[{task.label}] Simulation complete, loading tractogram...") + + # ===================================================================== + # Step 3: Load tractogram and sample E-field + # ===================================================================== + sft = tractography.load_tract(Path(task.bundle_path), Path(task.t1w_path)) + points = np.concatenate(sft.streamlines) + + log.info(f"[{task.label}] Sampling E-field on streamlines...") + + # Sample E-field + prefix = "M1_CST" if task.task_type == "m1" else task.label + e_vectors = sample_field_at_coordinates( + mesh_result, points, "E", output_dir=output_dir, file_prefix=prefix + ) + + # Split vectors by streamline + e_vecs_list = [] + idx = 0 + for sl in sft.streamlines: + e_vecs_list.append(e_vectors[idx : idx + len(sl)]) + idx += len(sl) + + # ===================================================================== + # Step 3b: Filter streamlines by angular deviation + # ===================================================================== + # Track original streamline ids through the drop chain so SIFT2 weights + # stay attached to their streamlines downstream (audit C-002). + orig_idx = np.arange(len(sft.streamlines)) + if task.max_angular_deviation_deg > 0: + ( + filtered_sl, + e_vecs_list, + n_removed, + orig_idx, + ) = tractography.filter_by_angular_deviation( + list(sft.streamlines), + e_field_vectors=e_vecs_list, + max_angle_deg=task.max_angular_deviation_deg, + roi_center=task.roi_coords, + roi_radius=task.roi_size_mm, + indices=orig_idx, + ) + else: + filtered_sl = list(sft.streamlines) + + # ===================================================================== + # Step 4: Calculate activating function + # ===================================================================== + log.info(f"[{task.label}] Calculating activating function...") + + new_sl, af_values, len_values, orig_idx = physics.calculate_scalar_map( + filtered_sl, + e_vecs_list, + mode=task.field_mode, + indices=orig_idx, + ) + + # Save TRK + trk_filename = "CST_M1_af.trk" if task.task_type == "m1" else f"{task.label}_af.trk" + trk_path = output_dir / trk_filename + io.save_tract_with_data(sft, new_sl, trk_path, "AF", af_values, len_values) + + # Get ROI masks (on filtered/midpoint streamlines to match af_values) + roi_masks, roi_segments = tractography.get_roi_masks( + new_sl, task.roi_size_mm, task.roi_coords + ) + + log.info(f"[{task.label}] Pipeline complete.") + + return PipelineResult( + task_type=task.task_type, + label=task.label, + success=True, + opt_matrix=opt_matrix, + opt_scalp_coords=opt_scalp_coords, + mesh_path=str(mesh_result), + trk_path=str(trk_path), + streamlines=new_sl, + af_values=af_values, + len_values=len_values, + e_vecs_list=e_vecs_list, + roi_masks=roi_masks, + roi_segments=roi_segments, + orig_indices=orig_idx, + pose_qc=pose_qc, + ) + + except Exception as e: + log.error(f"[{task.label}] Pipeline failed: {e}") + import traceback + + log.error(traceback.format_exc()) + return PipelineResult( + task_type=task.task_type, + label=task.label, + success=False, + opt_matrix=None, + opt_scalp_coords=None, + mesh_path=None, + trk_path=None, + streamlines=None, + af_values=None, + len_values=None, + e_vecs_list=None, + roi_masks=None, + roi_segments=None, + pose_qc=pose_qc, + error_message=str(e), + ) + + +def _warmup_mesh_cache(mesh_path: Path) -> None: + """ + Pre-load the mesh file to warm up the OS disk cache. + + This function reads the mesh file into memory, causing the OS to cache + the file data. When worker processes subsequently load the same file, + they benefit from the cached I/O, significantly reducing load times. + + Args: + mesh_path: Path to the mesh file (.msh) or m2m directory. + """ + log.info("Warming up mesh cache for parallel workers...") + + # Find the actual .msh file + if mesh_path.is_dir(): + # m2m directory - find the mesh file + msh_files = list(mesh_path.glob("*.msh")) + if not msh_files: + # Check parent directory for subject mesh + parent_msh = list(mesh_path.parent.glob("*.msh")) + msh_files = parent_msh + else: + msh_files = [mesh_path] + + for msh_file in msh_files: + if msh_file.exists(): + try: + # Read the file to populate OS cache + file_size_mb = msh_file.stat().st_size / (1024 * 1024) + log.debug(f"Pre-loading mesh: {msh_file.name} ({file_size_mb:.1f} MB)") + + # Read in chunks to avoid memory issues with very large files + chunk_size = 64 * 1024 * 1024 # 64 MB chunks + with open(msh_file, "rb") as f: + while f.read(chunk_size): + pass + + log.debug(f"Mesh cache warmed: {msh_file.name}") + except Exception as e: + log.warning(f"Could not warm cache for {msh_file}: {e}") + + +@dataclass(frozen=True) +class EstimationPreparation: + out_dir: Path + m1_out: Path + tgt_out: Path + viz_out: Path + m1_task: PipelineTask + tgt_task: PipelineTask + calibration_orientation: Any + target_orientation: Any + target_orientation_is_matrix: bool + + +@dataclass(frozen=True) +class EstimationAnalysis: + calibration_orientation: Any + mesh_m1: Path + mesh_target: Path + cst_streamlines: List[np.ndarray] + cst_af_values: List[np.ndarray] + target_streamlines: List[np.ndarray] + target_af_values: List[np.ndarray] + optimized_matrix: Optional[np.ndarray] + optimized_scalp_coords: Optional[np.ndarray] + results: Dict[str, Any] + af_cst_calibration: float + af_target_optimized: float + intensity_rmt: float + biological_threshold: float + cst_align: float + target_align: float + cst_depth: float + target_depth: float + optimization_gain: float + ratio_at_m1: float + intensity_from_m1_position: float + cst_align_corrected: float + target_align_corrected: float + + +@dataclass(frozen=True) +class EstimationSummaryContext: + config: SimNIBSConfig + ui: Any + start_time: float + out_dir: Path + viz_out: Path + calibration_orientation: Any + target_orientation: Any + target_orientation_is_matrix: bool + optimized_matrix: Optional[np.ndarray] + optimized_scalp_coords: Optional[np.ndarray] + results: Dict[str, Any] + af_cst_calibration: float + af_target_optimized: float + intensity_rmt: float + biological_threshold: float + cst_align: float + target_align: float + cst_depth: float + target_depth: float + optimization_gain: float + ratio_at_m1: float + intensity_from_m1_position: float + cst_align_corrected: float + target_align_corrected: float + m1_result: PipelineResult + target_result: PipelineResult + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + + +# ============================================================================= +# MAIN WORKFLOW FUNCTION +# ============================================================================= + + +def _prepare_estimation( + config: SimNIBSConfig, + ui: Any, +) -> EstimationPreparation: + # ========================================================================= + # Pre-processing: Medoid Logic + # ========================================================================= + if ui: + ui.update_step(1, "running") + + if config.target.medoid_endpoint: + if not config.target.bundle_path or not config.target.bundle_path.exists(): + raise WorkflowError("Medoid endpoint requested but bundle path is missing.") + + if ui: + ui.update_step_detail(f"Computing cortical medoid for {config.target.label}...") + log.highlight(f"Computing cortical medoid for: {config.target.label}") + try: + new_coords = tractography.get_bundle_cortical_medoid( + config.target.bundle_path, + config.subject.t1w_path, + reference_coord=config.target.coords, + ) + log.highlight(f"Medoid coordinates: {new_coords.tolist()}") + config.target.coords = new_coords.tolist() + except Exception as e: + raise WorkflowError(f"Failed to calculate medoid: {e}") from e + + # ========================================================================= + # Setup output directories + # ========================================================================= + out_dir = config.subject.derivatives_path / f"TIDE_{config.target.label}" + out_dir.mkdir(parents=True, exist_ok=True) + + m1_out = out_dir / "sim_m1" + m1_out.mkdir(parents=True, exist_ok=True) + tgt_out = out_dir / "sim_target" + tgt_out.mkdir(parents=True, exist_ok=True) + viz_out = out_dir / "visualizations" + viz_out.mkdir(parents=True, exist_ok=True) + + # ========================================================================= + # Warm up mesh cache before spawning workers + # ========================================================================= + _warmup_mesh_cache(config.subject.mesh_path) + + # ========================================================================= + # Determine optimization requirements + # ========================================================================= + cal_orientation = config.calibration.orientation + is_cal_matrix = orientation_is_matrix(cal_orientation) + m1_needs_opt = not is_cal_matrix + + tgt_orientation = config.target.orientation + is_tgt_matrix = orientation_is_matrix(tgt_orientation) + tgt_needs_opt = not is_tgt_matrix + + if ui: + ui.update_step(1, "complete") + + # ========================================================================= + # Create parallel tasks + # ========================================================================= + log.highlight("--- Step 1: Running M1 and Target Pipelines in Parallel ---") + + m1_task = PipelineTask( + task_type="m1", + label="M1", + mesh_path=str(config.subject.mesh_path), + m2m_path=str(config.subject.m2m_path), + output_dir=str(m1_out), + coil_path=str(config.coil.coil_path), + coil_distance_mm=config.coil.coil_distance_mm, + target_coords=config.calibration.coords, + scalp_coords=config.calibration.scalp_coords, + orientation_ref=config.calibration.orientation if not is_cal_matrix else None, + needs_optimization=m1_needs_opt, + opt_didt=1e6, + opt_search_radius=config.options.opt_search_radius, + opt_spatial_resolution=config.options.opt_spatial_resolution, + opt_angle_resolution=config.options.opt_angle_resolution, + opt_search_angle=config.options.opt_search_angle, + use_adm=config.options.adm_optimization, + sim_didt=1e6, + sim_coords=config.calibration.scalp_coords if not m1_needs_opt else None, + sim_orientation=cal_orientation if is_cal_matrix else None, + bundle_path=str(config.calibration.bundle_path), + t1w_path=str(config.subject.t1w_path), + roi_coords=config.calibration.coords, + roi_size_mm=config.options.roi_size_mm, + field_mode="af", + max_angular_deviation_deg=config.options.max_angular_deviation_deg, + ) + + tgt_task = PipelineTask( + task_type="target", + label=config.target.label, + mesh_path=str(config.subject.mesh_path), + m2m_path=str(config.subject.m2m_path), + output_dir=str(tgt_out), + coil_path=str(config.coil.coil_path), + coil_distance_mm=config.coil.coil_distance_mm, + target_coords=config.target.coords, + scalp_coords=config.target.scalp_coords, + orientation_ref=config.target.orientation if not is_tgt_matrix else None, + needs_optimization=tgt_needs_opt, + opt_didt=config.coil.device_didt_max, + opt_search_radius=config.options.opt_search_radius, + opt_spatial_resolution=config.options.opt_spatial_resolution, + opt_angle_resolution=config.options.opt_angle_resolution, + opt_search_angle=config.options.opt_search_angle, + use_adm=config.options.adm_optimization, + sim_didt=1e6, + sim_coords=config.target.scalp_coords if not tgt_needs_opt else None, + sim_orientation=tgt_orientation if is_tgt_matrix else None, + bundle_path=str(config.target.bundle_path), + t1w_path=str(config.subject.t1w_path), + roi_coords=config.target.coords, + roi_size_mm=config.options.roi_size_mm, + field_mode=config.options.field_mode, + max_angular_deviation_deg=config.options.max_angular_deviation_deg, + ) + return EstimationPreparation( + out_dir=out_dir, + m1_out=m1_out, + tgt_out=tgt_out, + viz_out=viz_out, + m1_task=m1_task, + tgt_task=tgt_task, + calibration_orientation=cal_orientation, + target_orientation=tgt_orientation, + target_orientation_is_matrix=is_tgt_matrix, + ) + + +def _execute_estimation_tasks( + ctx: Any, + ui: Any, + preparation: EstimationPreparation, +) -> tuple[PipelineResult, PipelineResult]: + out_dir = preparation.out_dir + m1_task = preparation.m1_task + tgt_task = preparation.tgt_task + + # ========================================================================= + # Execute parallel pipelines + # ========================================================================= + # ctx was created at start of workflow + + if ui: + ui.transition_to_parallel(NUM_ESTIMATION_WORKERS, 2, step_num=2) + else: + log.highlight("--- Step 1: Running M1 and Target Pipelines in Parallel ---") + + # Import worker wrapper if UI is enabled + if ui: + from tide.console import process_pipeline_task_with_reporting + + worker_func = process_pipeline_task_with_reporting + + # Create logs directory + worker_logs_dir = out_dir / "worker_logs" + worker_logs_dir.mkdir(exist_ok=True) + else: + worker_func = _run_pipeline_task + worker_logs_dir = None + + # Prepare worker IDs + worker_id_queue = ctx.Queue() + for i in range(NUM_ESTIMATION_WORKERS): + worker_id_queue.put(i) + + m1_result = None + tgt_result = None + + # Single-thread the numerical libraries for the spawned workers before the + # pool starts, so children inherit it before importing NumPy/SimNIBS (audit + # C-004); restored on exit for parent-side validation subprocesses. + worker_pool = ProcessPoolExecutor(max_workers=NUM_ESTIMATION_WORKERS, mp_context=ctx) + with _single_thread_child_env(), worker_pool as executor: + # Submit tasks + if ui: + m1_future = executor.submit(worker_func, m1_task, ui.status_queue, 0, worker_logs_dir) + tgt_future = executor.submit(worker_func, tgt_task, ui.status_queue, 1, worker_logs_dir) + else: + m1_future = executor.submit(_run_pipeline_task, m1_task) + tgt_future = executor.submit(_run_pipeline_task, tgt_task) + + # Wait for both to complete + wait([m1_future, tgt_future]) + + # Get results + m1_result = m1_future.result() + tgt_result = tgt_future.result() + + if ui: + ui.update_step(2, "complete") + + # Check for failures + if not m1_result.success: + raise WorkflowError(f"M1 pipeline failed: {m1_result.error_message}") + + if not tgt_result.success: + raise WorkflowError(f"Target pipeline failed: {tgt_result.error_message}") + + log.info("Both pipelines completed successfully.") + return m1_result, tgt_result + + +def _analyze_estimation( + config: SimNIBSConfig, + ui: Any, + preparation: EstimationPreparation, + m1_result: PipelineResult, + tgt_result: PipelineResult, +) -> EstimationAnalysis: + generated_calibration_matrix = m1_result.opt_matrix + generated_target_matrix = tgt_result.opt_matrix + cal_orientation = ( + m1_result.opt_matrix if m1_result.opt_matrix else config.calibration.orientation + ) + + mesh_m1 = Path(m1_result.mesh_path) + mesh_tgt = Path(tgt_result.mesh_path) + cst_trk_path = Path(m1_result.trk_path) + tgt_trk_path = Path(tgt_result.trk_path) + + new_sl_cst = m1_result.streamlines + af_cst = m1_result.af_values + e_vecs_list_cst = m1_result.e_vecs_list + cst_roi_masks = m1_result.roi_masks + + new_sl_tgt = tgt_result.streamlines + af_tgt = tgt_result.af_values + e_vecs_list_tgt = tgt_result.e_vecs_list + tgt_roi_masks = tgt_result.roi_masks + opt_matrix = np.array(tgt_result.opt_matrix) if tgt_result.opt_matrix else None + opt_scalp_coords = ( + np.array(tgt_result.opt_scalp_coords) if tgt_result.opt_scalp_coords else None + ) + + # Save NIfTI visualizations if requested (AF is signed upstream; NIfTI + # stores |AF| for compatibility with standard overlays/viewers). + if config.options.generate_visualizations: + io.save_points_as_nifti( + np.concatenate(new_sl_cst), + config.subject.t1w_path, + preparation.m1_out / "CST_M1_af.nii.gz", + values=np.abs(np.concatenate(af_cst)), + ) + io.save_points_as_nifti( + np.concatenate(new_sl_tgt), + config.subject.t1w_path, + preparation.tgt_out / f"{config.target.label}_af.nii.gz", + values=np.abs(np.concatenate(af_tgt)), + ) + + # Calculate alignment and depth metrics + cst_align, cst_depth = calculate_alignment_and_depth( + new_sl_cst, e_vecs_list_cst, cst_roi_masks, mesh_m1, config.calibration.coords + ) + tgt_align, tgt_depth = calculate_alignment_and_depth( + new_sl_tgt, e_vecs_list_tgt, tgt_roi_masks, mesh_tgt, config.target.coords + ) + cst_align_corrected = calculate_alignment_corrected(new_sl_cst, e_vecs_list_cst, cst_roi_masks) + tgt_align_corrected = calculate_alignment_corrected(new_sl_tgt, e_vecs_list_tgt, tgt_roi_masks) + + # Save configuration (after optimization, with generated matrices and + # post-optimization scalp coordinates) so the output YAML is fully + # re-runnable via `--workflow estimation` without triggering a new + # optimization or medoid computation. + save_config_to_output( + config, + preparation.out_dir, + "estimation", + generated_calibration_matrix=generated_calibration_matrix, + generated_target_matrix=generated_target_matrix, + generated_calibration_scalp_coords=( + list(m1_result.opt_scalp_coords) if m1_result.opt_scalp_coords is not None else None + ), + generated_target_scalp_coords=( + list(tgt_result.opt_scalp_coords) if tgt_result.opt_scalp_coords is not None else None + ), + medoid_resolved=bool(config.target.medoid_endpoint), + ) + + # ========================================================================= + # Step 2: Validation - Target in M1 Field + # ========================================================================= + if ui: + ui.update_step(3, "running") + ui.update_step_detail("Validating target in M1 field...") + + log.highlight("--- Step 2: Validation (Target in M1 Field) ---") + + # Load target tractogram for validation (need fresh sft object) + sft_tgt = tractography.load_tract(config.target.bundle_path, config.subject.t1w_path) + points_tgt = np.concatenate(sft_tgt.streamlines) + + e_vectors_tgt_m1 = sample_field_at_coordinates( + mesh_m1, points_tgt, "E", output_dir=preparation.m1_out, file_prefix="Target_in_M1" + ) + e_vecs_list_tgt_m1 = _split_vectors_by_streamline(e_vectors_tgt_m1, sft_tgt.streamlines) + + af_target_m1_calibration = calculate_target_in_field_metric( + sft_tgt.streamlines, + e_vecs_list_tgt_m1, + roi_center=config.target.coords, + roi_size_mm=config.options.roi_size_mm, + activation_length_mm=config.options.activation_length_mm, + max_angular_deviation_deg=config.options.max_angular_deviation_deg, + ) + + # ========================================================================= + # Step 3: Run Unified Estimation + # ========================================================================= + if ui: + ui.update_step(3, "complete") + ui.update_step(4, "running") + ui.update_step_detail("Calculating Unified Estimation Result...") + log.highlight("--- Step 3: Unified Estimation ---") + log.info(f"Calculating metrics for {config.target.label}...") + + results = run_unified_estimation( + cst_trk=cst_trk_path, + target_trk=tgt_trk_path, + cst_coords=config.calibration.coords, + target_coords=config.target.coords, + rmt=config.calibration.measured_rmt_mso, + weights_cst=config.subject.weights_cst_path, + weights_target=config.subject.weights_target_path, + surface_path=config.subject.surface_path, + gwi_threshold=config.options.gwi_threshold_mm, + roi_radius=config.options.roi_size_mm, + activation_len=config.options.activation_length_mm, + mso_floor_ratio=config.options.mso_floor_ratio, + mso_ceiling_ratio=config.options.mso_ceiling_ratio, + cst_orig_indices=m1_result.orig_indices, + target_orig_indices=tgt_result.orig_indices, + ) + + af_cst_calibration = results["cst_metric"] + af_target_optimized = results["tgt_metric"] + optimization_gain = ( + af_target_optimized / af_target_m1_calibration if af_target_m1_calibration > 0 else 0.0 + ) + ratio_at_m1 = af_target_m1_calibration / af_cst_calibration if af_cst_calibration > 0 else 0.0 + intensity_from_m1_position = ( + config.calibration.measured_rmt_mso * (af_cst_calibration / af_target_m1_calibration) + if af_target_m1_calibration > 0 + else 0.0 + ) + + intensity_rmt = config.coil.device_didt_max * (config.calibration.measured_rmt_mso / 100.0) + biological_threshold = intensity_rmt * (af_cst_calibration / 1e6) + + return EstimationAnalysis( + calibration_orientation=cal_orientation, + mesh_m1=mesh_m1, + mesh_target=mesh_tgt, + cst_streamlines=new_sl_cst, + cst_af_values=af_cst, + target_streamlines=new_sl_tgt, + target_af_values=af_tgt, + optimized_matrix=opt_matrix, + optimized_scalp_coords=opt_scalp_coords, + results=results, + af_cst_calibration=af_cst_calibration, + af_target_optimized=af_target_optimized, + intensity_rmt=intensity_rmt, + biological_threshold=biological_threshold, + cst_align=cst_align, + target_align=tgt_align, + cst_depth=cst_depth, + target_depth=tgt_depth, + optimization_gain=optimization_gain, + ratio_at_m1=ratio_at_m1, + intensity_from_m1_position=intensity_from_m1_position, + cst_align_corrected=cst_align_corrected, + target_align_corrected=tgt_align_corrected, + ) + + +def _write_estimation_summary(context: EstimationSummaryContext) -> None: + config = context.config + ui = context.ui + start_time = context.start_time + out_dir = context.out_dir + viz_out = context.viz_out + cal_orientation = context.calibration_orientation + tgt_orientation = context.target_orientation + is_tgt_matrix = context.target_orientation_is_matrix + opt_matrix = context.optimized_matrix + opt_scalp_coords = context.optimized_scalp_coords + results = context.results + af_cst_calibration = context.af_cst_calibration + af_target_optimized = context.af_target_optimized + intensity_rmt = context.intensity_rmt + biological_threshold = context.biological_threshold + cst_align = context.cst_align + tgt_align = context.target_align + cst_depth = context.cst_depth + tgt_depth = context.target_depth + optimization_gain = context.optimization_gain + ratio_at_m1 = context.ratio_at_m1 + intensity_from_m1_position = context.intensity_from_m1_position + cst_align_corrected = context.cst_align_corrected + tgt_align_corrected = context.target_align_corrected + m1_result = context.m1_result + tgt_result = context.target_result + clamped_est_intensity = results["intensity_est_clamped"] + intensity_flag_w = results["intensity_est_flag"] + sei_weighted = results["sei_weighted"] + sei_unweighted = results["sei_unweighted"] + multiplier_weighted = results["multiplier_weighted"] + multiplier_unweighted = results["multiplier_unweighted"] + + # ========================================================================= + # Step 5: Save Summary + # ========================================================================= + if ui: + ui.update_step(5, "complete") + ui.update_step(6, "running") + ui.update_step_detail("Writing final result summary...") + + log.highlight("--- Step 5: Saving Results ---") + + target_act_len = config.options.activation_length_mm + + # Format optimized matrix for display + m1_matrix_str = str(cal_orientation).replace("\n", "") if cal_orientation is not None else "N/A" + tgt_matrix_str = ( + str(opt_matrix.tolist()).replace("\n", "") + if opt_matrix is not None + else str(tgt_orientation).replace("\n", "") if is_tgt_matrix else "N/A" + ) + tgt_scalp_str = ( + f"[{opt_scalp_coords[0]:.2f}, {opt_scalp_coords[1]:.2f}, {opt_scalp_coords[2]:.2f}]" + if opt_scalp_coords is not None + else "N/A" + ) + + summary_lines = io.build_estimation_summary_lines( + subject_id=config.subject.id, + timestamp_str=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + out_dir=out_dir, + num_workers=NUM_ESTIMATION_WORKERS, + t1w_path=config.subject.t1w_path, + cst_bundle_path=config.calibration.bundle_path, + target_bundle_path=config.target.bundle_path, + spatial_mode=results["mode"], + weight_source=format_weight_sources( + results["weight_source_cst"], + results["weight_source_target"], + ), + roi_size_mm=config.options.roi_size_mm, + activation_length_mm=target_act_len, + calibration_label=config.calibration.label, + measured_rmt_mso=config.calibration.measured_rmt_mso, + m1_matrix_str=m1_matrix_str, + af_cst_w=af_cst_calibration, + af_cst_u=results["cst_unweighted"], + intensity_rmt=intensity_rmt, + biological_threshold=biological_threshold, + target_label=config.target.label, + target_coords=config.target.coords, + opt_scalp_str=tgt_scalp_str, + tgt_matrix_str=tgt_matrix_str, + af_tgt_w=af_target_optimized, + af_tgt_u=results["tgt_unweighted"], + cst_align=cst_align, + tgt_align=tgt_align, + cst_depth=cst_depth, + tgt_depth=tgt_depth, + optimization_gain=optimization_gain, + ratio_at_m1=ratio_at_m1, + intensity_from_m1_position=intensity_from_m1_position, + intensity_raw_w=results["intensity_est_raw"], + intensity_raw_u=results["intensity_est_u_raw"], + intensity_clamped_w=results["intensity_est_clamped"], + intensity_clamped_u=results["intensity_est_u_clamped"], + intensity_flag_w=results["intensity_est_flag"], + intensity_flag_u=results["intensity_est_u_flag"], + mso_floor_ratio=results["mso_floor_ratio"], + sei_w=sei_weighted, + sei_u=sei_unweighted, + multiplier_w=multiplier_weighted, + multiplier_u=multiplier_unweighted, + cst_align_corrected=cst_align_corrected, + tgt_align_corrected=tgt_align_corrected, + calibration_pose_qc=m1_result.pose_qc, + target_pose_qc=tgt_result.pose_qc, + aggregator_sensitivity=results.get("aggregator_sensitivity"), + ) + + summary_path = out_dir / f"TIDE_Results_{config.target.label}.txt" + try: + with open(summary_path, "w") as f: + f.write("\n".join(summary_lines)) + io.save_report_json( + summary_path, + "estimation_summary", + data={ + "workflow": "estimation", + "subject_id": config.subject.id, + "target_label": config.target.label, + "output_dir": out_dir, + "weight_source_cst": results["weight_source_cst"], + "weight_source_target": results["weight_source_target"], + "aggregator_sensitivity": results.get("aggregator_sensitivity"), + "cst_aggregates_weighted": results.get("cst_aggregates_weighted"), + "cst_aggregates_unweighted": results.get("cst_aggregates_unweighted"), + "target_aggregates_weighted": results.get("target_aggregates_weighted"), + "target_aggregates_unweighted": results.get("target_aggregates_unweighted"), + }, + text_lines=summary_lines, + ) + log.debug(f"Saved summary: {summary_path}") + except Exception as e: + raise WorkflowError(f"Failed to save summary: {e}") from e + + # Print final result + log.highlight("") + if intensity_flag_w != "WITHIN_RANGE" or results["intensity_est_u_flag"] != "WITHIN_RANGE": + log.highlight( + f" RESULT: Estimated Target I (Raw) = {results['intensity_est_raw']:.1f}% (Weighted) | {results['intensity_est_u_raw']:.1f}% (Unweighted)" + ) + log.highlight( + f" RESULT: Estimated Target I (Clamped) = {clamped_est_intensity:.1f}% (Weighted) | {results['intensity_est_u_clamped']:.1f}% (Unweighted)" + ) + log.highlight( + f" MSO Floor: {config.options.mso_floor_ratio * 100:.0f}% of RMT ({config.calibration.measured_rmt_mso}%)" + ) + else: + log.highlight( + f" RESULT: Estimated Target I = {clamped_est_intensity:.1f}% (Weighted) | {results['intensity_est_u_clamped']:.1f}% (Unweighted)" + ) + log.highlight(f" (Input RMT: {config.calibration.measured_rmt_mso}%)") + log.highlight( + f" SEI: {sei_weighted:.4f} (Weighted) | {sei_unweighted:.4f} (Unweighted) [AF_target/AF_CST; 1.0 = same as M1]" + ) + log.highlight( + f" Multiplier (M_CST/M_target): {multiplier_weighted:.4f} (Weighted) | " + f"{multiplier_unweighted:.4f} (Unweighted) [I_raw = RMT x multiplier]" + ) + log.highlight("") + + log.highlight("Workflow completed successfully.") + log.highlight("Output files:") + log.highlight(f" -> Results File: {Path(summary_path).resolve()}") + log.highlight(f" -> HTML Report: {Path(summary_path).with_suffix('.html').resolve()}") + log.highlight(f" -> Simulations: {Path(out_dir).resolve()}") + log.highlight(f" -> Visualizations: {Path(viz_out).resolve()}") + + if ui: + ui.update_step(6, "complete") + + # Prepare results for summary display + ui_results = [ + { + "label": config.target.label, + "weighted_mso": clamped_est_intensity, + "unweighted_mso": results["intensity_est_u_clamped"], + "weighted_mso_raw": results["intensity_est_raw"], + "unweighted_mso_raw": results["intensity_est_u_raw"], + "weighted_flag": intensity_flag_w, + "unweighted_flag": results["intensity_est_u_flag"], + "sei_weighted": sei_weighted, + "sei_unweighted": sei_unweighted, + "multiplier_weighted": multiplier_weighted, + "multiplier_unweighted": multiplier_unweighted, + "success": True, + } + ] + + output_files = [ + ("Results File", summary_path), + ("HTML Report", summary_path.with_suffix(".html")), + ("Simulations", out_dir), + ("Visualizations", viz_out), + ] + + elapsed_time = time.time() - start_time + ui.render_final_summary(ui_results, elapsed_time, output_files) + + +def _generate_estimation_visualizations( + config: SimNIBSConfig, + ui: Any, + mesh_m1: Path, + mesh_tgt: Path, + new_sl_cst: List[np.ndarray], + af_cst: List[np.ndarray], + new_sl_tgt: List[np.ndarray], + af_tgt: List[np.ndarray], + viz_out: Path, +) -> None: + # ========================================================================= + # Step 4: Generate 3D Visualizations + # ========================================================================= + if ui: + ui.update_step(4, "complete") + + if config.options.generate_3d_visualization and PYVISTA_AVAILABLE: + if ui: + ui.update_step(5, "running") + ui.update_step_detail("Generating 3D visualizations...") + + log.highlight("--- Step 4: Generating 3D Visualizations ---") + + # Get scalp point for depth analysis + try: + scalp_point = geometry.project_target_to_scalp(mesh_tgt, np.array(config.target.coords)) + except Exception: + scalp_point = None + + # Configure visualization (AF scalars are signed; colour scale uses + # magnitude so the bar spans peak activation strength). + viz_config = VisualizationConfig( + efield_vmax=80.0, + af_vmax=(float(np.percentile(np.abs(np.concatenate(af_tgt)), 99)) if af_tgt else 100.0), + dpi=config.options.visualization_dpi, + ) + + # Generate CST visualization + log.debug("Generating CST visualization...") + try: + cst_viz_outputs = generate_bundle_visualization( + mesh_path=mesh_m1, + streamlines=new_sl_cst, + af_values=af_cst, + roi_center=np.array(config.calibration.coords), + roi_radius=config.options.roi_size_mm, + output_dir=viz_out, + prefix="CST_M1", + config=viz_config, + scalp_point=scalp_point, + ) + log.debug(f"CST visualizations: {len(cst_viz_outputs)} files") + except Exception as e: + log.warning(f"CST visualization failed: {e}") + + # Generate Target visualization + log.debug("Generating target visualization...") + try: + tgt_viz_outputs = generate_bundle_visualization( + mesh_path=mesh_tgt, + streamlines=new_sl_tgt, + af_values=af_tgt, + roi_center=np.array(config.target.coords), + roi_radius=config.options.roi_size_mm, + output_dir=viz_out, + prefix=f"{config.target.label}_optimized", + config=viz_config, + scalp_point=scalp_point, + ) + log.debug(f"Target visualizations: {len(tgt_viz_outputs)} files") + except Exception as e: + log.warning(f"Target visualization failed: {e}") + else: + if not PYVISTA_AVAILABLE: + log.debug("PyVista not available - skipping 3D visualization") + + +def run_estimation_workflow( + config: SimNIBSConfig, + console_ui: bool = True, +): + """ + Executes the TIDE Estimation Workflow using the Unified Estimation Module. + + This workflow uses a 2-worker parallel processing system to accelerate + the optimization and simulation phases. Both the M1/CST and Target + pipelines run concurrently, providing up to 2x speedup. + + Workflow Steps: + 1. Pre-processing: Medoid calculation (if requested) + 2. Parallel Phase: M1 and Target pipelines run concurrently + - Each pipeline: Optimization → Simulation → E-field Sampling → AF + 3. Validation: Target in M1 field analysis (requires both results) + 4. Unified Estimation: Final intensity calculation + 5. Visualization: 3D bundle visualizations (if enabled) + 6. Summary: Results report generation + + Args: + Args: + config: SimNIBSConfig object with all pipeline parameters. + console_ui: Enable rich console UI (default: True). + """ + validate_workflow_config(config, "estimation") + + log.highlight("=== Starting TIDE Estimation Workflow (Parallel) ===") + log.info(f"Using {NUM_ESTIMATION_WORKERS} parallel workers") + + start_time = time.time() + + # Create multiprocessing context BEFORE UI creation + ctx = mp.get_context("spawn") + + # Create console UI + ui = None + if console_ui and sys.stdout.isatty(): + try: + from tide.console import create_console_ui + + ui = create_console_ui( + subject_id=config.subject.id, + num_workers=NUM_ESTIMATION_WORKERS, + total_points=2, # M1 and Target + current_step=1, + total_steps=len(ESTIMATION_STEPS), + workflow_name="Estimation", + enabled=True, + mode="sequential", + mp_context=ctx, + step_names=ESTIMATION_STEPS, + ) + ui.start() + except ImportError: + log.warning("Console UI not available, falling back to text logging") + ui = None + + # Log configuration parameters + log.info("=== Configuration Parameters ===") + log.info(f"Subject ID: {config.subject.id}") + log.info(f"Calibration site: {config.calibration.label}") + log.info(f"Target site: {config.target.label}") + log.info(f"Coil model: {config.coil.coil_model}") + log.info(f"Coil distance: {config.coil.coil_distance_mm} mm") + log.info(f"dI/dt max: {config.coil.device_didt_max / 1e6:.2f} A/µs") + log.info(f"Measured RMT (MSO): {config.calibration.measured_rmt_mso}%") + log.info(f"ROI size: {config.options.roi_size_mm} mm") + log.info(f"Activation length: {config.options.activation_length_mm} mm") + log.info(f"Field mode: {config.options.field_mode}") + log.info(f"ADM optimization: {config.options.adm_optimization}") + log.info(f"Optimization search radius: {config.options.opt_search_radius} mm") + log.info(f"Optimization spatial resolution: {config.options.opt_spatial_resolution} mm") + log.info(f"Optimization angle resolution: {config.options.opt_angle_resolution}°") + log.info(f"Optimization search angle: {config.options.opt_search_angle}°") + log.info(f"MSO floor ratio: {config.options.mso_floor_ratio}") + log.info(f"MSO ceiling ratio: {config.options.mso_ceiling_ratio}") + log.info("=" * 50) + + try: + preparation = _prepare_estimation(config, ui) + m1_result, tgt_result = _execute_estimation_tasks(ctx, ui, preparation) + analysis = _analyze_estimation(config, ui, preparation, m1_result, tgt_result) + + _generate_estimation_visualizations( + config, + ui, + analysis.mesh_m1, + analysis.mesh_target, + analysis.cst_streamlines, + analysis.cst_af_values, + analysis.target_streamlines, + analysis.target_af_values, + preparation.viz_out, + ) + _write_estimation_summary( + EstimationSummaryContext( + config=config, + ui=ui, + start_time=start_time, + out_dir=preparation.out_dir, + viz_out=preparation.viz_out, + calibration_orientation=analysis.calibration_orientation, + target_orientation=preparation.target_orientation, + target_orientation_is_matrix=preparation.target_orientation_is_matrix, + optimized_matrix=analysis.optimized_matrix, + optimized_scalp_coords=analysis.optimized_scalp_coords, + results=analysis.results, + af_cst_calibration=analysis.af_cst_calibration, + af_target_optimized=analysis.af_target_optimized, + intensity_rmt=analysis.intensity_rmt, + biological_threshold=analysis.biological_threshold, + cst_align=analysis.cst_align, + target_align=analysis.target_align, + cst_depth=analysis.cst_depth, + target_depth=analysis.target_depth, + optimization_gain=analysis.optimization_gain, + ratio_at_m1=analysis.ratio_at_m1, + intensity_from_m1_position=analysis.intensity_from_m1_position, + cst_align_corrected=analysis.cst_align_corrected, + target_align_corrected=analysis.target_align_corrected, + m1_result=m1_result, + target_result=tgt_result, + ) + ) + except Exception: + if ui: + ui.stop() + raise diff --git a/src/tide/workflows/grid_search.py b/src/tide/workflows/grid_search.py new file mode 100644 index 0000000..eaddfa5 --- /dev/null +++ b/src/tide/workflows/grid_search.py @@ -0,0 +1,1701 @@ +""" +Optimized Grid Search Workflow Module +===================================== + +This module implements a parallelized version of the TIDE grid search workflow +using Python's multiprocessing to process multiple grid points concurrently. + +Performance Characteristics +--------------------------- +- **Speedup formula**: T_parallel ≈ T_sequential / min(N_workers, N_grid_points) +- **Memory usage**: ~12GB × N_workers + 4GB parent/OS reserve +- **Default workers**: min(cpu_count - 1, 2, memory-safe worker limit) + +OpenMP Thread Control (CRITICAL) +-------------------------------- +SimNIBS uses the PARDISO solver with OpenMP parallelization internally. +Running multiple SimNIBS instances without controlling thread count causes +CPU oversubscription (N_workers × M_threads >> CPU_cores), leading to severe +cache thrashing and performance degradation. Each worker is configured to use +single-threaded mode via environment variables set BEFORE any SimNIBS imports. + +Mesh Caching Investigation (Proposal F Findings) +------------------------------------------------ +SimNIBS mesh objects (`sesame.FEM`) contain internal solver state and are not +picklable, preventing direct sharing between processes. Alternative approaches: +- Memory-mapped files: Not applicable to SimNIBS mesh format +- Shared memory: Would require deep modifications to SimNIBS internals + +Current approach: Rely on OS-level disk cache. After the first simulation loads +the ~500MB mesh file, subsequent workers benefit from the file being cached in +RAM by the OS. This provides near-instantaneous I/O for parallel workers without +requiring code changes to SimNIBS. + +Verification of Parallelization +------------------------------- +To verify parallelization is working: +1. Run with grid search workflow and check system monitor for multiple Python + processes (one per worker) +2. Compare execution time with `no_parallel=True` in config +3. Check log output for "Processing X grid points with Y workers" + +Usage +----- + from tide.workflows.grid_search_optimized import run_grid_search_workflow + run_grid_search_workflow(config) # Uses config.options.max_workers + + # Or override parallelization settings: + run_grid_search_workflow(config, max_workers=4) # Force 4 workers + run_grid_search_workflow(config, no_parallel=True) # Force sequential +""" + +import logging +import multiprocessing as mp +import os +import sys +import tempfile +import time +from concurrent.futures import Future, ProcessPoolExecutor, as_completed +from dataclasses import dataclass, field +from pathlib import Path +from typing import IO, Callable, Dict, List, Optional + +try: + import fcntl +except ImportError: + fcntl = None + +try: + import msvcrt +except ImportError: + msvcrt = None + +import numpy as np + +from tide.core import io, physics, tractography +from tide.core.geometry import ( + calculate_alignment_and_depth, + calculate_alignment_corrected, + compute_default_coil_orientation, + evaluate_coil_pose_qc, + project_target_to_scalp, + validate_coil_pose_for_dose, +) +from tide.interfaces.sampling import sample_field_at_coordinates +from tide.interfaces.simnibs_interface import SimNIBSInterface +from tide.interfaces.unified_estimation import ( + AnalysisConfig, + analyze_bundle, + load_surface_tree, + validate_calibration_metrics, +) +from tide.interfaces.visualization import save_af_visualization +from tide.utils.config import ( + SimNIBSConfig, + orientation_is_matrix, + save_config_to_output, + validate_workflow_config, +) +from tide.workflows._grid_reporting import ( + GridReportingContext, + initialize_grid_results_csv, + write_grid_results, + write_grid_summary, +) +from tide.workflows._shared import WorkflowError +from tide.workflows._shared import configure_worker_environment as _configure_worker_environment +from tide.workflows._shared import single_thread_child_environment as _single_thread_child_env +from tide.workflows._shared import split_vectors_by_streamline + +log = logging.getLogger(__name__) + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +DEFAULT_MAX_WORKERS = 2 +PARDISO_MEMORY_PER_WORKER_GB = 12.0 +GRID_MEMORY_RESERVE_GB = 4.0 +GRID_FORCE_WORKERS_ENV = "TIDE_GRID_FORCE_WORKERS" +MIN_WORKERS = 1 + +# Global variable to store assigned worker ID within each process +_process_worker_id = None +_worker_lock_file = None # Keep reference to prevent gc/closing + + +def _lock_worker_file(lock_file: IO[str]) -> None: + if fcntl is not None: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return + if msvcrt is None: + raise RuntimeError("No supported file-locking implementation is available.") + + lock_file.seek(0, os.SEEK_END) + if lock_file.tell() == 0: + lock_file.write("\0") + lock_file.flush() + lock_file.seek(0) + try: + msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1) + except OSError as exc: + raise BlockingIOError(str(exc)) from exc + + +def _init_worker(num_workers: int, lock_dir: str) -> None: + """ + Initialize worker process by attempting to acquire a persistent ID + via file locking. This is robust to process restarts (crashes). + """ + global _process_worker_id, _worker_lock_file + + _process_worker_id = 0 + try: + # Try to acquire a lock for each worker ID + for i in range(num_workers): + lock_path = os.path.join(lock_dir, f"worker_{i}.lock") + # Open file in append mode (creates if not exists) + f = open(lock_path, "a") # ignore: consider-using-with + try: + # Try non-blocking lock + _lock_worker_file(f) + + # If we got here, we acquired the lock + _process_worker_id = i + _worker_lock_file = f # Keep it open + return + except BlockingIOError: + # Lock held by another process, try next + f.close() + continue + + except Exception as e: + # Fallback (should normally not happen unless FS issues) + print(f"Worker init failed: {e}", file=sys.stderr) + _process_worker_id = 0 + + +# ============================================================================= +# DATACLASSES FOR SERIALIZATION +# ============================================================================= + + +@dataclass +class GridPointTask: + """ + Encapsulates all serializable data needed to process one grid point. + + This dataclass is designed to be picklable for inter-process communication + with ProcessPoolExecutor. All Path objects are converted to strings. + + Attributes: + index: Grid point index for result ordering. + cortex_coord: Target cortex coordinates [x, y, z]. + point_label: Human-readable label (e.g., "grid_P00"). + point_dir: Output directory path for this grid point. + msh_file: Path to head mesh file. + m2m_path: Path to m2m directory. + coil_path: Path to coil model file. + fixed_scalp_coords: Fixed scalp center coordinates. + grid_orientation_ref: Orientation reference (pos_ydir). + target_bundle_path: Path to target tractogram. + t1w_path: Path to T1w image. + surface_path: Optional path to cortical surface. + weights_target_path: Optional path to target weights. + adm_optimization: Whether to use ADM optimization. + opt_spatial_resolution: Spatial resolution for optimization. + opt_angle_resolution: Angle resolution for optimization. + opt_search_angle: Search angle for optimization. + opt_search_radius: Search radius for optimization. + field_mode: Field mode for physics calculation. + roi_size_mm: ROI radius in mm. + activation_length_mm: Activation length in mm. + gwi_threshold_mm: Max distance from the GWI surface in mm. + measured_rmt_mso: Measured RMT in %MSO. + af_cst_calibration: CST calibration efficiency value. + cst_metric_unweighted: Unweighted CST metric. + """ + + index: int + cortex_coord: List[float] + point_label: str + point_dir: str + msh_file: str + m2m_path: str + coil_path: str + fixed_scalp_coords: List[float] + grid_orientation_ref: List[float] + target_bundle_path: str + t1w_path: str + surface_path: Optional[str] + weights_target_path: Optional[str] + adm_optimization: bool + opt_spatial_resolution: float + opt_angle_resolution: float + opt_search_angle: float + opt_search_radius: float + field_mode: str + roi_size_mm: float + activation_length_mm: float + gwi_threshold_mm: float + max_angular_deviation_deg: float + measured_rmt_mso: float + af_cst_calibration: float + cst_metric_unweighted: float + mso_floor_ratio: float + mso_ceiling_ratio: float + generate_visualizations: bool + + +@dataclass +class GridPointResult: + """ + Results from processing one grid point. + + Attributes: + index: Grid point index for result ordering. + point_label: Human-readable label. + success: Whether processing completed successfully. + weighted_mso: Estimated MSO (weighted), or 999.9 on failure. + unweighted_mso: Estimated MSO (unweighted), or 999.9 on failure. + cortex_coord: Target cortex coordinates. + opt_scalp_coords: Optimized scalp coordinates. + opt_matrix: Optimized 4x4 coil matrix. + error_message: Error message if success is False. + """ + + index: int + point_label: str + success: bool + weighted_mso: float + unweighted_mso: float + cortex_coord: List[float] + opt_scalp_coords: Optional[List[float]] + opt_matrix: Optional[List[List[float]]] + weighted_mso_raw: float = 0.0 + unweighted_mso_raw: float = 0.0 + weighted_mso_flag: str = "WITHIN_RANGE" + unweighted_mso_flag: str = "WITHIN_RANGE" + sei_weighted: float = 0.0 + sei_unweighted: float = 0.0 + multiplier_weighted: float = 0.0 + multiplier_unweighted: float = 0.0 + target_metric_weighted: float = 0.0 + target_metric_unweighted: float = 0.0 + target_aggregates_weighted: Dict[str, float] = field(default_factory=dict) + target_aggregates_unweighted: Dict[str, float] = field(default_factory=dict) + target_weight_source: Optional[str] = None + tgt_align: float = 0.0 + tgt_align_corrected: float = 0.0 + tgt_depth: float = 0.0 + pose_qc: Optional[Dict[str, object]] = None + error_message: Optional[str] = None + + +@dataclass(frozen=True) +class GridWorkerPlan: + workers: int + requested_workers: Optional[int] + num_grid_points: int + cpu_count: int + available_memory_gb: Optional[float] + memory_worker_limit: int + memory_per_worker_gb: float + memory_reserve_gb: float + forced: bool + + def as_dict(self) -> Dict[str, object]: + return { + "workers": self.workers, + "requested_workers": self.requested_workers, + "num_grid_points": self.num_grid_points, + "cpu_count": self.cpu_count, + "available_memory_gb": self.available_memory_gb, + "memory_worker_limit": self.memory_worker_limit, + "memory_per_worker_gb": self.memory_per_worker_gb, + "memory_reserve_gb": self.memory_reserve_gb, + "forced": self.forced, + "solver": "PARDISO", + } + + +def _collect_parallel_grid_results( + future_to_task: Dict[Future, GridPointTask], + num_points: int, + use_parallel_ui: bool, + progress_callback: Optional[Callable[[int, int, str], None]], +) -> tuple[List[GridPointResult], int]: + results: List[GridPointResult] = [] + failed_count = 0 + + for completed, future in enumerate(as_completed(future_to_task), start=1): + task = future_to_task[future] + + try: + result = future.result() + results.append(result) + + if result.success: + if not use_parallel_ui: + log.info( + f"[{completed}/{num_points}] {result.point_label}: " + f"Weighted MSO={result.weighted_mso:.2f}%" + ) + else: + failed_count += 1 + if not use_parallel_ui: + log.error( + f"[{completed}/{num_points}] {result.point_label}: " + f"FAILED - {result.error_message}" + ) + except Exception as e: + failed_count += 1 + if not use_parallel_ui: + log.error(f"[{completed}/{num_points}] {task.point_label}: Worker exception - {e}") + results.append( + GridPointResult( + index=task.index, + point_label=task.point_label, + success=False, + weighted_mso=999.9, + unweighted_mso=999.9, + cortex_coord=task.cortex_coord, + opt_scalp_coords=None, + opt_matrix=None, + error_message=str(e), + ) + ) + + if progress_callback: + progress_callback(completed, num_points, task.point_label) + + return results, failed_count + + +# ============================================================================= +# WORKER FUNCTIONS +# ============================================================================= + + +class _GridPointReporter: + """ + No-op progress sink for :func:`process_grid_point`. + + The console worker passes an adapter whose hooks forward to the live UI + (audit C-003); the default instance makes every hook a no-op so the + non-console path is unchanged. ``process_grid_point`` owns the grid-point + physics; reporters only observe stage transitions. + """ + + def optimization(self) -> None: ... + + def simulation(self) -> None: ... + + def sampling(self) -> None: ... + + def activating_function(self) -> None: ... + + def bundle_analysis(self) -> None: ... + + def saving_results(self) -> None: ... + + def progress(self, pct: int) -> None: ... + + +_NULL_REPORTER = _GridPointReporter() + + +def _save_grid_point_visualizations( + task: GridPointTask, + streamlines: List[np.ndarray], + af_values: List[np.ndarray], +) -> None: + if not task.generate_visualizations: + return + + point_dir = Path(task.point_dir) + io.save_points_as_nifti( + np.concatenate(streamlines), + Path(task.t1w_path), + point_dir / f"{task.point_label}_af.nii.gz", + values=np.abs(np.concatenate(af_values)), + ) + save_af_visualization( + streamlines=streamlines, + values=af_values, + roi_center=task.cortex_coord, + roi_radius=task.roi_size_mm, + output_dir=point_dir, + prefix=task.point_label, + ) + + +def process_grid_point( + task: GridPointTask, reporter: Optional[_GridPointReporter] = None +) -> GridPointResult: + """ + Process a single grid point: optimization, simulation, and analysis. + + This is the main worker function executed in parallel processes. + It performs the complete pipeline for one grid point: + 1. Coil position optimization + 2. FEM E-field simulation + 3. E-field sampling on tractogram + 4. Activating function calculation + 5. Bundle analysis and intensity estimation + + Args: + task: GridPointTask containing all necessary parameters. + + Returns: + GridPointResult with success status and computed metrics. + + Note: + This function configures single-threaded mode at startup to prevent + OpenMP thread oversubscription across parallel workers. + """ + # CRITICAL: Configure environment before any heavy imports + _configure_worker_environment() + if reporter is None: + reporter = _NULL_REPORTER + + # Import heavy modules inside worker to ensure environment is set first + from pathlib import Path + + import numpy as np + + from tide.core import io, physics, tractography + from tide.core.geometry import ( + calculate_alignment_and_depth, + calculate_alignment_corrected, + evaluate_coil_pose_qc, + validate_coil_pose_for_dose, + ) + from tide.interfaces.sampling import sample_field_at_coordinates + from tide.interfaces.simnibs_interface import SimNIBSInterface + from tide.interfaces.unified_estimation import ( + AnalysisConfig, + analyze_bundle, + apply_intensity_bounds, + load_surface_tree, + validate_calibration_metrics, + ) + + # Import logging utilities to register the highlight method in spawned process + # (spawn creates fresh interpreter without parent's monkey-patched Logger class) + from tide.utils.logging import highlight + + logging.Logger.highlight = highlight + + log = logging.getLogger(__name__) + + try: + validate_calibration_metrics(task.af_cst_calibration, task.cst_metric_unweighted) + + point_dir = Path(task.point_dir) + point_dir.mkdir(exist_ok=True, parents=True) + + log.highlight(f"=== Processing {task.point_label} {task.cortex_coord} ===") + + # ===================================================================== + # 1. Optimization + # ===================================================================== + reporter.optimization() + log.info(f"Running standard optimization for {task.point_label}...") + + opt_matrix, opt_scalp_coords = SimNIBSInterface.run_optimization( + mesh_path=Path(task.msh_file), + output_dir=point_dir, + coil_path=Path(task.coil_path), + target_coords=task.cortex_coord, + scalp_centre=task.fixed_scalp_coords, + orientation_ref=task.grid_orientation_ref, + didt=1e6, + use_adm=task.adm_optimization, + spatial_resolution=task.opt_spatial_resolution, + angle_resolution=task.opt_angle_resolution, + search_angle=task.opt_search_angle, + search_radius_mm=task.opt_search_radius, + ) + pose_qc = evaluate_coil_pose_qc(Path(task.msh_file), opt_matrix, opt_scalp_coords) + if pose_qc.status == "WARN": + log.warning(f"{task.point_label}: Coil pose QC warning: {pose_qc.reasons}") + + io.save_optimization_result_txt( + point_dir / f"{task.point_label}_opt_result.txt", + opt_matrix, + opt_scalp_coords, + pose_qc=pose_qc.as_dict(), + ) + validate_coil_pose_for_dose(pose_qc, explicit_matrix=False) + reporter.progress(100) + + # ===================================================================== + # 2. Simulation + # ===================================================================== + reporter.simulation() + log.info(f"Running FEM simulation for {task.point_label}...") + + mesh_pt = SimNIBSInterface.run_simulation( + mesh_path=Path(task.m2m_path), + output_dir=point_dir, + coil_path=Path(task.coil_path), + didt=1e6, + orientation=opt_matrix.tolist(), + coords=None, + ) + reporter.progress(100) + + # ===================================================================== + # 3. Sampling E-field on tractogram + # ===================================================================== + reporter.sampling() + log.info(f"Sampling E-field on tractogram for {task.point_label}...") + + sft_tgt = tractography.load_tract(Path(task.target_bundle_path), Path(task.t1w_path)) + points_tgt = np.concatenate(sft_tgt.streamlines) + e_vectors_tgt = sample_field_at_coordinates( + mesh_pt, + points_tgt, + "E", + output_dir=point_dir, + file_prefix=task.point_label, + ) + + e_vecs_list = split_vectors_by_streamline(e_vectors_tgt, sft_tgt.streamlines) + reporter.progress(100) + + # ===================================================================== + # 3b. Filter streamlines by angular deviation + # ===================================================================== + # Track original streamline ids through the drop chain so SIFT2 weights + # stay attached to their streamlines in analyze_bundle (audit C-002). + orig_idx = np.arange(len(sft_tgt.streamlines)) + if task.max_angular_deviation_deg > 0: + ( + filtered_sl, + e_vecs_list, + n_removed, + orig_idx, + ) = tractography.filter_by_angular_deviation( + list(sft_tgt.streamlines), + e_field_vectors=e_vecs_list, + max_angle_deg=task.max_angular_deviation_deg, + roi_center=task.cortex_coord, + roi_radius=task.roi_size_mm, + indices=orig_idx, + ) + else: + filtered_sl = list(sft_tgt.streamlines) + + # ===================================================================== + # 4. Physics calculation and TRK saving + # ===================================================================== + reporter.activating_function() + log.info(f"Calculating activating function for {task.point_label}...") + + new_sl_tgt, af_tgt, len_tgt, orig_idx = physics.calculate_scalar_map( + filtered_sl, + e_vecs_list, + mode=task.field_mode, + indices=orig_idx, + ) + reporter.progress(50) + + tgt_roi_masks_post, _ = tractography.get_roi_masks( + new_sl_tgt, task.roi_size_mm, task.cortex_coord + ) + tgt_align, tgt_depth = calculate_alignment_and_depth( + new_sl_tgt, + e_vecs_list, + tgt_roi_masks_post, + Path(task.msh_file), + task.cortex_coord, + ) + tgt_align_corrected = calculate_alignment_corrected( + new_sl_tgt, + e_vecs_list, + tgt_roi_masks_post, + ) + + tgt_trk_path = point_dir / f"{task.point_label}_af.trk" + io.save_tract_with_data(sft_tgt, new_sl_tgt, tgt_trk_path, "AF", af_tgt, len_tgt) + + _save_grid_point_visualizations(task, new_sl_tgt, af_tgt) + reporter.progress(100) + + # ===================================================================== + # 5. Unified Analysis + # ===================================================================== + reporter.bundle_analysis() + log.info(f"Running unified bundle analysis for {task.point_label}...") + + # Load surface if available + surf_tree = None + if task.surface_path: + surf_tree = load_surface_tree(task.surface_path) + + point_config = AnalysisConfig( + cst_trk="", + target_trk=str(tgt_trk_path), + rmt=task.measured_rmt_mso, + cst_coords=np.zeros(3), + target_coords=np.array(task.cortex_coord), + surf_path=task.surface_path, + gwi_threshold=task.gwi_threshold_mm, + roi_radius=task.roi_size_mm, + activation_len=task.activation_length_mm, + target_weights=task.weights_target_path, + ) + + tgt_res = analyze_bundle( + name=task.point_label, + trk_path=str(tgt_trk_path), + roi_center=np.array(task.cortex_coord), + config=point_config, + surface_tree=surf_tree, + weight_path=point_config.target_weights, + orig_indices=orig_idx, + ) + reporter.progress(100) + + # ===================================================================== + # 6. Metrics and Estimation + # ===================================================================== + reporter.saving_results() + af_target_w = tgt_res.metric_weighted + af_target_u = tgt_res.metric_unweighted + + # SEI: Stimulation Efficiency Index = AF_target / AF_CST + # SEI > 1: target more efficient than CST (less stimulation needed) + # SEI = 1: same efficiency as CST; SEI < 1: more stimulation needed + sei_w = af_target_w / task.af_cst_calibration if task.af_cst_calibration > 0 else 0.0 + sei_u = af_target_u / task.cst_metric_unweighted if task.cst_metric_unweighted > 0 else 0.0 + + # Multiplier k = M_CST / M_target (intensity-invariant; I_raw = RMT * k) + multiplier_w = task.af_cst_calibration / af_target_w if af_target_w > 0 else 0.0 + multiplier_u = task.cst_metric_unweighted / af_target_u if af_target_u > 0 else 0.0 + + est_mso = ( + task.measured_rmt_mso * (task.af_cst_calibration / af_target_w) + if af_target_w > 0 + else float("nan") + ) + est_mso_u = ( + task.measured_rmt_mso * (task.cst_metric_unweighted / af_target_u) + if af_target_u > 0 + else float("nan") + ) + + # Apply physiological intensity bounds (non-finite -> ESTIMATION_FAILED). + bounded_w = apply_intensity_bounds( + est_mso, + task.measured_rmt_mso, + floor_ratio=task.mso_floor_ratio, + ceiling_ratio=task.mso_ceiling_ratio, + ) + raw_mso_w = bounded_w["model_raw"] + est_mso = bounded_w["best_estimate"] + flag_w = bounded_w["flag"] + if flag_w != "WITHIN_RANGE": + log.info( + f"{task.point_label}: Weighted I {flag_w} (raw={raw_mso_w:.1f}%, clamped={est_mso:.1f}%)" + ) + + bounded_u = apply_intensity_bounds( + est_mso_u, + task.measured_rmt_mso, + floor_ratio=task.mso_floor_ratio, + ceiling_ratio=task.mso_ceiling_ratio, + ) + raw_mso_u = bounded_u["model_raw"] + est_mso_u = bounded_u["best_estimate"] + flag_u = bounded_u["flag"] + if flag_u != "WITHIN_RANGE": + log.info( + f"{task.point_label}: Unweighted I {flag_u} (raw={raw_mso_u:.1f}%, clamped={est_mso_u:.1f}%)" + ) + + log.highlight( + f"==> ESTIMATED TARGET {task.point_label} I: {est_mso:.2f}% (Weighted) | {est_mso_u:.2f}% (Unweighted)" + ) + reporter.progress(100) + + return GridPointResult( + index=task.index, + point_label=task.point_label, + success=True, + weighted_mso=est_mso, + unweighted_mso=est_mso_u, + weighted_mso_raw=raw_mso_w, + unweighted_mso_raw=raw_mso_u, + weighted_mso_flag=flag_w, + unweighted_mso_flag=flag_u, + sei_weighted=sei_w, + sei_unweighted=sei_u, + multiplier_weighted=multiplier_w, + multiplier_unweighted=multiplier_u, + target_metric_weighted=af_target_w, + target_metric_unweighted=af_target_u, + target_aggregates_weighted=tgt_res.aggregates_weighted, + target_aggregates_unweighted=tgt_res.aggregates_unweighted, + target_weight_source=tgt_res.weight_source, + tgt_align=tgt_align, + tgt_align_corrected=tgt_align_corrected, + tgt_depth=tgt_depth, + pose_qc=pose_qc.as_dict(), + cortex_coord=task.cortex_coord, + opt_scalp_coords=opt_scalp_coords.tolist(), + opt_matrix=opt_matrix.tolist(), + ) + + except Exception as e: + log.error(f"Processing failed for {task.point_label}: {e}") + return GridPointResult( + index=task.index, + point_label=task.point_label, + success=False, + weighted_mso=999.9, + unweighted_mso=999.9, + cortex_coord=task.cortex_coord, + opt_scalp_coords=None, + opt_matrix=None, + error_message=str(e), + ) + + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + + +def _get_available_memory_gb() -> Optional[float]: + try: + import psutil + + return float(psutil.virtual_memory().available / (1024**3)) + except ImportError: + pass + + try: + available_pages = os.sysconf("SC_AVPHYS_PAGES") + page_size = os.sysconf("SC_PAGE_SIZE") + except (AttributeError, OSError, ValueError): + return None + return float(available_pages * page_size / (1024**3)) + + +def _resolve_grid_worker_plan( + requested_workers: Optional[int] = None, + num_grid_points: int = 1, +) -> GridWorkerPlan: + cpu_count = os.cpu_count() or 4 + available_memory_gb = _get_available_memory_gb() + if available_memory_gb is None: + memory_worker_limit = 1 + else: + usable_memory_gb = max(0.0, available_memory_gb - GRID_MEMORY_RESERVE_GB) + memory_worker_limit = max( + MIN_WORKERS, + int(usable_memory_gb / PARDISO_MEMORY_PER_WORKER_GB), + ) + + forced = os.environ.get(GRID_FORCE_WORKERS_ENV, "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + if requested_workers is None: + cpu_based = max(1, cpu_count - 1) + desired_workers = min(cpu_based, DEFAULT_MAX_WORKERS) + else: + desired_workers = max(MIN_WORKERS, requested_workers) + + desired_workers = min(desired_workers, num_grid_points) + workers = desired_workers if forced else min(desired_workers, memory_worker_limit) + + return GridWorkerPlan( + workers=max(MIN_WORKERS, workers), + requested_workers=requested_workers, + num_grid_points=num_grid_points, + cpu_count=cpu_count, + available_memory_gb=available_memory_gb, + memory_worker_limit=memory_worker_limit, + memory_per_worker_gb=PARDISO_MEMORY_PER_WORKER_GB, + memory_reserve_gb=GRID_MEMORY_RESERVE_GB, + forced=forced, + ) + + +def _calculate_max_workers( + requested_workers: Optional[int] = None, + num_grid_points: int = 1, +) -> int: + return _resolve_grid_worker_plan(requested_workers, num_grid_points).workers + + +# ============================================================================= +# MAIN WORKFLOW FUNCTION +# ============================================================================= + + +def run_grid_search_workflow( + config: SimNIBSConfig, + max_workers: Optional[int] = None, + no_parallel: Optional[bool] = None, + progress_callback: Optional[Callable[[int, int, str], None]] = None, + console_ui: bool = True, +) -> None: + """ + Execute the Conformal TIDE Grid Search Workflow with optional parallelization. + + This function is a drop-in replacement for the original `grid_search.py` + implementation. It maintains identical output format (CSV, TXT summary, + per-point configs) while adding multi-process parallelization. + + The workflow: + 1. M1 Calibration (sequential) - establishes baseline + 2. CST Analysis (sequential) - computes calibration efficiency + 3. Grid Generation (sequential) - creates target points + 4. Grid Point Processing (PARALLEL) - optimization + simulation per point + 5. Summary Generation (sequential) - aggregates parallel results + + Args: + config: SimNIBSConfig object with all pipeline parameters. + max_workers: Override for number of parallel workers. + - None: Use config.options.max_workers or auto-detect + - int: Force specific number of workers + no_parallel: Override for sequential processing. + - None: Use config.options.no_parallel + - True: Force sequential processing + - False: Force parallel processing (unless max_workers=1) + progress_callback: Optional callback for progress updates. + - Called as progress_callback(completed, total, point_label) + - completed: Number of grid points processed so far + - total: Total number of grid points + - point_label: Label of the just-completed grid point + console_ui: Enable rich console UI for parallel processing. + - True: Use console UI if running in interactive terminal + - False: Use simple text logging + + Note: + Parallelization settings priority (highest to lowest): + 1. Function arguments (max_workers, no_parallel) + 2. Config file (config.options.max_workers, config.options.no_parallel) + 3. Auto-detection based on system resources + """ + validate_workflow_config(config, "grid") + + log.info("=" * 60) + log.info("=== Starting TIDE Grid Search Workflow (OPTIMIZED) ===") + log.info("=" * 60) + + start_time = time.time() + + # Create multiprocessing context BEFORE UI creation + # CRITICAL: The UI's status_queue must use the same context as workers + # to avoid message loss between processes with different contexts + ctx = mp.get_context("spawn") + + # Create console UI early (in sequential mode) for Steps 1-5 + ui = None + if console_ui and sys.stdout.isatty(): + try: + from tide.console import create_console_ui + + ui = create_console_ui( + subject_id=config.subject.id, + num_workers=0, + total_points=0, + current_step=1, + total_steps=8, + workflow_name="Grid Search", + enabled=True, + mode="sequential", + mp_context=ctx, + ) + ui.start() + except ImportError: + log.warning("Console UI not available, falling back to text logging") + ui = None + + # Resolve parallelization settings + use_no_parallel = no_parallel if no_parallel is not None else config.options.no_parallel + use_max_workers = max_workers if max_workers is not None else config.options.max_workers + + # Use mesh path from config (m2m folder input) + msh_file = config.subject.mesh_path + + # ========================================================================== + # MEDOID LOGIC - Calculate cortex coords if requested + # ========================================================================== + if config.target.medoid_endpoint: + if not config.target.bundle_path or not config.target.bundle_path.exists(): + if ui: + ui.stop() + raise WorkflowError("Medoid endpoint requested but bundle path is missing or invalid.") + + log.warning(f"Medoid Endpoint Calculation ENABLED for Target: {config.target.label}") + try: + new_coords = tractography.get_bundle_cortical_medoid( + config.target.bundle_path, + config.subject.t1w_path, + reference_coord=config.target.coords, + ) + log.info(f"--> Replacing input coords with calculated Medoid: {new_coords}") + config.target.coords = new_coords.tolist() + config.grid.coords = new_coords.tolist() + log.info("Updated Grid Center to new Medoid coordinates.") + except Exception as e: + if ui: + ui.stop() + raise WorkflowError(f"Failed to calculate medoid endpoint: {e}") from e + + # ========================================================================== + # OUTPUT DIRECTORY SETUP + # ========================================================================== + grid_folder_name = "TIDE_grid_search" + if config.target.label: + grid_folder_name += f"_{config.target.label}" + out_dir = config.subject.derivatives_path / grid_folder_name + out_dir.mkdir(parents=True, exist_ok=True) + + generated_calibration_matrix = None + calibration_pose_qc = None + + sims_dir = out_dir / "simulations" + sims_dir.mkdir(exist_ok=True) + qc_dir = out_dir / "QC" + qc_dir.mkdir(exist_ok=True) + + # ========================================================================== + # PRE-LOAD SURFACE TREE (for GWI filtering) + # ========================================================================== + surf_tree = None + if config.subject.surface_path: + surf_tree = load_surface_tree(str(config.subject.surface_path)) + log.info("Surface loaded for GWI analysis in Grid Search.") + + # Determine Spatial Mode string for report + spatial_mode = "Baseline (Sphere Only)" + if surf_tree is not None: + spatial_mode = "Surface-Constrained (GWI)" + if config.subject.weights_cst_path or config.subject.weights_target_path: + spatial_mode += " + Weighted" + + # Log optimization parameters + log.info("=== Optimization Parameters from Config ===") + log.info(f" opt_search_radius: {config.options.opt_search_radius} mm") + log.info(f" opt_spatial_resolution: {config.options.opt_spatial_resolution} mm") + log.info(f" opt_angle_resolution: {config.options.opt_angle_resolution} deg") + log.info(f" opt_search_angle: {config.options.opt_search_angle} deg") + log.info(f" adm_optimization: {config.options.adm_optimization}") + + # ========================================================================== + # STEP 1: M1 CALIBRATION (Sequential) + # ========================================================================== + if ui: + ui.update_step(1, "running") + log.info("--- Step 1: M1 Calibration ---") + + m1_out = out_dir / "calibration_m1" + m1_out.mkdir(exist_ok=True, parents=True) + + cal_coords = config.calibration.coords + cal_orientation = config.calibration.orientation + generated_calibration_scalp_coords: Optional[List[float]] = None + is_matrix = orientation_is_matrix(cal_orientation) + + # Optimize the M1 coil pose unless a full 4x4 matrix was supplied. + # run_optimization auto-projects the scalp position and auto-orients the + # handle when scalp_coords / orientation are absent. + if not is_matrix: + if ui: + ui.update_step_detail("Running coil position optimization...") + log.info("M1 Calibration: Running coil position optimization...") + log.info(f"[M1_OPT] orientation_ref (pos_ydir): {config.calibration.orientation}") + opt_matrix, opt_scalp = SimNIBSInterface.run_optimization( + mesh_path=msh_file, + output_dir=m1_out, + coil_path=config.coil.coil_path, + target_coords=config.calibration.coords, + scalp_centre=config.calibration.scalp_coords, + orientation_ref=config.calibration.orientation, + didt=1e6, + use_adm=config.options.adm_optimization, + spatial_resolution=config.options.opt_spatial_resolution, + angle_resolution=config.options.opt_angle_resolution, + search_angle=config.options.opt_search_angle, + search_radius_mm=config.options.opt_search_radius, + ) + qc = evaluate_coil_pose_qc(msh_file, opt_matrix, opt_scalp) + calibration_pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"M1 Calibration: Coil pose QC warning: {qc.reasons}") + validate_coil_pose_for_dose(qc, explicit_matrix=False) + cal_orientation = opt_matrix.tolist() + generated_calibration_matrix = opt_matrix.tolist() + generated_calibration_scalp_coords = opt_scalp.tolist() + cal_coords = None + elif is_matrix: + qc = evaluate_coil_pose_qc(msh_file, np.asarray(cal_orientation)) + calibration_pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"M1 Calibration: Coil pose QC warning: {qc.reasons}") + validate_coil_pose_for_dose(qc, explicit_matrix=True) + + # M1 Simulation + if ui: + ui.update_step_detail("Running FEM E-field simulation...") + mesh_m1 = SimNIBSInterface.run_simulation( + mesh_path=config.subject.m2m_path, + output_dir=m1_out, + coil_path=config.coil.coil_path, + didt=1e6, + coords=cal_coords, + orientation=cal_orientation, + distance_mm=config.coil.coil_distance_mm, + ) + + # Update config with actual parallelization settings used + # This ensures the output YAML reflects the runtime parameters (whether from CLI args or original config) + config.options.max_workers = use_max_workers + config.options.no_parallel = use_no_parallel + + # Save configuration (after M1 optimization). Includes generated + # calibration matrix and resolved scalp coords so the top-level grid + # config is re-runnable without re-triggering M1 optimization or medoid + # computation. Per-grid-point configs are written separately by + # ``save_grid_point_config`` with the point-specific target settings. + save_config_to_output( + config, + out_dir, + "grid_search", + generated_calibration_matrix=generated_calibration_matrix, + generated_calibration_scalp_coords=generated_calibration_scalp_coords, + medoid_resolved=bool(config.target.medoid_endpoint), + ) + + if ui: + ui.update_step(1, "complete") + + # ========================================================================== + # STEP 2: CST ANALYSIS (Sequential) + # ========================================================================== + if ui: + ui.update_step(2, "running") + log.info("--- Step 2: CST Analysis ---") + + if ui: + ui.update_step_detail("Loading CST tractogram...") + sft_cst = tractography.load_tract(config.calibration.bundle_path, config.subject.t1w_path) + points_cst = np.concatenate(sft_cst.streamlines) + if ui: + ui.update_step_detail("Sampling E-field on CST streamlines...") + e_vectors_cst = sample_field_at_coordinates( + mesh_m1, points_cst, "E", output_dir=m1_out, file_prefix="M1_CST" + ) + + e_vecs_list_cst = split_vectors_by_streamline(e_vectors_cst, sft_cst.streamlines) + + # Filter CST streamlines by angular deviation. Track original ids through + # the drop chain so SIFT2 weights stay aligned in analyze_bundle (C-002). + cst_streamlines = list(sft_cst.streamlines) + cst_orig_idx = np.arange(len(sft_cst.streamlines)) + if config.options.max_angular_deviation_deg > 0: + ( + cst_streamlines, + e_vecs_list_cst, + _, + cst_orig_idx, + ) = tractography.filter_by_angular_deviation( + cst_streamlines, + e_field_vectors=e_vecs_list_cst, + max_angle_deg=config.options.max_angular_deviation_deg, + roi_center=config.calibration.coords, + roi_radius=config.options.roi_size_mm, + indices=cst_orig_idx, + ) + + # Calculate Physics + if ui: + ui.update_step_detail("Calculating activating function...") + new_sl_cst, af_cst, len_cst, cst_orig_idx = physics.calculate_scalar_map( + cst_streamlines, + e_vecs_list_cst, + mode="af", + indices=cst_orig_idx, + ) + + # Save CST TRK + cst_trk_path = m1_out / "M1_CST_af.trk" + io.save_tract_with_data(sft_cst, new_sl_cst, cst_trk_path, "AF", af_cst, len_cst) + + # Save CST NIfTI (AF is signed; NIfTI stores magnitude for viewer compat). + if config.options.generate_visualizations: + io.save_points_as_nifti( + np.concatenate(new_sl_cst), + config.subject.t1w_path, + m1_out / "M1_CST_af.nii.gz", + values=np.abs(np.concatenate(af_cst)), + ) + + # Configure CST Analysis + cst_config = AnalysisConfig( + cst_trk=str(cst_trk_path), + target_trk="", + rmt=config.calibration.measured_rmt_mso, + cst_coords=np.array(config.calibration.coords), + target_coords=np.zeros(3), + surf_path=str(config.subject.surface_path) if config.subject.surface_path else None, + gwi_threshold=config.options.gwi_threshold_mm, + roi_radius=config.options.roi_size_mm, + activation_len=config.options.activation_length_mm, + cst_weights=( + str(config.subject.weights_cst_path) if config.subject.weights_cst_path else None + ), + ) + + # Run Unified Analysis for CST + if ui: + ui.update_step_detail("Computing CST calibration efficiency...") + log.info("Calculating CST Efficiency (Unified Method)...") + cst_res = analyze_bundle( + name="CST", + trk_path=str(cst_trk_path), + roi_center=np.array(config.calibration.coords), + config=cst_config, + surface_tree=surf_tree, + weight_path=cst_config.cst_weights, + orig_indices=cst_orig_idx, + ) + + af_cst_calibration = cst_res.metric_weighted + + validate_calibration_metrics(af_cst_calibration, cst_res.metric_unweighted) + + log.info(f"CST Calibration Efficiency (Weighted): {af_cst_calibration:.4f} V/m²") + + # Pre-Calculation for Summary Report + intensity_rmt = config.coil.device_didt_max * (config.calibration.measured_rmt_mso / 100.0) + biological_threshold = intensity_rmt * (af_cst_calibration / 1e6) + + # CST geometric metrics (constant across grid points) + cst_roi_masks_post, _ = tractography.get_roi_masks( + new_sl_cst, config.options.roi_size_mm, config.calibration.coords + ) + cst_align, cst_depth = calculate_alignment_and_depth( + new_sl_cst, + e_vecs_list_cst, + cst_roi_masks_post, + mesh_m1, + config.calibration.coords, + ) + cst_align_corrected = calculate_alignment_corrected( + new_sl_cst, + e_vecs_list_cst, + cst_roi_masks_post, + ) + + # Sample target tract E-field on M1 mesh once for per-point validation + sft_tgt_full = tractography.load_tract(config.target.bundle_path, config.subject.t1w_path) + points_tgt_full = np.concatenate(sft_tgt_full.streamlines) + e_vectors_tgt_in_m1 = sample_field_at_coordinates( + mesh_m1, points_tgt_full, "E", output_dir=m1_out, file_prefix="Target_in_M1" + ) + e_vecs_list_tgt_in_m1_full = split_vectors_by_streamline( + e_vectors_tgt_in_m1, + sft_tgt_full.streamlines, + ) + tgt_streamlines_full = list(sft_tgt_full.streamlines) + + # M1 coil matrix string for per-point summary + if ( + isinstance(cal_orientation, list) + and len(cal_orientation) == 4 + and isinstance(cal_orientation[0], list) + ): + m1_matrix_str = str(cal_orientation).replace("\n", "") + else: + m1_matrix_str = ( + str(cal_orientation).replace("\n", "") if cal_orientation is not None else "N/A" + ) + + if ui: + ui.update_step(2, "complete") + + # ========================================================================== + # STEP 3: COORDINATE CHAIN (Sequential) + # ========================================================================== + if ui: + ui.update_step(3, "running") + log.info("--- Step 3: Establish Fixed Scalp Center ---") + + if config.grid.scalp_coords: + if ui: + ui.update_step_detail("Using provided scalp coordinates...") + log.info(f"Using provided grid scalp coordinates: {config.grid.scalp_coords}") + fixed_scalp_coords = np.array(config.grid.scalp_coords) + else: + if ui: + ui.update_step_detail("Projecting cortex target to scalp surface...") + log.info("No grid scalp_coords provided. Projecting grid center (cortex) to scalp...") + fixed_scalp_coords = project_target_to_scalp( + mesh_path=msh_file, target_coords=np.array(config.grid.coords) + ) + log.info(f"Calculated Fixed Scalp Center: {fixed_scalp_coords}") + + if ui: + ui.update_step(3, "complete") + + # ========================================================================== + # STEP 4: GRID ORIENTATION (Sequential) + # ========================================================================== + if ui: + ui.update_step(4, "running") + log.info("--- Step 4: Establish Grid Orientation Reference ---") + + if config.grid.orientation: + if ui: + ui.update_step_detail("Using provided grid orientation...") + grid_orientation_ref = config.grid.orientation + log.info(f"Using provided grid orientation (pos_ydir): {grid_orientation_ref}") + else: + if ui: + ui.update_step_detail("Computing automatic coil orientation...") + log.info( + "No grid orientation provided. Computing automatic default (45 deg posterior-medial)..." + ) + grid_orientation_ref = compute_default_coil_orientation( + mesh_path=msh_file, scalp_coords=fixed_scalp_coords + ) + log.info(f"Calculated Automatic Orientation (pos_ydir): {grid_orientation_ref}") + + if hasattr(grid_orientation_ref, "tolist"): + grid_orientation_ref = grid_orientation_ref.tolist() + + log.info(f"[GRID_SEARCH] Final grid_orientation_ref (pos_ydir): {grid_orientation_ref}") + + if ui: + ui.update_step(4, "complete") + + # ========================================================================== + # STEP 5: GRID GENERATION (Sequential) + # ========================================================================== + if ui: + ui.update_step(5, "running") + log.info("--- Step 5: Generating Grid Targets ---") + + if ui: + ui.update_step_detail("Extracting cortical grid points from tractogram...") + grid_points = tractography.extract_grid_endpoints( + trk_path=config.target.bundle_path, + anat_path=config.subject.t1w_path, + step_mm=config.grid.step_size_mm, + cortex_thickness_mm=config.grid.cortex_depth_mm, + target_center=config.grid.coords, + search_radius=config.grid.search_radius_mm, + ) + + # Save Global Grid Mask + if grid_points and config.options.generate_visualizations: + if ui: + ui.update_step_detail(f"Saving {len(grid_points)} grid points...") + log.info(f"Saving {len(grid_points)} grid points to NIfTI mask...") + io.save_points_as_nifti( + np.array(grid_points), + config.subject.t1w_path, + out_dir / "grid_points_mask.nii.gz", + ) + elif not grid_points: + if ui: + ui.stop() + raise WorkflowError("No grid points generated.") + + # Init CSV + results_csv = out_dir / "TIDE_grid_results.csv" + initialize_grid_results_csv(results_csv) + + if ui: + ui.update_step(5, "complete") + + # ========================================================================== + # STEP 6: GRID SEARCH (PARALLEL or Sequential) + # ========================================================================== + log.info("--- Step 6: Running Grid Search (Optimization + Simulation) ---") + + num_points = len(grid_points) + + # Determine actual worker count + if use_no_parallel: + worker_plan = _resolve_grid_worker_plan(1, num_points) + actual_workers = worker_plan.workers + log.info("Parallel processing DISABLED. Running sequentially.") + else: + worker_plan = _resolve_grid_worker_plan(use_max_workers, num_points) + actual_workers = worker_plan.workers + if use_max_workers is None: + desired_workers = min( + max(1, worker_plan.cpu_count - 1), + DEFAULT_MAX_WORKERS, + num_points, + ) + else: + desired_workers = min(use_max_workers, num_points) + if not worker_plan.forced and actual_workers < desired_workers: + log.warning( + "Grid workers capped at %d by the PARDISO memory model " + "(%s GB available, %.1f GB reserve, %.1f GB/worker). Set %s=1 " + "to force the requested count.", + actual_workers, + ( + f"{worker_plan.available_memory_gb:.1f}" + if worker_plan.available_memory_gb is not None + else "unknown" + ), + worker_plan.memory_reserve_gb, + worker_plan.memory_per_worker_gb, + GRID_FORCE_WORKERS_ENV, + ) + log.info(f"Processing {num_points} grid points with {actual_workers} workers") + + # Transition UI to parallel mode if using multiple workers + if ui and actual_workers > 1: + ui.transition_to_parallel(actual_workers, num_points) + elif ui: + ui.update_step(6, "running") + + # Create tasks + tasks = [] + for i, cortex_coord in enumerate(grid_points): + point_label = f"grid_P0{i}" if i < 10 else f"grid_P{i}" + point_dir = sims_dir / point_label + + task = GridPointTask( + index=i, + cortex_coord=list(cortex_coord), + point_label=point_label, + point_dir=str(point_dir), + msh_file=str(msh_file), + m2m_path=str(config.subject.m2m_path), + coil_path=str(config.coil.coil_path), + fixed_scalp_coords=( + fixed_scalp_coords.tolist() + if hasattr(fixed_scalp_coords, "tolist") + else list(fixed_scalp_coords) + ), + grid_orientation_ref=grid_orientation_ref, + target_bundle_path=str(config.target.bundle_path), + t1w_path=str(config.subject.t1w_path), + surface_path=( + str(config.subject.surface_path) if config.subject.surface_path else None + ), + weights_target_path=( + str(config.subject.weights_target_path) + if config.subject.weights_target_path + else None + ), + adm_optimization=config.options.adm_optimization, + opt_spatial_resolution=config.options.opt_spatial_resolution, + opt_angle_resolution=config.options.opt_angle_resolution, + opt_search_angle=config.options.opt_search_angle, + opt_search_radius=config.options.opt_search_radius, + field_mode=config.options.field_mode, + roi_size_mm=config.options.roi_size_mm, + activation_length_mm=config.options.activation_length_mm, + gwi_threshold_mm=config.options.gwi_threshold_mm, + max_angular_deviation_deg=config.options.max_angular_deviation_deg, + measured_rmt_mso=config.calibration.measured_rmt_mso, + af_cst_calibration=af_cst_calibration, + cst_metric_unweighted=cst_res.metric_unweighted, + mso_floor_ratio=config.options.mso_floor_ratio, + mso_ceiling_ratio=config.options.mso_ceiling_ratio, + generate_visualizations=config.options.generate_visualizations, + ) + tasks.append(task) + + # Execute tasks + results: List[GridPointResult] = [] + failed_count = 0 + + # ctx was created at the start of the workflow (before UI creation) + # to ensure the UI's status_queue uses the same context as workers + + # Determine if we should use parallel UI features + use_parallel_ui = ui is not None and actual_workers > 1 + + # Create worker logs directory for parallel mode + worker_logs_dir = out_dir / "worker_logs" if use_parallel_ui else None + if worker_logs_dir: + worker_logs_dir.mkdir(exist_ok=True) + + if actual_workers == 1: + # Sequential processing (no console UI) + for i, task in enumerate(tasks): + log.info(f"=== Processing {task.point_label} ({i + 1}/{num_points}) ===") + result = process_grid_point(task) + results.append(result) + if not result.success: + failed_count += 1 + log.error(f"Failed: {result.point_label} - {result.error_message}") + + # Emit progress callback + if progress_callback: + progress_callback(i + 1, num_points, task.point_label) + else: + # Parallel processing with optional console UI + # CRITICAL: Use "spawn" context to avoid MPI/PETSc fork conflicts. + # The default "fork" method inherits parent MPI state, causing segfaults. + + # Import worker wrapper if parallel UI is enabled + if use_parallel_ui: + from tide.console import process_grid_point_with_reporting + + worker_func = process_grid_point_with_reporting + else: + worker_func = process_grid_point + + # Prepare lock directory for robust worker ID assignment + lock_dir_obj = tempfile.TemporaryDirectory(prefix="tide_worker_locks_") + lock_dir = lock_dir_obj.name + + # Single-thread the numerical libraries for the spawned workers before + # the pool starts, so children inherit it before importing NumPy/SimNIBS + # (audit C-004); restored on exit for parent-side subprocesses. + worker_pool = ProcessPoolExecutor( + max_workers=actual_workers, + mp_context=ctx, + initializer=_init_worker, + initargs=(actual_workers, lock_dir), + ) + with _single_thread_child_env(), worker_pool as executor: + # Submit all tasks + if use_parallel_ui: + # Use reporting wrapper with status queue + future_to_task = {} + for idx, task in enumerate(tasks): + future = executor.submit( + worker_func, + task, + ui.status_queue, + -1, # Use persistent ID from initializer + worker_logs_dir, + ) + future_to_task[future] = task + else: + # Use original worker function + future_to_task = {executor.submit(process_grid_point, task): task for task in tasks} + + results, failed_count = _collect_parallel_grid_results( + future_to_task, + num_points, + use_parallel_ui, + progress_callback, + ) + + # Sort results by index to maintain order + results.sort(key=lambda r: r.index) + + estimation_valid_count = sum( + result.success + and result.weighted_mso_flag != "ESTIMATION_FAILED" + and result.unweighted_mso_flag != "ESTIMATION_FAILED" + and np.isfinite(result.weighted_mso_raw) + and np.isfinite(result.unweighted_mso_raw) + for result in results + ) + log.info( + "Grid search complete: %d/%d processed successfully; %d/%d produced " + "valid intensity estimates", + num_points - failed_count, + num_points, + estimation_valid_count, + num_points, + ) + + if ui: + ui.update_step(6, "complete") + + # ========================================================================== + # STEP 7: WRITE RESULTS AND SAVE CONFIGS + # ========================================================================== + if ui: + ui.update_step(7, "running") + log.info("--- Step 7: Writing Results ---") + + if ui: + ui.update_step_detail("Writing results to CSV and configs...") + reporting_context = GridReportingContext( + config=config, + out_dir=out_dir, + sims_dir=sims_dir, + results_csv=results_csv, + fixed_scalp_coords=fixed_scalp_coords, + grid_orientation_ref=grid_orientation_ref, + calibration_orientation=cal_orientation, + target_streamlines_full=tgt_streamlines_full, + target_vectors_in_m1=e_vecs_list_tgt_in_m1_full, + cst_result=cst_res, + af_cst_calibration=af_cst_calibration, + cst_align=cst_align, + cst_align_corrected=cst_align_corrected, + cst_depth=cst_depth, + intensity_rmt=intensity_rmt, + biological_threshold=biological_threshold, + m1_matrix_str=m1_matrix_str, + spatial_mode=spatial_mode, + num_workers=actual_workers, + calibration_pose_qc=calibration_pose_qc, + start_time=start_time, + worker_memory_model=worker_plan.as_dict(), + ) + final_grid_results = write_grid_results(results, reporting_context) + + if ui: + ui.update_step_detail("Generating summary report...") + log.info("Generating final summary report...") + + summary_result = write_grid_summary(results, final_grid_results, reporting_context) + summary_path = summary_result.summary_path + elapsed_time = summary_result.elapsed_time + stats_weighted = summary_result.weighted_statistics + stats_unweighted = summary_result.unweighted_statistics + stats_raw_weighted = summary_result.weighted_raw_statistics + stats_raw_unweighted = summary_result.unweighted_raw_statistics + stats_mult_weighted = summary_result.weighted_multiplier_statistics + stats_mult_unweighted = summary_result.unweighted_multiplier_statistics + status_counts = summary_result.status_counts + + if ui: + ui.update_step(7, "complete") + + # ========================================================================== + # STEP 8: VISUALIZATION + # ========================================================================== + log.info("--- Step 8: Generating Visualizations ---") + if ui: + ui.update_step(8, "running") + ui.update_step_detail("Running grid visualization script...") + + viz_dir = out_dir / "visualization" + if config.options.generate_visualizations: + viz_dir.mkdir(parents=True, exist_ok=True) + try: + from tide.interfaces.grid_visualization import run_grid_visualization + + run_grid_visualization( + csv_path=results_csv, + t1w_path=config.subject.t1w_path, + trk_path=config.target.bundle_path, + output_dir=viz_dir, + generate_interactive=config.options.generate_3d_visualization, + ) + log.info(f"Visualization outputs saved to: {viz_dir}") + except Exception as e: + log.warning(f"Visualization step failed: {e}") + else: + log.info("Visualization artifacts disabled by configuration.") + + if ui: + ui.update_step(8, "complete") + + # Render console UI final summary if enabled + if ui: + # Prepare results for summary display + ui_results = [ + { + "label": r.point_label, + "weighted_mso": r.weighted_mso, + "unweighted_mso": r.unweighted_mso, + "weighted_mso_raw": r.weighted_mso_raw, + "unweighted_mso_raw": r.unweighted_mso_raw, + "weighted_flag": r.weighted_mso_flag, + "unweighted_flag": r.unweighted_mso_flag, + "success": r.success, + "cortex_coord": r.cortex_coord, + } + for r in results + ] + + # Prepare output files list + output_files = [ + ("Results CSV", results_csv), + ("Summary", summary_path), + ("HTML Report", summary_path.with_suffix(".html")), + ("Simulations", sims_dir), + ] + if config.options.generate_visualizations: + output_files.append(("Visualizations", viz_dir)) + + # This will stop the UI and render final summary + ui.render_final_summary( + ui_results, + elapsed_time, + output_files, + stats_summary={ + "weighted": stats_weighted, + "unweighted": stats_unweighted, + "raw_weighted": stats_raw_weighted, + "raw_unweighted": stats_raw_unweighted, + "multiplier_weighted": stats_mult_weighted, + "multiplier_unweighted": stats_mult_unweighted, + "status_counts": status_counts, + }, + ) + else: + # Log Stats for no-UI mode + log.info("=== Statistical Summary ===") + log.info(f"{'Metric':<25} | {'Unweighted':<10} | {'Weighted':<10}") + log.info("-" * 51) + + metrics = ["mean", "median", "std", "mean_no_outliers", "outlier_count"] + labels = [ + "Mean", + "Median", + "Std Dev", + "Mean (w/o outliers)", + "Outliers (>2 SD)", + ] + + for metric, label in zip(metrics, labels): + u_val = stats_unweighted.get(metric, 0) + w_val = stats_weighted.get(metric, 0) + + if metric == "outlier_count": + log.info(f"{label:<25} | {int(u_val):<10} | {int(w_val):<10}") + else: + log.info(f"{label:<25} | {u_val:<10.2f} | {w_val:<10.2f}") + + # Multiplier (M_CST/M_target) — same panel as intensity so the user can + # rescale dose without rerunning. + log.info( + f"{'Mean Multiplier':<25} | " + f"{stats_mult_unweighted.get('mean', 0):<10.4f} | " + f"{stats_mult_weighted.get('mean', 0):<10.4f}" + ) + log.info( + f"{'Median Multiplier':<25} | " + f"{stats_mult_unweighted.get('median', 0):<10.4f} | " + f"{stats_mult_weighted.get('median', 0):<10.4f}" + ) + log.info( + f"{'Mean Raw I':<25} | " + f"{stats_raw_unweighted.get('mean', 0):<10.2f} | " + f"{stats_raw_weighted.get('mean', 0):<10.2f}" + ) + log.info( + f"{'Estimation Failures':<25} | " + f"{status_counts['unweighted']['estimation_failed']:<10} | " + f"{status_counts['weighted']['estimation_failed']:<10}" + ) + log.info("=" * 51) + log.info("--- Output files ---") + log.info(f" -> Results CSV: {Path(results_csv).resolve()}") + log.info(f" -> Summary: {Path(summary_path).resolve()}") + log.info(f" -> HTML Report: {Path(summary_path).with_suffix('.html').resolve()}") + log.info(f" -> Simulations: {Path(sims_dir).resolve()}") + if config.options.generate_visualizations: + log.info(f" -> Visualizations: {Path(viz_dir).resolve()}") + else: + log.info(" -> Visualizations: Disabled by configuration") diff --git a/src/tide/workflows/standard.py b/src/tide/workflows/standard.py new file mode 100644 index 0000000..8c55b0b --- /dev/null +++ b/src/tide/workflows/standard.py @@ -0,0 +1,559 @@ +""" +Standard Simulation and Optimization Workflows +============================================== +Standalone simulation and optimization without full TIDE estimation. +""" + +import logging +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +import numpy as np + +from tide.core import io, physics, tractography +from tide.core.geometry import ( + calculate_alignment_and_depth, + calculate_alignment_corrected, + evaluate_coil_pose_qc, + project_target_to_scalp, +) +from tide.interfaces.sampling import sample_field_at_coordinates +from tide.interfaces.simnibs_interface import SimNIBSInterface +from tide.interfaces.unified_estimation import AnalysisConfig, analyze_bundle, load_surface_tree +from tide.interfaces.visualization_3d import ( + PYVISTA_AVAILABLE, + VisualizationConfig, + generate_bundle_visualization, +) +from tide.utils.config import ( + SimNIBSConfig, + orientation_is_matrix, + save_config_to_output, + validate_workflow_config, +) +from tide.workflows._shared import WorkflowError, split_vectors_by_streamline + +log = logging.getLogger(__name__) + + +def run_standard_simulation(config: SimNIBSConfig) -> None: + """ + Runs a standard TMS simulation. + + If orientation is provided as a 3D vector (not a 4x4 matrix), the workflow + automatically runs optimization first to find the optimal coil position and + orientation, then uses the resulting 4x4 matrix for the simulation. + + Includes AF calculation and visualization if a bundle is provided. + """ + validate_workflow_config(config, "simulation") + + log.highlight("=== Starting Standard Simulation ===") + + # Log configuration parameters + log.info("=== Configuration Parameters ===") + log.info(f"Subject ID: {config.subject.id}") + log.info(f"Target site: {config.target.label}") + log.info(f"Coil model: {config.coil.coil_model}") + log.info(f"Coil distance: {config.coil.coil_distance_mm} mm") + log.info(f"dI/dt max: {config.coil.device_didt_max / 1e6:.2f} A/µs") + log.info(f"ROI size: {config.options.roi_size_mm} mm") + log.info(f"Activation length: {config.options.activation_length_mm} mm") + log.info(f"Field mode: {config.options.field_mode}") + log.info("=" * 50) + + # --- Medoid Logic --- + if config.target.medoid_endpoint: + if not config.target.bundle_path or not config.target.bundle_path.exists(): + raise WorkflowError("Medoid endpoint requested but bundle path is missing.") + + log.highlight(f"Computing cortical medoid for: {config.target.label}") + try: + new_coords = tractography.get_bundle_cortical_medoid( + config.target.bundle_path, + config.subject.t1w_path, + reference_coord=config.target.coords, + ) + log.highlight(f"Medoid: {new_coords.tolist()}") + config.target.coords = new_coords.tolist() + except Exception as e: + raise WorkflowError(f"Failed to calculate medoid: {e}") from e + + out_dir = config.subject.derivatives_path / f"simulation_{config.target.label}" + out_dir.mkdir(parents=True, exist_ok=True) + + # Note: Config save is moved AFTER optimization to include generated matrix + # Variables to store generated matrix and scalp coords for config save + generated_target_matrix = None + generated_target_scalp_coords = None + pose_qc = None + + sim_coords = config.target.scalp_coords + sim_orientation = config.target.orientation + + # Detect if orientation is a 4x4 matrix or a 3D vector + is_matrix = orientation_is_matrix(config.target.orientation) + + # --- Auto-Optimization Logic --- + # If orientation is a 3D vector (not a 4x4 matrix), run optimization first + # to find the optimal coil position and orientation + if not is_matrix: + if config.target.coords: + log.highlight("--- Running Auto-Optimization (3D vector orientation detected) ---") + log.info( + "Orientation is a 3D vector reference point. " + "Running optimization to find optimal coil position..." + ) + + try: + opt_matrix, opt_scalp_coords = SimNIBSInterface.run_optimization( + mesh_path=config.subject.mesh_path, + output_dir=out_dir, + coil_path=config.coil.coil_path, + target_coords=config.target.coords, + scalp_centre=config.target.scalp_coords, + orientation_ref=config.target.orientation, + didt=1e6, # Use normalized dI/dt for optimization + use_adm=config.options.adm_optimization, + spatial_resolution=config.options.opt_spatial_resolution, + angle_resolution=config.options.opt_angle_resolution, + search_angle=config.options.opt_search_angle, + search_radius_mm=config.options.opt_search_radius, + ) + + # Use the optimized 4x4 matrix for simulation + sim_orientation = opt_matrix.tolist() + generated_target_matrix = opt_matrix.tolist() # Save for config + generated_target_scalp_coords = opt_scalp_coords.tolist() + sim_coords = None # Matrix includes position, no separate coords needed + is_matrix = True # Update flag since we now have a matrix + qc = evaluate_coil_pose_qc( + config.subject.mesh_path, + opt_matrix, + opt_scalp_coords, + ) + pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"Target coil pose QC warning: {qc.reasons}") + + log.highlight( + f"Optimization complete. Optimal scalp position: {opt_scalp_coords.tolist()}" + ) + + # Save optimization result + io.save_optimization_result_txt( + out_dir / f"{config.target.label}_opt_result.txt", + opt_matrix, + opt_scalp_coords, + pose_qc=pose_qc, + ) + + except Exception as e: + raise WorkflowError(f"Auto-optimization failed: {e}") from e + else: + raise WorkflowError("No cortical coordinates provided for optimization.") + else: + # --- Projection Logic (only when using matrix directly) --- + # If we have a matrix, we don't need scalp projection + log.info("Using provided 4x4 transformation matrix directly.") + qc = evaluate_coil_pose_qc(config.subject.mesh_path, np.asarray(sim_orientation)) + pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"Target coil pose QC warning: {qc.reasons}") + + # Save configuration (after optimization, with generated matrix and + # resolved scalp coords) so the output YAML is fully re-runnable via + # `--workflow simulation` without re-triggering optimization or medoid logic. + save_config_to_output( + config, + out_dir, + "simulation", + generated_target_matrix=generated_target_matrix, + generated_target_scalp_coords=generated_target_scalp_coords, + medoid_resolved=bool(config.target.medoid_endpoint), + ) + + # --- Intensity Logic --- + sim_didt = 1e6 + if config.target.didt is not None: + sim_didt = config.target.didt + elif config.target.mso is not None and config.coil.device_didt_max: + sim_didt = config.coil.device_didt_max * (config.target.mso / 100.0) + + log.info(f"Starting simulation for {config.target.label}...") + log.info(f"Simulation: dI/dt={sim_didt / 1e6:.2f} A/µs") + + # --- Run Simulation --- + try: + mesh_path = SimNIBSInterface.run_simulation( + mesh_path=config.subject.m2m_path, + output_dir=out_dir, + coil_path=config.coil.coil_path, + didt=sim_didt, + coords=sim_coords, + orientation=sim_orientation, + distance_mm=config.coil.coil_distance_mm, + fields="veEjJ", + ) + except Exception as e: + raise WorkflowError(f"Simulation failed: {e}") from e + + log.highlight(f"Simulation complete: {mesh_path.name}") + + # --- E-field Mapping (if bundle provided) --- + if config.target.bundle_path and config.target.bundle_path.exists(): + log.info(f"Computing analysis for {config.target.label}...") + _process_bundle_mapping(config, mesh_path, out_dir, pose_qc=pose_qc) + + log.highlight("Standard simulation finished.") + log.highlight("Output files:") + log.highlight(f" -> Simulation: {out_dir.resolve()}") + log.highlight(f" -> Mesh: {mesh_path.resolve()}") + + +def run_standard_optimization(config: SimNIBSConfig) -> None: + """ + Runs a standard TMS optimization. + """ + log.highlight("=== Starting Standard Optimization ===") + + # Log configuration parameters + log.info("=== Configuration Parameters ===") + log.info(f"Subject ID: {config.subject.id}") + log.info(f"Target site: {config.target.label}") + log.info(f"Coil model: {config.coil.coil_model}") + log.info(f"Coil distance: {config.coil.coil_distance_mm} mm") + log.info(f"dI/dt max: {config.coil.device_didt_max / 1e6:.2f} A/µs") + log.info(f"ROI size: {config.options.roi_size_mm} mm") + log.info(f"Activation length: {config.options.activation_length_mm} mm") + log.info(f"Field mode: {config.options.field_mode}") + log.info(f"ADM optimization: {config.options.adm_optimization}") + log.info(f"Optimization search radius: {config.options.opt_search_radius} mm") + log.info(f"Optimization spatial resolution: {config.options.opt_spatial_resolution} mm") + log.info(f"Optimization angle resolution: {config.options.opt_angle_resolution}°") + log.info(f"Optimization search angle: {config.options.opt_search_angle}°") + log.info("=" * 50) + + # --- Medoid Logic --- + if config.target.medoid_endpoint: + if not config.target.bundle_path or not config.target.bundle_path.exists(): + raise WorkflowError("Medoid endpoint requested but bundle path is missing.") + + log.highlight(f"Computing cortical medoid for: {config.target.label}") + try: + new_coords = tractography.get_bundle_cortical_medoid( + config.target.bundle_path, + config.subject.t1w_path, + reference_coord=config.target.coords, + ) + log.highlight(f"Medoid: {new_coords.tolist()}") + config.target.coords = new_coords.tolist() + config.target.scalp_coords = None # Force re-projection + except Exception as e: + raise WorkflowError(f"Failed to calculate medoid: {e}") from e + + out_dir = config.subject.derivatives_path / f"optimization_{config.target.label}" + out_dir.mkdir(parents=True, exist_ok=True) + + if not config.target.coords: + raise WorkflowError("Target coordinates required for optimization.") + + sim_didt = 1e6 + if config.target.didt is not None: + sim_didt = config.target.didt + elif config.target.mso is not None and config.coil.device_didt_max: + sim_didt = config.coil.device_didt_max * (config.target.mso / 100.0) + + log.info(f"Starting optimization for {config.target.label}...") + log.info(f"Optimization: target={config.target.coords}, dI/dt={sim_didt / 1e6:.2f} A/µs") + + try: + orientation_to_use = ( + config.target.orientation + if isinstance(config.target.orientation, (list, str)) + else None + ) + + # Detailed logging for debugging orientation issues + log.info("=" * 60) + log.info("[STANDARD_OPT] === OPTIMIZATION PARAMETERS ===") + log.info(f"[STANDARD_OPT] target_coords (cortex): {config.target.coords}") + log.info(f"[STANDARD_OPT] scalp_coords: {config.target.scalp_coords}") + log.info(f"[STANDARD_OPT] orientation from config: {config.target.orientation}") + log.info(f"[STANDARD_OPT] orientation type: {type(config.target.orientation).__name__}") + log.info(f"[STANDARD_OPT] orientation_ref being sent: {orientation_to_use}") + log.info( + f"[STANDARD_OPT] search_angle: {config.options.opt_search_angle}° (±{config.options.opt_search_angle / 2}°)" + ) + log.info(f"[STANDARD_OPT] angle_resolution: {config.options.opt_angle_resolution}°") + log.info(f"[STANDARD_OPT] spatial_resolution: {config.options.opt_spatial_resolution} mm") + log.info(f"[STANDARD_OPT] search_radius: {config.options.opt_search_radius} mm") + log.info("=" * 60) + + matrix, scalp_coords = SimNIBSInterface.run_optimization( + mesh_path=config.subject.mesh_path, + output_dir=out_dir, + coil_path=config.coil.coil_path, + target_coords=config.target.coords, + scalp_centre=config.target.scalp_coords, + orientation_ref=orientation_to_use, + didt=sim_didt, + use_adm=config.options.adm_optimization, + spatial_resolution=config.options.opt_spatial_resolution, + angle_resolution=config.options.opt_angle_resolution, + search_angle=config.options.opt_search_angle, + search_radius_mm=config.options.opt_search_radius, + ) + qc = evaluate_coil_pose_qc(config.subject.mesh_path, matrix, scalp_coords) + pose_qc = qc.as_dict() + if qc.status == "WARN": + log.warning(f"Target coil pose QC warning: {qc.reasons}") + + log.highlight(f"Optimal scalp position: {scalp_coords.tolist()}") + + result_file = out_dir / f"{config.target.label}_opt_result.txt" + io.save_optimization_result_txt(result_file, matrix, scalp_coords, pose_qc=pose_qc) + + except Exception as e: + raise WorkflowError(f"Optimization failed: {e}") from e + + # Save configuration (after optimization) so the output YAML carries the + # generated 4x4 matrix and resolved scalp coords, enabling a direct + # `--workflow simulation` re-run without re-triggering optimization. + save_config_to_output( + config, + out_dir, + "optimization", + generated_target_matrix=matrix.tolist(), + generated_target_scalp_coords=scalp_coords.tolist(), + medoid_resolved=bool(config.target.medoid_endpoint), + ) + + log.highlight("Standard optimization finished.") + log.highlight("Output files:") + log.highlight(f" -> Optimization: {out_dir.resolve()}") + log.highlight(f" -> Result file: {result_file.resolve()}") + + +def _process_bundle_mapping( + config: SimNIBSConfig, + mesh_path: Path, + out_dir: Path, + pose_qc: Optional[Dict[str, object]] = None, +) -> None: + """Process E-field mapping to bundle with AF calculation and visualization.""" + prefix = f"{config.target.label}_{config.options.field_mode}" + viz_out = out_dir / "visualizations" + + log.debug(f"Mapping E-field to bundle: {config.target.bundle_path.name}") + + try: + sft = tractography.load_tract(config.target.bundle_path, config.subject.t1w_path) + points = np.concatenate(sft.streamlines) + + e_vecs = sample_field_at_coordinates( + mesh_path, points, "E", output_dir=out_dir, file_prefix=prefix + ) + + e_vecs_list = split_vectors_by_streamline(e_vecs, sft.streamlines) + + # Filter streamlines by angular deviation. Track original ids through the + # drop chain so SIFT2 weights stay aligned in analyze_bundle (C-002). + streamlines_to_process = list(sft.streamlines) + orig_idx = np.arange(len(sft.streamlines)) + if config.options.max_angular_deviation_deg > 0: + ( + streamlines_to_process, + e_vecs_list, + _, + orig_idx, + ) = tractography.filter_by_angular_deviation( + streamlines_to_process, + e_field_vectors=e_vecs_list, + max_angle_deg=config.options.max_angular_deviation_deg, + roi_center=config.target.coords, + roi_radius=config.options.roi_size_mm, + indices=orig_idx, + ) + + # Calculate AF + new_sl, scalars, lengths, orig_idx = physics.calculate_scalar_map( + streamlines_to_process, + e_vecs_list, + mode=config.options.field_mode, + indices=orig_idx, + ) + + alignment_qc = None + if config.target.coords: + roi_masks, _ = tractography.get_roi_masks( + new_sl, + config.options.roi_size_mm, + config.target.coords, + ) + alignment, depth = calculate_alignment_and_depth( + new_sl, + e_vecs_list, + roi_masks, + mesh_path, + config.target.coords, + ) + alignment_corrected = calculate_alignment_corrected( + new_sl, + e_vecs_list, + roi_masks, + ) + alignment_qc = { + "alignment": alignment, + "alignment_corrected": alignment_corrected, + "depth_mm": depth, + } + + # Save outputs + out_trk = out_dir / f"{prefix}.trk" + io.save_tract_with_data(sft, new_sl, out_trk, "AF", scalars, lengths) + + out_nii = out_dir / f"{prefix}.nii.gz" + if config.options.generate_visualizations: + io.save_points_as_nifti( + np.concatenate(new_sl), + config.subject.t1w_path, + out_nii, + values=np.abs(np.concatenate(scalars)), + ) + + # Run unified analysis + ana_config = AnalysisConfig( + cst_trk="", + target_trk=str(out_trk), + rmt=0.0, + cst_coords=np.zeros(3), + target_coords=np.array(config.target.coords) if config.target.coords else np.zeros(3), + surf_path=str(config.subject.surface_path) if config.subject.surface_path else None, + gwi_threshold=config.options.gwi_threshold_mm, + roi_radius=config.options.roi_size_mm, + activation_len=config.options.activation_length_mm, + target_weights=( + str(config.subject.weights_target_path) + if config.subject.weights_target_path + else None + ), + ) + + surf_tree = None + if ana_config.surf_path: + surf_tree = load_surface_tree(ana_config.surf_path) + + try: + result = analyze_bundle( + name=config.target.label, + trk_path=str(out_trk), + roi_center=ana_config.target_coords, + config=ana_config, + surface_tree=surf_tree, + weight_path=ana_config.target_weights, + orig_indices=orig_idx, + ) + + unit_str = "V/m²" if config.options.field_mode == "af" else "V/m" + log.highlight(f"Robust Metric (Weighted): {result.metric_weighted:.2f} {unit_str}") + log.highlight(f"Robust Metric (Unweighted): {result.metric_unweighted:.2f} {unit_str}") + + except Exception as e: + raise WorkflowError(f"Unified analysis failed: {e}") from e + + scalar_values = np.concatenate(scalars) if scalars else np.array([]) + finite_scalar_values = scalar_values[np.isfinite(scalar_values)] + metrics = {} + if result is not None: + metrics = { + "Robust Metric (Weighted)": f"{result.metric_weighted:.4f}", + "Robust Metric (Unweighted)": f"{result.metric_unweighted:.4f}", + } + for key in physics.AGGREGATOR_KEYS: + label = physics.AGGREGATOR_LABELS[key] + metrics[f"{label} (Weighted)"] = f"{result.aggregates_weighted.get(key, 0.0):.4f}" + metrics[f"{label} (Unweighted)"] = ( + f"{result.aggregates_unweighted.get(key, 0.0):.4f}" + ) + io.save_mapping_summary( + out_dir / f"{prefix}_summary.txt", + { + "Timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "Prefix": prefix, + "Mesh": mesh_path, + "Bundle": config.target.bundle_path, + "Anatomy": config.subject.t1w_path, + "Mode": config.options.field_mode, + "Threshold_Percent": "N/A", + "Total_Streamlines": len(new_sl), + "Max_Value": ( + f"{float(np.max(finite_scalar_values)):.6g}" + if finite_scalar_values.size + else "N/A" + ), + "Min_Value": ( + f"{float(np.min(finite_scalar_values)):.6g}" + if finite_scalar_values.size + else "N/A" + ), + "Metrics": metrics, + "QC": { + "pose_qc": pose_qc, + "alignment_qc": alignment_qc, + }, + "Output_Files": { + "Tractogram": out_trk.name, + "NIfTI map": ( + out_nii.name + if config.options.generate_visualizations + else "Disabled by configuration" + ), + }, + }, + ) + + # Generate 3D visualization + if config.options.generate_3d_visualization and PYVISTA_AVAILABLE: + log.debug("Generating 3D visualization...") + try: + viz_out.mkdir(parents=True, exist_ok=True) + scalp_point = None + if config.target.coords: + try: + scalp_point = project_target_to_scalp( + mesh_path, np.array(config.target.coords) + ) + except Exception: + pass + + viz_config = VisualizationConfig( + efield_vmax=80.0, + af_vmax=( + float(np.percentile(np.abs(np.concatenate(scalars)), 99)) + if scalars + else 100.0 + ), + ) + + generate_bundle_visualization( + mesh_path=mesh_path, + streamlines=new_sl, + af_values=scalars, + roi_center=( + np.array(config.target.coords) if config.target.coords else np.zeros(3) + ), + roi_radius=config.options.roi_size_mm, + output_dir=viz_out, + prefix=prefix, + config=viz_config, + scalp_point=scalp_point, + ) + except Exception as e: + log.warning(f"Visualization failed: {e}") + + except Exception as e: + if isinstance(e, WorkflowError): + raise + raise WorkflowError(f"E-field mapping failed: {e}") from e diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8ded986 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# TIDE Pipeline Tests diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..5205938 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,233 @@ +""" +Pytest Configuration and Fixtures for TIDE Pipeline Tests +========================================================== +Provides mock data generators and shared fixtures for testing. +""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +# ============================================================================= +# Mock Data Generators +# ============================================================================= + + +def generate_mock_streamline( + n_points: int = 50, + start: np.ndarray = None, + direction: np.ndarray = None, + step_size: float = 0.5, + curvature: float = 0.0, +) -> np.ndarray: + """ + Generate a mock streamline with optional curvature. + + Args: + n_points: Number of points in the streamline + start: Starting coordinate [x, y, z] + direction: Initial direction vector + step_size: Distance between points (mm) + curvature: Curvature factor (0 = straight, higher = more curved) + + Returns: + Nx3 numpy array of streamline coordinates + """ + if start is None: + start = np.array([0.0, 0.0, 0.0]) + if direction is None: + direction = np.array([1.0, 0.0, 0.0]) + + direction = direction / np.linalg.norm(direction) + points = [start.copy()] + current_pos = start.copy() + current_dir = direction.copy() + + for i in range(n_points - 1): + # Add curvature by rotating direction + if curvature > 0: + angle = curvature * step_size + # Rotate in XY plane + cos_a, sin_a = np.cos(angle), np.sin(angle) + rot = np.array([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]]) + current_dir = rot @ current_dir + + current_pos = current_pos + current_dir * step_size + points.append(current_pos.copy()) + + return np.array(points) + + +def generate_mock_efield( + streamline: np.ndarray, + magnitude: float = 100.0, + alignment: float = 1.0, + noise_std: float = 0.0, +) -> np.ndarray: + """ + Generate mock E-field vectors along a streamline. + + Args: + streamline: Nx3 streamline coordinates + magnitude: E-field magnitude (V/m) + alignment: 0-1, how aligned E-field is with tangent + noise_std: Standard deviation of noise to add + + Returns: + Nx3 array of E-field vectors + """ + _ = len(streamline) + + # Calculate tangent vectors + tangents = np.gradient(streamline, axis=0) + tangent_norms = np.linalg.norm(tangents, axis=1, keepdims=True) + tangents = tangents / (tangent_norms + 1e-9) + + # Create perpendicular component + perp = np.zeros_like(tangents) + perp[:, 0] = -tangents[:, 1] + perp[:, 1] = tangents[:, 0] + perp_norms = np.linalg.norm(perp, axis=1, keepdims=True) + perp = perp / (perp_norms + 1e-9) + + # Combine aligned and perpendicular components + e_field = alignment * tangents + (1 - alignment) * perp + e_field = e_field / np.linalg.norm(e_field, axis=1, keepdims=True) + e_field = e_field * magnitude + + # Add noise if requested + if noise_std > 0: + e_field += np.random.normal(0, noise_std, e_field.shape) + + return e_field + + +# ============================================================================= +# Pytest Fixtures +# ============================================================================= + + +@pytest.fixture +def single_straight_streamline(): + """Single straight streamline along X-axis.""" + return generate_mock_streamline(n_points=50, step_size=0.5, curvature=0.0) + + +@pytest.fixture +def single_curved_streamline(): + """Single curved streamline with moderate curvature.""" + return generate_mock_streamline(n_points=50, step_size=0.5, curvature=0.1) + + +@pytest.fixture +def bundle_straight(n_fibers: int = 10): + """Bundle of parallel straight streamlines.""" + streamlines = [] + for i in range(n_fibers): + offset = np.array([0.0, i * 2.0, 0.0]) + sl = generate_mock_streamline(n_points=50, start=offset, step_size=0.5) + streamlines.append(sl) + return streamlines + + +@pytest.fixture +def bundle_with_efield(): + """Bundle of streamlines with corresponding E-field vectors.""" + streamlines = [] + e_fields = [] + + for i in range(5): + offset = np.array([0.0, i * 2.0, 0.0]) + sl = generate_mock_streamline(n_points=30, start=offset, step_size=0.5) + ef = generate_mock_efield(sl, magnitude=100.0, alignment=0.8) + streamlines.append(sl) + e_fields.append(ef) + + return streamlines, e_fields + + +@pytest.fixture +def short_streamline(): + """Very short streamline (edge case).""" + return generate_mock_streamline(n_points=3, step_size=0.5) + + +@pytest.fixture +def empty_streamlines(): + """Empty list of streamlines (edge case).""" + return [] + + +@pytest.fixture +def mock_roi_center(): + """ROI center coordinate.""" + return np.array([10.0, 5.0, 0.0]) + + +@pytest.fixture +def mock_scalp_coords(): + """Mock scalp coordinates.""" + return np.array([-45.0, 30.0, 65.0]) + + +@pytest.fixture +def mock_brain_center(): + """Mock brain center coordinates.""" + return np.array([0.0, 0.0, 0.0]) + + +# ============================================================================= +# Mock SimNIBS Mesh Fixture +# ============================================================================= + + +@pytest.fixture +def mock_mesh(): + """ + Create a simple mock mesh with scalp and GM surfaces. + Returns a mock object that mimics SimNIBS mesh structure. + """ + mesh = MagicMock() + + # Create simple sphere-like node positions + n_nodes = 100 + theta = np.linspace(0, 2 * np.pi, n_nodes) + phi = np.linspace(0, np.pi, n_nodes) + + # Dummy nodes (not physically accurate, just for testing) + nodes = np.zeros((n_nodes + 1, 3)) # +1 for 1-indexed access + for i in range(1, n_nodes + 1): + nodes[i] = [ + 50 * np.sin(phi[i - 1]) * np.cos(theta[i - 1]), + 50 * np.sin(phi[i - 1]) * np.sin(theta[i - 1]), + 50 * np.cos(phi[i - 1]), + ] + + mesh.nodes.__getitem__ = lambda s: nodes + mesh.elm.tag1 = np.array([1005] * 50 + [1002] * 50) # Scalp + GM tags + mesh.elm.node_number_list = np.column_stack( + [np.arange(1, 51), np.arange(2, 52) % 50 + 1, np.arange(3, 53) % 50 + 1] + ) + mesh.elm.elm_type = np.array([2] * 100) # Triangle elements + + return mesh + + +# ============================================================================= +# Path Fixtures +# ============================================================================= + + +@pytest.fixture +def temp_output_dir(tmp_path): + """Temporary directory for test outputs.""" + output_dir = tmp_path / "test_output" + output_dir.mkdir() + return output_dir + + +@pytest.fixture +def mock_t1w_path(tmp_path): + """Mock T1w path (doesn't need to exist for unit tests).""" + return tmp_path / "mock_t1w.nii.gz" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..90e28e1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,907 @@ +""" +Subprocess Tests for the TIDE CLI Ordering Contract +==================================================== +Verify that ``main.py`` performs SimNIBS environment discovery before parsing +the workflow config or creating any output, and that the informational +commands (``--version`` / ``--help``) bypass SimNIBS entirely. These tests are +environment-independent: they neither require SimNIBS to be installed nor +mutate the caller's environment. +""" + +import logging +import os +import subprocess +import sys +import textwrap +import types +from pathlib import Path +from types import SimpleNamespace +from typing import Iterator, Optional + +import pytest +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +MAIN_PY = REPO_ROOT / "main.py" +SRC_DIR = REPO_ROOT / "src" +_RELAUNCH_MARKER = "_TIDE_SIMNIBS_RELAUNCHED" +sys.path.insert(0, str(SRC_DIR)) + + +@pytest.fixture(autouse=True) +def _restore_logging_state() -> Iterator[None]: + root = logging.getLogger() + original_handlers = list(root.handlers) + original_level = root.level + + yield + + for handler in root.handlers: + if handler not in original_handlers: + handler.close() + root.handlers[:] = original_handlers + root.setLevel(original_level) + + +def _simnibs_importable() -> bool: + proc = subprocess.run([sys.executable, "-c", "import simnibs"], capture_output=True) + return proc.returncode == 0 + + +SIMNIBS_PRESENT = _simnibs_importable() + + +def _run_cli(args, path_dir: Path): + """Invoke the CLI with ``simnibs`` hidden from PATH and no relaunch marker.""" + env = os.environ.copy() + env["PATH"] = str(path_dir) # a dir with no 'simnibs' launcher + env["PYTHONPATH"] = str(SRC_DIR) + env.pop(_RELAUNCH_MARKER, None) + return subprocess.run( + [sys.executable, str(MAIN_PY), *args], + env=env, + capture_output=True, + text=True, + ) + + +def _write_preflight_config( + tmp_path: Path, + *, + field_mode: str = "af", + weights_cst: Optional[Path] = None, + weights_target: Optional[Path] = None, +) -> tuple[Path, Path]: + out_dir = tmp_path / "derivatives_out" + weight_lines = [] + if weights_cst is not None: + weight_lines.append(f" weights_cst: {weights_cst}") + if weights_target is not None: + weight_lines.append(f" weights_target: {weights_target}") + weights_yaml = "\n".join(weight_lines) + if weights_yaml: + weights_yaml = f"\n{weights_yaml}" + + cfg = tmp_path / "preflight.yml" + cfg.write_text(textwrap.dedent(f""" + subject: + id: sub-TEST + derivatives_path: {out_dir} + m2m_path: {tmp_path / "m2m_sub-TEST"} + files: + t1w: {tmp_path / "t1.nii.gz"}{weights_yaml} + coil: + coil_model: MagVenture_C-B60.ccd + coil_path: {tmp_path} + coil_distance_mm: 4.0 + device_didt_max: 161e6 + experiment: + calibration: + label: M1 + bundle_path: {tmp_path / "cst.trk"} + measured_rmt_mso: 50.0 + coords: [0.0, 0.0, 0.0] + target: + label: Target + bundle_path: {tmp_path / "target.trk"} + coords: [0.0, 0.0, 0.0] + orientation: [0.0, 1.0, 0.0] + grid: + search_radius_mm: 4.0 + step_size_mm: 4.0 + cortex_depth_mm: 2.0 + options: + field_mode: {field_mode} + """)) + return cfg, out_dir + + +def _materialize_preflight_inputs(tmp_path: Path) -> None: + m2m_path = tmp_path / "m2m_sub-TEST" + m2m_path.mkdir(exist_ok=True) + (m2m_path / "sub-TEST.msh").write_text("") + for filename in ("t1.nii.gz", "cst.trk", "target.trk", "MagVenture_C-B60.ccd"): + (tmp_path / filename).write_text("") + + +def _write_stmpx_template(tmp_path: Path, contents: Optional[str] = None) -> Path: + stmpx_path = tmp_path / "session.stmpx" + stmpx_path.write_text( + contents + if contents is not None + else '\n' + ) + return stmpx_path + + +def _write_target_summary(config) -> None: + output_dir = config.subject.derivatives_path / f"TIDE_{config.target.label}" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / f"TIDE_Results_{config.target.label}.txt").write_text(textwrap.dedent(""" + --- Target Estimation (Target) --- + Target Coords (Cortex): [1.0, 2.0, 3.0] + Optimized Scalp Position: [4.0, 5.0, 6.0] + Optimized Matrix: [[1.0, 0.0, 0.0, 4.0], [0.0, 1.0, 0.0, 5.0], [0.0, 0.0, 1.0, 6.0], [0.0, 0.0, 0.0, 1.0]] + --- Geometric Analysis --- + """).strip()) + + +def test_version_bypasses_simnibs(tmp_path): + """--version exits 0 without needing SimNIBS on PATH.""" + result = _run_cli(["--version"], tmp_path) + assert result.returncode == 0 + assert result.stdout.strip() + + +def test_help_bypasses_simnibs(tmp_path): + """--help exits 0 and lists workflows without needing SimNIBS.""" + result = _run_cli(["--help"], tmp_path) + assert result.returncode == 0 + assert "estimation" in result.stdout + + +def test_python_m_tide_help_bypasses_simnibs(tmp_path): + """``python -m tide --help`` is an interpreter-explicit CLI fallback.""" + env = os.environ.copy() + env["PATH"] = str(tmp_path) + env["PYTHONPATH"] = str(SRC_DIR) + env.pop(_RELAUNCH_MARKER, None) + result = subprocess.run( + [sys.executable, "-m", "tide", "--help"], + env=env, + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "estimation" in result.stdout + + +def test_init_config_bypasses_simnibs_and_matches_canonical_template(tmp_path): + """--init-config works without SimNIBS and writes the canonical template.""" + destination = tmp_path / "generated.yml" + result = _run_cli(["--init-config", str(destination)], tmp_path) + + assert result.returncode == 0 + assert destination.read_text() == (REPO_ROOT / "config_template.yml").read_text() + assert "Wrote TIDE configuration template" in result.stdout + + +def test_init_config_refuses_to_overwrite_existing_file(tmp_path): + """--init-config must never silently replace a user's configuration.""" + destination = tmp_path / "config.yml" + destination.write_text("existing: true\n") + + result = _run_cli(["--init-config", str(destination)], tmp_path) + + assert result.returncode == 1 + assert destination.read_text() == "existing: true\n" + assert "Refusing to overwrite" in result.stderr + + +@pytest.mark.parametrize("flag", ["-v", "--version", "-h", "--help"]) +def test_research_use_statement_in_informational_output(tmp_path, flag): + """Both informational CLI paths carry the Research-Use-Only statement.""" + from tide.banner import RESEARCH_USE_HEADING, RESEARCH_USE_LINES + + result = _run_cli([flag], tmp_path) + assert result.returncode == 0 + assert RESEARCH_USE_HEADING in result.stdout + for line in RESEARCH_USE_LINES: + assert line in result.stdout + + +def test_bootstrap_uses_exact_simnibs_python_for_pip(monkeypatch): + """Bootstrap must use ``simnibs_python -m pip``, never a standalone pip script.""" + import tide.cli as cli + + simnibs_python = Path("/opt/SimNIBS/simnibs_env/bin/python") + src_dir = Path("/checkout/TIDE/src") + calls = [] + probe_envs = [] + + monkeypatch.setattr(cli, "_locate_simnibs_python", lambda: simnibs_python) + monkeypatch.setattr(cli, "_verify_simnibs_deps", lambda python: (True, [])) + monkeypatch.setattr(cli, "_source_checkout_src_dir", lambda: src_dir) + + def fake_run(args, check=False): + calls.append(args) + return SimpleNamespace(returncode=0) + + def fake_importable(python, env=None): + probe_envs.append(env) + return True + + monkeypatch.setattr(cli.subprocess, "run", fake_run) + monkeypatch.setattr(cli, "_tide_importable_under", fake_importable) + monkeypatch.setenv("PYTHONPATH", "/should/not/leak") + + cli.run_bootstrap(editable=True) + + assert calls == [ + [ + str(simnibs_python), + "-m", + "pip", + "install", + "-e", + str(src_dir.parent), + ] + ] + assert probe_envs and "PYTHONPATH" not in probe_envs[0] + + +def test_bootstrap_fails_if_tide_not_importable_after_install(monkeypatch): + """A successful pip exit is insufficient if the target interpreter cannot import tide.""" + import tide.cli as cli + + simnibs_python = Path("/opt/SimNIBS/simnibs_env/bin/python") + monkeypatch.setattr(cli, "_locate_simnibs_python", lambda: simnibs_python) + monkeypatch.setattr(cli, "_verify_simnibs_deps", lambda python: (True, [])) + monkeypatch.setattr(cli, "_source_checkout_src_dir", lambda: Path("/checkout/TIDE/src")) + monkeypatch.setattr( + cli.subprocess, + "run", + lambda args, check=False: SimpleNamespace(returncode=0), + ) + monkeypatch.setattr(cli, "_tide_importable_under", lambda python, env=None: False) + + with pytest.raises(SystemExit) as exc_info: + cli.run_bootstrap(editable=True) + + assert exc_info.value.code == 1 + + +def test_argument_parser_accepts_stmpx_path(tmp_path): + from tide.cli import create_argument_parser + + args = create_argument_parser().parse_args( + ["--config", str(tmp_path / "config.yml"), "--stmpx", str(tmp_path / "session.stmpx")] + ) + + assert args.stmpx == tmp_path / "session.stmpx" + + +def test_stmpx_is_rejected_for_non_estimation_workflow_before_output(tmp_path, capsys): + from tide.cli import run_headless + + cfg, out_dir = _write_preflight_config(tmp_path) + stmpx_path = _write_stmpx_template(tmp_path) + args = SimpleNamespace( + config=cfg, + workflow="grid", + verbosity="standard", + no_console_ui=True, + no_cache=False, + stmpx=stmpx_path, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + assert exc_info.value.code == 1 + assert "--stmpx" in capsys.readouterr().err + assert not out_dir.exists() + + +@pytest.mark.parametrize( + "contents", + [None, "", ""], + ids=["missing", "malformed", "missing-fmpm"], +) +def test_stmpx_input_preflight_fails_before_output(tmp_path, capsys, contents): + from tide.cli import run_headless + + _materialize_preflight_inputs(tmp_path) + cfg, out_dir = _write_preflight_config(tmp_path) + stmpx_path = tmp_path / "session.stmpx" + if contents is not None: + stmpx_path.write_text(contents) + args = SimpleNamespace( + config=cfg, + workflow="estimation", + verbosity="standard", + no_console_ui=True, + no_cache=False, + stmpx=stmpx_path, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + assert exc_info.value.code == 1 + assert "STMPX" in capsys.readouterr().err + assert not out_dir.exists() + + +def test_headless_estimation_exports_stmpx_after_workflow(tmp_path, monkeypatch): + from tide.cli import run_headless + + _materialize_preflight_inputs(tmp_path) + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["options"]["stmpx_dataset_name"] = "20260717-TIDE-SUB_TEST" + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + stmpx_path = _write_stmpx_template(tmp_path) + + estimation = types.ModuleType("tide.workflows.estimation") + estimation.run_estimation_workflow = lambda config, console_ui: _write_target_summary(config) + monkeypatch.setitem(sys.modules, "tide.workflows.estimation", estimation) + + args = SimpleNamespace( + config=cfg, + workflow="estimation", + verbosity="standard", + no_console_ui=True, + no_cache=False, + stmpx=stmpx_path, + ) + + run_headless(args) + + output_path = tmp_path / "session_updated.stmpx" + assert output_path.exists() + assert 'dataset="20260717-TIDE-SUB_TEST"' in output_path.read_text() + assert 'id="Target_Estimation"' in output_path.read_text() + + +def test_stmpx_export_failure_is_a_pipeline_failure(tmp_path, monkeypatch, capsys): + from tide.cli import run_headless + + _materialize_preflight_inputs(tmp_path) + cfg, _ = _write_preflight_config(tmp_path) + stmpx_path = _write_stmpx_template(tmp_path) + + def write_invalid_summary(config, console_ui): + output_dir = config.subject.derivatives_path / f"TIDE_{config.target.label}" + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / f"TIDE_Results_{config.target.label}.txt").write_text("invalid report") + + estimation = types.ModuleType("tide.workflows.estimation") + estimation.run_estimation_workflow = write_invalid_summary + monkeypatch.setitem(sys.modules, "tide.workflows.estimation", estimation) + args = SimpleNamespace( + config=cfg, + workflow="estimation", + verbosity="standard", + no_console_ui=True, + no_cache=False, + stmpx=stmpx_path, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + captured = capsys.readouterr() + assert exc_info.value.code == 1 + assert "Target Estimation" in captured.err + assert "PIPELINE COMPLETE" not in captured.out + captured.err + assert not (tmp_path / "session_updated.stmpx").exists() + + +@pytest.mark.parametrize("command", ["--cache-info", "--cache-clear"]) +@pytest.mark.parametrize( + "contents", + [None, "[unterminated", "[]", "subject: []", "subject:\n cache_dir: []"], +) +def test_cache_command_rejects_invalid_explicit_config( + tmp_path, + monkeypatch, + capsys, + command, + contents, +): + from tide.cli import run_cache_command + + cache_root = tmp_path / "cache" / "fixed_pose" / "aa" / "entry" + cache_root.mkdir(parents=True) + metadata = cache_root / "metadata.json" + metadata.write_text("{}") + config = tmp_path / "invalid.yml" + if contents is not None: + config.write_text(contents) + monkeypatch.setenv("TIDE_CACHE_DIR", str(tmp_path / "cache")) + + with pytest.raises(SystemExit) as exc_info: + run_cache_command([command, "--config", str(config)]) + + assert exc_info.value.code == 1 + assert "Could not read cache configuration" in capsys.readouterr().err + assert metadata.exists() + + +@pytest.mark.parametrize("command", ["--cache-info", "--cache-clear"]) +def test_cache_command_rejects_missing_config_argument(tmp_path, monkeypatch, capsys, command): + from tide.cli import run_cache_command + + cache_root = tmp_path / "cache" / "fixed_pose" / "aa" / "entry" + cache_root.mkdir(parents=True) + metadata = cache_root / "metadata.json" + metadata.write_text("{}") + monkeypatch.setenv("TIDE_CACHE_DIR", str(tmp_path / "cache")) + + with pytest.raises(SystemExit) as exc_info: + run_cache_command([command, "--config"]) + + assert exc_info.value.code == 1 + assert "path is missing" in capsys.readouterr().err + assert metadata.exists() + + +def test_grid_module_imports_without_fcntl(tmp_path): + script = textwrap.dedent(""" + import sys + import types + + sys.modules["fcntl"] = None + + simnibs = types.ModuleType("simnibs") + simnibs.opt_struct = types.SimpleNamespace(TMSoptimize=object) + simnibs.run_simnibs = lambda *args, **kwargs: None + simnibs.sim_struct = types.SimpleNamespace(SESSION=object) + simnibs.read_msh = lambda *args, **kwargs: None + sys.modules["simnibs"] = simnibs + + import tide.workflows.grid_search + """) + env = os.environ.copy() + env["PYTHONPATH"] = str(SRC_DIR) + + result = subprocess.run( + [sys.executable, "-c", script], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(SIMNIBS_PRESENT, reason="requires SimNIBS to be absent") +def test_missing_simnibs_exits_before_config(tmp_path): + """Workflow run aborts at SimNIBS discovery, before config or output.""" + out_dir = tmp_path / "derivatives_out" + cfg = tmp_path / "cfg.yml" + cfg.write_text(textwrap.dedent(f""" + subject: + id: sub-TEST + derivatives_path: {out_dir} + files: + t1w: {tmp_path / "t1.nii.gz"} + coil: + coil_model: "MagVenture_C-B60.ccd" + experiment: + calibration: + label: M1 + bundle_path: {tmp_path / "cst.trk"} + coords: [0.0, 0.0, 0.0] + target: + label: TGT + bundle_path: {tmp_path / "tgt.trk"} + coords: [0.0, 0.0, 0.0] + """)) + + result = _run_cli(["--config", str(cfg), "--workflow", "estimation"], tmp_path) + + assert result.returncode == 1 + assert "'simnibs' command not found" in result.stderr + # Ordering contract: discovery fails before the workflow touches the config + # or creates the derivatives tree. + assert not out_dir.exists() + + +@pytest.mark.parametrize("workflow", ["estimation", "grid", None]) +def test_dose_preflight_rejects_e_parallel_before_output(tmp_path, capsys, workflow): + from tide.cli import run_headless + + cfg, out_dir = _write_preflight_config(tmp_path, field_mode="e_parallel") + args = SimpleNamespace( + config=cfg, + workflow=workflow, + verbosity="standard", + no_console_ui=True, + no_cache=False, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + assert exc_info.value.code == 1 + assert "field_mode" in capsys.readouterr().err + assert not out_dir.exists() + + +@pytest.mark.parametrize("weight_key", ["weights_cst", "weights_target"]) +def test_weight_preflight_rejects_missing_file_before_output( + tmp_path, + capsys, + weight_key, +): + from tide.cli import run_headless + + missing = tmp_path / f"missing_{weight_key}.txt" + kwargs = {weight_key: missing} + cfg, out_dir = _write_preflight_config(tmp_path, **kwargs) + args = SimpleNamespace( + config=cfg, + workflow="estimation", + verbosity="standard", + no_console_ui=True, + no_cache=False, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + assert exc_info.value.code == 1 + assert str(missing) in capsys.readouterr().err + assert not out_dir.exists() + + +def test_workflow_exception_exits_nonzero_without_completion(tmp_path, monkeypatch, capsys): + from tide.cli import run_headless + + _materialize_preflight_inputs(tmp_path) + cfg, _ = _write_preflight_config(tmp_path) + + estimation = types.ModuleType("tide.workflows.estimation") + + def fail_workflow(*args, **kwargs): + raise RuntimeError("required stage failed") + + estimation.run_estimation_workflow = fail_workflow + grid = types.ModuleType("tide.workflows.grid_search") + grid.run_grid_search_workflow = lambda *args, **kwargs: None + standard = types.ModuleType("tide.workflows.standard") + standard.run_standard_simulation = lambda *args, **kwargs: None + standard.run_standard_optimization = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "tide.workflows.estimation", estimation) + monkeypatch.setitem(sys.modules, "tide.workflows.grid_search", grid) + monkeypatch.setitem(sys.modules, "tide.workflows.standard", standard) + + args = SimpleNamespace( + config=cfg, + workflow="estimation", + verbosity="standard", + no_console_ui=True, + no_cache=False, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + captured = capsys.readouterr() + assert exc_info.value.code == 1 + assert "required stage failed" in captured.err + assert "PIPELINE COMPLETE" not in captured.out + captured.err + + +def test_prepare_anatomy_writes_compressed_and_legacy_names(tmp_path: Path) -> None: + from tide.cli import _prepare_anatomy + + source = tmp_path / "source_T1w.nii.gz" + source.write_bytes(b"compressed nifti") + derivatives = tmp_path / "derivatives" + derivatives.mkdir() + config = SimpleNamespace( + subject=SimpleNamespace( + derivatives_path=derivatives, + t1w_path=source, + ) + ) + + _prepare_anatomy(config) + + assert (derivatives / "t1w.nii.gz").read_bytes() == source.read_bytes() + assert (derivatives / "t1w.gz").read_bytes() == source.read_bytes() + + +def test_prepare_anatomy_preserves_existing_legacy_name(tmp_path: Path) -> None: + from tide.cli import _prepare_anatomy + + source = tmp_path / "source_T1w.nii.gz" + source.write_bytes(b"current anatomy") + derivatives = tmp_path / "derivatives" + derivatives.mkdir() + legacy = derivatives / "t1w.gz" + legacy.write_bytes(b"existing legacy anatomy") + config = SimpleNamespace( + subject=SimpleNamespace( + derivatives_path=derivatives, + t1w_path=source, + ) + ) + + _prepare_anatomy(config) + + assert legacy.read_bytes() == b"existing legacy anatomy" + assert (derivatives / "t1w.nii.gz").read_bytes() == source.read_bytes() + + +def test_custom_coil_file_path_is_used_directly(tmp_path): + from tide.utils.config import SimNIBSConfig + + cfg, _ = _write_preflight_config(tmp_path) + custom_coil = tmp_path / "custom.ccd" + raw = yaml.safe_load(cfg.read_text()) + raw["coil"]["coil_path"] = str(custom_coil) + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + + config = SimNIBSConfig.from_yaml(cfg) + + assert config.coil.coil_path == custom_coil + assert config.coil.coil_model == custom_coil.name + + +def test_visualization_singular_key_is_a_compatibility_alias(tmp_path): + from tide.utils.config import SimNIBSConfig + + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["options"]["generate_visualization"] = False + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + + config = SimNIBSConfig.from_yaml(cfg) + + assert config.options.generate_visualizations is False + + +def test_stmpx_dataset_name_must_be_a_string(tmp_path): + from tide.utils.config import SimNIBSConfig + + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["options"]["stmpx_dataset_name"] = 123 + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + + with pytest.raises(ValueError, match="stmpx_dataset_name"): + SimNIBSConfig.from_yaml(cfg) + + +@pytest.mark.parametrize( + ("section", "key", "value"), + [ + ("calibration", "stmpx_file", "/tmp/pose.stmpx"), + ("target", "stmpx_file", "/tmp/pose.stmpx"), + ("calibration", "didt", 1e6), + ], +) +def test_unsupported_scientific_config_fields_fail_explicitly( + tmp_path, + section, + key, + value, +): + from tide.utils.config import SimNIBSConfig + + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["experiment"][section][key] = value + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + + with pytest.raises(ValueError, match=key): + SimNIBSConfig.from_yaml(cfg) + + +def test_shipped_template_parses(tmp_path, monkeypatch): + from tide.utils import config as config_module + + monkeypatch.setattr(config_module, "_detect_simnibs_coil_path", lambda: tmp_path) + + config = config_module.SimNIBSConfig.from_yaml(REPO_ROOT / "config_template.yml") + + assert config.subject.id + + +def test_preflight_rejects_malformed_orientation_before_output(tmp_path, capsys): + from tide.cli import run_headless + + cfg, out_dir = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["experiment"]["target"]["orientation"] = [ + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + [0.0, 0.0, 0.0], + ] + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + args = SimpleNamespace( + config=cfg, + workflow="estimation", + verbosity="standard", + no_console_ui=True, + no_cache=False, + ) + + with pytest.raises(SystemExit) as exc_info: + run_headless(args) + + assert exc_info.value.code == 1 + assert "orientation" in capsys.readouterr().err + assert not out_dir.exists() + + +def test_preflight_rejects_nonrigid_orientation_matrix(tmp_path): + from tide.utils.config import SimNIBSConfig, validate_workflow_config + + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["experiment"]["target"]["orientation"] = [ + [2.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + config = SimNIBSConfig.from_yaml(cfg) + + with pytest.raises(ValueError, match="orthonormal"): + validate_workflow_config(config, "estimation") + + +@pytest.mark.parametrize( + ("section", "key", "value", "message"), + [ + ("options", "roi_size_mm", 0.0, "roi_size_mm"), + ("options", "mso_floor_ratio", 1.5, "mso_floor_ratio"), + ("grid", "step_size_mm", 0.0, "step_size_mm"), + ], +) +def test_preflight_rejects_invalid_numeric_contracts( + tmp_path, + section, + key, + value, + message, +): + from tide.utils.config import SimNIBSConfig, validate_workflow_config + + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + if section == "grid": + raw["experiment"]["target"]["grid"][key] = value + else: + raw[section][key] = value + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + config = SimNIBSConfig.from_yaml(cfg) + + with pytest.raises(ValueError, match=message): + validate_workflow_config(config, "grid" if section == "grid" else "estimation") + + +def test_preflight_rejects_ambiguous_mesh_selection(tmp_path): + from tide.utils.config import SimNIBSConfig, validate_workflow_config + + _materialize_preflight_inputs(tmp_path) + (tmp_path / "m2m_sub-TEST" / "second.msh").write_text("") + cfg, _ = _write_preflight_config(tmp_path) + config = SimNIBSConfig.from_yaml(cfg) + + with pytest.raises(ValueError, match="multiple .msh"): + validate_workflow_config(config, "estimation") + + +def test_preflight_rejects_unsafe_output_label(tmp_path): + from tide.utils.config import SimNIBSConfig, validate_workflow_config + + _materialize_preflight_inputs(tmp_path) + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + raw["experiment"]["target"]["label"] = "../outside" + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + config = SimNIBSConfig.from_yaml(cfg) + + with pytest.raises(ValueError, match="label"): + validate_workflow_config(config, "estimation") + + +@pytest.mark.parametrize("missing_input", ["target_bundle", "surface"]) +def test_preflight_rejects_missing_scientific_inputs(tmp_path, missing_input): + from tide.utils.config import SimNIBSConfig, validate_workflow_config + + _materialize_preflight_inputs(tmp_path) + cfg, _ = _write_preflight_config(tmp_path) + raw = yaml.safe_load(cfg.read_text()) + if missing_input == "target_bundle": + (tmp_path / "target.trk").unlink() + else: + raw["subject"]["files"]["surface"] = str(tmp_path / "missing.white") + cfg.write_text(yaml.safe_dump(raw, sort_keys=False)) + config = SimNIBSConfig.from_yaml(cfg) + + with pytest.raises(FileNotFoundError): + validate_workflow_config(config, "estimation") + + +class TestSimnibsDescriptor: + """Environment-independent unit tests for the SimNIBS installation descriptor.""" + + def _descriptor(self): + if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + from tide.utils import simnibs_env + + return simnibs_env + + def test_python_candidates_posix_order(self, monkeypatch): + simnibs_env = self._descriptor() + monkeypatch.setattr(simnibs_env.sys, "platform", "linux") + root = Path("/opt/SimNIBS") + assert simnibs_env.python_candidates(root) == [ + root / "simnibs_env" / "bin" / "python3", + root / "simnibs_env" / "bin" / "python", + root / "bin" / "python3", + root / "bin" / "python", + ] + + def test_python_candidates_windows_order(self, monkeypatch): + simnibs_env = self._descriptor() + monkeypatch.setattr(simnibs_env.sys, "platform", "win32") + root = Path("C:/SimNIBS") + candidates = simnibs_env.python_candidates(root) + assert candidates[0] == root / "simnibs_env" / "Scripts" / "python.exe" + assert all(c.suffix == ".exe" for c in candidates) + + def test_select_python_returns_first_executable(self, tmp_path, monkeypatch): + simnibs_env = self._descriptor() + monkeypatch.setattr(simnibs_env.sys, "platform", "linux") + missing = tmp_path / "missing" + present = tmp_path / "python3" + present.write_text("") + present.chmod(0o755) + assert simnibs_env.select_python([missing, present]) == present + + def test_select_python_none_when_absent(self, tmp_path): + simnibs_env = self._descriptor() + assert simnibs_env.select_python([tmp_path / "nope"]) is None + + def test_resolvers_none_without_launcher(self, monkeypatch): + simnibs_env = self._descriptor() + monkeypatch.setattr(simnibs_env.shutil, "which", lambda name: None) + assert simnibs_env.simnibs_root() is None + assert simnibs_env.find_get_fields_at_coordinates() is None + assert simnibs_env.find_coil_models_dir() is None + + def test_coil_models_dir_resolves_with_fallback(self, tmp_path, monkeypatch): + simnibs_env = self._descriptor() + launcher = tmp_path / "bin" / "simnibs" + launcher.parent.mkdir(parents=True) + launcher.write_text("") + site = tmp_path / "simnibs_env" / "lib" / "python3.11" / "site-packages" + coil_parent = site / "simnibs" / "resources" / "coil_models" + coil_parent.mkdir(parents=True) + monkeypatch.setattr( + simnibs_env.shutil, + "which", + lambda name: str(launcher) if name == "simnibs" else None, + ) + # Specific Drakaki subdir absent -> falls back to parent coil_models dir. + result = simnibs_env.find_coil_models_dir() + assert result is not None and result.name == "coil_models" + (coil_parent / "Drakaki_BrainStim_2022").mkdir() + result = simnibs_env.find_coil_models_dir() + assert result is not None and result.name == "Drakaki_BrainStim_2022" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py new file mode 100644 index 0000000..0248d5d --- /dev/null +++ b/tests/test_critical_fixes.py @@ -0,0 +1,1594 @@ +""" +Regression tests for the applied critical fixes. + +Critical 1 (M1 optimization gating): a calibration block with only cortical +coords must take the optimization branch in both workflows. The gating reduces +to ``needs_optimization = not orientation_is_matrix(orientation)``; this is the +SimNIBS-free seam the workflows share, so it is tested directly here. + +Critical 2 (E-field realignment): unmatched points must be filled with NaN +(not silently zeroed), perturbed-but-close points must still match, and a loss +above 1% must raise. +""" + +import importlib +import json +import re +import sys +import textwrap +import types +import xml.etree.ElementTree as ET +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +# ============================================================================= +# Critical 1 — M1 optimization gating +# ============================================================================= + + +class TestM1OptimizationGating: + """The gating predicate shared by the estimation and grid workflows.""" + + def test_coords_only_calibration_needs_optimization(self): + from tide.utils.config import orientation_is_matrix + + # No orientation supplied (cortical coords only) -> must optimize. + assert orientation_is_matrix(None) is False + assert (not orientation_is_matrix(None)) is True + + def test_vector_orientation_needs_optimization(self): + from tide.utils.config import orientation_is_matrix + + # A 3-vector pos_ydir reference is not a finished pose -> must optimize. + assert orientation_is_matrix([10.0, 20.0, 30.0]) is False + + def test_eeg_label_needs_optimization(self): + from tide.utils.config import orientation_is_matrix + + # An EEG label is not a finished pose -> must optimize. + assert orientation_is_matrix("F8") is False + + def test_full_matrix_skips_optimization(self): + from tide.utils.config import orientation_is_matrix + + matrix = [ + [1.0, 0.0, 0.0, -13.0], + [0.0, 1.0, 0.0, -26.0], + [0.0, 0.0, 1.0, 85.0], + [0.0, 0.0, 0.0, 1.0], + ] + assert orientation_is_matrix(matrix) is True + assert (not orientation_is_matrix(matrix)) is False + + def test_empty_list_is_not_a_matrix(self): + from tide.utils.config import orientation_is_matrix + + assert orientation_is_matrix([]) is False + + +# ============================================================================= +# Critical 2 — E-field realignment +# ============================================================================= + + +def _diagonal_coords(n: int) -> np.ndarray: + """n points on the (x=y=z) diagonal, spaced sqrt(3) mm apart.""" + return (np.arange(n).reshape(-1, 1) * np.ones((1, 3))).astype(float) + + +class TestRealignSampledField: + """Tests for _realign_sampled_field (Critical 2).""" + + def test_perturbed_points_still_match(self): + from tide.interfaces.sampling import _realign_sampled_field + + coords = _diagonal_coords(5) + # 0.01 mm perturbation (norm ~0.017 mm) is well within the 0.1 mm tol. + out_coords = coords + 1e-2 + out_vals = np.array( + [[10.0, 0.0, 0.0], [0.0, 20.0, 0.0], [0.0, 0.0, 30.0], [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + ) + + result = _realign_sampled_field(coords, out_coords, out_vals) + + assert np.isfinite(result).all() + assert np.allclose(result, out_vals) + + def test_unmatched_point_filled_with_nan(self): + from tide.interfaces.sampling import _realign_sampled_field + + coords = _diagonal_coords(100) + # Drop the first point: 1% loss (not > 1%, so no raise). + out_coords = coords[1:].copy() + out_vals = np.tile([1.0, 2.0, 3.0], (99, 1)) + + result = _realign_sampled_field(coords, out_coords, out_vals) + + assert np.isnan(result[0]).all() + assert np.isfinite(result[1:]).all() + assert np.allclose(result[1:], out_vals) + + def test_raises_above_one_percent_loss(self): + from tide.interfaces.sampling import _realign_sampled_field + + coords = _diagonal_coords(100) + # Drop five points: 5% loss > 1% threshold -> raise. + out_coords = coords[5:].copy() + out_vals = np.tile([1.0, 2.0, 3.0], (95, 1)) + + with pytest.raises(RuntimeError): + _realign_sampled_field(coords, out_coords, out_vals) + + def test_no_zero_fill_for_unmatched(self): + from tide.interfaces.sampling import _realign_sampled_field + + coords = _diagonal_coords(100) + out_coords = coords[1:].copy() + out_vals = np.tile([1.0, 2.0, 3.0], (99, 1)) + + result = _realign_sampled_field(coords, out_coords, out_vals) + + # The unmatched row must be NaN, never a silent zero vector. + assert not np.allclose(result[0], 0.0) + assert np.isnan(result[0]).all() + + def test_equal_length_permutation_is_reordered(self, monkeypatch, tmp_path): + from tide.interfaces import sampling + + coords = _diagonal_coords(4) + permutation = np.array([2, 0, 3, 1]) + values = np.column_stack((np.arange(4), np.arange(4) + 10, np.arange(4) + 20)) + + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: "get_fields_at_coordinates", + ) + + def run_cli(*args, **kwargs): + output = np.column_stack((coords[permutation], values[permutation])) + np.savetxt(tmp_path / "bundle_coords_E.csv", output, delimiter=",") + return SimpleNamespace(stdout="") + + monkeypatch.setattr(sampling.subprocess, "run", run_cli) + + result = sampling.sample_field_at_coordinates( + tmp_path / "field.msh", coords, output_dir=tmp_path + ) + + assert np.array_equal(result, values) + + def test_duplicate_nearest_neighbor_assignment_raises(self): + from tide.interfaces.sampling import _realign_sampled_field + + coords = np.array([[0.0, 0.0, 0.0], [0.05, 0.0, 0.0]]) + out_coords = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) + out_vals = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + + with pytest.raises(RuntimeError, match="duplicate nearest-neighbour"): + _realign_sampled_field(coords, out_coords, out_vals) + + def test_coordinate_free_drop_cannot_return_wrong_length(self, monkeypatch, tmp_path): + from tide.interfaces import sampling + + coords = _diagonal_coords(3) + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: "get_fields_at_coordinates", + ) + + def run_cli(*args, **kwargs): + np.savetxt(tmp_path / "bundle_coords_E.csv", np.ones((2, 3)), delimiter=",") + return SimpleNamespace(stdout="") + + monkeypatch.setattr(sampling.subprocess, "run", run_cli) + + with pytest.raises(RuntimeError, match="omitted coordinates"): + sampling.sample_field_at_coordinates( + tmp_path / "field.msh", coords, output_dir=tmp_path + ) + + +class TestSamplingCommandSafety: + def test_windows_cmd_uses_literal_argument_list( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + from tide.interfaces import sampling + + coords = _diagonal_coords(3) + cli_cmd = str(tmp_path / "get_fields_at_coordinates.cmd") + mesh_path = tmp_path / "field with spaces.msh" + file_prefix = "bundle with spaces" + calls: list[tuple[object, dict[str, object]]] = [] + + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: cli_cmd, + ) + + def run_cli(args: object, **kwargs: object) -> SimpleNamespace: + calls.append((args, kwargs)) + output = np.column_stack((coords, np.ones((3, 3)))) + np.savetxt( + tmp_path / f"{file_prefix}_coords_E.csv", + output, + delimiter=",", + ) + return SimpleNamespace(stdout="") + + monkeypatch.setattr(sampling.subprocess, "run", run_cli) + + sampling.sample_field_at_coordinates( + mesh_path, + coords, + output_dir=tmp_path, + file_prefix=file_prefix, + ) + + coords_csv = tmp_path / f"{file_prefix}_coords.csv" + assert calls == [ + ( + [ + cli_cmd, + "--mesh", + str(mesh_path), + "--csv", + str(coords_csv), + ], + { + "check": True, + "cwd": str(tmp_path), + "shell": False, + "stdout": sampling.subprocess.PIPE, + "stderr": sampling.subprocess.PIPE, + "text": True, + }, + ) + ] + + def test_windows_cmd_rejects_shell_metacharacters( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + from tide.interfaces import sampling + + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: str(tmp_path / "get_fields_at_coordinates.cmd"), + ) + + with pytest.raises(ValueError, match="shell metacharacters"): + sampling.sample_field_at_coordinates( + tmp_path / "field&echo injected.msh", + _diagonal_coords(3), + output_dir=tmp_path, + ) + + +class TestFreshSamplingArtifacts: + """Sampling must consume only output produced by the current CLI call.""" + + def test_stale_expected_csv_is_ignored_for_fresh_fallback(self, monkeypatch, tmp_path): + from tide.interfaces import sampling + + coords = _diagonal_coords(3) + stale_values = np.full((3, 3), -1.0) + fresh_values = np.arange(9, dtype=float).reshape(3, 3) + np.savetxt( + tmp_path / "bundle_coords_E.csv", + np.column_stack((coords, stale_values)), + delimiter=",", + ) + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: "get_fields_at_coordinates", + ) + + def run_cli(*args, **kwargs): + np.savetxt( + tmp_path / "bundle_coords_vector.csv", + np.column_stack((coords, fresh_values)), + delimiter=",", + ) + return SimpleNamespace(stdout="") + + monkeypatch.setattr(sampling.subprocess, "run", run_cli) + + result = sampling.sample_field_at_coordinates( + tmp_path / "field.msh", coords, output_dir=tmp_path + ) + + assert np.array_equal(result, fresh_values) + manifest = json.loads((tmp_path / ".tide_run_manifest.json").read_text()) + assert manifest["artifacts"]["sampling:bundle:E"]["selected"] == ( + "bundle_coords_vector.csv" + ) + + def test_updated_expected_csv_is_accepted(self, monkeypatch, tmp_path): + from tide.interfaces import sampling + + coords = _diagonal_coords(3) + expected_out = tmp_path / "bundle_coords_E.csv" + expected_out.write_text("stale") + fresh_values = np.arange(9, dtype=float).reshape(3, 3) + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: "get_fields_at_coordinates", + ) + + def run_cli(*args, **kwargs): + np.savetxt( + expected_out, + np.column_stack((coords, fresh_values)), + delimiter=",", + ) + return SimpleNamespace(stdout="") + + monkeypatch.setattr(sampling.subprocess, "run", run_cli) + + result = sampling.sample_field_at_coordinates( + tmp_path / "field.msh", coords, output_dir=tmp_path + ) + + assert np.array_equal(result, fresh_values) + + def test_ambiguous_fresh_fallback_csvs_raise(self, monkeypatch, tmp_path): + from tide.interfaces import sampling + + coords = _diagonal_coords(3) + monkeypatch.setattr( + sampling.simnibs_env, + "find_get_fields_at_coordinates", + lambda: "get_fields_at_coordinates", + ) + + def run_cli(*args, **kwargs): + output = np.column_stack((coords, np.ones((3, 3)))) + np.savetxt(tmp_path / "bundle_coords_a.csv", output, delimiter=",") + np.savetxt(tmp_path / "bundle_coords_b.csv", output, delimiter=",") + return SimpleNamespace(stdout="") + + monkeypatch.setattr(sampling.subprocess, "run", run_cli) + + with pytest.raises(RuntimeError, match="Ambiguous sampling output"): + sampling.sample_field_at_coordinates( + tmp_path / "field.msh", coords, output_dir=tmp_path + ) + + +class TestFreshSimulationArtifacts: + """Simulation must not return meshes left by an earlier run.""" + + @staticmethod + def _load_interface(monkeypatch, run_simnibs): + class Position: + pass + + class TmsList: + def add_position(self): + return Position() + + class Session: + def add_tmslist(self): + return TmsList() + + fake_simnibs = SimpleNamespace( + opt_struct=SimpleNamespace(TMSoptimize=object), + run_simnibs=run_simnibs, + sim_struct=SimpleNamespace(SESSION=Session), + ) + monkeypatch.setitem(sys.modules, "simnibs", fake_simnibs) + sys.modules.pop("tide.interfaces.simnibs_interface", None) + module = importlib.import_module("tide.interfaces.simnibs_interface") + monkeypatch.setattr(module, "run_simnibs", run_simnibs) + return module + + def test_stale_mesh_is_ignored(self, monkeypatch, tmp_path): + output_dir = tmp_path / "sim" + output_dir.mkdir() + (output_dir / "stale_scalar.msh").write_text("stale") + + def run_simnibs(session): + (output_dir / "fresh_scalar.msh").write_text("fresh") + + module = self._load_interface(monkeypatch, run_simnibs) + + result = module.SimNIBSInterface.run_simulation( + mesh_path=tmp_path / "head.msh", + output_dir=output_dir, + coil_path=tmp_path / "coil.ccd", + didt=1e6, + coords=[0.0, 0.0, 0.0], + ) + + assert result == output_dir / "fresh_scalar.msh" + manifest = json.loads((output_dir / ".tide_run_manifest.json").read_text()) + assert manifest["artifacts"]["simulation_mesh"]["selected"] == ("fresh_scalar.msh") + sys.modules.pop("tide.interfaces.simnibs_interface", None) + + def test_ambiguous_fresh_meshes_raise(self, monkeypatch, tmp_path): + output_dir = tmp_path / "sim" + output_dir.mkdir() + + def run_simnibs(session): + (output_dir / "first_scalar.msh").write_text("first") + (output_dir / "second_scalar.msh").write_text("second") + + module = self._load_interface(monkeypatch, run_simnibs) + + with pytest.raises(RuntimeError, match="Ambiguous simulation output"): + module.SimNIBSInterface.run_simulation( + mesh_path=tmp_path / "head.msh", + output_dir=output_dir, + coil_path=tmp_path / "coil.ccd", + didt=1e6, + coords=[0.0, 0.0, 0.0], + ) + sys.modules.pop("tide.interfaces.simnibs_interface", None) + + +# ============================================================================= +# Bug 3 — failed estimate is flagged, not mislabeled as clamped +# ============================================================================= + + +class TestEstimationFailedFlag: + """apply_intensity_bounds must distinguish a failed estimate from a clamped one.""" + + def test_nan_yields_estimation_failed(self): + from tide.interfaces.unified_estimation import apply_intensity_bounds + + result = apply_intensity_bounds(float("nan"), rmt=50.0) + + assert result["flag"] == "ESTIMATION_FAILED" + assert np.isnan(result["best_estimate"]) + assert np.isnan(result["model_raw"]) + + def test_zero_is_still_clamped_low(self): + from tide.interfaces.unified_estimation import apply_intensity_bounds + + # A real (finite) low estimate is clamped, not flagged as a failure. + result = apply_intensity_bounds(0.0, rmt=50.0, floor_ratio=0.80) + + assert result["flag"] == "CLAMPED_LOW" + assert result["best_estimate"] == 40.0 + + +# ============================================================================= +# Config defaults for optional stability knobs +# ============================================================================= + + +class TestConfigOptionDefaults: + """Missing optional config knobs must resolve to shipped defaults.""" + + @staticmethod + def _write_config(tmp_path, extra_options=""): + cfg = tmp_path / "config.yml" + cfg.write_text(textwrap.dedent(f""" + subject: + id: sub-TEST + derivatives_path: {tmp_path} + m2m_path: m2m_sub-TEST + files: + t1w: t1.nii.gz + coil: + coil_model: MagVenture_C-B60.ccd + coil_path: {tmp_path} + coil_distance_mm: 4.0 + device_didt_max: 161e6 + experiment: + calibration: + label: M1 + bundle_path: cst.trk + measured_rmt_mso: 50.0 + coords: [0.0, 0.0, 0.0] + target: + label: Target + bundle_path: target.trk + coords: [0.0, 0.0, 0.0] + options: + roi_size_mm: 20.0 + activation_length_mm: 6.0 + """) + textwrap.indent(textwrap.dedent(extra_options), " ")) + return cfg + + def test_missing_optional_bounds_and_angular_filter_defaults(self, tmp_path): + from tide.utils.config import SimNIBSConfig + + config = SimNIBSConfig.from_yaml(self._write_config(tmp_path)) + + assert config.options.max_angular_deviation_deg == 0.0 + assert config.options.mso_floor_ratio == 0.70 + assert config.options.mso_ceiling_ratio == 1.40 + assert config.options.gwi_threshold_mm == 3.0 + + def test_configured_gwi_threshold_is_read(self, tmp_path): + from tide.utils.config import SimNIBSConfig + + cfg = self._write_config(tmp_path, "gwi_threshold_mm: 5.0\n") + + assert SimNIBSConfig.from_yaml(cfg).options.gwi_threshold_mm == 5.0 + + +def test_required_nifti_reference_failure_propagates(tmp_path): + from tide.core.io import save_points_as_nifti + + with pytest.raises(FileNotFoundError, match="Reference image"): + save_points_as_nifti( + np.zeros((1, 3)), + tmp_path / "missing.nii.gz", + tmp_path / "output.nii.gz", + ) + + +class TestWorkflowPreflight: + @staticmethod + def _config(tmp_path, field_mode="af", weights_cst=None, weights_target=None): + m2m_path = tmp_path / "m2m_sub-TEST" + m2m_path.mkdir(exist_ok=True) + mesh_path = m2m_path / "sub-TEST.msh" + for path in ( + mesh_path, + tmp_path / "t1.nii.gz", + tmp_path / "coil.ccd", + tmp_path / "cst.trk", + tmp_path / "target.trk", + ): + path.write_text("") + return SimpleNamespace( + options=SimpleNamespace( + field_mode=field_mode, + roi_size_mm=30.0, + activation_length_mm=6.0, + adm_optimization=True, + opt_spatial_resolution=2.0, + opt_angle_resolution=10.0, + opt_search_angle=30.0, + opt_search_radius=10.0, + generate_visualizations=True, + generate_3d_visualization=False, + visualization_dpi=300, + max_angular_deviation_deg=0.0, + gwi_threshold_mm=3.0, + mso_floor_ratio=0.7, + mso_ceiling_ratio=1.4, + max_workers=None, + no_parallel=False, + ), + subject=SimpleNamespace( + id="sub-TEST", + t1w_path=tmp_path / "t1.nii.gz", + m2m_path=m2m_path, + mesh_path=mesh_path, + surface_path=None, + weights_cst_path=weights_cst, + weights_target_path=weights_target, + ), + coil=SimpleNamespace( + coil_path=tmp_path / "coil.ccd", + coil_distance_mm=4.0, + device_didt_max=161e6, + ), + calibration=SimpleNamespace( + label="M1", + bundle_path=tmp_path / "cst.trk", + coords=[0.0, 0.0, 0.0], + scalp_coords=None, + orientation=None, + measured_rmt_mso=50.0, + ), + target=SimpleNamespace( + label="Target", + bundle_path=tmp_path / "target.trk", + coords=[0.0, 0.0, 0.0], + scalp_coords=None, + orientation=[0.0, 1.0, 0.0], + didt=None, + mso=None, + ), + grid=SimpleNamespace( + coords=[0.0, 0.0, 0.0], + scalp_coords=None, + orientation=[0.0, 1.0, 0.0], + search_radius_mm=4.0, + step_size_mm=4.0, + cortex_depth_mm=2.0, + ), + ) + + @pytest.mark.parametrize("workflow", ["estimation", "grid"]) + def test_dose_workflows_require_af(self, tmp_path, workflow): + from tide.utils.config import validate_workflow_config + + config = self._config(tmp_path, field_mode="e_parallel") + + with pytest.raises(ValueError, match="field_mode"): + validate_workflow_config(config, workflow) + + def test_standard_simulation_accepts_e_parallel(self, tmp_path): + from tide.utils.config import validate_workflow_config + + config = self._config(tmp_path, field_mode="e_parallel") + + validate_workflow_config(config, "simulation") + + def test_omitted_weights_remain_valid(self, tmp_path): + from tide.utils.config import validate_workflow_config + + validate_workflow_config(self._config(tmp_path), "estimation") + + @pytest.mark.parametrize("workflow", ["estimation", "grid"]) + def test_dose_workflows_require_both_configured_weights(self, tmp_path, workflow): + from tide.utils.config import validate_workflow_config + + missing = tmp_path / "missing.txt" + config = self._config(tmp_path, weights_target=missing) + + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): + validate_workflow_config(config, workflow) + + def test_simulation_ignores_unused_cst_weight(self, tmp_path): + from tide.utils.config import validate_workflow_config + + missing = tmp_path / "missing_cst.txt" + config = self._config(tmp_path, weights_cst=missing) + + validate_workflow_config(config, "simulation") + + @pytest.mark.parametrize("threshold", [0.0, -1.0]) + def test_non_positive_gwi_threshold_is_rejected(self, tmp_path, threshold): + from tide.utils.config import validate_workflow_config + + config = self._config(tmp_path) + config.options.gwi_threshold_mm = threshold + + with pytest.raises(ValueError, match="options.gwi_threshold_mm"): + validate_workflow_config(config, "estimation") + + +# ============================================================================= +# Bug 5 — single source for the threshold/percentile helpers +# ============================================================================= + + +class TestSingleSourceThreshold: + """The bundle-analysis path must reuse the physics implementations.""" + + def test_contiguous_threshold_is_shared(self): + from tide.core import physics + from tide.interfaces import unified_estimation + + assert ( + unified_estimation.get_max_contiguous_threshold is physics.get_max_contiguous_threshold + ) + + def test_weighted_percentile_is_shared(self): + from tide.core import physics + from tide.interfaces import unified_estimation + + assert unified_estimation.weighted_percentile is physics.weighted_percentile + + +# ============================================================================= +# QC text reporting +# ============================================================================= + + +class TestQcTextReporting: + """QC additions must preserve existing text report content.""" + + def test_optimization_result_adds_alignment_qc(self, tmp_path): + from tide.core import io + + output_path = tmp_path / "opt_result.txt" + matrix = np.eye(4) + scalp_coords = np.array([1.0, 2.0, 3.0]) + + io.save_optimization_result_txt( + output_path, + matrix, + scalp_coords, + pose_qc={"status": "PASS", "reasons": []}, + alignment_qc={ + "alignment": 0.0, + "alignment_corrected": 0.5, + "depth_mm": 12.3, + }, + ) + + text = output_path.read_text() + + assert "Optimized Scalp Position (x, y, z):" in text + assert "Full 4x4 Transformation Matrix:" in text + assert "--- COIL POSE QC ---" in text + assert "Pose QC: PASS" in text + assert "--- ALIGNMENT QC ---" in text + assert "Alignment: 0.0000" in text + assert "Alignment Corrected: 0.5000" in text + assert "Depth: 12.3 mm" in text + assert "--- FOR CONFIG FILE (copy/paste) ---" in text + + json_path = output_path.with_suffix(".json") + payload = json.loads(json_path.read_text()) + assert payload["report_type"] == "optimization_result" + assert payload["source_txt"] == str(output_path) + assert payload["data"]["optimized_scalp_position"] == [1.0, 2.0, 3.0] + assert "Alignment Corrected: 0.5000" in payload["text"]["content"] + assert any(section["title"] == "ALIGNMENT QC" for section in payload["sections"]) + + def test_mapping_summary_adds_qc_without_removing_existing_sections(self, tmp_path): + from tide.core import io + + output_path = tmp_path / "mapping_summary.txt" + image_dir = tmp_path / "visualizations" + image_dir.mkdir() + image_path = image_dir / "target_composite.png" + image_path.write_bytes(b"png placeholder") + + io.save_mapping_summary( + output_path, + { + "Timestamp": "2026-07-07 20:00:00", + "Prefix": "target_af", + "Mesh": "mesh.msh", + "Bundle": "bundle.trk", + "Anatomy": "t1w.nii.gz", + "Mode": "af", + "Threshold_Percent": "N/A", + "Total_Streamlines": 10, + "Max_Value": "2.0", + "Min_Value": "-1.0", + "Metrics": {"Robust Metric (Weighted)": "1.2345"}, + "QC": { + "pose_qc": {"status": "PASS", "reasons": []}, + "alignment_qc": { + "alignment": 0.0, + "alignment_corrected": 0.25, + "depth_mm": 9.8, + }, + }, + "Output_Files": {"Tractogram": "target_af.trk"}, + }, + ) + + text = output_path.read_text() + + assert "--- E-Field to Bundle Mapping Summary ---" in text + assert "--- INPUTS ---" in text + assert "--- PARAMETERS ---" in text + assert "--- RESULTS ---" in text + assert "--- ROBUST METRICS ---" in text + assert "--- QC ---" in text + assert "Target Coil Pose QC: PASS" in text + assert "Target Alignment Corrected: 0.2500" in text + assert "Target Depth: 9.8 mm" in text + assert "--- OUTPUT FILES (in outdir) ---" in text + + json_path = output_path.with_suffix(".json") + payload = json.loads(json_path.read_text()) + assert payload["report_type"] == "mapping_summary" + assert payload["source_txt"] == str(output_path) + assert payload["data"]["QC"]["alignment_qc"]["alignment_corrected"] == 0.25 + assert "Target Alignment Corrected: 0.2500" in payload["text"]["content"] + assert any(section["title"] == "QC" for section in payload["sections"]) + + html_path = output_path.with_suffix(".html") + html = html_path.read_text() + assert "TIDE Report" in html + assert "mapping_summary.txt" in html + assert "Target Coil Pose QC" in html + assert "visualizations/target_composite.png" in html + + +class TestLightweightVisualizationPayloads: + """Interactive report previews should stay bounded and compact.""" + + def test_grid_streamline_payload_is_capped_and_has_no_rgb_arrays(self): + from tide.interfaces.grid_visualization import _serialize_streamlines_for_html + + streamlines = [ + np.column_stack( + [ + np.linspace(i, i + 1, 12), + np.linspace(2 * i, 2 * i + 1, 12), + np.linspace(3 * i, 3 * i + 1, 12), + ] + ) + for i in range(10) + ] + + payload = _serialize_streamlines_for_html( + streamlines, + max_streamlines=3, + max_points_per_streamline=5, + decimals=1, + ) + + assert len(payload) == 3 + assert all(len(points) <= 5 for points in payload) + assert payload[0][0] == [0.0, 0.0, 0.0] + assert "rgb" not in json.dumps(payload) + + +def _load_workflow_module(monkeypatch, module_name): + simnibs = types.ModuleType("simnibs") + simnibs.opt_struct = SimpleNamespace(TMSoptimize=object) + simnibs.run_simnibs = lambda *args, **kwargs: None + simnibs.sim_struct = SimpleNamespace(SESSION=object) + simnibs.read_msh = lambda *args, **kwargs: None + visualization_3d = types.ModuleType("tide.interfaces.visualization_3d") + visualization_3d.PYVISTA_AVAILABLE = False + visualization_3d.VisualizationConfig = object + visualization_3d.generate_bundle_visualization = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "simnibs", simnibs) + monkeypatch.setitem(sys.modules, "tide.interfaces.visualization_3d", visualization_3d) + monkeypatch.delitem(sys.modules, "tide.interfaces.simnibs_interface", raising=False) + monkeypatch.delitem(sys.modules, module_name, raising=False) + return importlib.import_module(module_name) + + +class TestDoseEligibilityWorkflowGate: + def test_estimation_worker_stops_before_simulation_for_warned_automatic_pose( + self, + tmp_path, + monkeypatch, + ): + from tide.core.geometry import CoilPoseQC + + estimation = _load_workflow_module(monkeypatch, "tide.workflows.estimation") + task = estimation.PipelineTask( + task_type="target", + label="Target", + mesh_path=str(tmp_path / "head.msh"), + m2m_path=str(tmp_path / "m2m"), + output_dir=str(tmp_path / "out"), + coil_path=str(tmp_path / "coil.ccd"), + coil_distance_mm=4.0, + target_coords=[0.0, 0.0, 0.0], + scalp_coords=[0.0, 0.0, 1.0], + orientation_ref=[0.0, 1.0, 0.0], + needs_optimization=True, + opt_didt=1e6, + opt_search_radius=10.0, + opt_spatial_resolution=2.0, + opt_angle_resolution=10.0, + opt_search_angle=30.0, + use_adm=True, + sim_didt=1e6, + sim_coords=[0.0, 0.0, 0.0], + sim_orientation=None, + bundle_path=str(tmp_path / "target.trk"), + t1w_path=str(tmp_path / "t1.nii.gz"), + roi_coords=[0.0, 0.0, 0.0], + roi_size_mm=20.0, + field_mode="af", + ) + qc = CoilPoseQC(status="WARN", reasons=("coil_normal_not_inward",)) + monkeypatch.setattr(estimation, "_configure_worker_environment", lambda: None) + monkeypatch.setattr( + estimation.SimNIBSInterface, + "run_optimization", + lambda **kwargs: (np.eye(4), np.array([0.0, 0.0, 1.0])), + ) + monkeypatch.setattr(estimation, "evaluate_coil_pose_qc", lambda *args: qc) + monkeypatch.setattr( + estimation.io, + "save_optimization_result_txt", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + estimation.SimNIBSInterface, + "run_simulation", + lambda **kwargs: pytest.fail("Simulation must not run for a rejected pose"), + ) + + result = estimation._run_pipeline_task(task) + + assert result.success is False + assert "not dose-eligible" in result.error_message + + def test_grid_worker_excludes_warned_automatic_pose_before_simulation( + self, + tmp_path, + monkeypatch, + ): + from tide.core import geometry + from tide.core.geometry import CoilPoseQC + + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + task = grid_search.GridPointTask( + index=0, + cortex_coord=[0.0, 0.0, 0.0], + point_label="grid_P00", + point_dir=str(tmp_path / "grid_P00"), + msh_file=str(tmp_path / "head.msh"), + m2m_path=str(tmp_path / "m2m"), + coil_path=str(tmp_path / "coil.ccd"), + fixed_scalp_coords=[0.0, 0.0, 1.0], + grid_orientation_ref=[0.0, 1.0, 0.0], + target_bundle_path=str(tmp_path / "target.trk"), + t1w_path=str(tmp_path / "t1.nii.gz"), + surface_path=None, + weights_target_path=None, + adm_optimization=True, + opt_spatial_resolution=2.0, + opt_angle_resolution=10.0, + opt_search_angle=30.0, + opt_search_radius=10.0, + field_mode="af", + roi_size_mm=20.0, + activation_length_mm=6.0, + gwi_threshold_mm=3.0, + max_angular_deviation_deg=0.0, + measured_rmt_mso=50.0, + af_cst_calibration=1.0, + cst_metric_unweighted=1.0, + mso_floor_ratio=0.7, + mso_ceiling_ratio=1.4, + generate_visualizations=False, + ) + qc = CoilPoseQC(status="WARN", reasons=("coil_normal_not_inward",)) + monkeypatch.setattr(grid_search, "_configure_worker_environment", lambda: None) + monkeypatch.setattr( + grid_search.SimNIBSInterface, + "run_optimization", + lambda **kwargs: (np.eye(4), np.array([0.0, 0.0, 1.0])), + ) + monkeypatch.setattr(geometry, "evaluate_coil_pose_qc", lambda *args: qc) + monkeypatch.setattr( + grid_search.io, + "save_optimization_result_txt", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + grid_search.SimNIBSInterface, + "run_simulation", + lambda **kwargs: pytest.fail("Simulation must not run for a rejected pose"), + ) + + result = grid_search.process_grid_point(task) + + assert result.success is False + assert "not dose-eligible" in result.error_message + + +class TestGridWorkerResourcePlan: + def test_explicit_workers_are_capped_by_pardiso_memory(self, monkeypatch): + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + monkeypatch.setattr(grid_search, "_get_available_memory_gb", lambda: 28.0) + monkeypatch.setattr(grid_search.os, "cpu_count", lambda: 16) + + plan = grid_search._resolve_grid_worker_plan(4, 10) + + assert plan.workers == 2 + assert plan.memory_worker_limit == 2 + assert plan.memory_per_worker_gb == 12.0 + assert plan.memory_reserve_gb == 4.0 + assert plan.forced is False + + def test_force_override_retains_explicit_workers(self, monkeypatch): + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + monkeypatch.setattr(grid_search, "_get_available_memory_gb", lambda: 28.0) + monkeypatch.setenv("TIDE_GRID_FORCE_WORKERS", "1") + + plan = grid_search._resolve_grid_worker_plan(4, 10) + + assert plan.workers == 4 + assert plan.memory_worker_limit == 2 + assert plan.forced is True + + def test_unknown_memory_uses_one_worker_without_override(self, monkeypatch): + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + monkeypatch.setattr(grid_search, "_get_available_memory_gb", lambda: None) + + plan = grid_search._resolve_grid_worker_plan(4, 10) + + assert plan.workers == 1 + assert plan.memory_worker_limit == 1 + + +class TestGridProgressCallbacks: + def test_parallel_completions_all_emit_callbacks( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + tasks = [ + SimpleNamespace(index=0, point_label="grid_P00", cortex_coord=[0.0, 0.0, 0.0]), + SimpleNamespace(index=1, point_label="grid_P01", cortex_coord=[1.0, 0.0, 0.0]), + ] + success_future = grid_search.Future() + success_future.set_result( + grid_search.GridPointResult( + index=0, + point_label="grid_P00", + success=True, + weighted_mso=50.0, + unweighted_mso=51.0, + cortex_coord=tasks[0].cortex_coord, + opt_scalp_coords=None, + opt_matrix=None, + ) + ) + failed_future = grid_search.Future() + failed_future.set_exception(RuntimeError("worker failed")) + callbacks = [] + + results, failed_count = grid_search._collect_parallel_grid_results( + { + success_future: tasks[0], + failed_future: tasks[1], + }, + num_points=2, + use_parallel_ui=False, + progress_callback=lambda completed, total, label: callbacks.append( + (completed, total, label) + ), + ) + + assert failed_count == 1 + assert {result.point_label for result in results} == {"grid_P00", "grid_P01"} + assert [completed for completed, _, _ in callbacks] == [1, 2] + assert {label for _, _, label in callbacks} == {"grid_P00", "grid_P01"} + assert all(total == 2 for _, total, _ in callbacks) + + +class TestVisualizationControls: + def test_grid_point_optional_artifacts_are_skipped(self, monkeypatch, tmp_path): + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + task = SimpleNamespace( + generate_visualizations=False, + t1w_path=str(tmp_path / "t1.nii.gz"), + point_dir=str(tmp_path), + point_label="grid_P00", + cortex_coord=[0.0, 0.0, 0.0], + roi_size_mm=20.0, + ) + monkeypatch.setattr( + grid_search.io, + "save_points_as_nifti", + lambda *args, **kwargs: pytest.fail("NIfTI output must be disabled"), + ) + monkeypatch.setattr( + grid_search, + "save_af_visualization", + lambda *args, **kwargs: pytest.fail("plot output must be disabled"), + ) + + grid_search._save_grid_point_visualizations( + task, + [np.zeros((4, 3))], + [np.ones(4)], + ) + + def test_grid_visualizer_can_skip_interactive_html(self, monkeypatch, tmp_path): + from tide.interfaces import grid_visualization + + records = [{"label": "grid_P00"}] + calls = [] + monkeypatch.setattr(grid_visualization, "parse_grid_csv", lambda path: records) + monkeypatch.setattr( + grid_visualization, + "generate_scalar_nifti", + lambda *args: calls.append("nifti"), + ) + monkeypatch.setattr( + grid_visualization, + "generate_interactive_html", + lambda *args, **kwargs: calls.append("html"), + ) + + grid_visualization.run_grid_visualization( + csv_path=tmp_path / "results.csv", + t1w_path=tmp_path / "t1.nii.gz", + trk_path=tmp_path / "bundle.trk", + output_dir=tmp_path / "visualization", + generate_interactive=False, + ) + + assert calls == ["nifti"] + + def test_grid_visualizer_excludes_failed_nonfinite_rows(self, tmp_path): + from tide.interfaces.grid_visualization import parse_grid_csv + + csv_path = tmp_path / "results.csv" + csv_path.write_text( + "grid_point_labels,grid_point_coords,unweighted_mso_raw," + "weighted_mso_raw,unweighted_mso_clamped,weighted_mso_clamped," + "unweighted_mso_flag,weighted_mso_flag\n" + 'grid_P00,"[1, 2, 3]",50,49,50,49,WITHIN_RANGE,WITHIN_RANGE\n' + 'grid_P01,"[4, 5, 6]",nan,nan,nan,nan,ESTIMATION_FAILED,' + "ESTIMATION_FAILED\n" + ) + + records = parse_grid_csv(csv_path) + + assert [record["label"] for record in records] == ["grid_P00"] + + @staticmethod + def _clamp_map_records(): + return [ + { + "label": "grid_P00", + "coords": [1.0, 2.0, 3.0], + "weighted_mso_raw": 42.0, + "weighted_mso_clamped": 42.0, + "unweighted_mso_raw": 41.0, + "unweighted_mso_clamped": 41.0, + "weighted_mso_flag": "WITHIN_RANGE", + "unweighted_mso_flag": "WITHIN_RANGE", + "sei_weighted": 1.1, + }, + { + "label": "grid_P01", + "coords": [4.0, 5.0, 6.0], + "weighted_mso_raw": 95.0, + "weighted_mso_clamped": 70.0, + "unweighted_mso_raw": 90.0, + "unweighted_mso_clamped": 70.0, + "weighted_mso_flag": "CLAMPED_HIGH", + "unweighted_mso_flag": "CLAMPED_HIGH", + "sei_weighted": 0.6, + }, + ] + + @staticmethod + def _write_reference_t1w(tmp_path): + import nibabel as nib + + t1w_path = tmp_path / "t1.nii.gz" + image = nib.Nifti1Image(np.zeros((10, 10, 10), dtype=np.float32), np.eye(4)) + nib.save(image, str(t1w_path)) + return t1w_path + + def test_grid_nifti_maps_separate_raw_clamped_and_flag(self, tmp_path): + import nibabel as nib + + from tide.interfaces.grid_visualization import CLAMP_FLAG_CODES, generate_scalar_nifti + + records = self._clamp_map_records() + generate_scalar_nifti(records, self._write_reference_t1w(tmp_path), tmp_path) + + clamped = nib.load(str(tmp_path / "grid_mso_map.nii.gz")).get_fdata() + raw = nib.load(str(tmp_path / "grid_mso_raw_map.nii.gz")).get_fdata() + flags = nib.load(str(tmp_path / "grid_mso_flag_map.nii.gz")).get_fdata() + + # grid_mso_map.nii.gz keeps its historical clamped meaning. + assert clamped[1, 2, 3] == pytest.approx(42.0) + assert clamped[4, 5, 6] == pytest.approx(70.0) + assert raw[1, 2, 3] == pytest.approx(42.0) + assert raw[4, 5, 6] == pytest.approx(95.0) + assert flags[1, 2, 3] == CLAMP_FLAG_CODES["WITHIN_RANGE"] + assert flags[4, 5, 6] == CLAMP_FLAG_CODES["CLAMPED_HIGH"] + assert flags[0, 0, 0] == 0 + + def test_grid_label_sidecar_keeps_frozen_column_prefix(self, tmp_path): + from tide.interfaces.grid_visualization import generate_scalar_nifti + + records = self._clamp_map_records() + generate_scalar_nifti(records, self._write_reference_t1w(tmp_path), tmp_path) + + header = (tmp_path / "grid_mso_labels.tsv").read_text().splitlines()[0].split("\t") + frozen = [ + "label", + "x_ras", + "y_ras", + "z_ras", + "weighted_mso_clamped", + "weighted_mso_raw", + "unweighted_mso_clamped", + "sei_weighted", + ] + + assert header[: len(frozen)] == frozen + assert header[len(frozen) :] == [ + "unweighted_mso_raw", + "weighted_mso_flag", + "unweighted_mso_flag", + ] + + def test_grid_viewer_colours_both_raw_and_clamped_and_defaults_to_raw(self): + from tide.interfaces.grid_visualization import DEFAULT_VIEW_MODE, VIEW_MODES, _mso_to_hex + + assert DEFAULT_VIEW_MODE == "raw" + assert VIEW_MODES == { + "raw": "weighted_mso_raw", + "clamped": "weighted_mso_clamped", + } + + records = self._clamp_map_records() + scales = { + mode: { + "vmin": min(r[field] for r in records), + "vmax": max(r[field] for r in records), + } + for mode, field in VIEW_MODES.items() + } + saturated = records[1] + raw_hex = _mso_to_hex( + saturated["weighted_mso_raw"], + scales["raw"]["vmin"], + scales["raw"]["vmax"], + ) + clamped_hex = _mso_to_hex( + saturated["weighted_mso_clamped"], + scales["clamped"]["vmin"], + scales["clamped"]["vmax"], + ) + + # Same point, different scale extents: colours must not be conflated. + assert scales["raw"]["vmax"] > scales["clamped"]["vmax"] + assert isinstance(raw_hex, str) and isinstance(clamped_hex, str) + + def test_standard_mapping_skips_optional_nifti(self, tmp_path, monkeypatch): + standard = _load_workflow_module(monkeypatch, "tide.workflows.standard") + streamline = np.column_stack((np.arange(4, dtype=float), np.zeros(4), np.zeros(4))) + config = SimpleNamespace( + subject=SimpleNamespace( + t1w_path=tmp_path / "t1.nii.gz", + surface_path=None, + weights_target_path=None, + ), + target=SimpleNamespace( + label="Target", + bundle_path=tmp_path / "target.trk", + coords=None, + ), + options=SimpleNamespace( + field_mode="af", + max_angular_deviation_deg=0.0, + gwi_threshold_mm=3.0, + roi_size_mm=20.0, + activation_length_mm=6.0, + generate_visualizations=False, + generate_3d_visualization=False, + ), + ) + sft = SimpleNamespace(streamlines=[streamline]) + monkeypatch.setattr(standard.tractography, "load_tract", lambda *args: sft) + monkeypatch.setattr( + standard, + "sample_field_at_coordinates", + lambda *args, **kwargs: np.zeros((4, 3)), + ) + monkeypatch.setattr( + standard, + "split_vectors_by_streamline", + lambda *args: [np.zeros((4, 3))], + ) + monkeypatch.setattr( + standard.physics, + "calculate_scalar_map", + lambda *args, **kwargs: ( + [streamline], + [np.ones(4)], + [np.ones(4)], + np.array([0]), + ), + ) + monkeypatch.setattr(standard.io, "save_tract_with_data", lambda *args: None) + monkeypatch.setattr( + standard.io, + "save_points_as_nifti", + lambda *args, **kwargs: pytest.fail("NIfTI output must be disabled"), + ) + captured = {} + monkeypatch.setattr( + standard.io, + "save_mapping_summary", + lambda path, data: captured.update(data), + ) + monkeypatch.setattr( + standard, + "analyze_bundle", + lambda *args, **kwargs: SimpleNamespace( + metric_weighted=1.0, + metric_unweighted=1.0, + aggregates_weighted={}, + aggregates_unweighted={}, + ), + ) + monkeypatch.setattr( + standard, + "log", + SimpleNamespace( + debug=lambda *args: None, + highlight=lambda *args: None, + warning=lambda *args: None, + ), + ) + + standard._process_bundle_mapping(config, tmp_path / "field.msh", tmp_path) + + assert captured["Output_Files"]["NIfTI map"] == "Disabled by configuration" + + +class TestSurfaceLoadFailures: + def test_grid_surface_load_failure_propagates(self, tmp_path, monkeypatch): + grid_search = _load_workflow_module(monkeypatch, "tide.workflows.grid_search") + surface_path = tmp_path / "surface.white" + surface_path.write_text("") + config = SimpleNamespace( + subject=SimpleNamespace( + id="sub-TEST", + mesh_path=tmp_path / "head.msh", + derivatives_path=tmp_path, + surface_path=surface_path, + weights_cst_path=None, + weights_target_path=None, + ), + target=SimpleNamespace(label="Target", medoid_endpoint=False), + options=SimpleNamespace(no_parallel=True, max_workers=1), + ) + monkeypatch.setattr(grid_search, "validate_workflow_config", lambda *args: None) + monkeypatch.setattr( + grid_search, + "load_surface_tree", + lambda *args: (_ for _ in ()).throw(ValueError("invalid surface")), + ) + + with pytest.raises(ValueError, match="invalid surface"): + grid_search.run_grid_search_workflow(config, console_ui=False) + + def test_standard_mapping_surface_load_failure_propagates(self, tmp_path, monkeypatch): + standard = _load_workflow_module(monkeypatch, "tide.workflows.standard") + streamline = np.column_stack((np.arange(4, dtype=float), np.zeros(4), np.zeros(4))) + config = SimpleNamespace( + subject=SimpleNamespace( + t1w_path=tmp_path / "t1.nii.gz", + surface_path=tmp_path / "surface.white", + weights_target_path=None, + ), + target=SimpleNamespace( + label="Target", + bundle_path=tmp_path / "target.trk", + coords=None, + ), + options=SimpleNamespace( + field_mode="af", + max_angular_deviation_deg=0.0, + gwi_threshold_mm=3.0, + roi_size_mm=20.0, + activation_length_mm=6.0, + generate_visualizations=True, + generate_3d_visualization=False, + ), + ) + sft = SimpleNamespace(streamlines=[streamline]) + monkeypatch.setattr(standard.tractography, "load_tract", lambda *args: sft) + monkeypatch.setattr( + standard, + "sample_field_at_coordinates", + lambda *args, **kwargs: np.zeros((4, 3)), + ) + monkeypatch.setattr( + standard, + "split_vectors_by_streamline", + lambda *args: [np.zeros((4, 3))], + ) + monkeypatch.setattr( + standard.physics, + "calculate_scalar_map", + lambda *args, **kwargs: ( + [streamline], + [np.ones(4)], + [np.ones(4)], + np.array([0]), + ), + ) + monkeypatch.setattr(standard.io, "save_tract_with_data", lambda *args: None) + monkeypatch.setattr(standard.io, "save_points_as_nifti", lambda *args, **kwargs: None) + monkeypatch.setattr(standard.io, "save_mapping_summary", lambda *args: None) + monkeypatch.setattr( + standard, + "load_surface_tree", + lambda *args: (_ for _ in ()).throw(ValueError("invalid surface")), + ) + monkeypatch.setattr( + standard, + "analyze_bundle", + lambda *args, **kwargs: SimpleNamespace( + metric_weighted=1.0, + metric_unweighted=1.0, + aggregates_weighted={}, + aggregates_unweighted={}, + ), + ) + monkeypatch.setattr( + standard, + "log", + SimpleNamespace( + debug=lambda *args: None, + highlight=lambda *args: None, + warning=lambda *args: None, + ), + ) + + with pytest.raises(standard.WorkflowError, match="invalid surface"): + standard._process_bundle_mapping( + config, + tmp_path / "field.msh", + tmp_path, + ) + + +def test_standard_optimization_forwards_eeg_orientation(tmp_path, monkeypatch): + standard = _load_workflow_module(monkeypatch, "tide.workflows.standard") + config = SimpleNamespace( + subject=SimpleNamespace( + id="sub-TEST", + derivatives_path=tmp_path, + mesh_path=tmp_path / "head.msh", + ), + target=SimpleNamespace( + label="Target", + medoid_endpoint=False, + bundle_path=None, + coords=[0.0, 0.0, 0.0], + scalp_coords=[0.0, 0.0, 1.0], + orientation="F8", + didt=None, + mso=None, + ), + coil=SimpleNamespace( + coil_model="coil.ccd", + coil_path=tmp_path / "coil.ccd", + coil_distance_mm=4.0, + device_didt_max=161e6, + ), + options=SimpleNamespace( + roi_size_mm=20.0, + activation_length_mm=6.0, + field_mode="af", + adm_optimization=True, + opt_search_radius=10.0, + opt_spatial_resolution=2.0, + opt_angle_resolution=10.0, + opt_search_angle=30.0, + ), + ) + captured = {} + + def run_optimization(**kwargs): + captured.update(kwargs) + return np.eye(4), np.array([0.0, 0.0, 1.0]) + + pose_qc = SimpleNamespace( + status="PASS", + reasons=(), + as_dict=lambda: {}, + ) + monkeypatch.setattr(standard.SimNIBSInterface, "run_optimization", run_optimization) + monkeypatch.setattr(standard, "evaluate_coil_pose_qc", lambda *args: pose_qc) + monkeypatch.setattr(standard.io, "save_optimization_result_txt", lambda *args, **kwargs: None) + monkeypatch.setattr(standard, "save_config_to_output", lambda *args, **kwargs: None) + monkeypatch.setattr( + standard, + "log", + SimpleNamespace( + highlight=lambda *args: None, + info=lambda *args: None, + warning=lambda *args: None, + ), + ) + + standard.run_standard_optimization(config) + + assert captured["orientation_ref"] == "F8" + + +# ============================================================================= +# H-15 — laboratory-verified Softaxic STMPX export +# ============================================================================= + + +_P01_R_TARGET_MATRIX = [ + [-0.8853008359570536, -0.32864786304693355, -0.32898785991040186, 28.00473127057423], + [-0.4542258111548085, 0.7626956250015824, 0.4604066638138527, -70.37541724363432], + [0.09960593523730635, 0.5570331818824887, -0.8244954165714761, 74.36249582644216], + [0.0, 0.0, 0.0, 1.0], +] + + +def _write_stmpx_export_inputs(tmp_path: Path) -> tuple[Path, Path]: + stmpx_path = tmp_path / "P01_R.stmpx" + stmpx_path.write_text( + '\n' + '' + "" + ) + results_path = tmp_path / "TIDE_Results_Target.txt" + results_path.write_text(textwrap.dedent(f""" + --- Target Estimation (Target) --- + Target Coords (Cortex): [21.0, -59.0, 56.0] + Optimized Scalp Position: [28.00, -70.38, 74.36] + Optimized Matrix: {_P01_R_TARGET_MATRIX} + --- Geometric Analysis --- + """).strip()) + return stmpx_path, results_path + + +def test_stmpx_export_matches_laboratory_verified_p01_r(tmp_path, monkeypatch): + from tide.interfaces import stmpx + + stmpx_path, results_path = _write_stmpx_export_inputs(tmp_path) + original = stmpx_path.read_bytes() + (tmp_path / "P01_R_updated.stmpx").write_text("stale output") + monkeypatch.setattr(stmpx.time, "time", lambda: 1772752999.345) + + output_path = stmpx.export_target_to_stmpx( + stmpx_path, + results_path, + dataset_name="20260717-TIDE-SUB_01", + ) + + assert output_path == tmp_path / "P01_R_updated.stmpx" + assert stmpx_path.read_bytes() == original + assert output_path.read_bytes().startswith(b"\n") + + root = ET.parse(output_path).getroot() + fmpm = root.find("fmpm") + assert fmpm is not None + assert fmpm.get("dataset") == "20260717-TIDE-SUB_01" + assert [node.get("id") for node in fmpm.findall("fmp")] == [ + "P001", + "Target_Estimation", + ] + + fp = fmpm.findall("fmp")[-1].find("fp") + assert fp is not None + assert list(fp.attrib) == stmpx.FP_ATTR_ORDER + assert fp.attrib == { + "m00": "-0.328648", + "m10": "0.762696", + "y": "-70.3754", + "m21": "0.099606", + "m02": "0.328988", + "m22": "0.824495", + "m01": "-0.885301", + "m12": "-0.460407", + "ts": "1772752999345", + "z": "74.3625", + "id": "8700449", + "x": "28.0047", + "m20": "0.557033", + "m11": "-0.454226", + } + assert fp.find("b").attrib == {"x": "21.0000", "y": "-59.0000", "z": "56.0000"} + assert fp.find("f").attrib == {"x": "28.0000", "y": "-70.3800", "z": "74.3600"} + + +def test_stmpx_export_preserves_existing_dataset_when_unset(tmp_path, monkeypatch): + from tide.interfaces import stmpx + + stmpx_path, results_path = _write_stmpx_export_inputs(tmp_path) + monkeypatch.setattr(stmpx.time, "time", lambda: 1772752999.345) + + output_path = stmpx.export_target_to_stmpx(stmpx_path, results_path) + + fmpm = ET.parse(output_path).getroot().find("fmpm") + assert fmpm is not None + assert fmpm.get("dataset") == "20260304-092048_SUB_01" + + +def test_stmpx_rejects_xml_entities(tmp_path: Path) -> None: + from tide.interfaces.stmpx import validate_stmpx_input + + stmpx_path = tmp_path / "entity.stmpx" + stmpx_path.write_text( + ']>' "&payload;" + ) + + with pytest.raises(ValueError, match="Invalid STMPX XML"): + validate_stmpx_input(stmpx_path) diff --git a/tests/test_fixed_pose_cache.py b/tests/test_fixed_pose_cache.py new file mode 100644 index 0000000..5a8667e --- /dev/null +++ b/tests/test_fixed_pose_cache.py @@ -0,0 +1,374 @@ +import importlib +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Callable, Optional + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from tide.utils.artifacts import ( + CACHE_DISABLE_TOKENS, + cache_total_size, + clear_cache, + enforce_cache_limit, + entry_size, + fixed_pose_cache_enabled, + fixed_pose_cache_key, + iter_cache_entries, + resolve_cache_max_bytes, + restore_fixed_pose_artifacts, + store_fixed_pose_artifacts, +) + +MATRIX = [ + [1.0, 0.0, 0.0, 10.0], + [0.0, 1.0, 0.0, 20.0], + [0.0, 0.0, 1.0, 30.0], + [0.0, 0.0, 0.0, 1.0], +] + + +def test_cache_can_be_disabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TIDE_FIXED_POSE_CACHE", "false") + + assert fixed_pose_cache_enabled() is False + + +def test_disable_tokens_shared_by_env_and_config(monkeypatch: pytest.MonkeyPatch) -> None: + assert "no" in CACHE_DISABLE_TOKENS + for token in sorted(CACHE_DISABLE_TOKENS): + monkeypatch.setenv("TIDE_FIXED_POSE_CACHE", token) + assert fixed_pose_cache_enabled() is False + monkeypatch.setenv("TIDE_FIXED_POSE_CACHE", "1") + assert fixed_pose_cache_enabled() is True + + +def _write_inputs(tmp_path: Path) -> tuple[Path, Path]: + mesh_path = tmp_path / "m2m_subject" + mesh_path.mkdir() + (mesh_path / "subject.msh").write_bytes(b"head-mesh") + coil_path = tmp_path / "coil.ccd" + coil_path.write_bytes(b"coil-model") + return mesh_path, coil_path + + +def _cache_key( + mesh_path: Path, + coil_path: Path, + matrix: Optional[list[list[float]]] = None, +) -> str: + return fixed_pose_cache_key( + mesh_path=mesh_path, + coil_path=coil_path, + orientation=matrix if matrix is not None else MATRIX, + didt=1e6, + distance_mm=4.0, + fields="E", + runtime_signature={"simnibs": "4.5.0", "numpy": "1.26.4"}, + ) + + +def test_cache_key_tracks_every_numerical_input(tmp_path: Path) -> None: + mesh_path, coil_path = _write_inputs(tmp_path) + baseline = _cache_key(mesh_path, coil_path) + + changed_matrix = [row.copy() for row in MATRIX] + changed_matrix[0][3] = 10.5 + + assert _cache_key(mesh_path, coil_path, changed_matrix) != baseline + assert ( + fixed_pose_cache_key( + mesh_path=mesh_path, + coil_path=coil_path, + orientation=MATRIX, + didt=2e6, + distance_mm=4.0, + fields="E", + runtime_signature={"simnibs": "4.5.0", "numpy": "1.26.4"}, + ) + != baseline + ) + assert ( + fixed_pose_cache_key( + mesh_path=mesh_path, + coil_path=coil_path, + orientation=MATRIX, + didt=1e6, + distance_mm=5.0, + fields="E", + runtime_signature={"simnibs": "4.5.0", "numpy": "1.26.4"}, + ) + != baseline + ) + assert ( + fixed_pose_cache_key( + mesh_path=mesh_path, + coil_path=coil_path, + orientation=MATRIX, + didt=1e6, + distance_mm=4.0, + fields="veEjJ", + runtime_signature={"simnibs": "4.5.0", "numpy": "1.26.4"}, + ) + != baseline + ) + assert ( + fixed_pose_cache_key( + mesh_path=mesh_path, + coil_path=coil_path, + orientation=MATRIX, + didt=1e6, + distance_mm=4.0, + fields="E", + runtime_signature={"simnibs": "4.6.0", "numpy": "1.26.4"}, + ) + != baseline + ) + + coil_path.write_bytes(b"changed-coil-model") + assert _cache_key(mesh_path, coil_path) != baseline + coil_path.write_bytes(b"coil-model") + + (mesh_path / "subject.msh").write_bytes(b"changed-head-mesh") + assert _cache_key(mesh_path, coil_path) != baseline + + +def test_cache_round_trip_preserves_all_artifact_bytes(tmp_path: Path) -> None: + mesh_path, coil_path = _write_inputs(tmp_path) + key = _cache_key(mesh_path, coil_path) + cache_root = tmp_path / "cache" + source_dir = tmp_path / "source" + source_dir.mkdir() + artifacts = [ + source_dir / "subject_scalar.msh", + source_dir / "subject_scalar.msh.opt", + source_dir / "subject_coil_pos.geo", + ] + contents = [b"mesh-bytes", b"option-bytes", b"geometry-bytes"] + for path, content in zip(artifacts, contents): + path.write_bytes(content) + + stored = store_fixed_pose_artifacts(key, artifacts, cache_root=cache_root) + output_dir = tmp_path / "restored" + output_dir.mkdir() + restored = restore_fixed_pose_artifacts(key, output_dir, cache_root=cache_root) + + assert stored is True + assert [path.name for path in restored] == [path.name for path in artifacts] + assert [path.read_bytes() for path in restored] == contents + + +def test_corrupt_cache_entry_is_a_miss_and_does_not_touch_output(tmp_path: Path) -> None: + mesh_path, coil_path = _write_inputs(tmp_path) + key = _cache_key(mesh_path, coil_path) + cache_root = tmp_path / "cache" + source = tmp_path / "subject_scalar.msh" + source.write_bytes(b"valid") + store_fixed_pose_artifacts(key, [source], cache_root=cache_root) + + metadata_path = cache_root / key[:2] / key / "metadata.json" + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + cached_name = metadata["artifacts"][0]["cached_name"] + (metadata_path.parent / cached_name).write_bytes(b"corrupt") + output_dir = tmp_path / "output" + output_dir.mkdir() + existing = output_dir / source.name + existing.write_bytes(b"existing") + + restored = restore_fixed_pose_artifacts(key, output_dir, cache_root=cache_root) + + assert restored == [] + assert existing.read_bytes() == b"existing" + + +class TestFixedPoseSimulationCache: + @staticmethod + def _load_interface( + monkeypatch: pytest.MonkeyPatch, + run_simnibs: Callable[[object], None], + ) -> Any: + class Position: + pass + + class TmsList: + def add_position(self) -> Position: + return Position() + + class Session: + time_str = "20260712-120000" + + def add_tmslist(self) -> TmsList: + return TmsList() + + def save_matlab_sim_struct(session: object, path: str) -> None: + Path(path).write_bytes(b"current-session") + + fake_simnibs = SimpleNamespace( + __version__="4.5.0", + opt_struct=SimpleNamespace(TMSoptimize=object), + run_simnibs=run_simnibs, + sim_struct=SimpleNamespace( + SESSION=Session, + save_matlab_sim_struct=save_matlab_sim_struct, + ), + ) + monkeypatch.setitem(sys.modules, "simnibs", fake_simnibs) + sys.modules.pop("tide.interfaces.simnibs_interface", None) + module = importlib.import_module("tide.interfaces.simnibs_interface") + monkeypatch.setattr(module, "run_simnibs", run_simnibs) + return module + + def test_second_output_directory_uses_cache_without_simnibs( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + mesh_path, coil_path = _write_inputs(tmp_path) + cache_root = tmp_path / "cache" + monkeypatch.setenv("TIDE_CACHE_DIR", str(cache_root)) + first_output = tmp_path / "first" + second_output = tmp_path / "second" + first_output.mkdir() + second_output.mkdir() + calls = [] + + def run_first(session: object) -> None: + calls.append(session) + (first_output / "subject_scalar.msh").write_bytes(b"exact-mesh") + (first_output / "subject_scalar.msh.opt").write_bytes(b"exact-options") + (first_output / "subject_coil_pos.geo").write_bytes(b"exact-geometry") + (first_output / "fields_summary.txt").write_bytes(b"exact-summary") + + module = self._load_interface(monkeypatch, run_first) + first_result = module.SimNIBSInterface.run_simulation( + mesh_path=mesh_path, + output_dir=first_output, + coil_path=coil_path, + didt=1e6, + orientation=MATRIX, + ) + + def fail_run(session: object) -> None: + pytest.fail("SimNIBS must not run on a fixed-pose cache hit") + + monkeypatch.setattr(module, "run_simnibs", fail_run) + second_result = module.SimNIBSInterface.run_simulation( + mesh_path=mesh_path, + output_dir=second_output, + coil_path=coil_path, + didt=1e6, + orientation=MATRIX, + ) + + assert len(calls) == 1 + assert first_result.read_bytes() == second_result.read_bytes() == b"exact-mesh" + assert (second_output / "subject_scalar.msh.opt").read_bytes() == b"exact-options" + assert (second_output / "subject_coil_pos.geo").read_bytes() == b"exact-geometry" + assert (second_output / "fields_summary.txt").read_bytes() == b"exact-summary" + assert len(list(second_output.glob("simnibs_simulation_*.mat"))) == 1 + assert len(list(second_output.glob("simnibs_simulation_*.log"))) == 1 + first_manifest = json.loads( + (first_output / ".tide_run_manifest.json").read_text(encoding="utf-8") + ) + second_manifest = json.loads( + (second_output / ".tide_run_manifest.json").read_text(encoding="utf-8") + ) + assert first_manifest["artifacts"]["simulation_mesh"]["cache"]["status"] == "miss" + assert second_manifest["artifacts"]["simulation_mesh"]["cache"]["status"] == "hit" + + sys.modules.pop("tide.interfaces.simnibs_interface", None) + + +def _store_entry( + tmp_path: Path, + cache_root: Path, + mesh_path: Path, + coil_path: Path, + tag: int, + payload: bytes, +) -> Path: + """Store one synthetic cache entry with a distinct key; return its entry dir.""" + matrix = [row.copy() for row in MATRIX] + matrix[0][3] = float(tag) + key = _cache_key(mesh_path, coil_path, matrix) + source = tmp_path / f"art{tag}.msh" + source.write_bytes(payload) + assert store_fixed_pose_artifacts(key, [source], cache_root=cache_root) is True + return cache_root / key[:2] / key + + +def test_resolve_cache_max_bytes(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TIDE_CACHE_MAX_GB", raising=False) + assert resolve_cache_max_bytes(None) is None + assert resolve_cache_max_bytes(0) is None + assert resolve_cache_max_bytes(2) == 2 * 1024**3 + monkeypatch.setenv("TIDE_CACHE_MAX_GB", "3") + assert resolve_cache_max_bytes(None) == 3 * 1024**3 + assert resolve_cache_max_bytes(1) == 1024**3 # config wins over env + + +def test_enforce_cache_limit_evicts_oldest(tmp_path: Path) -> None: + cache_root = tmp_path / "cache" + mesh_path, coil_path = _write_inputs(tmp_path) + old = _store_entry(tmp_path, cache_root, mesh_path, coil_path, 1, b"a" * 4000) + mid = _store_entry(tmp_path, cache_root, mesh_path, coil_path, 2, b"b" * 4000) + new = _store_entry(tmp_path, cache_root, mesh_path, coil_path, 3, b"c" * 4000) + + import os + + for offset, entry in ((300, old), (200, mid), (100, new)): + stamp = 1_000_000 - offset + os.utime(entry, (stamp, stamp)) + + max_bytes = entry_size(mid) + entry_size(new) + freed_old = entry_size(old) + evicted, freed = enforce_cache_limit(cache_root, max_bytes) + + assert evicted == 1 + assert freed == freed_old + assert not old.exists() + assert mid.exists() and new.exists() + assert cache_total_size(cache_root) <= max_bytes + + +def test_touch_on_hit_spares_recently_used(tmp_path: Path) -> None: + import os + + cache_root = tmp_path / "cache" + mesh_path, coil_path = _write_inputs(tmp_path) + older = _store_entry(tmp_path, cache_root, mesh_path, coil_path, 1, b"a" * 4000) + newer = _store_entry(tmp_path, cache_root, mesh_path, coil_path, 2, b"b" * 4000) + + # Make `older` the more-recently-stored entry, then hit `newer` to bump it. + os.utime(older, (2_000_000, 2_000_000)) + os.utime(newer, (1_000_000, 1_000_000)) + + key = newer.name + output_dir = tmp_path / "restored" + output_dir.mkdir() + restored = restore_fixed_pose_artifacts(key, output_dir, cache_root=cache_root) + assert restored # cache hit bumped `newer` mtime + + max_bytes = entry_size(newer) # room for one entry only + evicted, _ = enforce_cache_limit(cache_root, max_bytes) + + assert evicted == 1 + assert newer.exists() # spared because it was just used + assert not older.exists() + + +def test_clear_cache_empties_root(tmp_path: Path) -> None: + cache_root = tmp_path / "cache" + mesh_path, coil_path = _write_inputs(tmp_path) + _store_entry(tmp_path, cache_root, mesh_path, coil_path, 1, b"a" * 100) + _store_entry(tmp_path, cache_root, mesh_path, coil_path, 2, b"b" * 100) + assert len(iter_cache_entries(cache_root)) == 2 + + removed, freed = clear_cache(cache_root) + + assert removed == 2 + assert freed > 0 + assert iter_cache_entries(cache_root) == [] diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..e196298 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,366 @@ +""" +Unit Tests for TIDE Geometry Module +==================================== +Tests for ray casting, coil orientation, and scalp projection. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +# ============================================================================= +# Tests for project_target_to_scalp +# ============================================================================= + + +class TestProjectTargetToScalp: + """Tests for ray-triangle intersection (Möller-Trumbore).""" + + @patch("tide.core.geometry.read_msh", new=MagicMock()) + def test_raises_on_missing_mesh(self, tmp_path): + """Should raise FileNotFoundError if mesh doesn't exist. + + ``read_msh`` is patched non-None so the missing-mesh path is reached + whether or not SimNIBS is importable in the test environment. + """ + from tide.core.geometry import project_target_to_scalp + + fake_path = tmp_path / "nonexistent.msh" + target = np.array([0.0, 0.0, 0.0]) + + with pytest.raises(FileNotFoundError): + project_target_to_scalp(fake_path, target) + + @pytest.mark.skip(reason="Requires integration test with real SimNIBS mesh") + @patch("tide.core.geometry.read_msh") + @patch("pathlib.Path.exists") + def test_returns_3d_array(self, mock_exists, mock_read_msh, mock_mesh): + """Result should be a 3D coordinate array.""" + # This test requires a complex mesh structure that is difficult to mock + # Marked for integration testing with real mesh files + pass + + def test_validates_target_at_center(self): + """Should raise ValueError if target is at brain center.""" + + # This tests the internal validation + # We can't easily mock the full mesh, so we check the error handling logic + pass # Covered by integration tests + + +# ============================================================================= +# Tests for compute_default_coil_orientation +# ============================================================================= + + +class TestComputeDefaultCoilOrientation: + """Tests for automatic coil handle orientation calculation.""" + + @pytest.mark.skip(reason="Requires integration test with real SimNIBS mesh") + @patch("tide.core.geometry.read_msh") + def test_returns_list_of_three(self, mock_read_msh): + """Result should be [x, y, z] list.""" + pass # Complex mesh mocking needed - use integration tests + from tide.core.geometry import compute_default_coil_orientation + + # Create mock mesh with proper nodes array + mock_mesh = MagicMock() + n_nodes = 200 + + # Create sphere-like node distribution + nodes = np.zeros((n_nodes + 1, 3)) + for i in range(1, n_nodes + 1): + theta = 2 * np.pi * (i / n_nodes) + phi = np.pi * ((i % 50) / 50) + r = 80 if i < 100 else 50 # Outer scalp, inner GM + nodes[i] = [ + r * np.sin(phi) * np.cos(theta), + r * np.sin(phi) * np.sin(theta), + r * np.cos(phi), + ] + + # Use the nodes array directly (MagicMock with return value) + mock_mesh.nodes = MagicMock() + mock_mesh.nodes.__getitem__ = MagicMock(return_value=nodes) + mock_mesh.elm.tag1 = np.array([1005] * 100 + [1002] * 100) + mock_mesh.elm.node_number_list = np.column_stack( + [ + np.arange(1, 101), + np.arange(2, 102) % 100 + 1, + np.arange(3, 103) % 100 + 1, + ] + ) + + mock_read_msh.return_value = mock_mesh + + scalp_pos = np.array([-45.0, 30.0, 65.0]) # Left hemisphere + result = compute_default_coil_orientation(Path("dummy.msh"), scalp_pos) + + assert isinstance(result, list) + assert len(result) == 3 + assert all(np.isfinite(result)) + + @pytest.mark.skip(reason="Requires integration test with real SimNIBS mesh") + @patch("tide.core.geometry.read_msh") + def test_left_hemisphere_orientation(self, mock_read_msh): + """Left hemisphere should orient handle toward right (medial).""" + pass # Complex mesh mocking needed - use integration tests + from tide.core.geometry import compute_default_coil_orientation + + # Setup mock mesh + mock_mesh = MagicMock() + n_nodes = 200 + nodes = np.zeros((n_nodes + 1, 3)) + for i in range(1, n_nodes + 1): + theta = 2 * np.pi * (i / n_nodes) + phi = np.pi * ((i % 50) / 50) + r = 80 if i < 100 else 50 + nodes[i] = [ + r * np.sin(phi) * np.cos(theta), + r * np.sin(phi) * np.sin(theta), + r * np.cos(phi), + ] + + mock_mesh.nodes = MagicMock() + mock_mesh.nodes.__getitem__ = MagicMock(return_value=nodes) + mock_mesh.elm.tag1 = np.array([1005] * 100 + [1002] * 100) + mock_mesh.elm.node_number_list = np.column_stack( + [ + np.arange(1, 101), + np.arange(2, 102) % 100 + 1, + np.arange(3, 103) % 100 + 1, + ] + ) + mock_read_msh.return_value = mock_mesh + + # Left hemisphere position + left_pos = np.array([-50.0, 20.0, 60.0]) + result = compute_default_coil_orientation(Path("dummy.msh"), left_pos) + + # The reference point should be anterior-right (positive X, positive Y) + # This is a functional test - just verify it runs and returns valid coords + assert np.isfinite(result).all() + + +# ============================================================================= +# Tests for coil-pose QC +# ============================================================================= + + +def _mock_scalp_qc_mesh(): + nodes = np.zeros((14, 3), dtype=float) + nodes[1:6] = np.array( + [ + [0.0, 0.0, -80.0], + [8.0, 0.0, -80.0], + [-8.0, 0.0, -80.0], + [0.0, 8.0, -80.0], + [0.0, -8.0, -80.0], + ] + ) + nodes[6:11] = np.array( + [ + [0.0, 0.0, 80.0], + [8.0, 0.0, 80.0], + [-8.0, 0.0, 80.0], + [0.0, 8.0, 80.0], + [0.0, -8.0, 80.0], + ] + ) + nodes[11:14] = np.array([[0.0, 0.0, 0.0], [5.0, 0.0, 0.0], [0.0, 5.0, 0.0]]) + + mesh = MagicMock() + mesh.nodes = nodes + mesh.elm.tag1 = np.array([1005, 1005, 1005, 1005, 1002]) + mesh.elm.elm_type = np.array([2, 2, 2, 2, 2]) + mesh.elm.node_number_list = np.array( + [ + [1, 2, 4], + [1, 4, 5], + [6, 7, 9], + [6, 9, 10], + [11, 12, 13], + ] + ) + return mesh + + +class TestCoilPoseQC: + """Tests for coil-pose physical plausibility checks.""" + + @patch("tide.core.geometry.read_msh") + def test_passes_crown_pose_with_inward_normal(self, mock_read_msh, tmp_path): + from tide.core.geometry import evaluate_coil_pose_qc + + mock_read_msh.return_value = _mock_scalp_qc_mesh() + mesh_path = tmp_path / "head.msh" + mesh_path.write_text("") + matrix = np.eye(4) + matrix[:3, 2] = [0.0, 0.0, -1.0] + matrix[:3, 3] = [0.0, 0.0, 80.0] + + qc = evaluate_coil_pose_qc(mesh_path, matrix, n_neighbors=4) + + assert qc.status == "PASS" + assert qc.reasons == () + assert qc.scalp_outward_dot < -0.9 + + @patch("tide.core.geometry.read_msh") + def test_warns_when_coil_normal_points_outward(self, mock_read_msh, tmp_path): + from tide.core.geometry import evaluate_coil_pose_qc + + mock_read_msh.return_value = _mock_scalp_qc_mesh() + mesh_path = tmp_path / "head.msh" + mesh_path.write_text("") + matrix = np.eye(4) + matrix[:3, 2] = [0.0, 0.0, 1.0] + matrix[:3, 3] = [0.0, 0.0, 80.0] + + qc = evaluate_coil_pose_qc(mesh_path, matrix, n_neighbors=4) + + assert qc.status == "WARN" + assert "coil_normal_not_inward" in qc.reasons + + @patch("tide.core.geometry.read_msh") + def test_warns_for_inferior_upward_firing_pose(self, mock_read_msh, tmp_path): + from tide.core.geometry import evaluate_coil_pose_qc + + mock_read_msh.return_value = _mock_scalp_qc_mesh() + mesh_path = tmp_path / "head.msh" + mesh_path.write_text("") + matrix = np.eye(4) + matrix[:3, 2] = [0.0, 0.0, 1.0] + matrix[:3, 3] = [0.0, 0.0, -80.0] + + qc = evaluate_coil_pose_qc(mesh_path, matrix, n_neighbors=4) + + assert qc.status == "WARN" + assert "inferior_scalp_surface" in qc.reasons + assert "upward_firing_low_inferior_pose" in qc.reasons + + def test_warned_automatic_pose_is_not_dose_eligible(self): + from tide.core.geometry import CoilPoseQC, validate_coil_pose_for_dose + + qc = CoilPoseQC(status="WARN", reasons=("coil_normal_not_inward",)) + + with pytest.raises(ValueError, match="not dose-eligible"): + validate_coil_pose_for_dose(qc, explicit_matrix=False) + + def test_warned_explicit_matrix_is_a_specialist_override(self): + from tide.core.geometry import CoilPoseQC, validate_coil_pose_for_dose + + qc = CoilPoseQC(status="WARN", reasons=("coil_normal_not_inward",)) + + validate_coil_pose_for_dose(qc, explicit_matrix=True) + + +# ============================================================================= +# Tests for corrected alignment QC +# ============================================================================= + + +class TestAlignmentCorrected: + """Tests for midpoint-aware alignment diagnostics.""" + + def test_straight_aligned_fibre(self): + from tide.core.geometry import calculate_alignment_corrected + + original_points = np.column_stack([np.arange(5, dtype=float), np.zeros(5), np.zeros(5)]) + midpoint_streamline = 0.5 * (original_points[:-1] + original_points[1:]) + e_vectors = np.tile([2.0, 0.0, 0.0], (len(original_points), 1)) + roi_mask = np.ones(len(midpoint_streamline), dtype=bool) + + alignment = calculate_alignment_corrected( + [midpoint_streamline], + [e_vectors], + [roi_mask], + ) + + assert np.isclose(alignment, 1.0) + + def test_straight_orthogonal_fibre(self): + from tide.core.geometry import calculate_alignment_corrected + + original_points = np.column_stack([np.arange(5, dtype=float), np.zeros(5), np.zeros(5)]) + midpoint_streamline = 0.5 * (original_points[:-1] + original_points[1:]) + e_vectors = np.tile([0.0, 2.0, 0.0], (len(original_points), 1)) + roi_mask = np.ones(len(midpoint_streamline), dtype=bool) + + alignment = calculate_alignment_corrected( + [midpoint_streamline], + [e_vectors], + [roi_mask], + ) + + assert np.isclose(alignment, 0.0) + + def test_curved_fibre(self): + from tide.core.geometry import calculate_alignment_corrected + + theta = np.linspace(0.0, np.pi / 2.0, 9) + original_points = np.column_stack([np.cos(theta), np.sin(theta), np.zeros_like(theta)]) + midpoint_streamline = 0.5 * (original_points[:-1] + original_points[1:]) + e_vectors = np.column_stack([-np.sin(theta), np.cos(theta), np.zeros_like(theta)]) + roi_mask = np.ones(len(midpoint_streamline), dtype=bool) + + alignment = calculate_alignment_corrected( + [midpoint_streamline], + [e_vectors], + [roi_mask], + ) + + assert alignment > 0.98 + + +# ============================================================================= +# Helper Function Tests +# ============================================================================= + + +class TestGeometryHelpers: + """Tests for geometry helper functions.""" + + def test_moller_trumbore_math(self): + """Test the Möller-Trumbore algorithm math isolated.""" + # Simple triangle in XY plane + vert0 = np.array([[0.0, 0.0, 0.0]]) + vert1 = np.array([[1.0, 0.0, 0.0]]) + vert2 = np.array([[0.0, 1.0, 0.0]]) + + # Ray from below pointing up + ray_origin = np.array([0.25, 0.25, -1.0]) + ray_direction = np.array([0.0, 0.0, 1.0]) + + # Compute intersection using M-T algorithm + edge1 = vert1 - vert0 + edge2 = vert2 - vert0 + h = np.cross(ray_direction, edge2) + a = np.einsum("ij,ij->i", edge1, h) + + epsilon = 1e-7 + valid_a = np.abs(a) > epsilon + f = np.zeros_like(a) + f[valid_a] = 1.0 / a[valid_a] + + s = ray_origin - vert0 + u = f * np.einsum("ij,ij->i", s, h) + + q = np.cross(s, edge1) + v = f * np.einsum("j,ij->i", ray_direction, q) + t = f * np.einsum("ij,ij->i", edge2, q) + + # Validate intersection + valid = valid_a & (u >= 0) & (u <= 1) & (v >= 0) & (u + v <= 1) & (t > epsilon) + + assert valid[0], "Ray should intersect triangle" + assert np.isclose(t[0], 1.0), "Intersection distance should be 1.0" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_physics.py b/tests/test_physics.py new file mode 100644 index 0000000..0a35961 --- /dev/null +++ b/tests/test_physics.py @@ -0,0 +1,709 @@ +""" +Unit Tests for TIDE Physics Module +=================================== +Tests for activating function calculation, threshold estimation, and RMT estimation. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from tide.core.physics import ( + AGGREGATOR_KEYS, + PRIMARY_AGGREGATOR, + calculate_scalar_map, + cross_streamline_aggregates, + get_max_contiguous_threshold, + median_of_top_percentile, + weighted_mean, + weighted_median_of_top_percentile, + weighted_percentile, +) + +# ============================================================================= +# Test Fixtures (local to this file) +# ============================================================================= + + +@pytest.fixture +def straight_bundle_with_uniform_efield(): + """ + Bundle of straight streamlines with uniform E-field aligned with tangent. + Expected: High E_parallel, moderate AF (low gradient on uniform field). + """ + streamlines = [] + e_fields = [] + + for i in range(5): + # Straight line along X-axis + x = np.arange(0, 25, 0.5) + sl = np.column_stack([x, np.ones_like(x) * i * 2, np.zeros_like(x)]) + + # Uniform E-field aligned with tangent (X direction) + ef = np.tile([100.0, 0.0, 0.0], (len(sl), 1)) + + streamlines.append(sl) + e_fields.append(ef) + + return streamlines, e_fields + + +@pytest.fixture +def curved_bundle_with_varying_efield(): + """ + Curved streamlines with varying E-field. + Expected: Non-zero AF due to the gradient term d(E·T)/ds. + """ + streamlines = [] + e_fields = [] + + for i in range(3): + # Curved path (quarter circle) + t = np.linspace(0, np.pi / 2, 30) + radius = 20.0 + sl = np.column_stack([radius * np.cos(t), radius * np.sin(t) + i * 5, np.zeros_like(t)]) + + # E-field with gradient (increasing magnitude) + magnitude = 50 + t * 100 # 50-200 V/m gradient + tangent = np.gradient(sl, axis=0) + tangent = tangent / (np.linalg.norm(tangent, axis=1, keepdims=True) + 1e-9) + ef = tangent * magnitude[:, np.newaxis] + + streamlines.append(sl) + e_fields.append(ef) + + return streamlines, e_fields + + +# ============================================================================= +# Tests for calculate_scalar_map +# ============================================================================= + + +class TestCalculateScalarMap: + """Tests for the main AF calculation function.""" + + def test_returns_correct_structure(self, straight_bundle_with_uniform_efield): + """Test that function returns expected tuple structure.""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + new_sl, af_vals, lengths = calculate_scalar_map(streamlines, e_fields, mode="af") + + assert isinstance(new_sl, list) + assert isinstance(af_vals, list) + assert isinstance(lengths, list) + assert len(new_sl) == len(af_vals) == len(lengths) + + def test_midpoint_streamlines_have_correct_length(self, straight_bundle_with_uniform_efield): + """Midpoint streamlines should have N-1 points for N-point input.""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + new_sl, _, _ = calculate_scalar_map(streamlines, e_fields, mode="af") + + for orig, midpt in zip(streamlines, new_sl): + # Should have one fewer point (midpoints between segments) + assert len(midpt) == len(orig) - 1 + + def test_af_values_finite(self, straight_bundle_with_uniform_efield): + """Signed AF values should be finite (no NaN/Inf).""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + _, af_vals, _ = calculate_scalar_map(streamlines, e_fields, mode="af") + + for af in af_vals: + assert np.all(np.isfinite(af)), "AF values must be finite" + + def test_af_values_abs_when_signed_false(self, straight_bundle_with_uniform_efield): + """signed=False returns non-negative magnitudes.""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + _, af_vals, _ = calculate_scalar_map(streamlines, e_fields, mode="af", signed=False) + + for af in af_vals: + assert np.all(af >= 0), "signed=False must return |AF|" + + def test_uniform_field_produces_low_gradient_term(self, straight_bundle_with_uniform_efield): + """Uniform E-field on straight fiber should have near-zero gradient term.""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + _, af_vals, _ = calculate_scalar_map(streamlines, e_fields, mode="af") + + # For uniform field, |AF| should be low (numerical noise only) + for af in af_vals: + assert np.mean(np.abs(af)) < 50, "Uniform field should produce low |AF|" + + def test_varying_field_produces_higher_af(self, curved_bundle_with_varying_efield): + """Varying E-field should produce measurable AF.""" + streamlines, e_fields = curved_bundle_with_varying_efield + + _, af_vals, _ = calculate_scalar_map(streamlines, e_fields, mode="af") + + # Check that |AF| has meaningful magnitude (signed values may be +/-) + all_af = np.concatenate(af_vals) + assert np.max(np.abs(all_af)) > 10, "Varying field should produce measurable AF" + + def test_e_parallel_mode(self, straight_bundle_with_uniform_efield): + """Test e_parallel mode returns projected field (signed).""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + _, e_par_vals, _ = calculate_scalar_map(streamlines, e_fields, mode="e_parallel") + + # For aligned field, |E_parallel| should be close to field magnitude + for e_par in e_par_vals: + assert np.mean(np.abs(e_par)) > 90, "|E_parallel| should be ~100 V/m for aligned field" + + def test_skips_short_streamlines(self): + """Streamlines with < 4 points should be skipped.""" + short_sl = [np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0]])] # Only 3 points + short_ef = [np.array([[100, 0, 0], [100, 0, 0], [100, 0, 0]])] + + new_sl, af_vals, lengths = calculate_scalar_map(short_sl, short_ef, mode="af") + + assert len(new_sl) == 0, "Streamlines with <4 points should be skipped" + + def test_empty_input_returns_empty(self): + """Empty input should return empty lists.""" + new_sl, af_vals, lengths = calculate_scalar_map([], [], mode="af") + + assert new_sl == [] + assert af_vals == [] + assert lengths == [] + + def test_adaptive_sigma_used_by_default(self, straight_bundle_with_uniform_efield): + """Test that adaptive sigma is computed when smooth_sigma=None.""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + # Should not raise, and should compute adaptive sigma internally + new_sl, af_vals, lengths = calculate_scalar_map( + streamlines, e_fields, mode="af", smooth_sigma=None + ) + + assert len(new_sl) > 0 + + def test_custom_sigma_accepted(self, straight_bundle_with_uniform_efield): + """Test that custom sigma can be provided.""" + streamlines, e_fields = straight_bundle_with_uniform_efield + + new_sl, af_vals, _ = calculate_scalar_map( + streamlines, e_fields, mode="af", smooth_sigma=5.0 + ) + + assert len(new_sl) > 0 + + +# ============================================================================= +# Tests for get_max_contiguous_threshold +# ============================================================================= + + +class TestGradientTermAnalytic: + """Analytic checks on the gradient term d(E·T)/ds with exact SI units.""" + + @staticmethod + def _straight_fiber(step_mm: float = 0.5, n_points: int = 50) -> np.ndarray: + x = np.arange(0, n_points * step_mm, step_mm)[:n_points] + return np.column_stack([x, np.zeros_like(x), np.zeros_like(x)]) + + def test_linear_field_yields_si_gradient(self): + """E·T rising 1 (V/m) per mm gives d(E·T)/ds = 1000 V/m² (mm→m).""" + sl = self._straight_fiber() + slope_per_mm = 1.0 + ex = slope_per_mm * sl[:, 0] + ef = np.column_stack([ex, np.zeros_like(ex), np.zeros_like(ex)]) + + # Tiny sigma keeps smoothing an identity so the gradient is isolated. + _, af_vals, _ = calculate_scalar_map([sl], [ef], mode="af", smooth_sigma=1e-6) + + assert len(af_vals) == 1 + assert np.allclose(np.median(af_vals[0]), slope_per_mm * 1000.0, rtol=1e-3) + + def test_gradient_sign_flips_with_field_reversal(self): + """Signed AF flips sign when the field gradient reverses.""" + sl = self._straight_fiber() + ex = sl[:, 0] + ef_pos = np.column_stack([ex, np.zeros_like(ex), np.zeros_like(ex)]) + ef_neg = -ef_pos + + _, af_pos, _ = calculate_scalar_map([sl], [ef_pos], mode="af", smooth_sigma=1e-6) + _, af_neg, _ = calculate_scalar_map([sl], [ef_neg], mode="af", smooth_sigma=1e-6) + + np.testing.assert_allclose(af_pos[0], -af_neg[0], rtol=1e-6, atol=1e-6) + + def test_uniform_field_yields_zero_gradient(self): + """Constant E·T on a straight fiber gives a vanishing gradient term.""" + sl = self._straight_fiber() + ef = np.tile([100.0, 0.0, 0.0], (len(sl), 1)) + + _, af_vals, _ = calculate_scalar_map([sl], [ef], mode="af", smooth_sigma=1e-6) + + assert np.allclose(af_vals[0], 0.0, atol=1e-6) + + @pytest.mark.parametrize("step_mm", [0.25, 0.5, 1.0, 2.0]) + def test_af_is_sampling_invariant_at_boundaries(self, step_mm: float): + sl = self._straight_fiber(step_mm=step_mm, n_points=int(24.0 / step_mm) + 1) + ex = sl[:, 0] + ef = np.column_stack((ex, np.zeros_like(ex), np.zeros_like(ex))) + + midpoint_sl, af_vals, lengths = calculate_scalar_map( + [sl], + [ef], + mode="af", + ) + + np.testing.assert_allclose(af_vals[0], 1000.0, rtol=1e-6, atol=1e-6) + np.testing.assert_allclose(midpoint_sl[0], (sl[:-1] + sl[1:]) / 2.0) + np.testing.assert_allclose(lengths[0], np.linalg.norm(np.diff(sl, axis=0), axis=1)) + + def test_af_preserves_gradient_under_streamline_reversal(self): + sl = self._straight_fiber(step_mm=0.75, n_points=41) + ex = sl[:, 0] + ef = np.column_stack((ex, np.zeros_like(ex), np.zeros_like(ex))) + + _, forward_af, _ = calculate_scalar_map( + [sl], + [ef], + mode="af", + ) + _, reversed_af, _ = calculate_scalar_map( + [sl[::-1]], + [ef[::-1]], + mode="af", + ) + + np.testing.assert_allclose(forward_af[0], reversed_af[0][::-1], rtol=1e-6, atol=1e-6) + + def test_af_is_sampling_invariant_on_curved_fiber(self): + radius_mm = 30.0 + total_length_mm = radius_mm * np.pi / 2.0 + comparison_s = np.linspace(5.0, total_length_mm - 5.0, 80) + results = [] + + for step_mm in (0.25, 1.0): + s = np.arange(0.0, total_length_mm, step_mm) + s = np.append(s, total_length_mm) + theta = s / radius_mm + sl = np.column_stack( + ( + radius_mm * np.cos(theta), + radius_mm * np.sin(theta), + np.zeros_like(theta), + ) + ) + tangent = np.column_stack((-np.sin(theta), np.cos(theta), np.zeros_like(theta))) + ef = tangent * (50.0 + s)[:, np.newaxis] + + _, af_vals, _ = calculate_scalar_map( + [sl], + [ef], + mode="af", + ) + input_s = np.concatenate( + ([0.0], np.cumsum(np.linalg.norm(np.diff(sl, axis=0), axis=1))) + ) + midpoint_s = (input_s[:-1] + input_s[1:]) / 2.0 + results.append(np.interp(comparison_s, midpoint_s, af_vals[0])) + + np.testing.assert_allclose(results[0], results[1], rtol=5e-3, atol=1.0) + + +class TestGetMaxContiguousThreshold: + """Tests for the contiguous segment threshold algorithm.""" + + def test_simple_case(self): + """Test with simple uniform values.""" + values = np.array([10.0, 20.0, 30.0, 20.0, 10.0]) + lengths = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) + + thresh = get_max_contiguous_threshold(values, lengths, target_length=2.0) + + # A contiguous segment of length 2 can have min value of 20 (indices 1-2 or 2-3) + assert thresh == 20.0 + + def test_target_length_exceeds_total(self): + """Returns 0 if target length exceeds total streamline length.""" + values = np.array([10.0, 20.0, 30.0]) + lengths = np.array([1.0, 1.0, 1.0]) # Total = 3mm + + thresh = get_max_contiguous_threshold(values, lengths, target_length=10.0) + + assert thresh == 0.0 + + def test_empty_input(self): + """Empty input returns 0.""" + values = np.array([]) + lengths = np.array([]) + + thresh = get_max_contiguous_threshold(values, lengths, target_length=1.0) + + assert thresh == 0.0 + + def test_all_same_values(self): + """Uniform values should return that value.""" + values = np.array([50.0, 50.0, 50.0, 50.0]) + lengths = np.array([1.0, 1.0, 1.0, 1.0]) + + thresh = get_max_contiguous_threshold(values, lengths, target_length=2.0) + + assert thresh == 50.0 + + def test_finds_optimal_window(self): + """Should find the window with highest minimum.""" + values = np.array([5.0, 100.0, 100.0, 100.0, 5.0]) + lengths = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) + + # Best 3mm window is indices 1-3 with min=100 + thresh = get_max_contiguous_threshold(values, lengths, target_length=3.0) + + assert thresh == 100.0 + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_single_streamline(self): + """Test with single streamline.""" + sl = [np.column_stack([np.arange(0, 10, 0.5), np.zeros(20), np.zeros(20)])] + ef = [np.tile([100.0, 0.0, 0.0], (20, 1))] + + new_sl, af_vals, lengths = calculate_scalar_map(sl, ef, mode="af") + + assert len(new_sl) == 1 + + def test_mismatched_lengths_skipped(self): + """Streamlines with mismatched E-field lengths should be skipped.""" + sl = [np.column_stack([np.arange(0, 10, 0.5), np.zeros(20), np.zeros(20)])] + ef = [np.tile([100.0, 0.0, 0.0], (15, 1))] # Wrong length + + new_sl, af_vals, lengths = calculate_scalar_map(sl, ef, mode="af") + + assert len(new_sl) == 0 + + def test_nan_handling(self): + """Test behavior with NaN values in input.""" + values = np.array([10.0, np.nan, 30.0]) + lengths = np.array([1.0, 1.0, 1.0]) + + # Should handle gracefully (may return 0 or propagate NaN) + thresh = get_max_contiguous_threshold(values, lengths, target_length=1.0) + + # Result should be finite or 0 + assert np.isfinite(thresh) or thresh == 0.0 + + +class TestApplyMSOBounds: + """Tests for the MSO physiological floor/ceiling bounding function.""" + + def setup_method(self): + """Import apply_intensity_bounds for each test.""" + from tide.interfaces.unified_estimation import apply_intensity_bounds + + self.apply_intensity_bounds = apply_intensity_bounds + + def test_within_range_returns_raw(self): + """MSO within bounds should pass through unchanged.""" + result = self.apply_intensity_bounds(raw_intensity=45.0, rmt=50.0, floor_ratio=0.70) + assert result["best_estimate"] == 45.0 + assert result["model_raw"] == 45.0 + assert result["flag"] == "WITHIN_RANGE" + + def test_clamped_low(self): + """MSO below floor should be clamped to floor.""" + result = self.apply_intensity_bounds(raw_intensity=25.0, rmt=50.0, floor_ratio=0.70) + assert result["best_estimate"] == 35.0 # 50 * 0.70 + assert result["model_raw"] == 25.0 + assert result["flag"] == "CLAMPED_LOW" + + def test_clamped_high(self): + """MSO above ceiling should be capped.""" + result = self.apply_intensity_bounds( + raw_intensity=100.0, rmt=50.0, floor_ratio=0.70, ceiling_ratio=1.50 + ) + assert result["best_estimate"] == 75.0 # 50 * 1.50 + assert result["model_raw"] == 100.0 + assert result["flag"] == "CLAMPED_HIGH" + + def test_ceiling_capped_at_100(self): + """Ceiling never exceeds 100% MSO even when 1.5 * RMT would.""" + # 1.5 * 80 = 120, must be capped to 100, and the device cap is the + # binding constraint, so the flag names the device rather than safety. + result = self.apply_intensity_bounds(raw_intensity=130.0, rmt=80.0, ceiling_ratio=1.50) + assert result["best_estimate"] == 100.0 + assert result["model_raw"] == 130.0 + assert result["flag"] == "DEVICE_LIMITED" + + def test_safety_ceiling_exactly_at_device_limit_flags_clamped_high(self): + """The safety ratio still binds when it lands exactly on 100% MSO.""" + result = self.apply_intensity_bounds(raw_intensity=120.0, rmt=50.0, ceiling_ratio=2.00) + assert result["best_estimate"] == 100.0 + assert result["flag"] == "CLAMPED_HIGH" + + def test_floor_never_exceeds_device_limit(self): + """A floor ratio above 100/RMT must not produce an unprogrammable floor.""" + # 1.20 * 85 = 102, which no stimulator can deliver. + result = self.apply_intensity_bounds( + raw_intensity=60.0, rmt=85.0, floor_ratio=1.20, ceiling_ratio=1.40 + ) + assert result["best_estimate"] == 100.0 + assert result["model_raw"] == 60.0 + assert result["flag"] == "CLAMPED_LOW" + + def test_exact_floor_is_within_range(self): + """MSO exactly at floor should be WITHIN_RANGE (not clamped).""" + result = self.apply_intensity_bounds(raw_intensity=35.0, rmt=50.0, floor_ratio=0.70) + assert result["flag"] == "WITHIN_RANGE" + + def test_deviation_pct(self): + """Deviation percentage should be correct.""" + result = self.apply_intensity_bounds(raw_intensity=25.0, rmt=50.0, floor_ratio=0.70) + assert np.isclose(result["deviation_pct"], 50.0) # |25-50|/50 * 100 + + def test_rmt_equal_mso(self): + """When raw_mso equals RMT, deviation should be zero.""" + result = self.apply_intensity_bounds(raw_intensity=50.0, rmt=50.0) + assert result["deviation_pct"] == 0.0 + assert result["flag"] == "WITHIN_RANGE" + assert result["best_estimate"] == 50.0 + + +# ============================================================================= +# Streamline identity through the drop chain (audit C-002) +# ============================================================================= + + +def _short_and_long_bundle(): + """Six streamlines; ids 0 (first), 3 (middle), 5 (last) are <4-point stubs + that calculate_scalar_map drops, so only ids 1, 2, 4 survive.""" + streamlines, e_fields = [], [] + for i in range(6): + if i in (0, 3, 5): + x = np.arange(0, 1.0, 0.5) # 2 points -> dropped (< 4) + else: + x = np.arange(0, 25, 0.5) + sl = np.column_stack([x, np.ones_like(x) * i * 2, np.zeros_like(x)]) + streamlines.append(sl) + e_fields.append(np.tile([100.0, 0.0, 0.0], (len(sl), 1))) + return streamlines, e_fields + + +class TestStreamlineIdentity: + """calculate_scalar_map / filter_by_angular_deviation index passthrough.""" + + def test_default_return_is_three_tuple(self, straight_bundle_with_uniform_efield): + """Without indices the return arity is unchanged (backward compatible).""" + streamlines, e_fields = straight_bundle_with_uniform_efield + out = calculate_scalar_map(streamlines, e_fields, mode="af") + assert len(out) == 3 + + def test_tracks_surviving_ids_after_drops(self): + """Surviving ids identify the kept originals, not compacted positions.""" + streamlines, e_fields = _short_and_long_bundle() + idx0 = np.arange(len(streamlines)) + new_sl, af_vals, lengths, surviving = calculate_scalar_map( + streamlines, + e_fields, + mode="af", + indices=idx0, + ) + assert list(surviving) == [1, 2, 4] + assert len(new_sl) == len(af_vals) == len(lengths) == len(surviving) + + def test_weights_stay_attached_after_drops(self): + """The C-002 fix: weights[surviving] reattaches each streamline's own + weight; positional indexing (the bug) would mis-assign.""" + streamlines, e_fields = _short_and_long_bundle() + weights = np.array([10.0, 11.0, 12.0, 13.0, 14.0, 15.0]) + _, _, _, surviving = calculate_scalar_map( + streamlines, e_fields, mode="af", indices=np.arange(len(streamlines)) + ) + # Correct (re-aligned) mapping + assert list(weights[surviving]) == [11.0, 12.0, 14.0] + # Positional (buggy) mapping would have been the wrong originals + assert list(weights[: len(surviving)]) == [10.0, 11.0, 12.0] + + def test_empty_input_returns_four_tuple_with_indices(self): + out = calculate_scalar_map([], [], mode="af", indices=np.array([], dtype=int)) + assert len(out) == 4 + assert out[3].size == 0 + + def test_filter_tracks_indices_in_sync(self): + """filter_by_angular_deviation filters the id array in lockstep.""" + from tide.core.tractography import filter_by_angular_deviation + + streamlines, e_fields = _short_and_long_bundle() + idx0 = np.arange(len(streamlines)) + # No ROI, generous angle: only the <4-point stubs (0, 3, 5) are removed. + filtered_sl, filtered_ev, n_removed, filtered_idx = filter_by_angular_deviation( + streamlines, + e_field_vectors=e_fields, + max_angle_deg=90.0, + indices=idx0, + ) + assert list(filtered_idx) == [1, 2, 4] + assert len(filtered_sl) == len(filtered_ev) == len(filtered_idx) + assert n_removed == 3 + + +# ============================================================================= +# SIFT2 weight validation (audit S-004) +# ============================================================================= + + +class TestWeightValidation: + """load_weights rejects unusable weight files instead of silent fallback.""" + + def _load(self, tmp_path, values): + from tide.interfaces.unified_estimation import load_weights + + p = tmp_path / "weights.txt" + np.savetxt(p, np.asarray(values)) + return load_weights(str(p), streamlines=[None] * len(values)) + + def test_loads_valid_weights(self, tmp_path): + w = self._load(tmp_path, [1.0, 2.0, 0.0, 3.5]) + assert list(w) == [1.0, 2.0, 0.0, 3.5] + + def test_rejects_negative(self, tmp_path): + with pytest.raises(ValueError): + self._load(tmp_path, [1.0, -2.0, 3.0]) + + def test_rejects_non_finite(self, tmp_path): + with pytest.raises(ValueError): + self._load(tmp_path, [1.0, np.nan, 3.0]) + + def test_rejects_zero_mass(self, tmp_path): + with pytest.raises(ValueError): + self._load(tmp_path, [0.0, 0.0, 0.0]) + + +class TestMedianOfTopPercentile: + """Shared top-5% aggregator used by bundle analysis and M1 validation (C-003).""" + + @staticmethod + def _legacy(values: np.ndarray, pct: float = 95.0) -> float: + """The inline formula the helper replaces, kept here as an oracle.""" + if values.size == 0: + return 0.0 + cutoff = np.percentile(values, pct) + top = values[values >= cutoff] + return float(np.median(top)) if top.size else 0.0 + + def test_empty_returns_zero(self): + assert median_of_top_percentile(np.array([])) == 0.0 + + def test_single_value(self): + assert median_of_top_percentile(np.array([7.0])) == 7.0 + + def test_matches_legacy_formula(self): + rng = np.random.default_rng(0) + for _ in range(20): + values = rng.uniform(0.0, 1000.0, size=rng.integers(1, 500)) + assert median_of_top_percentile(values) == self._legacy(values) + + def test_ties_at_cutoff_are_included(self): + # All-equal input: cutoff equals the value, every element is in the tail. + values = np.full(10, 5.0) + assert median_of_top_percentile(values) == 5.0 + + def test_known_top_tail(self): + values = np.arange(1.0, 101.0) # 1..100, p95 cutoff = 95.05 -> {96..100} + assert median_of_top_percentile(values) == 98.0 + + +class TestConsoleGridPointReporter: + """The console adapter forwards grid-point stages to UI phases (C-003).""" + + def test_hooks_map_to_worker_phases(self): + # Importing the console package eagerly pulls the SimNIBS-backed + # reporters, so this env-checks like the other integration tests. + pytest.importorskip("simnibs") + from tide.console.ipc import WorkerPhase + from tide.console.worker_reporter import _ConsoleGridPointReporter + + calls = [] + + class _FakeReporter: + def phase(self, phase, pct): + calls.append(("phase", phase, pct)) + + def progress(self, pct): + calls.append(("progress", pct)) + + adapter = _ConsoleGridPointReporter(_FakeReporter()) + adapter.optimization() + adapter.simulation() + adapter.sampling() + adapter.activating_function() + adapter.bundle_analysis() + adapter.saving_results() + adapter.progress(50) + + assert calls == [ + ("phase", WorkerPhase.OPTIMIZATION, 0), + ("phase", WorkerPhase.FEM_SIMULATION, 0), + ("phase", WorkerPhase.EFIELD_SAMPLING, 0), + ("phase", WorkerPhase.ACTIVATING_FUNCTION, 0), + ("phase", WorkerPhase.BUNDLE_ANALYSIS, 0), + ("phase", WorkerPhase.SAVING_RESULTS, 0), + ("progress", 50), + ] + + +class TestCrossStreamlineAggregates: + """Alternative cross-streamline aggregators reported alongside the primary one.""" + + thresholds = np.array([10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0]) + + def test_keys_and_order_follow_the_declared_inventory(self): + aggregates = cross_streamline_aggregates(self.thresholds) + assert tuple(aggregates.keys()) == AGGREGATOR_KEYS + assert PRIMARY_AGGREGATOR in aggregates + + def test_unweighted_primary_matches_the_committed_statistic(self): + aggregates = cross_streamline_aggregates(self.thresholds) + assert aggregates["median_top5"] == median_of_top_percentile(self.thresholds, 95.0) + + def test_unweighted_alternatives_match_numpy_definitions(self): + aggregates = cross_streamline_aggregates(self.thresholds) + assert aggregates["mean"] == pytest.approx(float(np.mean(self.thresholds))) + assert aggregates["median"] == pytest.approx(float(np.median(self.thresholds))) + assert aggregates["q90"] == pytest.approx(float(np.percentile(self.thresholds, 90.0))) + assert aggregates["q95"] == pytest.approx(float(np.percentile(self.thresholds, 95.0))) + + def test_weighted_branch_uses_the_weighted_definitions(self): + weights = np.linspace(0.5, 2.0, self.thresholds.size) + aggregates = cross_streamline_aggregates(self.thresholds, weights) + assert aggregates["median_top5"] == pytest.approx( + weighted_median_of_top_percentile(self.thresholds, weights, 95.0) + ) + assert aggregates["mean"] == pytest.approx(weighted_mean(self.thresholds, weights)) + assert aggregates["q90"] == pytest.approx( + weighted_percentile(self.thresholds, weights, 90.0) + ) + + def test_weights_shift_the_distribution(self): + top_heavy = np.where(self.thresholds >= 80.0, 10.0, 0.1) + weighted = cross_streamline_aggregates(self.thresholds, top_heavy) + unweighted = cross_streamline_aggregates(self.thresholds) + assert weighted["median"] > unweighted["median"] + assert weighted["mean"] > unweighted["mean"] + + def test_empty_input_returns_zeros_for_every_aggregator(self): + aggregates = cross_streamline_aggregates(np.array([])) + assert aggregates == {key: 0.0 for key in AGGREGATOR_KEYS} + + def test_zero_total_weight_mean_is_zero(self): + assert weighted_mean(self.thresholds, np.zeros_like(self.thresholds)) == 0.0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_refactoring_contracts.py b/tests/test_refactoring_contracts.py new file mode 100644 index 0000000..795fe4d --- /dev/null +++ b/tests/test_refactoring_contracts.py @@ -0,0 +1,843 @@ +import hashlib +import json +import re +import sys +from datetime import datetime +from pathlib import Path, PureWindowsPath +from types import SimpleNamespace +from typing import Any, Optional + +import numpy as np +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from tide.core import io +from tide.core.physics import AGGREGATOR_KEYS +from tide.utils.config import ( + CoilConfig, + GridConfig, + OptionsConfig, + SimNIBSConfig, + SubjectConfig, + TargetConfig, + save_config_to_output, + save_grid_point_config, +) + + +def _digest(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def test_pypi_release_is_gated_by_reusable_ci() -> None: + root = Path(__file__).parent.parent + ci = yaml.load( + (root / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8"), + Loader=yaml.BaseLoader, + ) + release = yaml.load( + (root / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8"), + Loader=yaml.BaseLoader, + ) + + assert "workflow_call" in ci["on"] + assert release["jobs"]["ci"]["uses"] == "./.github/workflows/ci.yml" + assert release["jobs"]["build"]["needs"] == "ci" + + +def _fake_aggregates(base: float) -> dict: + """Per-aggregator stand-in values, distinct per key so ordering is contract-checked.""" + return {key: base - 10.0 * index for index, key in enumerate(AGGREGATOR_KEYS)} + + +def _normalize_artifact(text: str, root: Path) -> str: + root_text = str(root) + normalized = text.replace(root_text.replace("\\", "\\\\"), "") + normalized = normalized.replace(root_text, "") + normalized = re.sub( + r'[^"\r\n]*', + lambda match: match.group(0).replace("\\\\", "/").replace("\\", "/"), + normalized, + ) + normalized = re.sub(r"\d{8}_\d{6}", "", normalized) + normalized = re.sub( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?", + "", + normalized, + ) + normalized = re.sub(r"TIDE \d+\.\d+\.\d+", "TIDE ", normalized) + return normalized + + +def test_artifact_normalization_handles_windows_paths() -> None: + root = Path(r"C:\Users\runneradmin\AppData\Local\Temp\pytest-0\test_contract") + text = ( + r"plain: C:\Users\runneradmin\AppData\Local\Temp\pytest-0\test_contract\nested\out.txt" + "\n" + r"json: C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-0\\test_contract\\nested\\out.txt" + ) + + assert _normalize_artifact(text, root) == ( + "plain: /nested/out.txt\njson: /nested/out.txt" + ) + + +def test_report_relative_paths_use_url_separators() -> None: + class ResolvedPath: + def __init__(self, value: PureWindowsPath) -> None: + self.value = value + + def resolve(self) -> PureWindowsPath: + return self.value + + root = PureWindowsPath(r"C:\Users\runneradmin\report") + image = root / "visualizations" / "target_composite.png" + + assert io._reporting._relative_path(ResolvedPath(image), ResolvedPath(root)) == ( + "visualizations/target_composite.png" + ) + + +def _make_config(root: Path) -> SimNIBSConfig: + return SimNIBSConfig( + subject=SubjectConfig( + id="sub-CONTRACT", + derivatives_path=root / "derivatives", + m2m_path=root / "derivatives" / "m2m_sub-CONTRACT", + t1w_path=root / "derivatives" / "t1w.nii.gz", + weights_cst_path=root / "derivatives" / "cst_weights.txt", + weights_target_path=root / "derivatives" / "target_weights.txt", + surface_path=root / "derivatives" / "surface.gii", + cache_dir=root / "cache", + cache_max_size_gb=4.5, + ), + coil=CoilConfig( + coil_model="contract.ccd", + coil_path=root / "coils" / "contract.ccd", + coil_distance_mm=4.0, + device_didt_max=161e6, + ), + calibration=TargetConfig( + label="M1", + bundle_path=root / "derivatives" / "cst.trk", + coords=[-1.0, 2.0, 3.0], + scalp_coords=[-2.0, 3.0, 4.0], + orientation=[0.0, 1.0, 0.0], + measured_rmt_mso=50.0, + ), + target=TargetConfig( + label="Target", + bundle_path=root / "derivatives" / "target.trk", + coords=[10.0, 20.0, 30.0], + scalp_coords=[11.0, 21.0, 31.0], + orientation=[1.0, 0.0, 0.0], + medoid_endpoint=True, + didt=80e6, + mso=60.0, + ), + options=OptionsConfig( + roi_size_mm=20.0, + activation_length_mm=6.0, + field_mode="af", + adm_optimization=True, + opt_spatial_resolution=2.0, + opt_angle_resolution=5.0, + opt_search_angle=30.0, + opt_search_radius=10.0, + generate_visualizations=True, + generate_3d_visualization=False, + visualization_dpi=200, + max_angular_deviation_deg=45.0, + gwi_threshold_mm=3.0, + mso_floor_ratio=0.7, + mso_ceiling_ratio=1.4, + max_workers=2, + no_parallel=False, + ), + grid=GridConfig( + coords=[10.0, 20.0, 30.0], + scalp_coords=[11.0, 21.0, 31.0], + orientation=[1.0, 0.0, 0.0], + search_radius_mm=12.0, + step_size_mm=3.0, + cortex_depth_mm=2.0, + ), + workflow="estimation", + ) + + +def test_report_sidecars_preserve_normalized_bytes(tmp_path: Path) -> None: + txt_path = tmp_path / "TIDE_Results_Target.txt" + lines = [ + "===========================================", + "--- TIDE Contract Report ---", + "===========================================", + "", + "Subject: sub-CONTRACT", + "Weighted MSO: 48.25", + "Unweighted MSO: 49.75", + "Status: PASS", + ] + txt_path.write_text("\n".join(lines), encoding="utf-8") + + json_path = io.save_report_json( + txt_path, + "estimation", + data={"values": np.array([48.25, 49.75]), "output": tmp_path / "result.trk"}, + text_lines=lines, + ) + + assert json_path is not None + html_path = txt_path.with_suffix(".html") + payload = json.loads(json_path.read_text(encoding="utf-8")) + assert list(payload) == [ + "schema_version", + "report_type", + "source_txt", + "generated_at", + "text", + "sections", + "data", + ] + assert payload["text"]["lines"] == lines + assert _digest(_normalize_artifact(txt_path.read_text(), tmp_path)) == ( + "7caee9021554f7623d7301d5f954c4f21f8e60bab1bb3ba230ddac863189be60" + ) + assert _digest(_normalize_artifact(json_path.read_text(), tmp_path)) == ( + "6fa16d9ea897df8208b5e848446f20720c35661f482c816071ceb40145803cb7" + ) + assert _digest(_normalize_artifact(html_path.read_text(), tmp_path)) == ( + "eeaa38302bbcbd380ce265326d2b7a010e0bd13a5dc824505cc9f5a6e7340ddd" + ) + + +def test_report_sidecar_write_failure_propagates(tmp_path, monkeypatch): + txt_path = tmp_path / "report.txt" + + def fail_html(*args, **kwargs): + raise OSError("html write failed") + + monkeypatch.setattr(io, "save_report_html", fail_html) + + with pytest.raises(OSError, match="html write failed"): + io.save_report_json(txt_path, "test", text_lines=["content"]) + + +def test_saved_configs_preserve_normalized_yaml_bytes(tmp_path: Path) -> None: + config = _make_config(tmp_path) + matrix = np.eye(4).tolist() + + workflow_path = save_config_to_output( + config, + tmp_path / "workflow", + "estimation", + generated_calibration_matrix=matrix, + generated_target_matrix=matrix, + generated_calibration_scalp_coords=[1.0, 2.0, 3.0], + generated_target_scalp_coords=[4.0, 5.0, 6.0], + medoid_resolved=True, + ) + grid_path = save_grid_point_config( + config, + tmp_path / "grid_P00", + "grid_P00", + [7.0, 8.0, 9.0], + [10.0, 11.0, 12.0], + matrix, + [13.0, 14.0, 15.0], + [0.0, 1.0, 0.0], + calibration_orientation=matrix, + ) + + workflow_text = _normalize_artifact(workflow_path.read_text(), tmp_path) + grid_text = _normalize_artifact(grid_path.read_text(), tmp_path) + assert _digest(workflow_text) == ( + "29035ad2218901cca3a8dcd00a6f8733904405d0a96c1dbdb1b67371f6f1e86e" + ) + assert _digest(grid_text) == ( + "82d2463333ef575c7f2c807c3ec546cf5e71c78fde74f626c79a0c9a6c1e4984" + ) + + +def test_stmpx_dataset_name_round_trips_only_when_configured(tmp_path: Path) -> None: + config = _make_config(tmp_path) + config.options.stmpx_dataset_name = "20260717-TIDE-SUB_01" + + config_path = save_config_to_output( + config, + tmp_path / "workflow", + "estimation", + ) + + raw = yaml.safe_load(config_path.read_text()) + replay_config = SimNIBSConfig.from_yaml(config_path) + assert raw["options"]["stmpx_dataset_name"] == "20260717-TIDE-SUB_01" + assert replay_config.options.stmpx_dataset_name == "20260717-TIDE-SUB_01" + + +@pytest.mark.parametrize( + ("saved_workflow", "replay_workflow"), + [ + ("estimation", "estimation"), + ("grid_search", "grid"), + ("simulation", "simulation"), + ("optimization", "optimization"), + ], +) +def test_saved_workflow_metadata_selects_replay_workflow( + tmp_path: Path, + saved_workflow: str, + replay_workflow: str, +) -> None: + config_path = save_config_to_output( + _make_config(tmp_path), + tmp_path / saved_workflow, + saved_workflow, + ) + + replay_config = SimNIBSConfig.from_yaml(config_path) + + assert replay_config.workflow == replay_workflow + + +def test_saved_grid_point_metadata_selects_estimation_replay(tmp_path: Path) -> None: + config_path = save_grid_point_config( + _make_config(tmp_path), + tmp_path / "grid_P00", + "grid_P00", + [7.0, 8.0, 9.0], + [10.0, 11.0, 12.0], + np.eye(4).tolist(), + [13.0, 14.0, 15.0], + [0.0, 1.0, 0.0], + ) + + replay_config = SimNIBSConfig.from_yaml(config_path) + + assert replay_config.workflow == "estimation" + + +def test_top_level_workflow_overrides_saved_metadata(tmp_path: Path) -> None: + config_path = save_config_to_output( + _make_config(tmp_path), + tmp_path / "estimation", + "estimation", + ) + raw = yaml.safe_load(config_path.read_text()) + raw["workflow"] = "simulation" + config_path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + replay_config = SimNIBSConfig.from_yaml(config_path) + + assert replay_config.workflow == "simulation" + + +def test_config_write_failure_propagates(tmp_path, monkeypatch): + config = _make_config(tmp_path) + + def fail_dump(*args, **kwargs): + raise OSError("config write failed") + + monkeypatch.setattr(yaml, "dump", fail_dump) + + with pytest.raises(OSError, match="config write failed"): + save_config_to_output(config, tmp_path, "estimation") + + +def test_workflow_support_contracts(monkeypatch: pytest.MonkeyPatch) -> None: + from tide.workflows._shared import ( + SINGLE_THREAD_ENV, + calculate_target_in_field_metric, + configure_worker_environment, + single_thread_child_environment, + split_vectors_by_streamline, + ) + + vectors = np.arange(24, dtype=float).reshape(8, 3) + streamlines = [np.zeros((3, 3)), np.zeros((1, 3)), np.zeros((4, 3))] + split = split_vectors_by_streamline(vectors, streamlines) + assert [len(item) for item in split] == [3, 1, 4] + assert np.shares_memory(split[0], vectors) + assert np.array_equal(np.concatenate(split), vectors) + + for key in SINGLE_THREAD_ENV: + monkeypatch.setenv(key, "7") + with single_thread_child_environment(): + assert all(__import__("os").environ[key] == "1" for key in SINGLE_THREAD_ENV) + assert all(__import__("os").environ[key] == "7" for key in SINGLE_THREAD_ENV) + + configure_worker_environment() + assert all(__import__("os").environ[key] == "1" for key in SINGLE_THREAD_ENV) + + points = np.column_stack((np.arange(7, dtype=float), np.zeros((7, 2)))) + e_field = np.column_stack((np.arange(7, dtype=float), np.zeros((7, 2)))) + metric = calculate_target_in_field_metric( + [points], + [e_field], + roi_center=[3.0, 0.0, 0.0], + roi_size_mm=10.0, + activation_length_mm=2.0, + max_angular_deviation_deg=0.0, + ) + assert metric == 1000.0 + + +def _grid_reporting_context( + root: Path, + config: SimNIBSConfig, +) -> Any: + from tide.workflows._grid_reporting import GridReportingContext + + return GridReportingContext( + config=config, + out_dir=root, + sims_dir=root / "simulations", + results_csv=root / "TIDE_grid_results.csv", + fixed_scalp_coords=np.array([13.0, 14.0, 15.0]), + grid_orientation_ref=[0.0, 1.0, 0.0], + calibration_orientation=np.eye(4).tolist(), + target_streamlines_full=[], + target_vectors_in_m1=[], + cst_result=SimpleNamespace( + weight_source="contract weights", + metric_unweighted=1200.0, + aggregates_weighted=_fake_aggregates(1250.0), + aggregates_unweighted=_fake_aggregates(1200.0), + ), + af_cst_calibration=1250.0, + cst_align=0.5, + cst_align_corrected=0.6, + cst_depth=14.0, + intensity_rmt=80e6, + biological_threshold=6250.0, + m1_matrix_str=str(np.eye(4).tolist()), + spatial_mode="SIFT2 Weighted", + num_workers=2, + calibration_pose_qc={"status": "PASS", "reasons": []}, + start_time=90.0, + worker_memory_model={ + "workers": 2, + "requested_workers": 2, + "num_grid_points": 1, + "cpu_count": 8, + "available_memory_gb": 40.0, + "memory_worker_limit": 3, + "memory_per_worker_gb": 12.0, + "memory_reserve_gb": 4.0, + "forced": False, + "solver": "PARDISO", + }, + ) + + +def test_grid_result_rows_preserve_success_and_failure_contracts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tide.workflows import _grid_reporting + + config = _make_config(tmp_path) + context = _grid_reporting_context(tmp_path, config) + context.sims_dir.mkdir() + _grid_reporting.initialize_grid_results_csv(context.results_csv) + monkeypatch.setattr(_grid_reporting, "save_grid_point_config", lambda **kwargs: None) + monkeypatch.setattr(_grid_reporting, "_write_point_summary_txt", lambda **kwargs: None) + + success = SimpleNamespace( + point_label="grid_P00", + success=True, + cortex_coord=[7.0, 8.0, 9.0], + opt_scalp_coords=[10.0, 11.0, 12.0], + opt_matrix=np.eye(4).tolist(), + unweighted_mso_raw=49.75, + weighted_mso_raw=48.25, + unweighted_mso=49.75, + weighted_mso=48.25, + unweighted_mso_flag="WITHIN_RANGE", + weighted_mso_flag="WITHIN_RANGE", + sei_weighted=1.0363, + sei_unweighted=1.005, + multiplier_weighted=0.96497, + multiplier_unweighted=0.99502, + target_metric_weighted=1300.0, + target_metric_unweighted=1260.0, + target_aggregates_weighted=_fake_aggregates(1300.0), + target_aggregates_unweighted=_fake_aggregates(1260.0), + pose_qc={"status": "PASS", "reasons": []}, + tgt_align=0.7, + tgt_align_corrected=0.8, + tgt_depth=12.0, + ) + failure = SimpleNamespace(point_label="grid_P01", success=False, sei_weighted=0.0) + + records = _grid_reporting.write_grid_results([success, failure], context) + + assert _digest(context.results_csv.read_text()) == ( + "93413d086506e4d3b2f7bd3a74af47498817515445fad4da27110dd756e9d88b" + ) + assert records[0]["weighted_mso"] == 48.25 + assert records[0]["unweighted_mso"] == 49.75 + assert records[1] == { + "label": "grid_P01", + "weighted_mso": 999.9, + "unweighted_mso": 999.9, + "weighted_mso_raw": 999.9, + "unweighted_mso_raw": 999.9, + "weighted_mso_flag": "N/A", + "unweighted_mso_flag": "N/A", + "sei_weighted": None, + "sei_unweighted": None, + "sei_rank_pct": None, + "multiplier_weighted": None, + "multiplier_unweighted": None, + "target_pose_qc": None, + "target_align": None, + "target_align_corrected": None, + "target_depth": None, + } + + +def _summary_kwargs(tmp_path: Path) -> dict: + return { + "subject_id": "sub-CONTRACT", + "timestamp_str": "2026-01-01 00:00:00", + "out_dir": tmp_path, + "num_workers": 2, + "t1w_path": tmp_path / "t1w.nii.gz", + "cst_bundle_path": tmp_path / "cst.trk", + "target_bundle_path": tmp_path / "target.trk", + "spatial_mode": "Sphere", + "weight_source": "Uniform", + "roi_size_mm": 40.0, + "activation_length_mm": 6.0, + "calibration_label": "M1", + "measured_rmt_mso": 50.0, + "m1_matrix_str": "[[1.0]]", + "af_cst_w": 1250.0, + "af_cst_u": 1200.0, + "intensity_rmt": 80.5, + "biological_threshold": 2242.24, + "target_label": "Target", + "target_coords": [1.0, 2.0, 3.0], + "opt_scalp_str": "N/A", + "tgt_matrix_str": "[[1.0]]", + "af_tgt_w": 1300.0, + "af_tgt_u": 1260.0, + "cst_align": 0.6, + "tgt_align": 0.7, + "cst_depth": 11.0, + "tgt_depth": 12.0, + "optimization_gain": 1.35, + "ratio_at_m1": 0.806, + "intensity_from_m1_position": 62.1, + "intensity_raw_w": 48.25, + "intensity_raw_u": 49.75, + "intensity_clamped_w": 48.25, + "intensity_clamped_u": 49.75, + "intensity_flag_w": "WITHIN_RANGE", + "intensity_flag_u": "WITHIN_RANGE", + "mso_floor_ratio": 0.70, + "sei_w": 1.0363, + "sei_u": 1.005, + "multiplier_w": 0.96497, + "multiplier_u": 0.99502, + } + + +def test_aggregator_sensitivity_block_is_purely_additive(tmp_path: Path) -> None: + from tide.interfaces.unified_estimation import build_aggregator_sensitivity + + kwargs = _summary_kwargs(tmp_path) + baseline = io.build_estimation_summary_lines(**kwargs) + + assert io.build_aggregator_sensitivity_lines(None) == [] + assert io.build_estimation_summary_lines(**kwargs, aggregator_sensitivity=None) == baseline + + sensitivity = build_aggregator_sensitivity( + cst_weighted=_fake_aggregates(1250.0), + cst_unweighted=_fake_aggregates(1200.0), + target_weighted=_fake_aggregates(1300.0), + target_unweighted=_fake_aggregates(1260.0), + rmt=50.0, + ) + extended = io.build_estimation_summary_lines(**kwargs, aggregator_sensitivity=sensitivity) + + assert extended[: len(baseline)] == baseline + appended = extended[len(baseline) :] + assert appended == io.build_aggregator_sensitivity_lines(sensitivity) + assert "--- Aggregator Sensitivity ---" in appended + for key in AGGREGATOR_KEYS: + assert any(io.AGGREGATOR_LABELS[key] in line for line in appended) + + +def test_grid_results_csv_header_appends_aggregator_columns(tmp_path: Path) -> None: + from tide.workflows import _grid_reporting + + frozen_columns = [ + "grid_point_labels", + "grid_point_coords", + "fixed_scalp_start_coords", + "optimized_scalp_point_coords", + "matrix4x4", + "measured_m1_mso", + "unweighted_mso_raw", + "weighted_mso_raw", + "unweighted_mso_clamped", + "weighted_mso_clamped", + "unweighted_mso_flag", + "weighted_mso_flag", + "sei_weighted", + "sei_unweighted", + "sei_rank_pct", + "multiplier_weighted", + "multiplier_unweighted", + ] + results_csv = tmp_path / "TIDE_grid_results.csv" + _grid_reporting.initialize_grid_results_csv(results_csv) + header = results_csv.read_text().splitlines()[0].split(",") + + assert header[: len(frozen_columns)] == frozen_columns + assert header[len(frozen_columns) :] == [ + name + for key in AGGREGATOR_KEYS + for name in (f"intensity_raw_{key}_unweighted", f"intensity_raw_{key}_weighted") + ] + + +def test_grid_point_report_exposes_both_weight_sources( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tide.workflows import _grid_reporting + + config = _make_config(tmp_path) + context = _grid_reporting_context(tmp_path, config) + point_dir = tmp_path / "simulations" / "grid_P00" + point_dir.mkdir(parents=True) + result = SimpleNamespace( + point_label="grid_P00", + cortex_coord=[7.0, 8.0, 9.0], + opt_scalp_coords=[10.0, 11.0, 12.0], + opt_matrix=np.eye(4).tolist(), + target_metric_weighted=1300.0, + target_metric_unweighted=1260.0, + target_aggregates_weighted=_fake_aggregates(1300.0), + target_aggregates_unweighted=_fake_aggregates(1260.0), + target_weight_source="External (target_weights.txt)", + tgt_align=0.7, + tgt_align_corrected=0.8, + tgt_depth=12.0, + weighted_mso_raw=48.25, + unweighted_mso_raw=49.75, + weighted_mso=48.25, + unweighted_mso=49.75, + weighted_mso_flag="WITHIN_RANGE", + unweighted_mso_flag="WITHIN_RANGE", + sei_weighted=1.0363, + sei_unweighted=1.005, + multiplier_weighted=0.96497, + multiplier_unweighted=0.99502, + pose_qc={"status": "PASS", "reasons": []}, + ) + captured = {} + monkeypatch.setattr( + _grid_reporting, + "calculate_target_in_field_metric", + lambda *args, **kwargs: 1000.0, + ) + monkeypatch.setattr( + _grid_reporting.io, + "save_report_json", + lambda *args, **kwargs: captured.update(kwargs["data"]), + ) + + _grid_reporting._write_point_summary_txt( + config=config, + point_dir=point_dir, + result=result, + tgt_streamlines_full=[], + e_vecs_list_tgt_in_m1_full=[], + cst_res=context.cst_result, + af_cst_calibration=context.af_cst_calibration, + cst_align=context.cst_align, + cst_align_corrected=context.cst_align_corrected, + cst_depth=context.cst_depth, + intensity_rmt=context.intensity_rmt, + biological_threshold=context.biological_threshold, + m1_matrix_str=context.m1_matrix_str, + spatial_mode=context.spatial_mode, + num_workers=context.num_workers, + out_dir=context.out_dir, + calibration_pose_qc=context.calibration_pose_qc, + ) + + report = (point_dir / f"TIDE_Results_{config.target.label}.txt").read_text() + assert "Weight Source: CST: contract weights; Target: External (target_weights.txt)" in report + assert captured["weight_source_cst"] == "contract weights" + assert captured["weight_source_target"] == "External (target_weights.txt)" + + +def test_grid_summary_preserves_normalized_text_contract( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tide.workflows import _grid_reporting + + class FixedDateTime(datetime): + @classmethod + def now(cls, tz: Optional[Any] = None) -> "FixedDateTime": + return cls(2026, 7, 13, 12, 0, 0, tzinfo=tz) + + config = _make_config(tmp_path) + context = _grid_reporting_context(tmp_path, config) + context.results_csv.write_text("header\n") + result = SimpleNamespace( + success=True, + weighted_mso=48.25, + unweighted_mso=49.75, + weighted_mso_raw=48.25, + unweighted_mso_raw=49.75, + weighted_mso_flag="WITHIN_RANGE", + unweighted_mso_flag="WITHIN_RANGE", + multiplier_weighted=0.96497, + multiplier_unweighted=0.99502, + ) + records = [ + { + "label": "grid_P00", + "weighted_mso": 48.25, + "unweighted_mso": 49.75, + "weighted_mso_raw": 48.25, + "unweighted_mso_raw": 49.75, + "weighted_mso_flag": "WITHIN_RANGE", + "unweighted_mso_flag": "WITHIN_RANGE", + "sei_weighted": 1.0363, + "sei_unweighted": 1.005, + "sei_rank_pct": 100.0, + "multiplier_weighted": 0.96497, + "multiplier_unweighted": 0.99502, + "target_pose_qc": {"status": "PASS", "reasons": []}, + "target_align": 0.7, + "target_align_corrected": 0.8, + "target_depth": 12.0, + } + ] + monkeypatch.setattr(_grid_reporting.time, "time", lambda: 100.0) + monkeypatch.setattr(_grid_reporting, "datetime", FixedDateTime) + captured = {} + monkeypatch.setattr( + _grid_reporting.io, + "save_report_json", + lambda *args, **kwargs: captured.update(kwargs["data"]), + ) + + summary = _grid_reporting.write_grid_summary([result], records, context) + + normalized = _normalize_artifact(summary.summary_path.read_text(), tmp_path) + assert _digest(normalized) == ( + "12a21a15a56057ef8a817f2b5f75c4b2d588a0545458fc8ac42c7b668f1eb8fd" + ) + assert captured["weight_source_cst"] == "contract weights" + assert captured["weight_source_target"] == "External (target_weights.txt)" + assert captured["worker_memory_model"]["memory_per_worker_gb"] == 12.0 + assert summary.elapsed_time == 10.0 + + +def test_grid_summary_separates_raw_and_clamped_statistics_and_excludes_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from tide.workflows import _grid_reporting + + config = _make_config(tmp_path) + context = _grid_reporting_context(tmp_path, config) + context.results_csv.write_text("header\n") + results = [ + SimpleNamespace( + success=True, + weighted_mso=35.0, + unweighted_mso=35.0, + weighted_mso_raw=20.0, + unweighted_mso_raw=25.0, + weighted_mso_flag="CLAMPED_LOW", + unweighted_mso_flag="CLAMPED_LOW", + multiplier_weighted=0.4, + multiplier_unweighted=0.5, + ), + SimpleNamespace( + success=True, + weighted_mso=50.0, + unweighted_mso=52.0, + weighted_mso_raw=50.0, + unweighted_mso_raw=52.0, + weighted_mso_flag="WITHIN_RANGE", + unweighted_mso_flag="WITHIN_RANGE", + multiplier_weighted=1.0, + multiplier_unweighted=1.04, + ), + SimpleNamespace( + success=True, + weighted_mso=float("nan"), + unweighted_mso=float("nan"), + weighted_mso_raw=float("nan"), + unweighted_mso_raw=float("nan"), + weighted_mso_flag="ESTIMATION_FAILED", + unweighted_mso_flag="ESTIMATION_FAILED", + multiplier_weighted=float("nan"), + multiplier_unweighted=float("nan"), + ), + SimpleNamespace( + success=False, + weighted_mso=999.9, + unweighted_mso=999.9, + weighted_mso_raw=999.9, + unweighted_mso_raw=999.9, + weighted_mso_flag="N/A", + unweighted_mso_flag="N/A", + multiplier_weighted=None, + multiplier_unweighted=None, + ), + ] + monkeypatch.setattr(_grid_reporting.time, "time", lambda: 100.0) + captured = {} + monkeypatch.setattr( + _grid_reporting.io, + "save_report_json", + lambda *args, **kwargs: captured.update(kwargs["data"]), + ) + + summary = _grid_reporting.write_grid_summary(results, [], context) + + assert summary.weighted_statistics["mean"] == 42.5 + assert summary.unweighted_statistics["mean"] == 43.5 + assert summary.weighted_raw_statistics["mean"] == 35.0 + assert summary.unweighted_raw_statistics["mean"] == 38.5 + assert summary.weighted_multiplier_statistics["mean"] == 0.7 + assert summary.unweighted_multiplier_statistics["mean"] == 0.77 + assert summary.status_counts == { + "total_points": 4, + "processing_failed": 1, + "weighted": { + "included": 2, + "within_range": 1, + "clamped_low": 1, + "clamped_high": 0, + "estimation_failed": 1, + }, + "unweighted": { + "included": 2, + "within_range": 1, + "clamped_low": 1, + "clamped_high": 0, + "estimation_failed": 1, + }, + } + assert captured["statistics"]["weighted_raw"]["mean"] == 35.0 + assert captured["status_counts"] == summary.status_counts + + report = summary.summary_path.read_text() + assert "--- Raw Statistical Summary ---" in report + assert "Processing Failures: 1" in report + assert "Estimation Failed | 1" in report diff --git a/tests/test_tractography.py b/tests/test_tractography.py new file mode 100644 index 0000000..f51a701 --- /dev/null +++ b/tests/test_tractography.py @@ -0,0 +1,316 @@ +""" +Unit Tests for TIDE Tractography Module +======================================== +Tests for ROI masking, data extraction, and medoid calculation. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from tide.core.tractography import ( + extract_grid_endpoints, + get_bundle_cortical_medoid, + get_data_in_roi, + get_roi_masks, +) + +# ============================================================================= +# Test Fixtures (local) +# ============================================================================= + + +@pytest.fixture +def simple_streamlines(): + """Simple parallel streamlines for testing.""" + streamlines = [] + for i in range(5): + x = np.arange(0, 25, 0.5) + sl = np.column_stack([x, np.ones_like(x) * i * 5, np.zeros_like(x)]) + streamlines.append(sl) + return streamlines + + +@pytest.fixture +def streamlines_with_tips(): + """Streamlines with distinct endpoints for medoid testing.""" + streamlines = [] + for i in range(10): + # Start clustered around origin, end clustered around (50, 0, 30) + start = np.array([i * 0.5, i * 0.5, 0]) + end = np.array([50 + i * 0.5, i * 0.5, 30 + i * 0.2]) + + n_points = 20 + t = np.linspace(0, 1, n_points) + sl = start[np.newaxis, :] * (1 - t[:, np.newaxis]) + end[np.newaxis, :] * t[:, np.newaxis] + streamlines.append(sl) + + return streamlines + + +# ============================================================================= +# Tests for get_roi_masks +# ============================================================================= + + +class TestGetRoiMasks: + """Tests for ROI mask generation.""" + + def test_returns_two_lists(self, simple_streamlines): + """Should return point masks and segment masks.""" + point_masks, segment_masks = get_roi_masks( + simple_streamlines, roi_size_mm=10.0, target_coords=None + ) + + assert isinstance(point_masks, list) + assert isinstance(segment_masks, list) + assert len(point_masks) == len(simple_streamlines) + assert len(segment_masks) == len(simple_streamlines) + + def test_segment_mask_length(self, simple_streamlines): + """Segment masks should have N-1 elements for N-point streamlines.""" + point_masks, segment_masks = get_roi_masks(simple_streamlines, roi_size_mm=10.0) + + for sl, p_mask, s_mask in zip(simple_streamlines, point_masks, segment_masks): + assert len(p_mask) == len(sl) + assert len(s_mask) == len(sl) - 1 + + def test_mask_with_target_coords(self, simple_streamlines): + """Masks should respect target coordinate center.""" + target = np.array([12.5, 2.5, 0.0]) # Near middle of first streamline + + point_masks, segment_masks = get_roi_masks( + simple_streamlines, roi_size_mm=5.0, target_coords=target + ) + + # First streamline should have some points in ROI + assert np.any(point_masks[0]), "Should have points in ROI near target" + + def test_mask_without_target_uses_tips(self, simple_streamlines): + """Without target, should mask near streamline endpoints.""" + point_masks, segment_masks = get_roi_masks( + simple_streamlines, roi_size_mm=3.0, target_coords=None + ) + + # Should have masks at tips + for p_mask in point_masks: + # First or last few points should be in mask + assert p_mask[0] or p_mask[-1], "Tips should be in ROI" + + def test_empty_streamlines(self): + """Empty input should return empty lists.""" + point_masks, segment_masks = get_roi_masks([], roi_size_mm=10.0) + + assert point_masks == [] + assert segment_masks == [] + + +# ============================================================================= +# Tests for get_data_in_roi +# ============================================================================= + + +class TestGetDataInRoi: + """Tests for extracting data within ROI.""" + + def test_returns_array(self, simple_streamlines): + """Should return numpy array of values.""" + values = [np.random.rand(len(sl)) for sl in simple_streamlines] + + result = get_data_in_roi(simple_streamlines, values, roi_size_mm=5.0) + + assert isinstance(result, np.ndarray) + + def test_with_lengths(self, simple_streamlines): + """Should return tuple when lengths provided.""" + values = [np.random.rand(len(sl) - 1) for sl in simple_streamlines] + lengths = [np.ones(len(sl) - 1) * 0.5 for sl in simple_streamlines] + + result_values, result_lengths = get_data_in_roi( + simple_streamlines, values, roi_size_mm=5.0, lengths=lengths + ) + + assert isinstance(result_values, np.ndarray) + assert isinstance(result_lengths, np.ndarray) + + def test_filters_to_roi(self, simple_streamlines): + """Should only return values within ROI.""" + # Create values that increase along streamline + values = [np.arange(len(sl)) for sl in simple_streamlines] + + # Target at start of streamlines + target = np.array([0.0, 0.0, 0.0]) + + result = get_data_in_roi(simple_streamlines, values, roi_size_mm=3.0, target_coords=target) + + # Should only get low values (near start) + if len(result) > 0: + assert np.mean(result) < np.mean(np.concatenate(values)) + + +# ============================================================================= +# Tests for get_bundle_cortical_medoid +# ============================================================================= + + +class TestGetBundleCorticalMedoid: + """Tests for medoid calculation (with mocked load).""" + + @patch("tide.core.tractography.load_tract") + def test_returns_3d_coordinate(self, mock_load, streamlines_with_tips, tmp_path): + """Should return a 3D coordinate array.""" + # Setup mock + mock_sft = MagicMock() + mock_sft.streamlines = streamlines_with_tips + mock_load.return_value = mock_sft + + result = get_bundle_cortical_medoid( + trk_path=tmp_path / "dummy.trk", + anat_path=tmp_path / "dummy.nii.gz", + cortex_thickness_mm=4.0, + ) + + assert result.shape == (3,) + assert np.isfinite(result).all() + + @patch("tide.core.tractography.load_tract") + def test_medoid_is_on_streamline(self, mock_load, streamlines_with_tips, tmp_path): + """Medoid should be an actual streamline endpoint.""" + mock_sft = MagicMock() + mock_sft.streamlines = streamlines_with_tips + mock_load.return_value = mock_sft + + result = get_bundle_cortical_medoid( + trk_path=tmp_path / "dummy.trk", anat_path=tmp_path / "dummy.nii.gz" + ) + + # Collect all endpoints + all_endpoints = [] + for sl in streamlines_with_tips: + all_endpoints.append(sl[0]) + all_endpoints.append(sl[-1]) + all_endpoints = np.array(all_endpoints) + + # Medoid should match one of the endpoints + distances = np.linalg.norm(all_endpoints - result, axis=1) + assert np.min(distances) < 0.01, "Medoid should be an actual endpoint" + + @patch("tide.core.tractography.load_tract") + def test_uses_reference_coord(self, mock_load, streamlines_with_tips, tmp_path): + """Reference coordinate should influence cluster selection.""" + mock_sft = MagicMock() + mock_sft.streamlines = streamlines_with_tips + mock_load.return_value = mock_sft + + # Reference near the high-Z end + ref = [50.0, 0.0, 30.0] + + result = get_bundle_cortical_medoid( + trk_path=tmp_path / "dummy.trk", + anat_path=tmp_path / "dummy.nii.gz", + reference_coord=ref, + ) + + # Result should be near the high-Z end + assert result[2] > 20.0, "Should select cluster near reference" + + +# ============================================================================= +# Tests for extract_grid_endpoints +# ============================================================================= + + +class TestExtractGridEndpoints: + """Tests for grid point extraction.""" + + @patch("tide.core.tractography.load_tract") + def test_returns_list_of_coordinates(self, mock_load, streamlines_with_tips, tmp_path): + """Should return list of [x, y, z] coordinates.""" + mock_sft = MagicMock() + mock_sft.streamlines = streamlines_with_tips + mock_load.return_value = mock_sft + + result = extract_grid_endpoints( + trk_path=tmp_path / "dummy.trk", + anat_path=tmp_path / "dummy.nii.gz", + step_mm=4.0, + cortex_thickness_mm=4.0, + ) + + assert isinstance(result, list) + if len(result) > 0: + assert len(result[0]) == 3 + + @patch("tide.core.tractography.load_tract") + def test_respects_step_size(self, mock_load, streamlines_with_tips, tmp_path): + """Grid points should be spaced by step_mm.""" + mock_sft = MagicMock() + mock_sft.streamlines = streamlines_with_tips + mock_load.return_value = mock_sft + + step = 4.0 + result = extract_grid_endpoints( + trk_path=tmp_path / "dummy.trk", + anat_path=tmp_path / "dummy.nii.gz", + step_mm=step, + cortex_thickness_mm=4.0, + ) + + if len(result) > 1: + # Check that points are on grid + points = np.array(result) + remainder = np.mod(points, step) + # Should be very close to 0 (on grid) + assert np.allclose(remainder, 0, atol=0.01) or np.allclose(remainder, step, atol=0.01) + + +# ============================================================================= +# Edge Case Tests +# ============================================================================= + + +class TestTractographyEdgeCases: + """Tests for edge cases and error handling.""" + + def test_empty_streamlines_roi_mask(self): + """Empty streamlines should return empty masks.""" + p_masks, s_masks = get_roi_masks([], roi_size_mm=10.0) + assert p_masks == [] + assert s_masks == [] + + def test_single_point_streamline(self): + """Single-point streamlines should be handled.""" + streamlines = [np.array([[0.0, 0.0, 0.0]])] + + p_masks, s_masks = get_roi_masks(streamlines, roi_size_mm=10.0) + + # Should have a point mask but empty segment mask + assert len(p_masks) == 1 + assert len(s_masks) == 1 + assert len(s_masks[0]) == 0 # No segments in single-point streamline + + @patch("tide.core.tractography.load_tract") + def test_empty_tractogram(self, mock_load, tmp_path): + """Empty tractogram should raise or return empty.""" + mock_sft = MagicMock() + mock_sft.streamlines = [] + mock_load.return_value = mock_sft + + # extract_grid_endpoints should return empty list + result = extract_grid_endpoints( + trk_path=tmp_path / "dummy.trk", + anat_path=tmp_path / "dummy.nii.gz", + step_mm=4.0, + cortex_thickness_mm=4.0, + ) + + assert result == [] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_unified_estimation.py b/tests/test_unified_estimation.py new file mode 100644 index 0000000..6015b76 --- /dev/null +++ b/tests/test_unified_estimation.py @@ -0,0 +1,314 @@ +""" +Unit Tests for the Active Unified Estimation Path +================================================= +Covers the functions that produce every published MSO/SEI number: +``weighted_percentile``, ``analyze_bundle`` (ROI + GWI masking, top-5% median +aggregation) and ``run_unified_estimation`` (SEI, multiplier, ΔMSO identity). +""" + +import re +import sys +from pathlib import Path + +import numpy as np +import pytest + +# ruff: noqa: E402 + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +nib_streamlines = pytest.importorskip("nibabel.streamlines") +from nibabel.streamlines import Tractogram +from nibabel.streamlines.trk import TrkFile +from scipy.spatial import cKDTree + +from tide.interfaces.unified_estimation import ( + AnalysisConfig, + analyze_bundle, + format_weight_sources, + run_unified_estimation, + validate_calibration_metrics, + weighted_percentile, +) + +# ============================================================================= +# Helpers +# ============================================================================= + + +def _make_bundle(af_values, center=(0.0, 0.0, 0.0), n_points=17, offsets=None): + """Build straight streamlines centred on ``center`` with constant per-fibre AF. + + ``af_values`` gives one constant AF magnitude per streamline. Points span + ±8 mm along X at 1 mm spacing so the bundle sits well inside a 20 mm ROI. + """ + if offsets is None: + offsets = np.linspace(-2.0, 2.0, len(af_values)) + cx, cy, cz = center + streamlines = [] + af_per_point = [] + x = np.linspace(-8.0, 8.0, n_points) + for af, dy in zip(af_values, offsets): + sl = np.column_stack([x + cx, np.full_like(x, cy + dy), np.full_like(x, cz)]).astype( + np.float32 + ) + streamlines.append(sl) + af_per_point.append(np.full((n_points, 1), af, dtype=np.float32)) + return streamlines, af_per_point + + +def _write_trk(path, streamlines, af_per_point): + tractogram = Tractogram( + streamlines, + data_per_point={"AF": af_per_point}, + affine_to_rasmm=np.eye(4), + ) + TrkFile(tractogram).save(str(path)) + return path + + +def _config(cst_trk, tgt_trk, rmt=50.0): + return AnalysisConfig( + cst_trk=str(cst_trk), + target_trk=str(tgt_trk), + rmt=rmt, + cst_coords=np.zeros(3), + target_coords=np.zeros(3), + roi_radius=20.0, + activation_len=4.0, + ) + + +# ============================================================================= +# weighted_percentile +# ============================================================================= + + +class TestWeightedPercentile: + def test_uniform_weights_match_searchsorted(self): + values = np.array([60.0, 80.0, 100.0, 120.0, 140.0]) + weights = np.ones_like(values) + # cum weights [.2,.4,.6,.8,1.0]; searchsorted(0.5) -> index 2. + assert weighted_percentile(values, weights, 50.0) == 100.0 + + def test_empty_returns_zero(self): + assert weighted_percentile(np.array([]), np.array([]), 50.0) == 0.0 + + def test_zero_weights_returns_zero(self): + values = np.array([1.0, 2.0, 3.0]) + assert weighted_percentile(values, np.zeros(3), 50.0) == 0.0 + + def test_weight_shifts_percentile(self): + values = np.array([10.0, 100.0]) + # Almost all mass on the small value -> median sits at the small value. + assert weighted_percentile(values, np.array([99.0, 1.0]), 50.0) == 10.0 + + +# ============================================================================= +# analyze_bundle — ROI and GWI masking +# ============================================================================= + + +class TestAnalyzeBundleMasking: + def test_constant_af_metric_equals_value(self, tmp_path): + streamlines, af = _make_bundle([100.0] * 5) + trk = _write_trk(tmp_path / "b.trk", streamlines, af) + cfg = _config(trk, trk) + res = analyze_bundle("B", str(trk), np.zeros(3), cfg) + assert res.n_analyzed == 5 + assert np.isclose(res.metric_weighted, 100.0) + assert np.isclose(res.metric_unweighted, 100.0) + + def test_roi_excludes_distant_streamlines(self, tmp_path): + near, af_near = _make_bundle([100.0] * 5, center=(0.0, 0.0, 0.0)) + far, af_far = _make_bundle([100.0] * 2, center=(500.0, 0.0, 0.0)) + trk = _write_trk(tmp_path / "b.trk", near + far, af_near + af_far) + cfg = _config(trk, trk) + res = analyze_bundle("B", str(trk), np.zeros(3), cfg) + assert res.n_total == 7 + assert res.n_analyzed == 5 + + def test_gwi_surface_filter_keeps_near_streamlines(self, tmp_path): + streamlines, af = _make_bundle([100.0] * 5) + trk = _write_trk(tmp_path / "b.trk", streamlines, af) + cfg = _config(trk, trk) + near_tree = cKDTree(np.vstack(streamlines)) + res = analyze_bundle("B", str(trk), np.zeros(3), cfg, surface_tree=near_tree) + assert res.n_analyzed == 5 + + def test_gwi_surface_filter_excludes_far_streamlines(self, tmp_path): + streamlines, af = _make_bundle([100.0] * 5) + trk = _write_trk(tmp_path / "b.trk", streamlines, af) + cfg = _config(trk, trk) + far_tree = cKDTree(np.array([[1000.0, 1000.0, 1000.0]])) + res = analyze_bundle("B", str(trk), np.zeros(3), cfg, surface_tree=far_tree) + assert res.n_analyzed == 0 + + def test_configured_gwi_threshold_governs_surface_mask(self, tmp_path): + streamlines, af = _make_bundle([100.0] * 5) + trk = _write_trk(tmp_path / "b.trk", streamlines, af) + # Surface sits 5 mm above every streamline point. + tree = cKDTree(np.vstack(streamlines) + np.array([0.0, 0.0, 5.0])) + + strict = _config(trk, trk) + relaxed = _config(trk, trk) + relaxed.gwi_threshold = 6.0 + + assert analyze_bundle("B", str(trk), np.zeros(3), strict, surface_tree=tree).n_analyzed == 0 + assert ( + analyze_bundle("B", str(trk), np.zeros(3), relaxed, surface_tree=tree).n_analyzed == 5 + ) + + def test_missing_explicit_weight_path_raises(self, tmp_path): + streamlines, af = _make_bundle([100.0] * 5) + trk = _write_trk(tmp_path / "b.trk", streamlines, af) + cfg = _config(trk, trk) + missing = tmp_path / "missing_weights.txt" + + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): + analyze_bundle("B", str(trk), np.zeros(3), cfg, weight_path=str(missing)) + + +# ============================================================================= +# run_unified_estimation — SEI, multiplier, ΔMSO identity +# ============================================================================= + + +class TestRunUnifiedEstimation: + def _run(self, tmp_path, cst_af, tgt_af, rmt=50.0, **kwargs): + cst_sl, cst_data = _make_bundle([cst_af] * 5) + tgt_sl, tgt_data = _make_bundle([tgt_af] * 5) + cst_trk = _write_trk(tmp_path / "cst.trk", cst_sl, cst_data) + tgt_trk = _write_trk(tmp_path / "tgt.trk", tgt_sl, tgt_data) + return run_unified_estimation( + cst_trk=cst_trk, + target_trk=tgt_trk, + cst_coords=[0.0, 0.0, 0.0], + target_coords=[0.0, 0.0, 0.0], + rmt=rmt, + roi_radius=20.0, + activation_len=4.0, + **kwargs, + ) + + def test_identity_case(self, tmp_path): + res = self._run(tmp_path, 100.0, 100.0, rmt=50.0) + assert np.isclose(res["intensity_est_raw"], 50.0) + assert np.isclose(res["sei_weighted"], 1.0) + assert np.isclose(res["multiplier_weighted"], 1.0) + + def test_lower_target_efficiency_raises_mso(self, tmp_path): + res = self._run(tmp_path, 100.0, 50.0, rmt=50.0) + assert res["intensity_est_raw"] > 50.0 + assert np.isclose(res["intensity_est_raw"], 100.0) + assert np.isclose(res["sei_weighted"], 0.5) + + def test_ceiling_ratio_clamps_estimate(self, tmp_path): + res = self._run( + tmp_path, + 100.0, + 50.0, + rmt=50.0, + mso_ceiling_ratio=1.40, + ) + assert np.isclose(res["intensity_est_raw"], 100.0) + assert np.isclose(res["intensity_est_clamped"], 70.0) + assert res["intensity_est_flag"] == "CLAMPED_HIGH" + + def test_mso_monotonic_in_target_efficiency(self, tmp_path): + high = self._run(tmp_path, 100.0, 80.0, rmt=50.0)["intensity_est_raw"] + low = self._run(tmp_path, 100.0, 40.0, rmt=50.0)["intensity_est_raw"] + assert low > high + + def test_multiplier_reconstructs_mso(self, tmp_path): + rmt = 50.0 + res = self._run(tmp_path, 100.0, 50.0, rmt=rmt) + assert np.isclose(res["intensity_est_raw"], rmt * res["multiplier_weighted"]) + + def test_sei_minus_one_equals_relative_mso_gap(self, tmp_path): + # With MSO_standard == RMT, |SEI - 1| == |RMT - MSO_raw| / MSO_raw. + rmt = 50.0 + res = self._run(tmp_path, 100.0, 50.0, rmt=rmt) + raw = res["intensity_est_raw"] + rel_gap = abs(rmt - raw) / raw + assert np.isclose(abs(res["sei_weighted"] - 1.0), rel_gap) + + def test_uniform_weights_file_matches_no_weights(self, tmp_path): + cst_sl, cst_data = _make_bundle([60.0, 80.0, 100.0, 120.0, 140.0]) + tgt_sl, tgt_data = _make_bundle([40.0, 60.0, 80.0, 100.0, 120.0]) + cst_trk = _write_trk(tmp_path / "cst.trk", cst_sl, cst_data) + tgt_trk = _write_trk(tmp_path / "tgt.trk", tgt_sl, tgt_data) + w_cst = tmp_path / "w_cst.txt" + w_tgt = tmp_path / "w_tgt.txt" + np.savetxt(w_cst, np.ones(5)) + np.savetxt(w_tgt, np.ones(5)) + + common = dict( + cst_coords=[0.0, 0.0, 0.0], + target_coords=[0.0, 0.0, 0.0], + rmt=50.0, + roi_radius=20.0, + activation_len=4.0, + ) + no_w = run_unified_estimation(cst_trk=cst_trk, target_trk=tgt_trk, **common) + with_w = run_unified_estimation( + cst_trk=cst_trk, + target_trk=tgt_trk, + weights_cst=w_cst, + weights_target=w_tgt, + **common, + ) + assert np.isclose(no_w["intensity_est_raw"], with_w["intensity_est_raw"]) + assert with_w["weight_source"] == "External (w_cst.txt)" + assert with_w["weight_source_cst"] == "External (w_cst.txt)" + assert with_w["weight_source_target"] == "External (w_tgt.txt)" + + @pytest.mark.parametrize("weight_arg", ["weights_cst", "weights_target"]) + def test_missing_weight_path_is_not_dropped(self, tmp_path, weight_arg): + missing = tmp_path / f"missing_{weight_arg}.txt" + + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): + self._run(tmp_path, 100.0, 100.0, **{weight_arg: missing}) + + def test_zero_target_metric_yields_estimation_failed(self, tmp_path): + res = self._run(tmp_path, 100.0, 0.0, rmt=50.0) + assert np.isnan(res["intensity_est_raw"]) + assert res["intensity_est_flag"] == "ESTIMATION_FAILED" + + def test_zero_cst_metric_raises_before_inversion(self, tmp_path): + with pytest.raises(ValueError, match="CST calibration"): + self._run(tmp_path, 0.0, 100.0, rmt=50.0) + + +class TestCalibrationValidation: + @pytest.mark.parametrize( + ("weighted", "unweighted"), + [ + (0.0, 1.0), + (-1.0, 1.0), + (float("nan"), 1.0), + (1.0, 0.0), + (1.0, -1.0), + (1.0, float("inf")), + ], + ) + def test_rejects_unusable_metric(self, weighted, unweighted): + with pytest.raises(ValueError, match="CST calibration"): + validate_calibration_metrics(weighted, unweighted) + + def test_accepts_positive_finite_metrics(self): + validate_calibration_metrics(100.0, 101.0) + + +class TestWeightSourceFormatting: + def test_matching_sources_keep_historical_value(self): + assert format_weight_sources("Uniform", "Uniform") == "Uniform" + + def test_different_sources_report_both(self): + assert format_weight_sources("Uniform", "External (target.txt)") == ( + "CST: Uniform; Target: External (target.txt)" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..415d5e1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1161 @@ +version = 1 +revision = 1 +requires-python = ">=3.11, <3.13" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", + "python_version < '0'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987 }, + { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499 }, + { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369 }, + { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613 }, + { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719 }, + { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920 }, + { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499 }, + { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994 }, + { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867 }, + { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124 }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542 }, +] + +[[package]] +name = "cachebox" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/f6/85f176d2518cf1d1be5f981fc2dadf6b131e33fefd721f36b330e3434d6c/cachebox-5.2.3.tar.gz", hash = "sha256:b1f68246685aa739bbbd2734befb1465363a1e1042407c154feadb065f17a099", size = 63686 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/88/154179d492f2c000fe6efab3c3ff6b8eb94fbfaa09efe47999bce6b1e29f/cachebox-5.2.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:996f49d04b234082530afcc650bdd00556afbebc19c6c0daaafb85950340cb3c", size = 374245 }, + { url = "https://files.pythonhosted.org/packages/7d/9d/3b03f2e063161bcb1a5e0969d521b5c622c2da02252a5c8bd4ef0e4f9914/cachebox-5.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23a3300ebbb526fa12ce6fa53699002f5fba6da23b4bbbaf8ba8b18a3f03e6b3", size = 356308 }, + { url = "https://files.pythonhosted.org/packages/bb/9b/8da38af731e3832e9f987548e4bfb610d7f3054019e12c44a94ba9272b37/cachebox-5.2.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79c63ee1589364caa04c018405e625d2e44e0bf9994f2715b2f322075d8c45b6", size = 395666 }, + { url = "https://files.pythonhosted.org/packages/01/dd/1522aa808f94c904c5eb3640991799fed14dd43c1dd99a9f7b71bd95b1e3/cachebox-5.2.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebd0f8d4ebc3943c1ddcbbdc54f1a8ddf95505c862ed5731319cebd1eb98ae41", size = 353362 }, + { url = "https://files.pythonhosted.org/packages/dd/52/95bf883ec9b69a76f3a7d9fb14d015d9a4bdab0143a3eff62ceebc8b1419/cachebox-5.2.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:569966efcc6309aa7d774443e3513cdbb8671efae0158138ba2ebb7d8cc9d8ed", size = 371007 }, + { url = "https://files.pythonhosted.org/packages/4b/3d/cc02066d5ccfcb8b35adbaf867977fdb54572cda56ace56da396f0caa3bf/cachebox-5.2.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5774d06f0da37dd566239a4376d6ca8cf983d3e4c3228712ec22b4130f662f21", size = 390670 }, + { url = "https://files.pythonhosted.org/packages/b3/50/8e4d59b3e344405d8393d6cc5cc92754d3cc1d81134041ebffd3f5ab73e6/cachebox-5.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae5bf8755bc66bcf42e7ca5c42d703a041a7aaad58f9a0c3be54d5b1cefd2641", size = 395765 }, + { url = "https://files.pythonhosted.org/packages/e5/d4/d731cff1c4cec22404bd3ddda05b233c5efaa5f13d7abf4e2728905b7cdd/cachebox-5.2.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:63f061cc6a5ca70bbce2e6be0588fe2fee00a93a1b0581b1086d54b10288cdb6", size = 425707 }, + { url = "https://files.pythonhosted.org/packages/36/01/3ec8aadceb0dcc66dbd0b9b32966cf7b6928ed84471424c24d21b0af62d0/cachebox-5.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577c781f18b559f4dc9eea176c6aed008843ef4b8e045cf61bb519e09dccc9ef", size = 564759 }, + { url = "https://files.pythonhosted.org/packages/db/23/31cbc8623ecc2e25900f7e8f20f11bfb84786989a59a8046e70b27cbea6d/cachebox-5.2.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7f691e25572a3ddbb018e19d796f774713bd6b0f7ce9be2e71f6e18572de264a", size = 669309 }, + { url = "https://files.pythonhosted.org/packages/34/29/5a9e92bdc7b32dc865e73dd776638244f900136daee5bb0591a67e1530fa/cachebox-5.2.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:33368adf86669c29b936fbae5d6219cf90aacd4b1db71dae2e23d584a8219cd6", size = 643705 }, + { url = "https://files.pythonhosted.org/packages/04/90/5273a412855fdc11f674e4749aee6d5ec0a91f5c1a9f6e922f7fa0cb7a83/cachebox-5.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38ce67b7b45713e49459a09411d07f82de04022c04aecde6202cd32f934c2b1f", size = 609751 }, + { url = "https://files.pythonhosted.org/packages/a1/a4/0fadb5e6a00f373cc3fe56b4415cdea2fc0147f6ec475611762d16eb4b05/cachebox-5.2.3-cp311-cp311-win32.whl", hash = "sha256:a7cd2c81347063ab6c512d0f569aeb5f75fc2dfe686c8486258ffd08052324f4", size = 275485 }, + { url = "https://files.pythonhosted.org/packages/03/83/67c1bf83f815294d2c3acd7631f25b5cbe6067e1d56495f76829dd60057b/cachebox-5.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:7e45798d6b969794840bb302857946d710ecb32af78dfcb3ab40f4e68ee7fdaf", size = 288024 }, + { url = "https://files.pythonhosted.org/packages/e4/e7/6fa6abfc9c4c07b88f09a88466fa93c7081fd679d8e06f8f558bb4ac845c/cachebox-5.2.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:09c0340e9daa7b4530801e5a570cb0c1a1ad941a85d245d360020d3986d0e787", size = 377791 }, + { url = "https://files.pythonhosted.org/packages/3a/79/89e4423352d0ca33bbf80fc1b4b665e654a93de8b16cf41e96fcac81801a/cachebox-5.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3162758792626685ec34950eedd565d015b115d0ff0d751d2716031fc32d51b", size = 359562 }, + { url = "https://files.pythonhosted.org/packages/d2/ab/e533c2751e6a3411ebe369277aaed03199b9e4586a48f0a3712a1f4b418b/cachebox-5.2.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a189a780c3ccd7b9d157074ba6bf3e191e522b39abbdb590075111851f02d50d", size = 397910 }, + { url = "https://files.pythonhosted.org/packages/7a/0d/b8492d6ca53278499a37c9f9d51afd4ad77bfbe813d6281944d45b97a1e7/cachebox-5.2.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:410b67baa99d433644199b11289627f7ebba4ee5786f95ca9858f238afcee157", size = 353699 }, + { url = "https://files.pythonhosted.org/packages/78/d4/fd20b3a5362651303fa12d3ee62f56af2bd396e4a7303d7014a1a1e5b392/cachebox-5.2.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f81474dc19d3865fa5e57263f834bc6bbc00e471a594fb9d934ed552732c02fd", size = 372510 }, + { url = "https://files.pythonhosted.org/packages/71/94/3ec55c946d300cc4eaed3a0f79740051ac6e11ef4032421332c6ca15f5d5/cachebox-5.2.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85ccd827193b3e3e887a88a16b88ef7ed174e7e65be515b5253322aa75e665c3", size = 392802 }, + { url = "https://files.pythonhosted.org/packages/01/b1/1a3c4e436ad8a4c4ba3e70f4c62e1f927cbbb3c943a9bba5813b8b815bde/cachebox-5.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a1e7d3cb8a5e7e68996a8619e3ef8771a124d14568c251f9e586eba88d759c1", size = 398223 }, + { url = "https://files.pythonhosted.org/packages/0a/ea/d36ad3976c4396b350b96a1582411b7a00e56c144eec0bb5ba5f36ce7d86/cachebox-5.2.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:adcedfcfcb933b21e7fdcfe560c79887bc8287abceab0586aa3730417dd0277d", size = 427696 }, + { url = "https://files.pythonhosted.org/packages/a8/36/71845b5c7a9ffbd85e6fdb470c11a174f499bd5238fa37b1214157c2454d/cachebox-5.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c7f0c72c51a3a9e7049ea6ff2a43cd3877ab7fee966eb65771a59621563b75e3", size = 567854 }, + { url = "https://files.pythonhosted.org/packages/e8/a2/baf0e5a8392e64e352b137ccd7356b3d98068c842fd19f510a7790c05d34/cachebox-5.2.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c48c10e498d573511aafbd545570e7f43b40a7428dc282183bf5adc334d9e1a8", size = 670306 }, + { url = "https://files.pythonhosted.org/packages/a5/22/cd4e4c1d624b8ef9fb4b8bebf0bf5d2d74a399cf1ac46b667bb79d15359a/cachebox-5.2.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2f1e086ab5ffd082a68bb63699d517655a59b06414927bfc84e01df91b81e34d", size = 645943 }, + { url = "https://files.pythonhosted.org/packages/0a/d6/55859981f5ec6a9e412baaa4db6aa5973a00008750b3f054cdefcb6491fc/cachebox-5.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:649d18399f13735bb82daa33800196f815529c49e967767c40ca221723e68afa", size = 612309 }, + { url = "https://files.pythonhosted.org/packages/d7/1e/313f650467ac85824c4199188f8f1ee3386cd12eb665dbf7c88d372e4956/cachebox-5.2.3-cp312-cp312-win32.whl", hash = "sha256:0a17aeb4e5b1c6ef1c3db8fc5186f9986e215ba5ea5a5d08baa45bcf55f261b2", size = 279789 }, + { url = "https://files.pythonhosted.org/packages/c5/50/3b334f887accfa811cf5c7533b8ce22c523eb009363a86401198899dadd2/cachebox-5.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:cfd69114141ab362acaa2099e425a1b965cf7b021a539a4e953143d593930b74", size = 290917 }, + { url = "https://files.pythonhosted.org/packages/ce/7b/5eead1ca0d437b1993a742c6571079ae58ae4db50d94d42e87b514aed6c3/cachebox-5.2.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c798cddfb780156db09d3d96ed5da4c2d5fc01dad4bc7b54db5b20c34f221926", size = 376199 }, + { url = "https://files.pythonhosted.org/packages/77/e3/5e45042f9b552a5087cafc2e0fed834e632531fca17818201d72e78593ce/cachebox-5.2.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:c8f3de4afeb3fd721620be3d02f2338bcbc3fdbd464ca14e1c474088c9669db0", size = 357109 }, + { url = "https://files.pythonhosted.org/packages/d4/51/3c4743b718b42e4b80166fa61f8722b603eba7bf206768a7892c4699dce7/cachebox-5.2.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b39022c258872185327acffa9ad42d6bdf42f37d006d35c825a684eb5fa98d40", size = 396433 }, + { url = "https://files.pythonhosted.org/packages/f8/9b/678da91187bdb2836db2b8da62519da75359b46bc28697799a7caa314519/cachebox-5.2.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a0599fb85dcb6df9a86502435643fe90c793bbcd50b5d85217c70f2bc2e38fc", size = 354287 }, + { url = "https://files.pythonhosted.org/packages/df/06/769446da6c9f2855499aaa19e2d7260aa47934bc2e15a931e5b737f8685a/cachebox-5.2.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3cdbe8f1b7716a44dc82ef3a6830a612260c7379478cfa80804632e2e6252b8e", size = 372507 }, + { url = "https://files.pythonhosted.org/packages/79/cf/86c60994a7be734abef0395e440dc11714f84ffcd369cbcd8e61c3d58126/cachebox-5.2.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:783d1b9a0b3c77c43e7ae331b9d6561ad75827e16b2484e2a6cc289ec4d392ee", size = 390831 }, + { url = "https://files.pythonhosted.org/packages/9d/db/acfb55f8d5ee4ea1c5f2d32ede25d4d04e944ba09d2832c27c085022490d/cachebox-5.2.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c6476a2a842906fee782d92f8fbcb03ecfd22eecc39adb7fb5b047d7e1cf020", size = 396277 }, + { url = "https://files.pythonhosted.org/packages/dc/4f/35e27e85a48e15671c5863addcabde910eb311800a621c3e47c04bd36d17/cachebox-5.2.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:184bbcfa1370415b6d1f09e4fb74ab697dac8df09f522aa217a2fac65f973744", size = 426980 }, + { url = "https://files.pythonhosted.org/packages/09/4b/50f2cadf20c02db9e449f2e9fee95f3eb5768ab1804dd0a5eba6c98119ad/cachebox-5.2.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:f89df36b46f8f5e11c0c49701ec3cebddf51191f96afb7bb75c394faf3c1cbc8", size = 565539 }, + { url = "https://files.pythonhosted.org/packages/43/53/b8e948cadb48b8bcf1d13c2aa4a788ff0e95b50ddb808c18e998499b4680/cachebox-5.2.3-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:fb0bdcd9e28686e3b91d5210c843542858f0f10de151181aee27a7978fe4992e", size = 670870 }, + { url = "https://files.pythonhosted.org/packages/29/7b/d68ca3f59a9d6963c2f6b19bc4b1926a37db2e4a4f6c9891d12788e49ce2/cachebox-5.2.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:5196f0d2c2f99c92ddf0d2c37803ff90509d14a5df211b7754feb8b61ffd8740", size = 644542 }, + { url = "https://files.pythonhosted.org/packages/f8/c8/44ae6d5dff09f044d61a92591e6a8db17f3b2ee51a54d375cce90271527b/cachebox-5.2.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:73671850d8c3634ab217398c83715d3feb52589ec97bd8e2f4d22e472741ea48", size = 610235 }, + { url = "https://files.pythonhosted.org/packages/9a/1b/31cf2449da9a296f6c6c0002c7ae91a25c3a4bfef071763bbeb85300b402/cachebox-5.2.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:70c718f6bb77e6ba142b9a055b81ce85412a0c0e5e82a154489b45e6f91d09ec", size = 287614 }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705 }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419 }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901 }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742 }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061 }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239 }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173 }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841 }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304 }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455 }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036 }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739 }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277 }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819 }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281 }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843 }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328 }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061 }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031 }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239 }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589 }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733 }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652 }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229 }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552 }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806 }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316 }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274 }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468 }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460 }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330 }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828 }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 }, +] + +[[package]] +name = "coverage" +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662 }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168 }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587 }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497 }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607 }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563 }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726 }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301 }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361 }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129 }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081 }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988 }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754 }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225 }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774 }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838 }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197 }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705 }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441 }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556 }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815 }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117 }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475 }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619 }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689 }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189 }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059 }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893 }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429 }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810 }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 }, +] + +[[package]] +name = "deepdiff" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachebox" }, + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/26/4a2bad8eb430d8d805a4642c4bff25103a37548d74ab346f8b1e024abcc5/deepdiff-9.1.0-py3-none-any.whl", hash = "sha256:80c0460e1993b04f6f0ca79abf25548b129fd218478c4ebb08f80560f5d10610", size = 184662 }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + +[[package]] +name = "dipy" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h5py" }, + { name = "nibabel" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "scipy" }, + { name = "tqdm" }, + { name = "trx-python" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/a3/488a52b7fe5afb4f8978c7d5e537f6bfb9c5620c910ca8f41fafc64f2472/dipy-1.9.0.tar.gz", hash = "sha256:eb0f7a211202d48f30961743528c86ec9804fcba8ca440a7484e0a113fa2cc4f", size = 6518336 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/cf/dcbdf07ec4f47a0147c0b7dff12b35e196c88bf25741aee7828db56ba84a/dipy-1.9.0-1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49b414a34a4f3cb6c7c56e35413e6a27de975fd1df22c6fad143f06348957f67", size = 8660732 }, + { url = "https://files.pythonhosted.org/packages/0e/9b/76d6c5836647fa740d8f32d705d11de2bc445bd011c7cafa790fecd33993/dipy-1.9.0-1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23a519ff742c90686b166d08d8b524a743a4834cb88d40a717c5912dc5d8eb4e", size = 8324629 }, + { url = "https://files.pythonhosted.org/packages/52/10/c35b1ecccc2fc204a60e217f9c3f8258b30fd9b596cc2c594fca8eb2cf88/dipy-1.9.0-1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:82918bb4f37771d8372c2a3bfee9a8b6cbf93c574adfcb1b210cacd414504d19", size = 8690858 }, + { url = "https://files.pythonhosted.org/packages/19/c8/6ccf0e5a02aa58146fde6ec9c2629eb6cb6cd8574703d4b44d21eedcbac9/dipy-1.9.0-1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1a6951b799c7ec9d650b3b98140bafa20b3f96c85e8b99c6243187fec7e143ea", size = 8304714 }, + { url = "https://files.pythonhosted.org/packages/db/14/70c9d60d2ccdbf45efd139af545d7a90382c57d8b06504ffe2c7126b044f/dipy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0abd802100250871d5eb065bf56933f49082935181e8d47e2362ed3e41e9b4fc", size = 8835789 }, + { url = "https://files.pythonhosted.org/packages/b8/8f/e68aaf9fc09fb0096a97276df850bffc034d8fc4c2c5ab7cef43a853ca59/dipy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f5cc43ad0ba37be236006b20bd0ed957640e07565e06d5997e47fdbbd7a0c2d", size = 8227637 }, + { url = "https://files.pythonhosted.org/packages/ba/1f/0f079be9e0e8ff5ede7efe87cddcc78986782c340dfd56443086023299e5/dipy-1.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34144ed9bdda83e2dceae820d550bf4f184a537cf8efc3e5b9f8872103ed521a", size = 8692478 }, + { url = "https://files.pythonhosted.org/packages/53/ad/18090e5d7cabd50c494b0f8840e61c0f7a795ca0f18ce7e8b66b5318b5b3/dipy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cee0ccd5fd73afc19992b63af3e9cad0d3dee8c6a62b6c68a615c36424255f61", size = 9043508 }, + { url = "https://files.pythonhosted.org/packages/a8/02/b5e780a9f8366322973efeb26d1f03e86d3035526a9dd1bbd4f308883b5b/dipy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:8da3f5311cfd71dc635b3996b8421192a596f1759851a050a63d085856e9cb9f", size = 8267976 }, + { url = "https://files.pythonhosted.org/packages/0b/79/be82ae92cbb0dc2c7e0c1219ea828c82b4fffd355c8e5a142ba67bbe7ccd/dipy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c3f5450c3982595db4811f38dd4be07cd3e077d5f4d0e4e8911ebf5157ca82da", size = 8806755 }, + { url = "https://files.pythonhosted.org/packages/e7/a5/a2eccb60f6fda958ba4210aaa1f5739f0d5b48fafd9d5f66d69583c3d904/dipy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58f7365086fcc135eb48cdb0cd76525e6ac960f7b693b071034dd4be4594c284", size = 8204429 }, + { url = "https://files.pythonhosted.org/packages/7b/91/c5c65894e25afa6703806ade4846c57f68ba9dc62fad97023b6b673794f7/dipy-1.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33c7eda4b1953cdacda1bf0da1a7970fef79260fa82e09d6b58d43fabd5a4299", size = 8702455 }, + { url = "https://files.pythonhosted.org/packages/00/8a/37a036e285dc3308c58996dc3c21991cb70fa38397ebb2e711ddc3c6c962/dipy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4066f696a84ca20a550ba5b7879a51ae61bac2c719b5cba0fcbef855d8d612ec", size = 9035532 }, + { url = "https://files.pythonhosted.org/packages/e1/52/50ba6c80bb7e0a397a20678d86a3b6b67fe7f15fc8ad569c6d355d43be95/dipy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f18e2593ee0bc2acad60272ce6b44472401f797e216617e5738d752073a5be1", size = 8205675 }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708 }, +] + +[[package]] +name = "flake8" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/3c/3464b567aa367b221fa610bbbcce8015bf953977d21e52f2d711b526fb48/flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", size = 48219 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/01/cc8cdec7b61db0315c2ab62d80677a138ef06832ec17f04d87e6ef858f7f/flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3", size = 57570 }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793 }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130 }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952 }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308 }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932 }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271 }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473 }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389 }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131 }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704 }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298 }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800 }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666 }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598 }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575 }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211 }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562 }, +] + +[[package]] +name = "h5py" +version = "3.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/33/acd0ce6863b6c0d7735007df01815403f5589a21ff8c2e1ee2587a38f548/h5py-3.16.0.tar.gz", hash = "sha256:a0dbaad796840ccaa67a4c144a0d0c8080073c34c76d5a6941d6818678ef2738", size = 446526 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/95/a825894f3e45cbac7554c4e97314ce886b233a20033787eda755ca8fecc7/h5py-3.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:719439d14b83f74eeb080e9650a6c7aa6d0d9ea0ca7f804347b05fac6fbf18af", size = 3721663 }, + { url = "https://files.pythonhosted.org/packages/bf/3b/38ff88b347c3e346cda1d3fc1b65a7aa75d40632228d8b8a5d7b58508c24/h5py-3.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3f0a0e136f2e95dd0b67146abb6668af4f1a69c81ef8651a2d316e8e01de447", size = 3087630 }, + { url = "https://files.pythonhosted.org/packages/98/a8/2594cef906aee761601eff842c7dc598bea2b394a3e1c00966832b8eeb7c/h5py-3.16.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:a6fbc5367d4046801f9b7db9191b31895f22f1c6df1f9987d667854cac493538", size = 4823472 }, + { url = "https://files.pythonhosted.org/packages/52/a0/c1f604538ff6db22a0690be2dc44ab59178e115f63c917794e529356ab23/h5py-3.16.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:fb1720028d99040792bb2fb31facb8da44a6f29df7697e0b84f0d79aff2e9bd3", size = 5027150 }, + { url = "https://files.pythonhosted.org/packages/2e/fd/301739083c2fc4fd89950f9bcfce75d6e14b40b0ca3d40e48a8993d1722c/h5py-3.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:314b6054fe0b1051c2b0cb2df5cbdab15622fb05e80f202e3b6a5eee0d6fe365", size = 4814544 }, + { url = "https://files.pythonhosted.org/packages/4c/42/2193ed41ccee78baba8fcc0cff2c925b8b9ee3793305b23e1f22c20bf4c7/h5py-3.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ffbab2fedd6581f6aa31cf1639ca2cb86e02779de525667892ebf4cc9fd26434", size = 5034013 }, + { url = "https://files.pythonhosted.org/packages/f7/20/e6c0ff62ca2ad1a396a34f4380bafccaaf8791ff8fccf3d995a1fc12d417/h5py-3.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:17d1f1630f92ad74494a9a7392ab25982ce2b469fc62da6074c0ce48366a2999", size = 3191673 }, + { url = "https://files.pythonhosted.org/packages/f2/48/239cbe352ac4f2b8243a8e620fa1a2034635f633731493a7ff1ed71e8658/h5py-3.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:85b9c49dd58dc44cf70af944784e2c2038b6f799665d0dcbbc812a26e0faa859", size = 2673834 }, + { url = "https://files.pythonhosted.org/packages/c8/c0/5d4119dba94093bbafede500d3defd2f5eab7897732998c04b54021e530b/h5py-3.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5313566f4643121a78503a473f0fb1e6dcc541d5115c44f05e037609c565c4d", size = 3685604 }, + { url = "https://files.pythonhosted.org/packages/b0/42/c84efcc1d4caebafb1ecd8be4643f39c85c47a80fe254d92b8b43b1eadaf/h5py-3.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:42b012933a83e1a558c673176676a10ce2fd3759976a0fedee1e672d1e04fc9d", size = 3061940 }, + { url = "https://files.pythonhosted.org/packages/89/84/06281c82d4d1686fde1ac6b0f307c50918f1c0151062445ab3b6fa5a921d/h5py-3.16.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ff24039e2573297787c3063df64b60aab0591980ac898329a08b0320e0cf2527", size = 5198852 }, + { url = "https://files.pythonhosted.org/packages/9e/e9/1a19e42cd43cc1365e127db6aae85e1c671da1d9a5d746f4d34a50edb577/h5py-3.16.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:dfc21898ff025f1e8e67e194965a95a8d4754f452f83454538f98f8a3fcb207e", size = 5405250 }, + { url = "https://files.pythonhosted.org/packages/b7/8e/9790c1655eabeb85b92b1ecab7d7e62a2069e53baefd58c98f0909c7a948/h5py-3.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:698dd69291272642ffda44a0ecd6cd3bda5faf9621452d255f57ce91487b9794", size = 5190108 }, + { url = "https://files.pythonhosted.org/packages/51/d7/ab693274f1bd7e8c5f9fdd6c7003a88d59bedeaf8752716a55f532924fbb/h5py-3.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2b2c02b0a160faed5fb33f1ba8a264a37ee240b22e049ecc827345d0d9043074", size = 5419216 }, + { url = "https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:96b422019a1c8975c2d5dadcf61d4ba6f01c31f92bbde6e4649607885fe502d6", size = 3182868 }, + { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286 }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "isort" +version = "5.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798 }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216 }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911 }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209 }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888 }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304 }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650 }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949 }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125 }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783 }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726 }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738 }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718 }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480 }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930 }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158 }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388 }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068 }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934 }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537 }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685 }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024 }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241 }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742 }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966 }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417 }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238 }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569 }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997 }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262 }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036 }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295 }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987 }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532 }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420 }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892 }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603 }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, +] + +[[package]] +name = "matplotlib" +version = "3.8.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/aa/607a121331d5323b164f1c0696016ccc9d956a256771c4d91e311a302f13/matplotlib-3.8.3.tar.gz", hash = "sha256:7b416239e9ae38be54b028abbf9048aff5054a9aba5416bef0bd17f9162ce161", size = 35879872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/07/7e245ce1d7daec77cb1ca3b8caf094afb04c4c552a904787a1d684a2b606/matplotlib-3.8.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5184e07c7e1d6d1481862ee361905b7059f7fe065fc837f7c3dc11eeb3f2f900", size = 7601453 }, + { url = "https://files.pythonhosted.org/packages/24/db/6ec78a4f10673a641cdb11694c2de2f64aa00e838551248cb11b8b057440/matplotlib-3.8.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7e7e0993d0758933b1a241a432b42c2db22dfa37d4108342ab4afb9557cbe3e", size = 7494995 }, + { url = "https://files.pythonhosted.org/packages/c3/9a/9ba49c25d563f5318f28f57e37d1232cb89416a40224395e9b42fa8c1315/matplotlib-3.8.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04b36ad07eac9740fc76c2aa16edf94e50b297d6eb4c081e3add863de4bb19a7", size = 11389595 }, + { url = "https://files.pythonhosted.org/packages/ef/1d/bf1d78126c3d106100232d3a18b7f3732e7dc3b71ee38ab735e4064b19cc/matplotlib-3.8.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c42dae72a62f14982f1474f7e5c9959fc4bc70c9de11cc5244c6e766200ba65", size = 11619137 }, + { url = "https://files.pythonhosted.org/packages/b8/1d/bb533b106bbdeeff05368e4540778b86df576ee9ef886e54bc45b73f6678/matplotlib-3.8.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bf5932eee0d428192c40b7eac1399d608f5d995f975cdb9d1e6b48539a5ad8d0", size = 9551948 }, + { url = "https://files.pythonhosted.org/packages/a1/27/8a807464b0cf47fdf3ba8cbb542d4f3a551da0254d7588667857f8a8a88a/matplotlib-3.8.3-cp311-cp311-win_amd64.whl", hash = "sha256:40321634e3a05ed02abf7c7b47a50be50b53ef3eaa3a573847431a545585b407", size = 7648318 }, + { url = "https://files.pythonhosted.org/packages/41/ab/7c8a94d30c2d86d8effdfe5846ac1f2ad75aa9d97f4f3ece49ae004c4c61/matplotlib-3.8.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:09074f8057917d17ab52c242fdf4916f30e99959c1908958b1fc6032e2d0f6d4", size = 7599026 }, + { url = "https://files.pythonhosted.org/packages/7f/23/2333941006444e8e3a8078eb425ce56410ef7d76bc13ab3e9f6db5101705/matplotlib-3.8.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5745f6d0fb5acfabbb2790318db03809a253096e98c91b9a31969df28ee604aa", size = 7491681 }, + { url = "https://files.pythonhosted.org/packages/13/b6/ed740cb55fd9199b6cba22cc16c65f2574ebbf826172ea2dbc3b857f03e3/matplotlib-3.8.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97653d869a71721b639714b42d87cda4cfee0ee74b47c569e4874c7590c55c5", size = 11382991 }, + { url = "https://files.pythonhosted.org/packages/c6/66/2a08ecbafb0970f54e79c6a91139d62c7821b9f67eccb204b94412ce344d/matplotlib-3.8.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:242489efdb75b690c9c2e70bb5c6550727058c8a614e4c7716f363c27e10bba1", size = 11606938 }, + { url = "https://files.pythonhosted.org/packages/03/63/fe7da070f1237d1f2a4dcefd4ae171dd8d7af4ec9fa6a6ee004995d2972b/matplotlib-3.8.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:83c0653c64b73926730bd9ea14aa0f50f202ba187c307a881673bad4985967b7", size = 9547713 }, + { url = "https://files.pythonhosted.org/packages/50/ce/a6bc93f7a44dd1fd23698698e369e141f4f24e7098d0a5937808afee3f5e/matplotlib-3.8.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef6c1025a570354297d6c15f7d0f296d95f88bd3850066b7f1e7b4f2f4c13a39", size = 7647636 }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mypy" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/22/25fac51008f0a4b2186da0dba3039128bd75d3fab8c07acd3ea5894f95cc/mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", size = 2990299 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/c4/2ce11ff9ba6c9c9e89df5049ab2325c85e60274194d6816e352926de5684/mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", size = 10795101 }, + { url = "https://files.pythonhosted.org/packages/bb/b7/882110d1345847ce660c51fc83b3b590b9512ec2ea44e6cfd629a7d66146/mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", size = 9849744 }, + { url = "https://files.pythonhosted.org/packages/19/c6/256f253cb3fc6b30b93a9836cf3c816a3ec09f934f7b567f693e5666d14f/mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", size = 12391778 }, + { url = "https://files.pythonhosted.org/packages/66/19/e0c9373258f3e84e1e24af357e5663e6b0058bb5c307287e9d1a473a9687/mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", size = 12461242 }, + { url = "https://files.pythonhosted.org/packages/a9/d7/a7ee8ca5a963b5bf55a6b4bc579df77c887e7fbc0910047b7d0f7750b048/mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", size = 9205536 }, + { url = "https://files.pythonhosted.org/packages/08/24/83d9e62ab2031593e94438fdbfd2c32996f4d818be26d2dc33be6870a3a0/mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", size = 10849520 }, + { url = "https://files.pythonhosted.org/packages/74/e8/30c42199bb5aefb37e02a9bece41f6a62a60a1c427cab8643bc0e7886df1/mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55", size = 9812231 }, + { url = "https://files.pythonhosted.org/packages/a6/70/49e9dc3d4ef98c22e09f1d7b0195833ad7eeda19a24fcc42bf1b62c89110/mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", size = 12422003 }, + { url = "https://files.pythonhosted.org/packages/33/14/902484951fa662ee6e044087a50dab4b16b534920dda2eea9380ce2e7b2d/mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", size = 12497387 }, + { url = "https://files.pythonhosted.org/packages/aa/88/c6f214f1beeac9daffa1c3d0a5cbf96ee05617ca3e822c436c83f141ad8f/mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", size = 9302230 }, + { url = "https://files.pythonhosted.org/packages/3a/e3/b582bff8e2fc7056a8a00ec06d2ac3509fc9595af9954099ed70e0418ac3/mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", size = 2553257 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "nibabel" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/d2/da723207ba389c9d6b6d55f21dbbba8d24c08f846c42832a69d8fdbb97da/nibabel-5.2.1.tar.gz", hash = "sha256:b6c80b2e728e4bc2b65f1142d9b8d2287a9102a8bf8477e115ef0d8334559975", size = 4503320 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/3f/ce43b8c2ccc4a7913a87c4d425aaf0080ea3abf947587e47dc2025981a17/nibabel-5.2.1-py3-none-any.whl", hash = "sha256:2cbbc22985f7f9d39d050df47249771dfb8d48447f5e7a993177e4cabfe047f0", size = 3296610 }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005 }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297 }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567 }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913 }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901 }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868 }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109 }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613 }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172 }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643 }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803 }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754 }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222 }, + { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274 }, + { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836 }, + { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505 }, + { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420 }, + { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457 }, + { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166 }, + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893 }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475 }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645 }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445 }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235 }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756 }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248 }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415 }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266 }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814 }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408 }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160 }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172 }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232 }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653 }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195 }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969 }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323 }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838 }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830 }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383 }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934 }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684 }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137 }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267 }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510 }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058 }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776 }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358 }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786 }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pooch" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/43/85ef45e8b36c6a48546af7b266592dc32d7f67837a6514d111bced6d7d75/pooch-1.9.0.tar.gz", hash = "sha256:de46729579b9857ffd3e741987a2f6d5e0e03219892c167c6578c0091fb511ed", size = 61788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175 }, +] + +[[package]] +name = "pycodestyle" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/8f/fa09ae2acc737b9507b5734a9aec9a2b35fa73409982f57db1b42f8c3c65/pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", size = 38974 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/90/a998c550d0ddd07e38605bb5c455d00fcc177a800ff9cc3dafdcb3dd7b56/pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67", size = 31132 }, +] + +[[package]] +name = "pyflakes" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/f9/669d8c9c86613c9d568757c7f5824bd3197d7b1c6c27553bc5618a27cce2/pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", size = 63788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a", size = 62725 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, +] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/15/da3df99fd551507694a9b01f512a2f6cf1254f33601605843c3775f39460/pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", size = 63245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4b/8b78d126e275efa2379b1c2e09dc52cf70df16fc3b90613ef82531499d73/pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a", size = 21949 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/f4/ac9c4ccbc5984ebc3bef6dbdbcdaf553a1aae07c08e63b8b25a6239ecc45/pytest-xdist-3.5.0.tar.gz", hash = "sha256:cbb36f3d67e0c478baa57fa4edc8843887e0f6cfc42d677530a36d7472b32d8a", size = 78977 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/37/125fe5ec459321e2d48a0c38672cfc2419ad87d580196fd894e5f25230b0/pytest_xdist-3.5.0-py3-none-any.whl", hash = "sha256:d075629c7e00b611df89f490a5063944bee7a4362a5ff11c7cc7824a03dfce24", size = 42017 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864 }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565 }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824 }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075 }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323 }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663 }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626 }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779 }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076 }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552 }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729 }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141 }, +] + +[[package]] +name = "pyvista" +version = "0.43.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pillow" }, + { name = "pooch" }, + { name = "scooby" }, + { name = "vtk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/b2/a62c7b92890a8181fce024ce5526ed1a2791817d416147547e711d92f010/pyvista-0.43.3.tar.gz", hash = "sha256:e039cdeb0c7cbb42a16fbdfbbede65a7dc656def787e76c79a43eb0badc88c9b", size = 1853185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/6b/7546903fb8674c7e08a5c48b9d3a888378f7297545ae8c74703441319ac4/pyvista-0.43.3-py3-none-any.whl", hash = "sha256:5eb589bbea294761cd44aed0330481019e8eca2ddb950a1f332a5a132000e93d", size = 1898996 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/e5/af35f7ea75cf72f2cd079c95ee16797de7cd71f29ea7c68ae5ce7be1eda0/PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", size = 125201 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/0d/26fb23e8863e0aeaac0c64e03fd27367ad2ae3f3cccf3798ee98ce160368/PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", size = 187867 }, + { url = "https://files.pythonhosted.org/packages/28/09/55f715ddbf95a054b764b547f617e22f1d5e45d83905660e9a088078fe67/PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", size = 167530 }, + { url = "https://files.pythonhosted.org/packages/5e/94/7d5ee059dfb92ca9e62f4057dcdec9ac08a9e42679644854dc01177f8145/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", size = 732244 }, + { url = "https://files.pythonhosted.org/packages/06/92/e0224aa6ebf9dc54a06a4609da37da40bb08d126f5535d81bff6b417b2ae/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", size = 752871 }, + { url = "https://files.pythonhosted.org/packages/7b/5e/efd033ab7199a0b2044dab3b9f7a4f6670e6a52c089de572e928d2873b06/PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", size = 757729 }, + { url = "https://files.pythonhosted.org/packages/03/5c/c4671451b2f1d76ebe352c0945d4cd13500adb5d05f5a51ee296d80152f7/PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", size = 748528 }, + { url = "https://files.pythonhosted.org/packages/73/9c/766e78d1efc0d1fca637a6b62cea1b4510a7fb93617eb805223294fef681/PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", size = 130286 }, + { url = "https://files.pythonhosted.org/packages/b3/34/65bb4b2d7908044963ebf614fe0fdb080773fc7030d7e39c8d3eddcd4257/PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", size = 144699 }, + { url = "https://files.pythonhosted.org/packages/bc/06/1b305bf6aa704343be85444c9d011f626c763abb40c0edc1cad13bfd7f86/PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", size = 178692 }, + { url = "https://files.pythonhosted.org/packages/84/02/404de95ced348b73dd84f70e15a41843d817ff8c1744516bf78358f2ffd2/PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", size = 165622 }, + { url = "https://files.pythonhosted.org/packages/c7/4c/4a2908632fc980da6d918b9de9c1d9d7d7e70b2672b1ad5166ed27841ef7/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef", size = 696937 }, + { url = "https://files.pythonhosted.org/packages/b4/33/720548182ffa8344418126017aa1d4ab4aeec9a2275f04ce3f3573d8ace8/PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", size = 724969 }, + { url = "https://files.pythonhosted.org/packages/4f/78/77b40157b6cb5f2d3d31a3d9b2efd1ba3505371f76730d267e8b32cf4b7f/PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", size = 712604 }, + { url = "https://files.pythonhosted.org/packages/2e/97/3e0e089ee85e840f4b15bfa00e4e63d84a3691ababbfea92d6f820ea6f21/PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", size = 126098 }, + { url = "https://files.pythonhosted.org/packages/2b/9f/fbade56564ad486809c27b322d0f7e6a89c01f6b4fe208402e90d4443a99/PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", size = 138675 }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + +[[package]] +name = "scikit-learn" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/59/44985a2bdc95c74e34fef3d10cb5d93ce13b0e2a7baefffe1b53853b502d/scikit_learn-1.5.2.tar.gz", hash = "sha256:b4237ed7b3fdd0a4882792e68ef2545d5baa50aca3bb45aa7df468138ad8f94d", size = 7001680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/91/609961972f694cb9520c4c3d201e377a26583e1eb83bc5a334c893729214/scikit_learn-1.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:03b6158efa3faaf1feea3faa884c840ebd61b6484167c711548fce208ea09445", size = 12088580 }, + { url = "https://files.pythonhosted.org/packages/cd/7a/19fe32c810c5ceddafcfda16276d98df299c8649e24e84d4f00df4a91e01/scikit_learn-1.5.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:1ff45e26928d3b4eb767a8f14a9a6efbf1cbff7c05d1fb0f95f211a89fd4f5de", size = 10975994 }, + { url = "https://files.pythonhosted.org/packages/4c/75/62e49f8a62bf3c60b0e64d0fce540578ee4f0e752765beb2e1dc7c6d6098/scikit_learn-1.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f763897fe92d0e903aa4847b0aec0e68cadfff77e8a0687cabd946c89d17e675", size = 12465782 }, + { url = "https://files.pythonhosted.org/packages/49/21/3723de321531c9745e40f1badafd821e029d346155b6c79704e0b7197552/scikit_learn-1.5.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8b0ccd4a902836493e026c03256e8b206656f91fbcc4fde28c57a5b752561f1", size = 13322034 }, + { url = "https://files.pythonhosted.org/packages/17/1c/ccdd103cfcc9435a18819856fbbe0c20b8fa60bfc3343580de4be13f0668/scikit_learn-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:6c16d84a0d45e4894832b3c4d0bf73050939e21b99b01b6fd59cbb0cf39163b6", size = 11015224 }, + { url = "https://files.pythonhosted.org/packages/a4/db/b485c1ac54ff3bd9e7e6b39d3cc6609c4c76a65f52ab0a7b22b6c3ab0e9d/scikit_learn-1.5.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f932a02c3f4956dfb981391ab24bda1dbd90fe3d628e4b42caef3e041c67707a", size = 12110344 }, + { url = "https://files.pythonhosted.org/packages/54/1a/7deb52fa23aebb855431ad659b3c6a2e1709ece582cb3a63d66905e735fe/scikit_learn-1.5.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3b923d119d65b7bd555c73be5423bf06c0105678ce7e1f558cb4b40b0a5502b1", size = 11033502 }, + { url = "https://files.pythonhosted.org/packages/a1/32/4a7a205b14c11225609b75b28402c196e4396ac754dab6a81971b811781c/scikit_learn-1.5.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f60021ec1574e56632be2a36b946f8143bf4e5e6af4a06d85281adc22938e0dd", size = 12085794 }, + { url = "https://files.pythonhosted.org/packages/c6/29/044048c5e911373827c0e1d3051321b9183b2a4f8d4e2f11c08fcff83f13/scikit_learn-1.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:394397841449853c2290a32050382edaec3da89e35b3e03d6cc966aebc6a8ae6", size = 12945797 }, + { url = "https://files.pythonhosted.org/packages/aa/ce/c0b912f2f31aeb1b756a6ba56bcd84dd1f8a148470526a48515a3f4d48cd/scikit_learn-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:57cc1786cfd6bd118220a92ede80270132aa353647684efa385a74244a41e3b1", size = 10985467 }, +] + +[[package]] +name = "scipy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/85/cdbf2c3c460fe5aae812917866392068a88d02f07de0fe31ce738734c477/scipy-1.12.0.tar.gz", hash = "sha256:4bf5abab8a36d20193c698b0f1fc282c1d083c94723902c447e5d2f1780936a3", size = 56811768 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/32/7915195ca4643508fe9730691eaed57b879646279572b10b02bdadf165c5/scipy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:408c68423f9de16cb9e602528be4ce0d6312b05001f3de61fe9ec8b1263cad08", size = 38908720 }, + { url = "https://files.pythonhosted.org/packages/21/d4/e6c57acc61e59cd46acca27af1f400094d5dee218e372cc604b8162b97cb/scipy-1.12.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:5adfad5dbf0163397beb4aca679187d24aec085343755fcdbdeb32b3679f254c", size = 31392892 }, + { url = "https://files.pythonhosted.org/packages/e3/c5/d40abc1a857c1c6519e1a4e096d6aee86861eddac019fb736b6af8a58d25/scipy-1.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3003652496f6e7c387b1cf63f4bb720951cfa18907e998ea551e6de51a04467", size = 34733860 }, + { url = "https://files.pythonhosted.org/packages/d4/b8/7169935f9a2ea9e274ad8c21d6133d492079e6ebc3fc69a915c2375616b0/scipy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b8066bce124ee5531d12a74b617d9ac0ea59245246410e19bca549656d9a40a", size = 38418720 }, + { url = "https://files.pythonhosted.org/packages/64/e7/4dbb779d09d1cb757ddbe42cae7c4fe8270497566bb902138d637b04d88c/scipy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8bee4993817e204d761dba10dbab0774ba5a8612e57e81319ea04d84945375ba", size = 38652247 }, + { url = "https://files.pythonhosted.org/packages/9a/25/5b30cb3efc9566f0ebeaeca1976150316353c17031ad7868ef46de5ab8dc/scipy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a24024d45ce9a675c1fb8494e8e5244efea1c7a09c60beb1eeb80373d0fecc70", size = 46162940 }, + { url = "https://files.pythonhosted.org/packages/0d/4a/b2b2cae0c5dfd46361245a67102886ed7188805bdf7044e36fe838bbcf26/scipy-1.12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e7e76cc48638228212c747ada851ef355c2bb5e7f939e10952bc504c11f4e372", size = 38911995 }, + { url = "https://files.pythonhosted.org/packages/71/ba/744bbdd65eb3fce1412dd4633fc425ad39e6b4068b5b158aee1cd3afeb54/scipy-1.12.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f7ce148dffcd64ade37b2df9315541f9adad6efcaa86866ee7dd5db0c8f041c3", size = 31433326 }, + { url = "https://files.pythonhosted.org/packages/db/fd/81feac476e1ae495b51b8c3636aee1f50a1c5ca2a3557f5b0043d4e2fb02/scipy-1.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c39f92041f490422924dfdb782527a4abddf4707616e07b021de33467f917bc", size = 34165749 }, + { url = "https://files.pythonhosted.org/packages/11/7d/850bfe9462fff393130519eb54f97d43ad9c280ec4297b4cb98b7c2e96cd/scipy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ebda398f86e56178c2fa94cad15bf457a218a54a35c2a7b4490b9f9cb2676c", size = 37790844 }, + { url = "https://files.pythonhosted.org/packages/7e/7f/504b7b3834d8c9229831c6c58a44943e29a34004eeb34c7ff150add4e001/scipy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:95e5c750d55cf518c398a8240571b0e0782c2d5a703250872f36eaf737751338", size = 38026369 }, + { url = "https://files.pythonhosted.org/packages/f3/31/91a2a3c5eb85d2bfa86d7c98f2df5d77dcdefb3d80ca9f9037ad04393acf/scipy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e646d8571804a304e1da01040d21577685ce8e2db08ac58e543eaca063453e1c", size = 45816713 }, +] + +[[package]] +name = "scooby" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/06/9a8600207fd72a29ee965e9a4c61b750cc3fa106768f14a7b3ee3e36cb61/scooby-0.11.2.tar.gz", hash = "sha256:0575c73636ec4c2587bea1f8a038798ddcb249e02067fae897dac3bf4f4e444d", size = 242928 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/bc/1173f502f1870e3bae81c148326c5cbcc19ec77df79a9aaf17a59911355c/scooby-0.11.2-py3-none-any.whl", hash = "sha256:f34c36bbee749b2c55816a080521f216d88304e635017e911c12249607d38c49", size = 20142 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638 }, +] + +[[package]] +name = "tide-pipeline" +source = { editable = "." } +dependencies = [ + { name = "defusedxml" }, + { name = "dipy" }, + { name = "matplotlib" }, + { name = "nibabel" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pyyaml" }, + { name = "scikit-learn" }, + { name = "scipy" }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-xdist" }, + { name = "types-pyyaml" }, +] +viz = [ + { name = "pyvista" }, + { name = "vtk" }, +] + +[package.metadata] +requires-dist = [ + { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, + { name = "defusedxml", specifier = "==0.7.1" }, + { name = "dipy", specifier = "==1.9.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = "==7.0.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = "==5.13.2" }, + { name = "matplotlib", specifier = "==3.8.3" }, + { name = "mypy", marker = "extra == 'dev'", specifier = "==1.8.0" }, + { name = "nibabel", specifier = "==5.2.1" }, + { name = "numpy", specifier = "==1.26.4" }, + { name = "pandas", specifier = "==2.2.3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.3" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = "==4.1.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.5.0" }, + { name = "pyvista", marker = "extra == 'viz'", specifier = "==0.43.3" }, + { name = "pyyaml", specifier = "==6.0.1" }, + { name = "scikit-learn", specifier = "==1.5.2" }, + { name = "scipy", specifier = "==1.12.0" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.12" }, + { name = "vtk", marker = "extra == 'viz'", specifier = "==9.3.0" }, +] +provides-extras = ["dev", "viz"] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "tqdm" +version = "4.68.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337 }, +] + +[[package]] +name = "trx-python" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deepdiff" }, + { name = "nibabel" }, + { name = "numpy" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/9d/b6724ee97692992046b83104b8d1dc52b80556044134b4c4fb5d719f47d5/trx_python-0.4.0.tar.gz", hash = "sha256:264e25d57dd98ead71a009a2e35a8143c98ff5b32fcabce7464a9d1a8c88195d", size = 120843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/bc/328fc1a42f39665635c901b031ae0ee1727d1cad6c7f897d9b64f3252755/trx_python-0.4.0-py3-none-any.whl", hash = "sha256:4927551d1144de1507a4664be427e79cd0c2830c4396b4d64dfeabb74becb408", size = 55870 }, +] + +[[package]] +name = "typer" +version = "0.26.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564 }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/48/b3bbe63a129a80911b60f57929c5b243af909bc1c9590917434bca61a4a3/types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062", size = 11974 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/df/aabb870a04254ceb8a406b0a4222c1b14f7fdf3d2d7633ba49364aca27f3/types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24", size = 14923 }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, +] + +[[package]] +name = "vtk" +version = "9.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/86/bcd5dc64141d90604bc5a9efcb6f0a2a11983e6c001a2d9d9e0bbb4fedcf/vtk-9.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a3cd59108b21f55b873a63878a0decec0a707bd960b59d5e15b37d1ad873590f", size = 76476999 }, + { url = "https://files.pythonhosted.org/packages/c2/45/ffb4e2fc07c12f772cfddeb095f0a9a72bad7097c7ab0f08a292fc474be1/vtk-9.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d2bdd2c60f0fa5d1926c11b72d96dc23caf9ff41781bae76e48edd09fb8aa03", size = 70111238 }, + { url = "https://files.pythonhosted.org/packages/00/cf/e827806a34efe69ce286ba842605535c40b45ea338f8315dd0e0986e89a9/vtk-9.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a02bf6067cae7abfd7f6b1330c69555b715be8ec71a3c8d6471af45a96e8e56", size = 91995090 }, + { url = "https://files.pythonhosted.org/packages/0a/a5/c5e380300715cab30166d5ec9bba2ccabbf41452d3b09e2d7c1bfcfc11d5/vtk-9.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:ff0eedcde5821c023623f70951f2499e9d59e709e288b67a2e2334abafacc322", size = 52420923 }, + { url = "https://files.pythonhosted.org/packages/13/f1/f36b1978f1bee2323303207043a1c4b99387cfd2d9a8b7b6b79f0262d8a4/vtk-9.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:94678fa0476e113500f3b99e9692b92b83a5b058caace7bac3b5f780b12b36ed", size = 76620745 }, + { url = "https://files.pythonhosted.org/packages/39/8d/d705ad84092cee806769084dca1cd143d296903854caaeb9164bbb0f595a/vtk-9.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:371b96afca3ed41a0bf1cd80a42f4b906ca2f470a13df32f39b22a9169d996d7", size = 70146262 }, + { url = "https://files.pythonhosted.org/packages/97/a6/91b79b1a4f90bcbd104613bc44a3038f6b63140554de211c71b8304d98a0/vtk-9.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cfa8d73acbab386b9d6ef8a1a01149fd096a21a23547f10bf0cf98d88300724", size = 92039877 }, + { url = "https://files.pythonhosted.org/packages/de/ca/0ba875f1924fc6b662c5c87a16d45604d3cc79fd1161bf8540b3096af941/vtk-9.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:75d27b73270a42923ebefd87a8522f7717618c36825b8058c4d3aa8e64d6145d", size = 52426002 }, +]