From 4f8cb0289e11ca33a83e9e8d354f2f9f6d50431c Mon Sep 17 00:00:00 2001 From: marcotag93 Date: Thu, 20 Aug 2026 17:04:53 +0200 Subject: [PATCH 1/3] feat: prepare TIDE v1.30.0 for public release --- .flake8 | 10 + .gitattributes | 133 ++ .github/workflows/ci.yml | 134 ++ .github/workflows/release.yml | 254 +++ .gitignore | 174 +++ CITATION.cff | 45 + README.md | 846 ++++++++++ SECURITY.md | 23 + config_template.yml | 140 ++ install.py | 490 ++++++ main.py | 40 + pyproject.toml | 164 ++ scripts/check_release_metadata.py | 91 ++ src/tide/__init__.py | 3 + src/tide/__main__.py | 10 + src/tide/assets/logo.ansi | 17 + src/tide/assets/logo.png | Bin 0 -> 491627 bytes src/tide/banner.py | 201 +++ src/tide/cli.py | 956 ++++++++++++ src/tide/console/__init__.py | 135 ++ src/tide/console/console_ui.py | 1045 +++++++++++++ src/tide/console/estimation_reporter.py | 149 ++ src/tide/console/ipc.py | 352 +++++ src/tide/console/renderer.py | 720 +++++++++ src/tide/console/styles.py | 328 ++++ src/tide/console/terminal.py | 468 ++++++ src/tide/console/worker_reporter.py | 318 ++++ src/tide/core/_reporting.py | 659 ++++++++ src/tide/core/geometry.py | 614 ++++++++ src/tide/core/io.py | 632 ++++++++ src/tide/core/physics.py | 664 ++++++++ src/tide/core/tractography.py | 477 ++++++ src/tide/interfaces/grid_visualization.py | 861 +++++++++++ src/tide/interfaces/sampling.py | 206 +++ src/tide/interfaces/simnibs_interface.py | 487 ++++++ src/tide/interfaces/stmpx.py | 190 +++ src/tide/interfaces/unified_estimation.py | 677 ++++++++ src/tide/interfaces/visualization.py | 120 ++ src/tide/interfaces/visualization_3d.py | 1378 +++++++++++++++++ src/tide/utils/artifacts.py | 395 +++++ src/tide/utils/config.py | 1042 +++++++++++++ src/tide/utils/logging.py | 151 ++ src/tide/utils/simnibs_env.py | 121 ++ src/tide/workflows/_grid_reporting.py | 823 ++++++++++ src/tide/workflows/_shared.py | 100 ++ src/tide/workflows/estimation.py | 1338 ++++++++++++++++ src/tide/workflows/grid_search.py | 1701 +++++++++++++++++++++ src/tide/workflows/standard.py | 559 +++++++ tests/__init__.py | 1 + tests/conftest.py | 233 +++ tests/test_cli.py | 891 +++++++++++ tests/test_critical_fixes.py | 1593 +++++++++++++++++++ tests/test_fixed_pose_cache.py | 374 +++++ tests/test_geometry.py | 366 +++++ tests/test_physics.py | 709 +++++++++ tests/test_refactoring_contracts.py | 807 ++++++++++ tests/test_tractography.py | 316 ++++ tests/test_unified_estimation.py | 313 ++++ uv.lock | 1161 ++++++++++++++ 59 files changed, 27205 insertions(+) create mode 100644 .flake8 create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 CITATION.cff create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 config_template.yml create mode 100644 install.py create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 scripts/check_release_metadata.py create mode 100644 src/tide/__init__.py create mode 100644 src/tide/__main__.py create mode 100644 src/tide/assets/logo.ansi create mode 100644 src/tide/assets/logo.png create mode 100644 src/tide/banner.py create mode 100644 src/tide/cli.py create mode 100644 src/tide/console/__init__.py create mode 100644 src/tide/console/console_ui.py create mode 100644 src/tide/console/estimation_reporter.py create mode 100644 src/tide/console/ipc.py create mode 100644 src/tide/console/renderer.py create mode 100644 src/tide/console/styles.py create mode 100644 src/tide/console/terminal.py create mode 100644 src/tide/console/worker_reporter.py create mode 100644 src/tide/core/_reporting.py create mode 100644 src/tide/core/geometry.py create mode 100644 src/tide/core/io.py create mode 100644 src/tide/core/physics.py create mode 100644 src/tide/core/tractography.py create mode 100644 src/tide/interfaces/grid_visualization.py create mode 100644 src/tide/interfaces/sampling.py create mode 100644 src/tide/interfaces/simnibs_interface.py create mode 100644 src/tide/interfaces/stmpx.py create mode 100644 src/tide/interfaces/unified_estimation.py create mode 100644 src/tide/interfaces/visualization.py create mode 100644 src/tide/interfaces/visualization_3d.py create mode 100644 src/tide/utils/artifacts.py create mode 100644 src/tide/utils/config.py create mode 100644 src/tide/utils/logging.py create mode 100644 src/tide/utils/simnibs_env.py create mode 100644 src/tide/workflows/_grid_reporting.py create mode 100644 src/tide/workflows/_shared.py create mode 100644 src/tide/workflows/estimation.py create mode 100644 src/tide/workflows/grid_search.py create mode 100644 src/tide/workflows/standard.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_critical_fixes.py create mode 100644 tests/test_fixed_pose_cache.py create mode 100644 tests/test_geometry.py create mode 100644 tests/test_physics.py create mode 100644 tests/test_refactoring_contracts.py create mode 100644 tests/test_tractography.py create mode 100644 tests/test_unified_estimation.py create mode 100644 uv.lock 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 0000000000000000000000000000000000000000..956eeee8cce8c1dd02a537a2fd2f0d27d72b9021 GIT binary patch literal 491627 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 z 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
sn1t%h=#vU$slxCve&2KM$pYl2m-9Z}20n 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

xo_Pc3Cr8ucZ=jC|So zfK^p*`7-SV+6! z2?s2LzXsVBAP1A|*3#W{kZH~HqxTR^^pb98MA}CW^0B5;vbk{PS!uJ1QwkX~#kP+L zbej^qRO^MCR>4KY)+HLqyx2ky>4E=f0+I9ypi=n7cwjyb&%y{EysFWEr&iIO(G4*d z7abWWG6@x+(39Xy?EDICL(?Cj#f{L*R(V+CimXnzpB#tuo7Gf=JD|t7qvva4WZ#z` zoX`_|Ps(G(vi_r930oGN!z5$ZasTZmI zQU35!KNbEhzJQTh2L6c4l}ejO z`$xdzCWnFk&X1|T@?EN|(;&=@;oHdz$T^~jGn2SN-Ry!lZcIWbHY`j5r;qkHjsW6o z-ZG*(`YXmmO`NX>NN95ij^}SPXVjJ}%Eo{3*l`=aS5{#5o*Rm^!f|F>0-vOnYt-Of4+d58R`1SxC@dni0)K_z3e6;*1Q$aDfn`{1hLeEW2dV{twi~%ep z*!$*yT<}7=8|r6HDr9|`DA^i{A62YhSI)DmG`m;ot*T8PYz69lp)v@3suTBfBRT0e z_U%RSHjN#)#Oyb6gPXQ%BBpV<<*AHw?@rx6*2ie#h(GX$>4^cd!E|_h#HIJ=UGg*!r?hpB zv*nhOsuibDWF`ohki^eDXeUxj6_7~royY13p=m#kIak&qmIH&v(ICv?1`B2^b~rJ6 zm)G58(v>$d911GMQbckv3LurQk;-L$G|MVLo{bD`5HO3w-1~au)aM+^S6zCVVHG{I zL7x`u?FlTniehRC>a0UZVFSyK;Uxvu`UMIdTY5zd2lX!qlMtgj9i}n6JN_XhhKD5% ztM$nTp|M6f!deG^MDcRZ`UXL2gL0%^(vm%So_?wrwigf~M3o~O*!E{!Ar2c3g9c}N zxOu|1xiJs)W&Q=|sj1IAybh3o|=TfxKks>S;IZs#jau)?V0c zn-Z5R3nRt~%EqImhgcnj8rIVL|f* zW{R7Ty_+f7M;;P?M$f&t2?jjYJfnPS&4zWsYsbTQ$j?_}_h2m-h?=KFh&I6D{YmmK zHu`<-*G2YU6lUWITgnViJUvL<-JKddrz2Ay<`jKCp7{c67WiYfv$#GQnzi@^{ zLQu-=XhLgwK_odcLUkE4&9^ta3+zp%CgshweL~3aZHHr7O+1u=1SRapF_;j;E{PNY z^Q6~`<(9%(yhfZed|w?OS!LW6n+X1Lh*YOh6POeBV&%h=KZN^D_RXcRPg!Vb_7n7K z*G?qx$6G8r(z`nx$gaH&?f$ZaGM~*^xtj|A1_O=X@TcRWl56TPNQ6O3ARbSlD2jHr z&~RXEdFg6F>#~$H_2fDa-vwX3a=D62zxlZ7$KwhWE`mNjrEyVdO4^liN>Cmr8ER~C zx;q}VhCyOY{q18Mvib~3KIWXmBKzc4j!b&Lvsqi5z0$E}p01t~bP-cZ;Dv5ItdD|L zq&=moDs6+IV2k;SP{^N8zPF>T)gBiteDy+>0}D#I&xAH!N@UswLhUwvbs$A1>n&wWN3kz~<4qc}BZpd*K8qsXBKRj4!t3SM7 znq_@KHHn{=4MzA!l;QY^XsA0iVZjzS{D@B zCq_OKpfiw|)`ftMgD^q&aS_m(5hfjD10zoL3nu88WCAz$9- zV*-}B-h>mVJK}T{{9YGam+4=MJtNxHO%XHe79`x9a z_ZFk8QUUu>U|~za2zI1A^6!2w|2Hf4Ekb|%f~>Z2b6xiFP{rK_qAR8EQe66C$dE(A zlo~{5En@W>9~RL+f#Yk`06#@<)8Dzhrt4F7*57s8KhCt3Mq0l2YSRX2gdB13;VCxd zujCMmCbFeJf9!igs;Uw}(CV$%V}Dmr>XJHEkGoTX_8+^WCiEseO@*J`zr;u%Zm>Lk zh)HxNIp@AT=Ux}a`DMZGiexlAk}~l6EXStX4#?Y(_?uHd{@6@TM( zL;w9gj&e~qd$iZ54dg+1?wg@$8fgABXRBP0#$c0pQ3Du7k{*^e9U>2Bw17~nHhuak zmzb4ykFzQ!BCYKh8YUeHBj>4A27)v)!T&yGYr4fDGSw*)?V zhU?B&?w9?^4j)d;WRkcD&_-EeO{r;EhIC)zhl*8TsGbM$66j6>3-AXMpqQhJ? zvfLVW=a4m%{URJ#*iM24Aw!6ydJ>HvI5I}<=XXxRM37VxYRUrCBJt7&NCh0Wb1*Ec zEkDG&q>I99%49UUvo7e~|3IQHXhg6|@CbsVL%I?aa(dse(&-Uf~&-OiQu37)j z_NDvxl2tWb%rAlqzFQ8GgM8k=Tq2bQuvAX#R)9uO*3b*j=8dfS<0qubs2AR09~2x4 zX=*A_hjXZg@UxOCu{I;&iw`qek8GSubCsbqkD+3$BxL3@iVzVmP=>$JkjIAN6UR?l zvZxv02u8VdAj>9(HL}R;KDoI(v^Yv)9m(;XT)}tL-Q4wI6F1Ugo-!V6BV#`m*3|kx z1~N4aico?n0{xv)lE&28oGT{{sL1Lh1R{r$hN#^crv+EMsa~v+nF_2kRMEytuq9Ac z*isgc3iZB^w@08Z<$2w4q_MTA)>M^#=(Y2YvF25liV8~CTX&`bIjh(LCc{bb9hE72 z?{YsQMBlqdrC+crtjX!(%G#xY_ zIBbrl4{)!eFQm{VRt>OP{()5;%Zjk8FpzK;A1Pf|_E7jGR%lMvl1*}zg!-^^aq)_+ zIS-NV3xm!XUSeZ$>fmfqqIq)-MT_la6jb;XS!SAzX|4ZmsyuzJpCeCxA(|3)6oi1K zrkITpY_El!!Qw@|pzA?p3X8qWtUh9?p6JL(!ZG|+h1_JzIh-__-~IhAnU$z9R$0Iq zelBj=VrdRWULmoWPtkX@%)xrz5YApJ#Mh)7tX*Q5`!-EtRtEGKhgHU>L>W zamdC~6iU0tsM1xe?jdFu*B`Pr_0U1ePyuk*bJqFY(!|0Zrw-+tE#D=3e!PG{D0p3p zLQ6KwNXbl`Y>7h3A*j84Cf4%W8)YgxTQ_Y0T?~~O zW#ANLV|d`@WgzKhKny0_S$05lp9Dk*Ea>(%BlhQI7Q?pX(m+CS;Dz6=7{qOWER_E* zce>x}w`GEzKXi)hmlnL35<$VrCGXjUcdjTq)U~7m9jO{CW9w)Ai{_%Az0U4BgvhPcp;rx<&zL za#fg64TPk4{EjSSV2*}`4U{G+s>NWwVX&bG@Jlc8*x3$M#5pfX14TfoKxUQDuM+(*@FWA@c*R`EZe_qF z435^<3EqsE2bR0C1Nuz@F_8nS`1dKMYKcQgPXg~7W49=5r9?J3CTlf|(tGD@{@|k<7 zoJtYD{-GT%N477(*=22C63>OIFp z){LSZ)_HKLR9IE)r#7 zBk_V$5RdHYoi42jL2h4+a=w~DDX+ZHxk#5;Fr?KTFzEhDSJeyPlj_-3A9*eK(55o_ zOc{7K#shu6c)*IMZc2T= z-4%IcH6M`o&Wu%6V^}2ZrAy)@wM5)B>-9UWy26jLRm%OqgC5}-*(NBeGS8RFpxoi$ zG>YBlH`baFi?tHxv^MA#@ww2`_IrCs=6Sct8Y`!-Qv9C~W&S5b|K3R~=l(Nxd_&P6 zS!{gvO3qr}_rbF`o=w=)7RssJ_BDXx1PmF&!p_1UUFd^t~uV>|Qew=9oqYIG^IeZ(K*WcFR#jmLg?&lb; zr%PwgeQD-O_V_g!*G^&!$`W;Rrt%d>)3?}kW4?4L_ey?H)RI4viZbkpcB`n?WtY&q z;*St9KtW|YQA8m605{~i`#_!3W`Bnfc*#KfXsMi`LTA|*#I=`+k6b=$@J}NLSP&jA z@g?rRMAF-udKy+EQ2Ecvm`;?ASgB|*rgF;C_aQjpY&|v)lA%G7@P!lZ=Ln&J2L4FA z+w7m1Z#mQr{7Dpurc0jqNhn&5q7Q|{M zEjsp4?CJ^?yc5T$A7W_iNszJy{>h0zri(2ZjI)O%G6QSQDm@Pm@M=}^1I~-EkqA2c zG(gc|BgLif{as*sGF5)xg%i$DCHl)KJ=?|6_2}1>+8C2O2XO%0KphMmMe$bhWMyxB)t68U$iCMNp4_BArqWhq6A8A2 z*;7DbHZX2?Hj@}^}owi5cK zz}U~};=Tlq&-gOq^Z^Nw_A3KqqAqt|(+mh!d_E*jvGWEA`)S-%xLYY6sYL5vRHYE`|B5+y7FY~?_WQWwhLdfHgAhMeyW zB})C1BwmmwVCPP@LXUX=a=A=C*G9cc8@?zJxi_4 z{)L&cB%p}5_xB1`QrlJ~XI%S)L`n{=+75u@pbO`-TM9hJAH)1u~{5zyWM zP?dH-0u1(PsH@v1R~iqzvJz-K@AOQwY-RPMjC8aD#{O<*L9IL5=!9Em6c=F4Ys(R) zz(+_7PJy5XBhvp|?pIF%<7l%Q(LOiZv6bX115((N{zT@LLcx<4I1M+jcI@+0X)q^f zlHC917_%gELO!Hz&ohDPEgIZj?)n~ zBUrJf&s((EG(z{rw$Xt!9fy_85{A?3+_|2n+RlTV)*<+}>u`YJcLDRflAOq?7--pJ zkQ>iePlbu=AU_Dj{07_U{ca)}7>M7iC}E0M*xW(@Aw_}|Yp@KCeHwN{df!}+Lf@8= zqj#~n64eoBA1PlQuU>4CUbLhBvFvOLK$K5&h?uXB`W&sSg1M9p#oJ9jK7nDH<2{1y znoLVIE}4&bRePb*B}Z^L^*f6GdI%n8GQ*#+stwZVm{U0?CCAyFOJA!|tlW{Ps2gSR z&h5c3XwlUg-PEKn*&8mF%NqkH4e(m(o%HZcK1wMbn2%WmdCYGR*V^t&sS(1b-fb_`H49sRc;W~|xmK~A=|XtfWvBatk45G=7SLJF++S@a?KUG17*(Ok z1XfjOhI!6mfKuWhPPwtaP`pgr(5VP$k1e?M`f>r%OqptssfD{2;@^tpMuV@od6_iBQPXRnoQmyMVPK);Qex}0o)~)dipi_dlma1Valy+aR z0>^F?e$Ov|G1euJK+{+pzsV|4%B)J&_E8S=5}9h5l};MxvFvT^@E-T$>%Ow%J#8<4 z)&IXlfAkhSIL3;;#%nVBUnt>};?iKH?QzfT;l1<2`@*NFz7)!Kkit4!+Z*|jJW{NT zl1IvTs7Xpm`LT=F8!b80m#tF6gR77wcIy1kG3Dl>iDol(oIr6)DThLwR>Wsk9C`he z#5tgpz*fZ@so`iP@h`e)7Tck|;rUr*T8C+Cn$~;LAzOW#1XstzyBr%f1^X~!)W~Xx zeBHdHH_6L9`m665%~I+4b0BvZA~YXGe0atCfyry1juQm^Z(~&1H$bKs3XpkY?7lqs z#E3#GcvXYQ&!L>qv<2Rkq{WsDWVeXHntu|=!w*-aDg&TFsXjVWnc|n~)L60_q}US< z{LSfN;4i6v`TiFTqh-VLOeN|sJa;G{Qs~R&4%Td`OGdjhOzB($u4B9U z9wJE4n1+Jy!Ub#{k_8L8^0Wwyob^hOSC($5svfa~Jh~45C()GZZ(9;a$9r83$k1+y z1vu(IO??r|m?ac!II9gO7`)#`=8MI{pJcYyfOPBn*XG>Y-ysNh``P2=S+cqO)kzw< zzQ^|p91cQtJ|X*Ft=Tao+Xu=b_RBLd-v9?NA=L*U)ie2G2$GVGJ{>?BQc6J2M5R@R zwxeI_*|rm31k?6dMja=QBnr;5B>0?vt+j2_@YP( zTmH9YBg|P#baCznRxqUdaLVpy0*ypt46^)`|DOd|zL95?W^vM|lNG))zU|M%yGUUq zE0g9Nlk8RIx~i@t^@399oG&N1Ge`<<)Iqg@Od`z0?P5F53P>?kg>Lzjh1QHPUdm3j zjhoQ~&0Ve;)=>;09DS%j6-Pg#_6&Ld8uz|vMZ9S=iI^I-Ruq+4tzvoRnOPpXFAfy! z^(rsKB&kp}X2K)2qdpli&k2k>Yp_YHT7&Ui15Q5f# z#VU)hkM$%A!c2oiFN#lnO0D<7t_cN7;uqP`5QKHM>;bmTR-HQK34o2Z${qu`EN70fub^Z$r(n5O8#@6b+*dKm8POo=r zviJNyp3W(-uC9&N*|FKiwy|R;O`0@pY};1D9UG0+pkZU%wrw=Fot)k8Kj&Ppt93Eg zT<>_tcm|x5KHUyp*GyuFyWqFuAaTqL9`U3crBeT!WTMzgU2ihj->~#1Cvi+)w<09a zj9QF|L9R!C^nUj1kU8V3($&4?x%3ZJvm-E69qgi2{g{;R18`PD2ocJ74QTF-2YSOc zy!oHP8&hev4_LBYv4@vEdrhD#j-_o-2+D+=)!0IEtQ2rZC;9#XY|?jxS}BN@D~ahB zM08jY)l0*C78cohf)|kN`o78&zIng)fSfbNnt=<oUcR|SCZt44Vp<%n5#(tsps*t2C1<#wt`qs zF3E-e(y?07jgzi`tbU~|)b9L>l6c>+GAe=6@mnt7xmyY@{^?5MzBPl8$uuJ}d&u+C zXMdz+LzcX)=?4~S2ujWuw~}<1%vGRTnY=W|t`aI1IemZ<_bf(Ljd1bK*j|xQSNVv* zpP}3!NOwvhPeB5tL;9FtvGMI*JpL!G{Rm#Ej6zw0tll@4jtCP2dh+ZW6AlBqlYr8Z z-_bHWfYFQ~!?ae31H0*VZSzXwudFM2v15EHnJjp?7WoME`H+$!%fd@HKb61sR&FON zf(r027TAJ?`V_jjOo^?^u_yF+a5nX7wQy(WIWQfxG(6J*+4VB)QV0;zewH_sFQi8b zZ>fR|pIj8dTGG3Ar6Z+Rf)0)7m*iaa?l9$UAC|TtN&Ss5wfdOC+Y8#CFyWCGmV){% zfkhYr`|x08G~h(NLYygIsmgPI1Qoz;*50gl4r~9AYiJ^$Wyq`b>C2Akc<(n^44=9{ zdSX9gu*7dh#OI?t75_ej#^F`o{zP1uh2w#)LGk2aG9-8q^!2-*-&KJ%4#bP})ODk# z?Z{*@*izs?DY1n{DJY03rr+@AznuviBuT;9-s*kqYwL&S!DOf4r`usmk3_*xt6hTL zA=dMi=Wgw43ancUSu5n-y5O73@lrK&WEsbXqz8P#VLNO|x8E#L$zwp#<`%6eGytQf zQJ<19cmFC$dn~uxg@}@}mKhcdL*x|++L{37%dx|%OL6{~yxy4m?R(V1){IK3o>T~c zJlMAXgAK6fF@vB#k2bZr80+sT4EubHds>>Og|O9|@PVP0J0BtadnkT{v!NzPXqH*! zk8KvA6+1kN9pYsX6#`G8QMn5qnJ7O3+l+ zS;KU5?Zg(b1CQgT^`1cWpmj3KOoqeSKevFLsWpBOv3Jt%i_@h9;#HUKg+t%UO!zM5 zhd<;;fEA^OaJwCo+xtXVKb?;1z7gy6K|%;Oa1vg`xtDG+F7J{(jAP z2=LANohf;0wX2NpP(gkQ`r)-W(5lrx!EBKG!P6S4 zhqELEvrOLiY{;+@4fY(_&9_1r77Xq0Bi-6bJn62_G_kv1NB*`Hx*DNP8|)# z>>DI2u|wvTO$#gwH=9*-&JJ?pOaL-VQcA|rPd?$1?B97mD>J+~>_Us4(l_K7LRf)@ z2qiv+eyzNEv?PQ#*%Z6~<%ppFAwcYX^jSO`Xdq(ZQY&_X=$wD7gGp6>1gUb+X~z0(+Q-;#`9lx56>HHI)tK9 z4!Qa{f7$7cue=M&MU5+j-OEV(TdV4rRrx|i_}?LCj(YRU>hY^MAaYPNRzNmazGGxR z%R{Y5l0R?iuQ9ZuGDR8YQq}jgA`Qd4o!~k_y}nKQ`4ho~PF2iJ&v;2!-ig=o;gLW# zN(D&YkaqZttMd44=s=$ts=#YmAAQnBq=YMJ1xT=mjK8ucaqRon>68EsPoI=oyJsI# zFTgxT9SlP8Bt)TrSdukwf{il>!vbnp6OJ+ZfmMN*<(HP%2`}nVfn!Q2#e?3?w)d7! zL7x@Hk8ZkpMl^FTW?|)jw@|w=d z_(b{#9plda9ik*YjZrh14Ve-r}4D{!-nGP z$+ot|cm7S8szB!I@CyU5Wlsi=CoSokJ5Qa5xOQeq%);@ZH`bPDDgp6bWY`iaFqMl3=1z;mDv6$6 zp44>l@A{SG2)ApDRfVZbIgf8d*U#`H+RLFt#uM&u8)uep9EO{RGZ!|5f6WAC7eZET z2?>QjP;d|12FBkYWa#o-iRf4)b4G?WjV2-l8)SS_(q&`Z%9jgeq|>67_u{btF>)kT z`RlOU(N|G@GS(hI|2rv;lY9*hFXrS)UB;GV%vu$8rLQ6!*|nkk_k%Aw7<#y-gGLZ+ z+dKt1BU;*l$zaeD&w}j+`0habOqk zK<*`!XqeJ4-~Rl;u%0WJbLnTcfOT8f&!yD56D4LcGEfqd_u+*!Q-o(Z?n2YnsNebI z{q!4Y`a98RRdiQU{6rhFb_P10MDiNl>d2jrPCVABhdY{ETO*w1yyV})-`b4vNRWCC z`m7amPaEBAqO;hcHy{a>U${lYe&qXd*>u(kRl@c6O#Uv;oxD81rAG>MVVeEDH;($JJ#v z8oYEOa?!5h51vJyO*9{j2fG6wTlxLSsID2|XdG6p{YFK^)9w4&tQ6s9F}C&nZm7rZ z384+OlLF1vH0~`<%rsUii$bpAl-q;s2vNX}K?PT#*c zY~HYJfz_mle7}gq$Q#YsZ=X|rM+b7=+gx*|uM+wyDRWOyNcuPrvTm+ai?V%RT?Uu^ ziknqC^!_IkPbx!8cU|AiX`5>Q6QCaFD5_Z2xzE7)pfnyA9#r2>NF{@|hgxHgauNQC{|O=Mb} z+aZ}Gk6tJMPRZ5wY9i*i!Jufyq{4LC9Lj_`=IavU`wJ%H%O>}MqBqxf;FPbSA`UXEB6l&?G zN%_AK!*wnC%ATqEQt!+nDeugSSY}2xmhK~OSc`cJC9nEXs9#P=_`=#>ga_UO;R?zG zPza~xUA@0cE13o%7U!);Aq=t|aUrXy4c-|jr$6EV)DuX}Ap%eljLkulW15*DF-#eq zw;@qZ*a=NH`a;!b=)^0R<;tDCKTn**aa_~9Qzt%oj|yppI_{Qvtmc$v6^WsZ>xf!< zFv33Il9)PcwD=A{`#bGc1rGRiSNaWtP2;q@epadrkmzAXl$LGc_k^>XZ)-2=L<3q7 zi^v4Z2Wep5fgLTYn$YYv5Dn^tG?=T5_d5`$hz847)*ddi){o1xCrlv)hIy+uqSX`d zd_N5}ZMhsr+ISyPU3odfc#yY>(1gO&-8zG-gu<{*F~W{9;^sg>)8r}!nxst;!TpPr1)6(eNW-O>Rdm&X`B_#SLnfh`6cKMZqb-3Z@+-rNc3UlzTy=M=-9)s)GTYUwIJuR_zy6Mt>C`}7$RoKe9 z$HOPNVU;Em#}XiDNKgRLYOcM&4)MiVa#C|W0(%>R+Co{R`LgX5ajFE6Bd2wOmv>3m zv)NSWYPKi-_l^!2DIXD?9%r-WXSaY`a&dCcp%D=}3)J1H=Gx1_MV*54XyeLAQ~Pm( z-l{pjGg6F=lxk!)DGUXs|iW5NA`_@j@puDk4ow5tRt$U|#c zr;I6iHEB&7D}_7P-*nkc55CNHA*5l6O&u~}*!*`h|1%x&{M+?FyUQ=v{oC~jy};*N z;@d>8s=r<)XRp86C6dn+@KO8MX+rh?{YreAfcselVl=Omx#J_6!12>B>AaXjI#(#E zA9!0D5a}o-VXT6ssDf~<5ACswZk^#xJ@L|#vZ}>`Lz~eE;H19K2!qd+*8mhy^=HZR z6C++S?CbH~7h5BJlb}@SQCN<%cZHpY5tWdfRGEG3@X(IfKX-t!EKVf$LfOCo+RqFj zIU%5Qt-&ZpJpSBKOk*Z)1Ddb9R1t1K9yLfyP#Lqj(_o+^7uqV#-8%G*%a`(HZNwm# zt{F#|jG`rBU{E`pQ2YmHFKVX9(J56HzMpqCD^PRCOlPouz_d(Gwg#Hf7&A>A$o>A4 z*EBUCnE>I#uIf8v;jvHnp=!L`esI@oH=ahd1;Q)*?{#zOUX;9k^7P zvj^vKeK+28CIwz(hL~b6j*V*qk{O+gg5~vF!JVXL+pFqj5~??e`b!N&jl$2ke0RQe zgg{ILEcz-MDB90*jgddiWD6pk<kJwOFCV zWb;(C~46Nki9dhV-C>iPb5DLkHDR=@3lqQ-B^{jV0e1^ zqp6M8_&tSzrX}+37>x7PuTu3qW~%KkH{%d2{m+X~c?Lj}1@Bto^dH`|q_vCB5(727 z224QpNC=OPcX|YjbXcc#{xHP%r*p6Br$C=W6>BBxiD})n6ghrVo1#PxQd&W9se~XB zP9+cCT``?A>)!jUshS!G20MN-Op3=F$Vlh!xUa=MxJF~pm#gZ*uD+_u|;q3@Phhx}BlK2(iTPwnuK>e7jCU%M!3Z z*PnE=TBR1c7;*nM94km{4aIa7WeyN8bL%(!mz4oR3KDnx`UOAvwmt4H&|ZYqJckXe zsKJ{ZrEjhq9e8X3FoJ7d&Gz)yZ#l;*pIXGFlw|twAQEUaG@W=PQZAQMJl(1<5Hy~i zE4VRK>yKg#2Q|+JBZ6~DJZPF`a71VI3^XhEqv15}f!aSqpAZ_35^!@vjlAV5$Npdx;wK&?aA*Pd$sNsS5tQfBP^;OmaP*GIL;RBPFB1Dj~&G@bqx!p z`J-ALxo1}s#bdY|s4-T%$9m;TStKD0{7M7;n%=lh^ZxuyRBlClW#{WE6o%ij=u+|?9DjR&z<@CTyCa#q zeikeUup){XHud+yIjmeoUe(X1>=t*4aoXh3&&mW!VQ(44TEE+Mp;z36Uoa&6TI4dK zw2T~<4}I(ZksBJt8h!Uz4UkD2{6n#~A(a<>RPUQvZ39yhLt`V;ZI$rN5&|1HxsYYt zo5CS)p~JiFv*@wjt}nUqZ?LllMFbp{K|OYw{q>^f`T43#rBCTC&YqH&(u9q17G1QK z4z#D0UJal66=iN3RA4EC(Nw!i`(~Fkw7N@Ls!^rWV!GzgKSS;1#I%dad{P`ZL}s8l zc=la5kcQhf7ZE`tYuvc~{*!FMm_VIB zxASDb-&txDK2A&iMwuhml;)bUmw#@L5;e;b3OIl1(fWBu_~Vuf*^857=-m7xhD@yM z;uFy-2j(B7^i%JTfqEV{X`=b6;1Xsy^^-Eddz4p;q3wQXu$A1rR-Gs5RnGW(o9%${ z^L$pVnJ!nnD4w$xRR0ggBsUiSl|udAh1k%myzCE5xlZ~h&nhEPk+WCL?u}8VuK3lm z??f`fM+&8!ybpa?`1-Af0g(s1NjTKPA0yxY!QcL!U9c}I6-b;0pw<4rhF~LTw}PYV z52u$&u5*05=5lFOml|XO*=FJc{BQSxyd?C)IT*=un7@g+E^)vKuPbG^AV;ZAN9Fy( zj@i_PINSUYmq_7jpPXdnAfC(1^Y{P21AU*-rc>><^496m=11LX3?nBVAejcGJ!;^t zqjINEa=ry%ennKlKR9qm%QbHHin&we9W7^=AK%ELvR@dDUqBzFI3}bi_5$q7X9x4n znnxI-8cHVzOJf!dhC`x#(jJzF_quEJmcL=pm|BQDQ({Cpf&5N+24wCmBnsKim7b2) z^oLyafq|6HM#m^3`E@h&iQ8L2!=U=& zcapfY`GoQ?FDLU4xoN&y2rj@GYQjJ5rtFBG4@odGL1_h(dmm7MrWXDp?Lddda0G}SrixQmh`}~{7 z+4{paGVLrbkgcCXA_av$Mu}US1@4>b5pa+WsWOm9Tz?6?FaKWI4hz*&WfSX!Ap8kr zzbS)b!0zwG42-9A{7zJd2`5z^L$kRQrA@3K>M+I1=#0YeTlv(6Hz*))#CwR~Y_@x% zu16+z9rHrcb^jnF(p#Ccd;C{_0o+kEcjHtBh5ypsWNp{1^)NMrbQrIsx!Bwh%H(lI z&CGistK+?Z!wSPz;uwX92HB)HwXBH>a4IZZ`K}_4A{GZBaG*TsdU@P1{^)TC(biFt z80h?7wGQossatbQjp_|>->GnN!vy|X66JJs7zn2^)+#`yiC}NIG8w4zDOM6NV0#HR zL?6MY-kVsN8-i>YNQXPV$fLqC`Gr1iMnY5> ze|B80JrVEyoEVTZjro9_CsppxALC_AxvRXNyWWF$^G^rUCj%8sSB;GYxZpLX@!#|D zdJ50%R^=qvJUh#Z%m|=WBMb9qD(bxIy^Y*O))212!LMLc*?N8k7sy~W3O};0)Qbj* zQt_Lx+NRX6mQ0bd3*=lO@43UCJienW<_aTxos4&8p1xv^S@h0ywZ_YEt!(nc-qcTo ztQoB9D|R5AKKT)1Pqy;S>?m$E)UV@2ed+pLFQ}f6d_=&b;}oHcoRxxKc(F!$XItD3 z^TlTkKfU*S&6D%#eWkKO@6)XyFzrdVqw#-hB}u1$1M|1@Up2P>g>B@?!M1gh^ET(% zhBucI6%f@2jTDITTR)c9ysXe=yydRMPl)9AOVQWi`o*oeCrOIKI&tN2gP+slBe?2g zrd|-q*Io4CWegQnB&Zj?46ED}w@2s|siq;832^jqu44$DKgKB)LJ^O1A$m9OU44aD z%WuvU$9d=O2CY7}eJd94Y`AbWrIq;T$|zxkWdA%EpGRq?I?u`8=8~hfL52;F?}3-P zqy;htXB|Vowpz8P*t@F}h60jR2Lvdglv1<_JYqi(BbB5JUE}RUq;?S*mz9e_Qtz?P z8H`1d@Onzts#c<^_u?wkNOPUgEP`n80)~DFTHm>^+_3mJf56%M+yYkSR*>K;*xA0L zj}j^kdGw%a-E$7S_$gM6h;q)pYhE=1j(;zz37o$YGS(+GsoCgeNg~SX|O9M@I1T@YvqSo)DgYhZcrdmld5 z4avZYLo@^sXy%d|!X%aUaVdFga(#mpxE`SfFoK(I$Th47!d>OSUMIT%(ulogqy93y z-|I_~100LCUX?u12A0oeZSR~9i>}3g*5_4oIHqj&)fX1Pe;rS$dF$Qnh#|iq8U=1y zeQS4_F5`Iq68B!^5zvNO?RR|{OdWGO+60$g37wgEAGOs^k#K%}{xW=VD-<@CnrLjH zk$%9hjA_@YA02t6Y+ft$2@U%!79sNl!WO?m6}HN8a`o}5{ej{X3_MW%IvO7uE`6dy z#Qsjp6_22FU))4ICEKME{o? zD8%sKmP`4F_3~>!+H>EK%+QjSwG-?YKVb$uxa?R}WmkYDq1tX8)fTV8861^V6sJLeT=CgdSWOF3Xq|B z_eZ1JgDzl6@i|$EGb;rVn?ehm)CG-l=~fFXLnG37vc`=w=zft#fZOQgF@ItFa7Fj> zL9A;#VYYd*$Oi6QSvci)A*%;EmY`UTrOhZ7P?n%7!KbSlw*+xld8~YT1!H+5O@fUy z+@O*Z09L5(h@(x^sLb+R;kdEldPLvtmqSQ|@AFv?o6e6v%XJLYjjoa4sk=bB^`a&d z*P`nSdFR=%Bs7mDN`Oq_1f)L?kK=z#{J`YuMHea{ zP8ZW~nB!P;n711H1q4aYVY5?M;W6yV&cn4|Vr?Xh*#nab1k~_K&fX7wY&M#q6t{Z* z&^G86%8hmGw^;3e-8qc+z0-wUNMKrusP;Jb&SbsNaM;H!y1X*M7}HRDGtNilP*$&m zg5_bO2AYu!Ein1yE!T)f>##8lOUmGoTILhiDYvw{4`*c*hH0!^-0fDFFK%x{+tY?n z0hsXa+woq!hl}hitTk|x{am`g7Ay9~`;IY|FOClnByB*kv;KgLGW629H)33YQfmFTd+m1ymJf8nt1@x}O_I2~pY4|WRaPbGyKB~u^mwz0S!4!J>nys#o->~$ zD$5Odu_0_LHI0EZ<-_YIe1$r#JXuZwxn_{Z!X|J)NS+o4js1%zi{`9v2Csg?%`jeh z(6-!h@gBwjNu0ex4pT~mNwnhaf>9T~Pga?H4;CsgRr#A-R8q2E17o;dnV3>8^gg3v z;zVPTAXX8c@aB1J@$8G1Wc>csV$rG^^{D!KIMG_=Wg$MSaX13Ed}5;52r)V)Ko?Fp zc=N+2IT)+hRq}q9p;Ybt`uAss_e$QF+H;4+gKOu1-+)96o_KNv$Ye<+`$Sv1f4}fy zVc;OyDv7Vf?Ch#rc$Uh*?KCxI;ZIqC#K=Z^D&qheEl0&5K;LBu+SVl4&q6bnPVJW$ zqFMQsTjsS`n@YA#Tqh$K{lIH!6~AXxdsAdo2K}kTKqFq=Y&>}wlB8=8l7VWS4kF^~ zZ;+>k`sV7(n`Xq~-Lu98$$VPK(-obi1~IdOCCFm>b&i_l^%p5s!YiV{Se6hJlG$Xp zLH&0qNZCV*>3j~VEb1RgOVNgfhJ%#m4wkd2iX!QN+OwEi4%V9}ZDt13njYrqf^~6Z z=zLjF6$~T7ZU&jzH^5K zO@aKxdfcr}jN=yy;L> zcv_#&$t?<1pq&Oa-f={wphBF9@srU%B+P9#LM_zYFf(0e5M@hmBcHwDU8`wlkApgnW))I4L{?JiNu*ETa(;ug8w7z%@@G_58(!pA5Zgx@OY#vxPwZv!>YXm*xJ$c&YL_ zC@i*>?SoNSqxsG3?3&wemML?F+P!$x;4?PfyJEs3cu7 z_BLNuo#S9fn9|v{wgd5Yjhy1e9S|NRtBv0H!=rvo7`u=Y3Yx;oYdZZtJc<82;mZZG z#Q*`{mgj{=*PAp(Di(MIzuy@~vvG`8MBxAbQ$Barig*@ZwLKkR)*l>RlOiEOC}eRQIndv$h?_18jx5Z zC=qWQ;B3g)#S&S4?!Nk2FJG}ugo7s%WV*7*l3xExW{^gbbJzKf%r@1=oN+K8_ zR{OB}Kywn00g(&^^w|>8H8Y&vmp#xs*%AlH2pwQ>T{Ho-e2FIQHX>Sy{Sbfgp%j1t znpCW(j%oV(Qpehx}48ow@VoPZ5_;+m8%_g=dxPE=>L#P(^lrT9_LftvC%cTTHZu&;C)< zZP~1%OtJlf;`0wDlz#6~5FVMAWsby17u^tQpI!vi87x;vGhQ!;GrY!^LL#ic(z~^^ zl7M()m2ZDS!IOc2=m-ZHQn8lPl3JIqb|U-v1a%*QY$A!OdlkTc2a;>U+L+1BO2BemUXTUc)b1Ikd;Bne>4>?T?fWisy1`8@ z-(y1a6>^2%A#^|jmPXwPi%+pgyXK@TmJd>ccvTp5@iwi6T!>ipX_t1ic$Zx}j@PbU zkKjf4_WAPGthXvNBF@TZAzefYinCMYi0Q*TGa%d(PodJ-QhY>!J*OyHlJ!Ad2X%aH z1SE~}+Z4pfN&D*)XMp7y1|YJc?a~`8DTcEifB4;R2rR`M%VWJdwT`Y*=KAQPe=F-b zZ5Sm;9S3!T0QU8_E=lXE^XF+#MHGb;)P)p$g%pM6d}b|XEy)$fPO9v>!O?(KTy#%*$YixCD z%<3VM@0!-ZU8a+_LJ~6U>mD_+qdJXDI~C35(#{TnG$esK{6uxu6p!7`8COfm>h&@_ zgWS3`wnP>%=(QQ}2TzJYo=ccr*AJcEP$4@pE!-4$hR6eT&;bAoTkO_mzgEgukI8Ah z&YL$o+hZNCB^A^|3<(o0J#CKC4Yd5*c7MckpW_sdHNukqr@aRz5ie z+eU}(9^S{mlTJ!%qdCSsAGg4v{dFepb+!vb@b^>pda$X)8A`rHJWAp~rF_0D8au%= zeZ(;h3fioIQJ{D?khWjn_d|~`=E<+dKB>X+$~Y+#$>){&O_T7%5!Vq$B z9iqe4^ErZ}?z0Y|5O1h4s0&sJ2m4Rt2gkENGAX~g?4SS_QstGHf7m5xWShGcp{kam`=8+vcFC znHF5~;W1lj<8{?oV4JL%eyqud?FppIF$~kCW&IY$gFHjG;QUD_6tup&A+q}>R_v-( zOod>f@P#(+fV01$r^U%R4}zeU)!^q-np9RK)gU>gh(yL?2z$5MDQ*DDTsbAb)eH=^ zK+ae8V9i?evFuP26ZCQ|m|X}y(HvP*0nSDEISd~Ptl>n$2$|-ESLO5lb&_<}%g3%# zlQPNxUK5Q4B^;b`VG`FxoIKcqyJg$M8TTf<@!o*d6Xei?C{*QLYVLAYeI>0VSu0I? zfiMcGfX?Ov$t6Ldn0z7HfZQR5Enaa8*oWFDKe{uq*`Oac(;Afa8!H zjG>~0^sYj!zn5+LEo44$rv|_nR;t7hLyEzjfsduZ=LOxUa|gqi!HG0q7Z|ZHo}je5 zTUM*td936x&1Uk{lJNVdgvZ;jvn9XVupv51Rns=It}V|%{=9N@Uhi#!D^f%9r^@SD#*;cn41|0>1q5&(SbJQ>|=^&C7Ee6mI--;5K8Wq zJH{zt1acPbqQ6~j5W29Y5Vj)`z?E_~-gfN7etW9CQt5ME+tYw7qQHA(^qIh|STNIkrar|a(<8gZuIgS>H>xso0q1+ck1KJau zv>Tw%ZXhMf432@gMyyI4tYQ6AFNO2)48Kr7S8@isFqs{^*?9zuJeUjSi!QrY>;9w6 zkhT{bX{`m-8qd5emn@Y3$#8$7t~_?1KhSZHX69M%mc-Z+-ta-YucN55XrkT^l1A)` z(zGnHgZHa2(ZGaG*loJIMsnN>aaIT)VvYW3Rr{;$!zn?0p4)6UFT*gS|BHPPUW!A5 z`Zz&t1F^8H(9Z|F4VfVnx=qR1@*OYXAL!a>Lx zJA4MuK5l}c#6C)A^@na2HJe8-{_m2G8~-0YK02heP2)f2Gztb!X2az3leK-vb)dQ4 zv((;O zWA|a>eooy##UoX)xO9mM+IhoU4GIuGtrHtF^Fh=O#&?iC&7(9{K~;wnKNW%ka#|M^ zpIGXTMTP6^mh*?#;w2n={UD!b;N?0qamcF%RnUK`^KkUzk6(Q^d9%|_)I&MrFMR+Wpu7h*OT3|gj0;I8BX&a6rzTT zY8f#srx4Fqa@5Ba69Dz|U?=*)&0w(O0Te-F^US)9Vf8vn(rd@H6~ACt z*3wbc(TtschEA}M6-`xpU>dUpxiyqH7f)g4iz}iAm&bi*BBfx#y2g4dt79vXt5u}3 zC)!Xw4~NW7wYTq4>o|BppUl-wh?cAVb@u9N-~YjJvoL%28;z(|c{uOSzWK5|wPDuL z-5-;%Rc$7B^lMkmlSZ^hT{Ojn%0=9ZU(aA>#-Xb|8OUXvEp-*(>AT4`zhC1*iC_7` zRJkx%sKYs4+^}r_#L@o(%!iQi<=G#9Hg8m13hp7c-N}41(bx#v&WFj)P#45aJ{HY} ziX?H%dU(!iwyzE5m?tds3RLB4T-rbn@#%j-OK6y#mJNM~mQKk@Azf3d*5vX^qnH0r_D;Tn+9eF8oxe4Nw ze}WGL2)ypdcvy^{^l*G8Qtp(e#cHHfsfMzdQ@LlQ!9C1;o)SHOiio=;mu~cAO2EvgqvQ>ifCRte}Fn%i|>BLGBhArXnC!i+`gp@~GY~S}9sAzVdn+ z)7zD?T#wgEB#jA51gx&_1PO7P$u&Aa!2V=_`M5oz@DV$)YhK-9(Tl47?n+mj<=;+q^% z51oi+sy(C3UkTW|iA;P{7xLGBLE*mXuy8w=pNX*tC2f;5f~Fqkq%(nfG_@Pvj~N=g8fP zNJ|^(PIk+1yMe@p(xG%?oie!`8<7&E%ozimLBZue9Ftcow#ec{Iv>m5w|CeVBJ>Jy zlv!d6RaCI*qpQnm%mle@%ng$R5L>m_8I~(Ad@hCbejLads!7aM{M}r6;y1oVviMmm zrD-T*RzNbV1N(cg+3d(Ev)P1G0P;b+`iD2H0Uoz`KW?NlRjz@wtyi_&S;t>2SzC#} zjNV@B>^GYhucq-hcrTo@eM1YMUEj|!wE?wFu#^ZxUAw7%*L#bXkF}F|pH=$5@**6P zm#;b!cEwMc26*CBYEmJ~fPLzUSfV=Z7$y*dbdz~_RvIg+P}IcxI{RsToxE^=N~MPp ztv*Jchn(CWE!5x~{a`gvN2!Bu6;nb?JIKgRzZ@5X=_TgxmXCoM8kiLvjQvS^et(~r z1J=QwcH3XWwY8fo1`D95n+ht~YY(mSLwpyS(zuLIC`A$39aVT4V3u>Eztwo0Ab=Z|);(hT3y-lNrvB--&sq-I6XiMc^SzFezph`pKzZ1Xd0S&Jq)af;u z-0AsvC2ejY4k3p4cXnP$1Ow<756?C_zD4`64i+)Ix3sx8gFaK#Zhb`o0FR7HH5`@g zd#e;4-(?I!_G)tvw(i^;ce|>49ZCRSjIYaN45@~z)a-r?H%MQ5Qh_wDl`H~%vcCLL zR5!LYP=5RUB0k8V9gkyqLZI9|bN+HPzf8Rel$CRiLRm61^Z_k}8sr}L6iM7vc!qpVjgqTWs?)X~ zKf-<|!b&7|DRC?w=_qt0X)rSer?FbJ`*)46L~NUm(+;{(vl=G%@UI1t7^oir&wuZPpw#-sfFpl&i39kg(ns(4VAk>kihU|B6X?)TPe(?hHSif38&s7gb&&%gzD`_{L#9Rei_y=2G3Zy$WsIpu9!svA~!5T?`Xt*@1zHe zVx7iaU;eYn$~XTDc+)LXnFjv12Uy0y{XRJ9c{X(z`7sGaU>&_eIy6TP&Pt@VJu3Ic zlTt4eIEH!Bh%=!&uFk6)y@n~n5UeUKG z`f&M{Tg)_T=(@rz8>Pp9P_(^?Y=y#tUD}pjhTaioIl5}4?B5caWd1PQatT0Ub>*KJ z0WVoKD|__o&?f?(u0Ieu$@igtUQo<2ngqDL_L(qw4Iq=jRi~~IkVXzlZ(=l&-2kPl zO%`-AJ6~lV$+$5;janX?m!u`Ft8-}>L$vDQd$8*bBF~ydNSL`JkfSsz0VfFcAx4Wf zm;Jqb@16UgD0+r=-bxzw(m_hBGF=qmFl}TEmQw8ze(%whhLm>3q^99E6_$LvT-tEh z{^`D5q|-WCTf!6$8){n$?7jK3CFXj)ISjtyFX&in3XXLq=}kTwvB1?!I&SArxDGxZKLbU^ zQk_IL#smq9nbLdFt4Vs$Z@eac6Zv1&Rw6SNlo29!Esba5yV_h?{AC4M5i zBmCoYqFQb4g8U$K&9mY2!Kwqfl*M8#Q6VVP4qqW`wLT*K9!yqLCkJJ>A}YwGL3#Rk5~5DXg=~S=H=@_WVf~0B*+FG9TeDr!35pri<(O2haymbBR|O>g z$G}Q&JoE94K&>@#L27l?_KaaksAMHbW`d3ZDuGRbQ@lKz-3g`E$rryM-8>X=Us6UH331(8cd4eg2@1YeMS@W{E3D1-v(fM6F7%5DKX})dnEdqr z=ZIJ4g(P_^@uJ_fU2gby)<@vSNFj}nmY#`>%f~<7Lxu5Di&4nQR=aPg@QmYsDy(H8 z!vQxACj{7HSbXl?9iEoWUHp^Y>OLcQrw7Go`IxL!LE8;>uP;^m#nM9+$?#y^5tr8G z`B{iRq9~-$Wc&zk)F~p7aget3aCdkH{ABmhzkrLwUe2WX{iJf*v4NmAamu>4xpOX8 z1q?V;snBa76chbQW3U> z?V&VceOgGfWHuv~l0)AQ9snPaff4di#Y5Fwu(^M=G4B2FWkFuE(+jo2w+9;-lLIPy zC!%zNq2YQ!JPfo{yalGfFWObqakn)T14ZbUQ;*_4`J{x^oanm9VeW2~X+28X3$ikg z-^RS65_@a23Ys;zbFoB7kQ`9@OQ|NC=v_*7OY}!HqX~@c{M85bCZKYwF$2qwJ547}9Sk5=1FKoR({qiRZimBB3T~y}JYprnC3j1f6`g0c__rW&L z5CnQJ4?BR1nk^X`3hN!Qo-Qwj2VMWi@Ylz+rvpQfKu1&gz9FG$2$P$>9E_E7)r<`y z?!C#O5;j?X@|nHCM>PecTD;r{=ca(UhfoDz-*|@_0=JqEA3!C-bnkGre6U^j9CvtG zqr+t!HYzX-NZ-*o+qeWG18c7exFMquBFq>GV5t^yoP}Q6j7rEcQOT+OWwELZu+5>E zplyoPmv=}82N^ETdwep8B?GPT>QNBRe@LhIm-XJ)&-au&;#K4UE0x&@D8%mB&9lQR zy^o#deuH_%S}kquAl_E}NF|*9r)8w73(kXnB;5hh=$blhKBC{dpA3Dl&;2Zsh&{Kd z`dqX^=ioV$g!1Q$`X*+eRPfS>10v>-r6lP21%%n^kJXkI>WuRR*mI3TAv z2psT0eRQl1j(K3CV(Mxp-nx6Z-wurxig9cVC{~(fbeu43_b)UxSyElDO(BwLhGMdJ zTrUz1|6VREMmW>_w1a5z#{`|n9rxn5Spit`)voo*%<%f_p*o49tW}wXCIPvZ2hCVp zec%EAe`q=f_P7?V4F?n3wrv}Y(b%?=#_R-*Z6}TGG;VC$wv(o@zB%VT-*1@N*WPQb z=echK9L4XboK)%y1eHjhE)3&l z1P$5()nkW?=61AkX+KlhD7LqWs$-pTxH*YWATaH_9S59?26g|Od)?#8tC#=rx(vi* za)Er6g?{|uN}rpGW6u#)06lPJGDHvyBzwG&%gQL-`)1mev)Qe&WH&`>jE@lz#0Gfh zN0X`u)^4$7IzmK*REfr4MacB0K~$VE{qBt@ofvWEYU(H5SRsCfL#TjTSW$sJ>onc2|?r zYPVkq8)g}S1~vY@4SCHh1yR-4&@$33G~B}BAZAMC&g8~()w=)?sUPfTO5KlH6K zjjc8?p(>8U1`|u#x27!Mp@}ebsbe8$c%|H|*`qf3<7FquQ+4W-o|+IPD|a(Gue%OU zr8%@n6;cC^@2bs4X6dJi%J`aPu+xyb)ZZ`+b$#h3X@(ftjevWsr-jUvvH?Zjh1$i# zgF!1-mx(dn`Dy6FjMa0z87yUg;3ju-n;Cb!f-El=Qy}d<+M?C2G^mo6C)~mZ$!LQw zm9PAZq{>Br_C z&}mS}?=qgU+@rkHQt016tnxEiUw<0KPzJd^Q8jD`k4BCpm!T!Fhp!a15-*DX^4a%0 zDD2oR8w4XPRSf{`d)z3Y!v&UF)uY#|L|Qe+QkA|V$=?en)^?EK7-w{$d>v1l*c&4y z4!Gxf`@4D^5C8Rgd?iiss%pT%UtVO%T>2|y6@Zb>TXdi3truzeZ7-6Fp~jhz28Zk2 zokaKW@jvh=I=OSu>8`A`iT8hwinmY-*3{T)Uy%WX&#SA~P~>{QW>uV?L~1^B0a{B5 zKnq_W$Illhx-$m#ugr2Q^hpSG8d0PilR>nN-q|BwLm#_J+$+ijCR3YI zb#a>nuN3J{;|y$~YsnrtM(0q^!fWy*Fs zHhNjio&NoAVNR8P$2;!N^O7Vjf?>Sn$nWE&#(hJ!5e)-YLFr=DH3hPP<7-QexS$Pb^9!OJ zSMy;w;*yi9DaL`%$Gz+Kzp~W}(~~yxA}XS+j(R9u@~F=so_RSgGy8qcD)vcn0*g;8 ztEyjqAL^@w`^@!jaQ5W|yn9SM8URcYsUJwxz}8M*=An^%iR^-@AFdU*%iLAj7>f~C zYR$%TA8$NSgh4GaMF^vX`lg`XwZK^|4^TPp%Zwn*V z1OlP+I8%c}%LY;2Vo(~`Rp>P zBf#NZIGn9DW@8I&JjpQ9rt3;1+E@Qx+{951@!Dg{n7H`~nP@C#F<;dKFYYSC zK--00V|;oVugM`!htVa5_lx5qI<02rm`ciKKLUms&G9KOgfxc8t?${MSl+)=g74+TZWeg8JD zsQtCN2Z!x5wzV}ApxZr#uxA#m2l%?C&@YHHyoS{Icj?@^o^eB&1C=@!gI7|*=B%;H{TpNXhr z;&~57uh^htKCyC(ITRD%bA-I2t2h2vIh2i-iBI;d`dk*`fX)o6ed? z2(Y&)aC5|x*Vl7^D|PaskVF%dlu4A?{DmeKz}O*I;G5j|n9F1H+zzA@-J%2rS@M%H@hjav)ot8v4glD2qeS6HaP_0;Fo5x??1VKrE? znCXM2x?$K!p1Vpu&<&;>VjMpoD5DVu^-KH^eHUKkIe4D@e*ayryTIZB`2gr{=FbHO zcdZfCFl9AfxiJe8(BXbQ6~nM+|BLGN4*Oupdjg>u+ok49&!Z`y{Ml{3f;D=PiXr5~2t zBBM>J6k>dVkYkw*EX<*=aLCFHTxZB$K%6)0j*( zJ;TtS>k)O%*4)DNSA^`|~_) zhQGbVoQ*l|K+`JvGl_vY6;Y(Lr~t-8Zl^5Ibi(2CrwG|G}I zsZJs(=|Kfl3bhPU`LPdcd z;jO<^EeuoyRA>fCMocHwY`uN#e&1mf&$icna~}V&3+86i+kY*%7S4Z?c6w_r7LETx zC7#Gxm+X#nJl8Lds z=NC4XSGKznhWD$%stY(obFeCC(&vJy0U-rc$?`h-Wo8|*-#_W|%w_9L_$>K7pEp;S zx4%e5@Qr~DcZP)^>ilG&6B_hrjPdtRW;aw&t;pa2a85{r_f-7t*zZXHD;TW-5#nlD*zgSx zAJSdnMT70^)Oto05kY%QUPz%svU$hZG2JGGb@174IRZRxT?HM5KKcjxJTXQo39KDW z7q_T$r-rQ$bp6XOyr{7+tI`Jm2<_iZo+4L1))cys64p_n#dKs-he&|3Vz>z0(U8QA zyimaQ`_eL?jvr()VhO;G7+3{puU>56QSiT=|wAyd-tIz*B(NJXu z?zAUs;~dqUr6Z!}n7&vbYgHLE1k$~OD+~~I-V9aZ#;w72spmqX^~#w}!snjOStq}8 z6~FtX)@AcWvk@*h&*~hT%~bZ3>hUfksd4iMyXbf3`9-@Arw_yJ$dqJrFSFTMbK^ww z;b`HSIA*$J+0}yNDbAYS*-^}H2byfBPb+Uzpauy`ZXaduldVS68>|~$BibhMI4%wL zL-raV*@GY`gL%1>o5}sHbJWFNyN%GO`-&b>ZJ2wnq=5dG`@FTO(zx9WGS%Sj8o*vc zZd^{D=)@XH88|Hm4`XunQ;kRyc*rqLftO81t-Hw%`z%E@EF>lik-AtHfhECZqCMqO zR!(FAq{^v*W2-_A9IPG;(^c;r{%qJgqxgubCh_h0x)g zQT!$gmZiP|l*bhR_-i%(BA03nM!TFJFrI8|^!@hOCe&{)CV5YU?JO01C z^BH9Snx)s0y%BY#qiYqU^*Hg=`xO1U7iih?JH}W4mC87d=O*qg^H1ER)E=O;bL%^{ zC9zufzxA455gM*tOwMZAQto?W-kaOQFA3?uGcdc8o-Suw%4g7!<8zqOYCnmuz86X< zy)aPVXi*#IJxi$AKIsW0$AN>hv_JGA@EXT=J1bDt$cba&+H)^htmr7$J{U z+K4A3X(NH?*d)wC8&{o8O3-mn(o$>_x_`eu2b@QOx0TU&(dZC$uTu zHfS-#CpVrYpU5uYZ3ZJV$&oJy-tj|o5~Zqr7Gm8-R0pvSCnRGOu+jztFEyJo7FTT` z9V-k%oB3~St%;7g<|yjiMJM@+38di>Z`S*gZ@*aavb|@Lq||q?EYHg!0|F8WD=%C{ zIvi>*3sEX8BuHpD0-dJocBFcHYKzo^bMI0{YjnC>YPBr~Wz!E)MY*2kTy7MN*}v-f zD1rW51emOrJva6r5qEWtNrUZE$22+%6-9gxM4aHAe;zbDMLaMguJiXxNTX^frcvZ# z-HiGNruFlTsVtb!BBG+E*MY0OsD$hjO*O^fA2D=F-7a>+Ma%8#Z@D_9t#rm<>hs&^ z7>(SzRfg`!{fHh#d84HABcJ5jr|N8QFd_kaI9^ASm#}tRbSNbpf?|RXbb(gB*?4z& zxy!7j8otR=Kfz|J0*teUq+NDTE1l0Zw2ZM~wHAUHcmB@ZWvN)WxYI(HEx~onMzp7oqyno{f_tsn#E@6 zsiO?p9PZM`hSHYOF9R$O$}8|YY~5sEM;a{f{0Iy1rC|A!TCe8Q_C2RWa8inxrWGJM z$R$i|G}os8(%nf8C9!X+Tt0c>raolSwOsM9eXWlDe7Py~a(Og_(tgpHhs?XD z=-4$TK9M*iDj1k%n!P&x4F}y=6;$pDwkC@b&3{~N*V8ANi;H|;BCOJDgANY((j!lo zx6l*X#?t2kKvO`stue!u)cIzK8^2baLuyfQk0Gsx;dQ+eK*;|MaA}EQ(h>Z_^=NMn zDNmHjW6}M-6!u7_Mot3sHpy*|gc1)`$AoYEw(L4>ytmonf=|A%?(k$HKbAJgk~~^w zN%H^!sqFTLc2kbu&Q3Ovk)lb?$$W9j|UtD5}2BcZ)v8Rj#Tb6xUz^c{iJ3} zG8|O5$U|jK_@#rmxc8Re{chJS<}S~FK$S9ReMq$n@?A*_;P%MBKJ0aaYL=PJCT`Y< zn#S0fUL~N|&$c=FVga(HKtyr~OF_Tif_jyCman;-kT0vtq>p8bGe4@NsTKJBQ3qpE zEJXoJ1*IV7e9q?6dQ2QCaYjrkcfpA0+qeVcBEcS8~hyumil~b`jwZdTY*?0G2}AzK?ji8KhBZ}Er}>r9zV7fd26Esqho6u zLESHex%r*<*mSbUH6a9^^!BL=wNBPaN!ErDl-MMWbJ;qzGF`oRw%w#KEL)~4WU)R+ z#9m)>n!JQ4s!}xq@0jQT)kC?@(;O(m44Mrb!04!d={&aCMqpD7GrP;?&?C33J$+Eb z?OZ;hz=bcSwh@Q>HdZ;+^XENN9MeaWfzv%VrpL|zafWaDu-_~pweIL-L2{MPS=Ss8;lRTbKI!Tnw8Iud zTLS1HF?*8CYkYqDx=fMJcnxle5jv#iCyfYQe>#|UGjEkAEyzj0w2bo{GSO5KR}kz? zj8<2aYE#XZ@b(d7#P4xcpku}27K)r}?K{5z-~O#aa%VYUQ&_{X$X@z?Y;@rYSVB7@ zfi8n;k!KW<*TKExEgyodo%n42oa_O<@w6R1qjqA6@M6SKPfTqc1wi8fP}g696oQ1v zcE%fdOQ;U#`eHMp^@t2P8N*sj)o3O+`_x|%93KH_)s2303O0se5p-``D;w@h^)$HO zD9Og~tSRl*EK*!q%P`u$twOb3TC4wq>JFQBN&gI%m+F|lbBBY7On$D8@#2+%x*Q^# zP36&sFn;HK7NzcZ$B>X%fSSrdH5FPbdzVG%8X|fDdgsg6tNEo;oH8>x(Z%hF9@Cu_ zm5ZlEHS6omU_O@++fp(qG(grI<(M0ODFKFu=~^x4_TL}Ya&mhkMYJf`c&s{ouohQl z-%A+4ubq|(U*}C(-E&sJr~&Tj{4c+gzqi;sekq? z+pAR~s`ij%zST`jWNO6IU53XV$`mty()2JwJu75#_5YOw8_xiGsJ&KYxuj5?K*fly zyM<*s0T}gC_zeY~gg-t4VP>n4$Pno>ToU|=wm8I?Vu{YW7Uy6oI5(e%h_rQYRU+`& z11=k^j{ZUb2x00j;QSOgXrs1Ahw}8q!g^w~29Gwccb&L}L!FJawYBAjzxuz}m1k0X z!?6T!k6w*|X3!CqD|Jitoi})L#c}M>NHYlPO{N+kvn8kN13 z9KU5v_DvGkdA^zmx(B^SiiD9Lh~20-lv1kDA3346SQ`OUHLZclteCMcqkivn_zc*1 zJej@`!=$K~CtLecc5IMLZ9w(@i!Xj{fvtGf_Pqpym#0E|jiiN|ZI8e8Fml?Yzaj-! zG@R5{K5wnZ4!S54iN6c^-c<9!H+58-8SJEMD5~o=H6^4Yw-Y1W=IdELo-fD~>UEN` ze4At>6zpcLtDQDvTHOMnFR1m3x$i`+&vffi^RDbHpbefKYklfyRL zv~fn$UO1XOULHgsnKD{Y^PvSWa z(LvWJ+s2JRntKi3@mmT4;c<^b=2znX;Bjhfpkdvc{y}cFFs9_y0K6cQ+7?@r`NTrupVVM37~ zlSQkNfb7rJf%*7}yue z<~um>baWRlB&Xb51TTUF+n$!Vo}w0 zm0`LLKQkXu?|7(NrCX;B6f8C?mp?`N>I;E5jsl~d8BV*y9@jlOKo?oLgwvR64lGvg z`9u1l=bOX#|5<>Kz_8g06wBzUi493~yGwi)Tzzs`#^vgtoP8pnM?~71w?9ni7=;KH zuYU&eTPVl`w{xjPWpN)}`8dFiT*K0;esUcS#l*$x4c-ZO?mH2G;PvgIf%72J8Un&4 zlkRz)>TI(sTEA7JQoz@gSIbTy`S~4=O`E;XeIdUUv()^d8`H12pUe|$n zb1`e5t^*I`Bp_?}^-7G)Gj#Ju&nf%@M!px$6g@o&{X3)&S~fbW{3!m2kECcUe+H*< zVaNN%7hb!?Q|EOwTh3z@oO9D!?Bl!JC8&;Wp9ce!7;*3*rV_K-d(ptn$a+O?q75(y zFYBJcB)wm-;fu)huK~-lC7TEl|jX#~?rn5_!sV0PE8Mwo9^P#tk zb~Qs4I*r9fqXkrYnnNBXI92Hjf9koRk?O3kN^wX<4y`y=w3AyikH2!qxl(XLq!Ou1 zF5m7h|Jsx8_QM~|pDUs=9(5@=5XTJG7xGYl7KkZ;onoqnd72_J8q99WGFcsm6Nd3~ zg{;_Xsn-l}`gdANFYN_Vk{3=01BK=I$Z^hx7sR!lvow`?f#J5TtE8w6&|^ z(&62zYejyw-4X+GBmA*kVTAm0O&?FU^YhuDlK*j-zcg+4e@pfxqJL1%t3qE5yZ_z7 zGebz@OQ#Os_jU)|WE}r)JS4`Jc2kEVN^5Hhz zJMFx4glPlK`^5#*!voDmb4+}xmWKG7V~AFb84^$>(pu;~+2~{BmR2;j^K8r_W5|t0 ze|=MkE%U>;^LCgLOvI(kHtfwtv%PtJ4|UbyK4#;^A0u3#Q_RI6k*IahxeHTB@#0li zk~3Y%bPHu%ZdTAPHduyjz{$`O0ADrR%9KCAU6W+Kjd; zug~UO*u{s2>)s>sD1^PhT=!5@G}VBZ;Pi0ui9{fLVMQKR|LOAc%BXrRrNQ86vQ@?T zz4BR9&Hb=bMhoAkH zN@c!yKkKf{;Y-Jt&|;gkS%HbQ)n&Ir0rJ870l)1!+_phdV6oa@fVQA2B*bK!y4MwZ&jDKKOab6=_W>pc&D^GQCke$p|F>1%Z3!y;b3i~GVKL38W!*% zQXd2{Q`5jTR0`=Km; z(5`+c_hn2+UVGRzuT8%8CovJG(B?u~hrg>8LvkNQAVF5;Y+Xn$S2!!#rlmA>s$K9t zTOtA@Ur3RF7p-s@Xpp6-Ef6fVudK)>33>%q8{LgU+YW5pX*&Snh<_vc^sj|>rf_qz}*fP1VXnxGyP%Y`-GR3pKdK|-n{j3(!DwKkK=)U%e{;c5Cm%zjv ztu@V+aE@uK;y=|#;ZO25U;-j+R6^?BtyS(Ou%%pq(}ti~`SoupD`2P;N%?Pd%9U5i zCoTWt^PPIVvqs~DAEhxJ0o;7LPevA5K_iA{DyyL!%Z}*v;qp6^XG?W6Vlz zp#-<<*fHrcEhMpB1o*kPInZ+z_R=kJkplAS`}g&GBg3FGirD@59-V-H0S6+rKhKB^Xhnhq)F%b?#6Ko-8bpgF1z$|84Xa`P0P^B4pUJe*rXHe zXv;FMOWgt3EM3&$ye{~0<8-}j*6pl|66RRv--sIRrUnG(4H|poAW&)gG82jzyVn65u*rnAD{AoiNXX9_`&n}i$IVv1&hBqu&IudQ5;^zaiO zh?s-PM1%EsV}?aC+xP68$}(iKJ5eV54AO0dxwtrbTe6#%c4x6NbuLI~aJ{xS+w8XAYmOyEz zv%Q4#dptMlZ_u4l>z|VW$Cc8nKX?RWZQMCysX?{NkJc$qcF^ap-Xi~ge8LEamM)?c zoQv(;q?tw3tv(#v9_6>iJ^j{w_VzouFAP=}$?xUu&iS5(r5E+2gpfR6HY!#3YnFJ) zn_tC%bZr)^ee=eJ6c?~c+|j-%8@9IVof?@(YIlhqwh%U=6by#nVBTCJ#+@Ru^4_L= zd|VMPDEAUM^o^gH|5Y&hiHkHkIg3}vaQHO*^yoC=@6T014gxD^%;_Zu2)>PMj8}7% z8odN0e%tXmUYp~3mA9K+Y3KY|Nz=ddOmz*g`B=-xHItkMQ}WOmyb_*ktq*Ex`+r=Q z3Nz=NX(Sb!Y}HJ#8ZXxTzPF=)?u8Dxv1MNQ9ksVbWI=br&Yj&y0GYt$*%^6$e?VV- zKT89J{s*FCeo+iZt+wtp_F9#@t_&q+#$9i?FdVgsGJ&FQWy@tti*Ics!pVOwiSr2a zFPFTN`6`Z++Dr9I0?!#UQpm_eyF*hkjQ8)R_ivAl#5p;uj=1u+i6@iQ=~~mcL}+a}5;ba3AtQ^~ zG<4^nz)mM5OFcvfIdgVyopPVR#SN!yb1b5Hq@uSkVVz$k4WW_(us$vJDZHM$y29=H ztw-(C8-_AW-9E}!y zT&;{_tmL1k-Pu;sm^?>&MQQGP*z#RmgWCi^!r68P^uF(2AI*XkGBTH~j4|4KR9{H>$yZf++5d|>$BwoMtyXn6I zI(?RbhTP#==4op{3uyDxmH;|%s{ph?+&>k(zW2Hl1nI@uy{G8R%sbxW_8v+sCBfj4 zF-!UiL!sw;!9%!PTOE49v6(^wURPWT2#n#113r^e>e|%^mxqdZmzN-mn|?qbG}^ay z;MGGoKRHI+f>_T-vFqsoFS76_Vr*v{(%k@NF;d)NEF9NP`tO6d(^F0gt2Gup(s(v! zgo%*?z4nr!mF4Tf33mUbl#wK?f+Y1*5^4<)56&a|o}2tQRr?6v8+zqu4X(W6XcHld zpr7dE7?wOLi(z>G@2F8-EFzY8RW(;4NZN>TwqX@`Ktz=8S!C>?eRm0BOw)71L~l^+ zfm?lZ;4;ShmD}-DKy8K{+ctxm0LH! zu!}5zyu{*t0Z6rsDxTlRy0@J}9SZJu+L(vFe8Mj;HQIx*BcFnGOtll)zi1)K8Jsm* z41-yiJ4donsv!7WnvOf$ z8~F%%D$%_+Iq=a=vl}S}rDHZ>@(V#xi*~22gao*Lh>jY+sOwY*mx+e(<589uX!|wm z>>q*!s?iuLrL-inX=XEgp*G{L9U-ygYsIakyx~`zsP<{SuVC4WhC=wNtaC-sx1R~p zTM0^Ks%%fIOOaSZroPSIa5b_ExW&I@-hoRrwZ*% zHlg$|bzLBr&GZ7Z&=sP^n}3QqSU4P+ zIql%95l(Gi9Sb#wk2W1}@L@ZwxtU_7RS975eBVXD1y}%d8%z@LFe3ziL@4rzGU!(M z5X>1K$313`4-);(3bGgbz!BtaN7o6HTYfRV2mUo;7W+A+i@ObH z!3t|FUDiC5B+~hDhBBqsev>+%{gT9C^Tk`?e_J)B=6?z)W6EHi1NxdeC`yP4Wx3)C}g{r*!T3h(E3$bn%dpc5TRxD{b3wv1}g=&26`jR z`l>iz!n_043>;}#ZZhj_(tr%98SsiCqH};6lDqz7yw4${et8hb%tLOIKi(vvu)p% z?-#HmduFn{Iw_G=#TjbmsJGh+>HC;>6%r6$8WhrF(NVy1S?kdURAccTxh4OE%|o!H zB;JRyao(Fh2Qx$uY`kCT;A2Q%$&Q=ks4iWp z+x0Up@SeRbQwukP;K*%eREDyXJ(Zy#;TkjIz&{snZGGS>L$TJ)Md!+esAvHpxp6Af zy$L>0TNPNi0uI>1Wzg17fyr5sCI}ej9xbfaZn8E7FXNX3E6zhcj071h(FR*IuP6O} z!pJ=hUP>Q%KHmYbYad`+{62`E&mSaa*;K*5nL8%jaN2mwU21VtBY4yXHJV9JypHXK@JxjnU|?0zs@$AEF_iZP zFP$iA=I%3nCV$gMl%CfYTciuMxJA@XxK^*y`YRb{oWUlTPP9!zPw8`#cp7?g9ld}D zg_xx)@IlPzawL*sXIzj|nO0yXOO?-lFbOMP@?wd-m{utR>KJXc@lO%Rmd+AyJep$_ z^|mpQ?#DnJtRii~mg{ZU@?d`66Zbiyay$#><=1Q&DpP86HU{6xt@FeF*aKN{Ned9|LL>A&IvHBR#WlC@`c+!Tac~i zy&lsG#10_)8loDXqiR3E}z@VYk+3N8~{Q;kUQO+eVI?riSUJ5 zn-Co>`b*f^W<}#cv=vu$hA=`NVGx(HOqn4_q9am(vUm} zwg02jYii#hxVx}TQqxkKx88JxZZ4R$haW9x^6e=Z<%<_QXs09S<-UApDht z4ne4862yg&X8NAUb;BPy>BDXmX~|9l*B4p;!>GRl1L^I)OQf$%4{K83Yyj`5E*gnh z+Pv82*bV|5grC93czU>OFWy)*MQ4&jae9&j|v=DFMU#|WiZemLxTszQrO~X!m=yG?#{W} zxx8D61oPEI!6WfVVsKxWejTHZC95S1^I#Iud%oim$uqND3dFTCH4)Jj$&Kv{X9#IDw&7&X}g@{OSgo z`l~H%b!9|UWwK#^g@U6h1`LLp+T~FK#q99}Q1Fru(ahV}%4dH1s)Tn94V=aIXEzcv zj=j2M+o(h8-W;$3;b(DJ+F<(`5tLgULm|}hORBu5>M{9Y7$+@p#RRJdvvyVa&Tm4* zE!Y&JwmBY`ns1YY%0Tjg6H0-T0b%}TaQn3f?5p>|BpDbGbDq3%%U*OuTbdDZyjX)| z`+3a)LVIaWhEqA^XNtE+2K#X~9D{59@uUZ2(q^>TrsJ}fz2jNDPj(A&z|+&dm*cB| zOqdkFnS$2=(iQ$`zDh%=lbudR_-=%{WQa2GcnF}$oE<0d+fOL-6RD~D{NvTxt0AUH zRfDvQ)y-Skc1ve3{hRbN?md<$Qx-0pt=|>*kjv5Dm)5&b&^KNnGs>~6PWGsqWOQo7 zR@uTdS)Hp^l}+ot>Cf}cbl5TzodiYRW|Q)aQ7ELG znizZhDn1$dm{8x*$iY#b+K)aigq)pv4E*iGe+P3AjqVG%GyC?Wasq1dcp9PydU@sQd}dX%e>-O+8+5qAQO!_IO+90|Lh+kb6h_NkJfVSD zTX;P`Zwps`Tcz=Sr*MXI8`H@dcjNi|GtMqbNcHC$@wX%kiOHv!NWD3qItt12%Gu4L}O%;P^MT+sPgwJGe2l!N&3EpOB1YFLi(AqOm};VK=8 zsw=h8Xhm~-(5v(Z)4JiXIeO$9skz-nK_Y=Vx)IaUXX6I{tveC*k&{Y5Uj!yd}^;9DpYpoW{e zF&FB&a}iNq=$9{e+rxBZH;-%6iYCUg;kjz<$9r@7 zKDgL|3bmpx$C`R;Iy_v0WeS&+1M5nOz(Zz6mx#`_T~|icW|rtP)5x#P0hWpdR%o2h z+biE2!^YrPBbeneP)P4Nw2iM;*XoI|+D$iSQDSkdrQ2t`xiZIiKcW`ykmrB4&G08bQ6NBb=7eWn2#=zna|HK3NZ#m@V z%(7s`e4%MAP7+a>zUDrse@b)n3#Tw_#>nANY8+Zk0~{Nrqw-7rkx~e4EU9#y*}f_oR2eoP${#WJfbnT2FhjOXxFdqmN6SM& z(pvK-{ZOOYQLQ=k#XaY&H@kKu6nqG*%O`|zEQl!sQ&LN`ehCB?HziSR5>d|}GwXh5 zJ_5L_mr2tg&qrR8J@x>MApIfKIF9PsKb=ouKYC%j?B)y0K)AWpzCSxcRBDN8z7wOk zzuNlwZ_|2ChcAKmNdo45Vve z&0y!63aMQNeR^_q91ygEcWe|E6ZS99<;f{gb^HTn84n(abwp)Ez zLyMu2910d~_1@*07}`$(JpQ&~qU*V(-1(u)()uihZ7~dGxbZ?iq>EIf+mfi@kotSf zCx22CaX!&3;!a#YL1zeATJ%otV5iTOr6(R^#4^U@Bi9=4BjQ2t3f=)E&AN-PEF8$G zCNnNt4&gSKvO$M{$;$NxSML7PUS|f!i_xiy1qHAety!Sz)TQpLN?CnlMaS4s`$l>W zVX%=C!Nw@y!0p&|gwIGwC>le`nGwR7{?l7-sOVr3QGnT#ghpo54%x#Y(wx-rw?`hV zvCVLMx4AsB_E?+61uty53El#Gs0BMOBh@0n=Q3`U^=kuIbPNO|4m9$|YEGnyY)@kG zU2ZW`W-UV-c!Ce}!7ci8jCe#n9oaNc34GqVE5_Wo?XG7j#5rSSNj)GpAt9W=)0RI-E?5tjfSXSk zZZGAsne}Hh@Nq*!ul`KYn^$an@$MK@pZgAXN00TSf`R2oIgq13PoCs9%ik_FRRsHI zZQ8fZh~g)Cw5b_b$rhQ9{~u4%dPB)QX!QM1t=>lxU&86jji@r=eB9JOj`Mo`>>@1z zBKZIr!m*N*ybbSlt?62n&2!#)*tXo00u|!_%JAEw|0pYXP!Il~|8oU+^N=J}RO{X( z)P#N+*KzWzHR<6+vAN*4m{`7rJ8x`o=$VuP~8v>pRDbUltL$WGXwp5 zAcraG28sO2EenYYc}ww^l$Nk#AFm9Cz&5ZS}Awa=$oR; zLtV=FsGgYyP})#}`GU2FOqa$%rH)=m$ewNS2BqrnCTl;9k1VmmLEwZ`9-aEEB#kcJLdG$mP&!8H_m7z^SG;27Trt!p@u~k&7ijf+OgaCcONV%V$4E`)srgCp&Xmw2G?LIUc!rvKKsm~mVnnyD;R1szpFGYZ&{ zmI#RWx(Wn54X|K7(MUN#=(k5tbA^c3gP&R64;wF&47sT4eJ)48}8Ij_`nD&6OwP*evhGamj?( zM+EH2ca*#Az%H70rR3o>&@lo;*D{yR2QVsopd>H<3)@N#7tJgozUeitJTyG%j|I-YZhqYCx!w0_CY_*}j9nnp&aryK`6>aK{N-J$I5d@` zH?pUY4uwGmM=@eM0xZ?^MReAiL5v}{j%0-0V0hJ7qSnk|XO5NO3kf@=_2K>-$C8<* z11j%E%++Bu-(FL+coix+A! z9ymi!U!wSwFj{dTcNb6~!epRUM%LvAqd1bbWd!f1G8|ayF`8kd+LH-w?9Qd9r}67F z@vwfKV4CA1nm%1#>VgCnVIOD13JX6HTe0;o27fI?-@iZq=1g*4TBzn{s6qVxS~=%3 zs}zV0gvPk?A#zQt?Rn#}3-m?+!JL3Q#XsBY{c?-10F2)IoQ#ngBnKB-{hAApWBUS% z`4|=G&!aA!+akdJ>)%oJ!LK{bdbn#}Oio2HL?(Xh|;!bhfdH>(uC*R@kG*_;9X4b4(H}h-( zM~hKQPzr#Eiy%B{`$xP3n_DQfGj!md`}N{?MvdJBnJRCTB~YdpKp)`FMwirRY{-^4v}DUO z_px&8GDWX9u+suXo^&vvXZl{$2cCLHoktJWX(I{xGfj5?B&j;b?~ zkny6i*$vq=yLI~44ZY)W>S(CDd^pzP#}R)rUMQE_DO3d$=gRyKUjAnS6v)WQ?btxj z^sfT30CX_~b$k{-UgKpem>)}QfnT|u%~t1?D=%gu z&Nm`wlcg~D78*PRIgh2^_CkJbmiW=XMa4wO<0w!>$5hEm@-{X=Cq--ek*)}HMQ)5T zE(Cya!Iv3xp{R((O8nD#>6JKFD8^TyC#C__7M{{W4^qssBrD*LqwtX);8s@H_C+sN zz<-wy5tXp@Gr#`+e0mQLDK%P+kB&8r5npI4YhH~`!0MilWo1YzVFjY@p?3eE1X{k^bS3!DIzCd_ z6UkaCJW&~gOhhXq?e4Qzk_q7I86O|gZt%hHt%#H*NR^XxMqZ`>+@yz|pr4W(Lyh{n z{WGM-c2dy5!3{}kfOiNMD5f+atkqJhqAmizLU!460&`m{7z|$kIy|p2Q}wHJ<6Y*= z(m2uM_nEo)QH`8{J9{~tKk5c079rCaWyFE4HHSuxmEL+~&6K8hB8axt;fvU0J^qiw zfd_LTVx0-cl=?bjNcmp6Q;`dDt9JNRhE@ibm*&fYghEk z`S$wV9c2|Qjj@V&vG6bc@3eKhEqZ?DO{@yYvUOyMppf8hsY(%ez`KN6y|A&%c+%Ec zFP7fN?@70{#FC;^@2$ix`}rbScGVm(8} zc>BhAgjJwj@>&@nMfe?a}$4 z?^Z}yHoc5`^w0op*c;)AkXa;Nf3i_CKmPnBDw&@*->tnrJ`u&B!BMCoT$BogTKeP2 zUF3Cj3syoi^ToH;E5%BNLBqDE| zg*Ax^e6LA8zHGl>w$_F@^amwXjlWZR3>)c3j=|A;a3W3_3zA{=k8S|gFUseKbs<;^ zGi&tRAHz{*1lC;kj4JRujI2S=bcZA-syg2>0mKQ3$?NeJo-}wesRP zAtCS8U-PZD*amSVd(juYf1*WpY@jA>j{hS+He(6*0cSSX${J5Y&k0wsbK3_4=bup; z^UWQkt`e@4c$9}ty|&ov`~8G9JX5GQJlrD$rs1DrebAyojX3LaNl$ zwBt3M+1KNR*{JW#p+1=r0kc-3fBX9NaFvAIV)ks~#bQrQkj9xUY3F+!8xno7;UUR_ z{+H<~-LlL|Z4%f;XM7PKr-5Z~K5%6G(SRr!j7p>UP`ueh#eiDOlS{dGbgw;GzmX9W z{IJ$uRr_LMga6sOaP3{`wqkiytKT!u1|Ki|o^UN8iX?T0W$;6$)v2+-f~RL#DH3=% zn!pT{HW9^AkH~1TcobuYsg}BZ7lb}&$23-U0 zf?1TNkY}e=X89cxyoJ+CE@uUxD9RB`Qnix-%lk#V;XQJch|Dzcvr=4x-kRB}osMvY zoR}K7-2K#>Jgho*Htxbks99UJ+L=5nIzg6mf0sYE7E{KZ`Uqw&|qyJ_h81bH)Dz=^>YdI`I=e(z# z>RZbP$-N^&9eoyV7X}k59j2T-|5QpRsIRevu$!ssC*gRL=-Hdl_d zBd}X~+G>4x{RyF_*(VU$bRE&E0!xUz$r!vp&zZTA(d|*b9%_8^LMCQ6t1ceT3TlzY z(NdckIZ`{C&S|uaE>~FRm*WxL07QOesWhqRAIqZT(v$rbb3NIC_B$3kznXrba~D6! zy-d*S9eZ_>La0+o6cyOq9HzwsD65|3rex+?T=K-b>ZVj?L1T`jg5#XQA&&RW=8jf( z@?#>@g_9izt8`TG{Vp>YG$}STzB)TCwkaYR=YOdQ5+F>lU^T-X*AMzGu0mP&MZ4=z#e&R_0$Jae{O|w;DK0NrDMhh)7>z5^fEnM| z5%I3T=EVinclZ;CK)SeX(jI&{B_S~erESd>);Q|I9g@kgtq?G$OPQf!TcAFv%6gcaEztWgaR+fWTue>;y%Iea|~-F zM(>miQMs!#Y&4TYwS#r?qUi9j*SGvwgwWHlI8w&=k2!BxjSb3#|1J+irZtME|8%JR za>j7v`PCBgTZJM^T35P<1|B^Avh+&{@Wd|82qOoxucz%h;#W{%s5UFNa}x6)kRF`r zY5&>{9iNyrD6~+o+k7DrB(mSq_qzma2niq+3kHQwvhbA8SG<`83a15KCS{=U{?TE~ zkax8@ijFZ_&Ro7f)3EDjPd}SF6*xt~*TKNzn5sTAMB}!--oTqM@iZDr*-nt|-_AM+&!eXtN+@>uO-&@j5cNdo zA_=hU#`BjQGEA9c*MPL`<9Ch@!Rk!*v$UlTb0USDJe?aw*|jN zCbn7sa?6K9DghK7NF1K^DUDoC=LWusK7YrYMT)f}LM6+wt?dqzT{Bs9&{8E>CdCR) zQ(3wU4`jSzSYLY*AxKK=!D(h%4Ol5Oy@kIr;ro|Ri$Lv7By=q55hI?)>M*^O&t}IB zWn@@1VlF{hWxVj=?&(t@iKo^p;va>o1%DFTiUDoKrJ#DWooBreP5#MLvm7~f{@CmE zO?_Nt?48t4#>R7Km?A^ZNOMGu*|f5%Y#1*-eVs()a&(dbOYKBSEurhy04}e6N7#Dj zGic9@<%m7S5bunh%gNZtkcOk3Yz}>KF$Nj3Tez)ET_w#|O1N|@8i}))&I4c0w1(t< z#v=^E;c}%cgKO6cD?LPJ!$PM{3eVO}hLNW@KT>vC*D9TQ#txA8Ynj5mrxDH=-bIn` zyrITwe?|*fpbch)O9}rZDoUWf880wa*T;$9UK4=-Gi0JUVjHX*kdOq}LD0Fmp~VNe zVpS(>tu5~x%=dsCs0wpv>RTfO)cRJl2XA$l7n$cDE|o;^`W5r2^Wb<;ARnSwpZ6&? zek7!YxD}SIc^5vnrrOkPaI*(1d*$AI7BA$8K3{VnpU`)R{(`D;w(K@zTdk7tWeC(& zey_*1Q@K>L?IlI^Ssw#*=6lP-Uu#SgKLRk~mA1YGr8VU-SdWW%N8&X7A*(24K}x`S zebjb0GHj$266oQ?(9t~BUwh$==8`b6 z!R_1i-~xcu-rk{?O{KqED0K7M3l7PF6UmcpD?)|`e18^8D_a6+yHohIJ?Nhe>SajX zlJw0OB*T-sY0sAaIo%m_SQL%fx`4pT+X_djlf^tX{P-7y8RRxj#im!l4*`ylB_OCZ z$p^!*2%Z~eYYxNx%J0cwBl6uOxB9h`J~Xb6b}zl7dXFU8>NVY(E~O=8H;W6 z%Dcmkc6GVkbDFfK;832e#Na90u1k4s9&=VtNT{ZgG6cZtOklt>iTrxBy4htjLRA1p z<=PX+udX0eT$ZU%S)?pnvV;EC?+@8wd~WpikEYs@%jJds=I&$|w;iz_3RdkXHA}4s z*3@D4V1Opx@$K|3)p+1`c+RG<$Z-PrP{`qC$=4_GHS3-HQ{0**d(cQOE@uaGx~ew? z3sRF!{_%ChQ*BY_&a;@k;3V_@r3~^s9O5@_LqH?5H}n(eqwu|9fN(Q^6o>8F{@wc$ z%RjcX1%PxrkY#_HCGDs4+omFq_)vY#5?!F>5*u`7*;Biyju8k;!R`nt!J}LtwQJ8I zY&8}ar%P+{!y9C)n|sUY@ZEo_ZB~BK_glT(c=Hv4dqMd}bzv#-Uv*f)JENqj<)zewof~^m(P3_7<8-`K~ycOnyCU4k+y~Q!4MB8O;~yxt@y>f zm||=FG`+#C&md@mIJC)=`JgA$0~(-2d>?_>B6%0_z3s^HyxiRzoj7gbGq9tfS? z7O|N+vcv1hO-cQ`L=38N_1#Mw>N58H2+Yz~YRQ2*lUo?x0$YmMqsQqO3M%S%C32XK zW_k)6t6WPJg-?#3voOD4hDj@rjHw?~zummk;{51KS3;fGQT729j$`^E-;rE~S* z`?fO}*$Z>aFj5v7j5Vz#i(K6y4DW8-_x?TAgr=;SUjdcua_wPGs#^hW%4fB*H z72u!Pc`19PUA`!g4Q`V&Z7*fmOFtNZ!WHLbsp2O8!6Sxxevp3>l*gD;)a^uyN}FZT zLY?|lpYXR_JP<6u)lav#Xl}4`HSb`N*B4Z~OP|T6g+a;EE!SwUk@$VD{-)%it<6#K zbrlKx<7W2<_j^&ms4;l)pD4@MPw?tp2ABh+JU|#?pTy!kJka`?QtqUZ(Mxg!xN)<5b;3W_KDStQp5X# z9v{3g=pOA^(;mG}n-d~ZzX2s2o|cLW-^YDtXdQf=-#e`kUE!~&H7E1#2%k$+NLsP< z{LLw_$lq`*OZMJZ?W-D2qHgiHhf;-03n#v!RH7cE@|xp(Qj@`h6GjaJzp15zL|XD8;b0srVWFBP z#cryl7**!*CRJ&Z3&Rc5NUO0y?~Z|udg8Yu%;BU1lb@)A>*S9xE3VpNA9A^xO|Cy( zPoW)q7@ce158u+n$z^=GA{$7Q#CTw*rF~P^wt|CT6`<}l(Y;=2>%+1GNg5ItV~>9e zuU@&9VWMIpptGqL2Id)#$aWd^@T>c3O=qWx90Nc ztVJS7DH*E%PS+`Kx9Q(xUKcmb#t+_p|KIKcOgV)N7K$jjn_P|&7J|!(Kq*naxa(mV zBXY^}_!)Cj$39;5BSQ~`n@yKHaDae+6!yV<1ZpL{enS8Y5Zbr36G~Xrsa#I&!86Mw zRnFh!!$o$=IoMFg^F)txrkxnJV3?$fiQq9aTprH+Na5^;z+;Rw?L(@E3i3bu;TCq-g!!DHy=5Rn* z!#Zt=HK`qR8AN|U7GuJt48^V-s3qAdIbqky)dbgM$&Mrg-{SshqoT{iV0!1zi#-+U z?`&O9O4E6OKGo#C+rC$Z;*lGo$Cge#Gi<2%8b}kP2Pd^vS2LL>9uzGNnq&qs9(ozs z^x*wTC=Gn<`37k|#WNTHL&wxNHOxNIyQB!&QmL?0g?`I<9X(+@|-P%+yt&MdImxPDXuLwq@0)uCWF1)bBz+ zkKze1q&)1?>_Y_`BT+xJci}xm=gZQfa8iURKkv8tMdqX8uPVAoqQ1YQS1T~K8H%6g z4i@qv*=)V^vf;baK~RDxxOGD^^;$$kkZR5Z4}PUmbL)+)ivak zrVoCyd`7hNf{5^o@b6&X(jEG{fK1}m%0mWi22g(KnYXed+gZcwGv}T5Q=^kSu$~&2 z8dXFOlKEGJMf@EByt)m@p28S4agYSa)M}q|erv`ovCNoAnj z@%6QszdAvFp!a*tO5+5xcMmQ4NXe-8R0-o*v?YITsAw(}?q12V(n$yh=0-RPmG3lc zvs2wAMh;JA42EnYx64y(TF+&)e?L#kKwlA~_OAY6z@QVH9+KKg=X|d%FkBYNDS5LI zU*U=E31WtkDAc$~123B@mPO=@RAhLK*3#v5VGNh_W7Hfg($efhL(N!(P|Ok`u-h1$ zhNQUWX-nI}9My%8M4>uiyQhJarI8n* zShP?W(U5q2n6+RKA>AQ|`z4z{SU~9YlNmc{Pw?fpRwb553}ItDNAI>o5rc&Ll5>~M z7Sw|hbgh6`y7A4qG}y<3^|pv4U+|n+X6slp@!#) z9N=#hb^ezGjlb^FpO+}@DryhZ<(Oc!g`Bz;Jg78V@>4SsLjdw(0XxE)`(PZ^o60tY_*smL7I@;(3A z>)W~94VSZH;cRiH{h$uTX6Kv8iJ%XHr74Od4$5_1`LkLM^o6QAk|pZfoBU@0uN)W_ zh&b6HvMz|7^oubU8HiX&Y`MTCGdOebaKl_$Q(b9IKAu*F5Mxo7Aa!_Gdx_iDw2#RS z|MBNT0@Bf`>wE=QVJW?=U3$cWQusHvJ%irxc@!h$p9^ZI*{%s`aHTwz1Lo6Sv$v=_ zrG2@Rnh`oem{Y~w(sb67aE_*Se4hk~NPA-_yxt+lWZ@ub%WOU^nVSUHZ_as{r5RYB zN{5L|aH{#{L6WRKnD1cPX#Reu-?^j-Eza-?XxaH>!^N`b4BPWRQtx>s5?YjdPUTzmoMFR< zmcM!P)GHI^L}SB!M^?xlz&`WG8RRAYW2M%tdJME3%MWt2=obUJT2->YoDQ5Us&dEmvH5fTro<`;6L_+mxKTB{%8ef znluz9N&f+NwR+ypIA%B$|MeQ=n9J{zf?~f77M;Dl1hzWxUQeTnf+l;8vV8I~E@6Nv zwChx1$7I)M_0@KV?Ew7Ej>8py2;7~&JDCJil?w6p%lSW8rTMRcu|;@<{xv6kdtH9Q zJ&@R93k7sbAgcS}L>kqcx#W2Y)>T^W>4ahIH{1PY9%c37R>ka9eytXvHavUy5*>}X zbJKW-syN)2be@7gHw+k1xT>Rprx_Q0G=b)M4ov_P*%D)yH^-O3Jo4%VL$|d$0VeTE z+FnK`lOWR&dQiM4Jr8k%ACp@J3PF zBxpuzXH)_2l36Icw=FaM9zbIt0}~>jbk}Zfv;~W?)YKA-fr4lKis1+iCO|WKs2Se; z@!u&(hz%xouO9SvtQq3op8|bAPrPmv&471(nbA&Qwj#JFn*15}2_8%8xvxaL*`mCY zz#<9$z7U}t*URZhcrK8+B15lr+m4JR9*LH`Je@gM|r|!my&=r|&8iY4(Hc!F%PI2{wXw z7p#>OTwZva?c0xZfMonZmFCLz#i`1`VNUcWG-Sp_iCOH+`_bw@DKK)BivtNBS0bM8 z_e=!nP@J^y(mOg#F|lh^mV{xKGI!qlgzC*;4rlY9>8?S|h0q|ocJ1Dk(}ve9&liqZ z^v=TH)J?w!KTJtdwl&$)h|h8w<&4=ChLq{y?^_ebcIWqlzayHd9yK|Pi_ei^3l>rm zRDgsftD_hjfQFo(|M6NvaWlp@5r^DT_Ew$e7S!KSXH(=RfDU5g_B2FL?r+>Td)s4# zzRxNJd@kAZO(T&)Vg2J;TDMHZQhP>1Q%6m$e8m7D$XZW8S=4tU{<2v|JPU!#b}<<@ zp?O>g~0g;GMT+vFJeZq!a>Pk zQ?`dDT%ci5bMA9b;3DSu5O6bX+1V(i*uJ1R@kI#5{(C#y~%&#icdW5P~8~v26`}y zYUM8!*@4mR7X5%7y)ytq9XJhD_#eXq-l7iV($sr;a~~(^Uv2vsQ|Mo zVzbO`Xlmbrh1yZi4p|L5D($5xF9UKZdNsN6P8|n?-4{zIIggd_5Qve^eM$Bly=n=R z5bQd>d&8(7lwctm%!{Gz60^(A&D>4PMOiW*^TWUqX}mxk5MKY=tGi`XsvCZq7YWg- zikQug82M8Ox#}E`o_u2G6@WsOr zbJGwMUOH*t5?(pu(cgC&i2D-ae91Na9LA%yBe~&A=nZRJ8r0zf)7wF&{oi9|vjdHj z=;^K1^LX4jgEgtokS$P4hGyp88UBGmA~Tqw!p-w3fYG{EyXIYWs7roC5wSS6I$UsK zk)3B?ceeZOO>%ctn-CifG8o1M$k~FeJl?0}g9}b)>{KN1`E@Xe$fhIEGpO!wZx;W| z{FPA()9R@KX9l(I*RB84GcdjLn$``dl&#^9R54~NG1`AR-+6+y!H&L??A&axgL0Vo z9}~9rcevHweTA!X2`MHM^(N!e_hPA9_5va)n}&DCO9WuR*|5H%{uGcg+6GsQgfW!E zfv-KNaQ7Tn11laRGI}My9&N%Zs@A?&MJ?K-I@pj&n&lv9>9--9**!-SZZ`;CxU5ABCve9&~ znrTh2p?TFX(ku$~NL?k+JB*^6rK;ExA&12x#@Os&=a_2~s&>7fhxIX3`(oK&hl)<> zCwbnyR?o2-sakVnFe0SPwTx7p4b<81@nRwyKC;tS^G}Yy`Ah26L0v_N4^8ovbu&KA zIB?X>6i4BfdHv9Jk?S!Bj9TrYl}dqdkL|wkou?|0nMC7G?t9}r8MAdUPcj`jr+>Wy zswzxF*$MWzzIS{U9_dmsX#g&(ZVB{3{r+-vj|r&Ak@jSB-ba?3$>8Ir>@0qNTt%~(wzuBXyiXaB(lAMlkK&nj)h-xK{6!|6L1Li z9lf-o<@bOHGJ_$<^=`0~FDpaRHG|G{r3k!Wq9ktfpgaoyh1iB+=3DG8b+vIi0S znq`1{Py?|#VSJSnFJwmd(O>J_w|QRi^$MzNO51Z9fOOpW z|fox*u zim?CIV;Gv*n21`E&-)^Rcz`x)5m0MBt>KF~*0kkE^+0lQlJ2Ho*}flAm0#`fDfYh4 zkzS)Z)QRzjf2X1_T1-&Logu@YikBs#-m9hfQ*dnHvw&e@U1F*!%65Tc>F`5-7XjR8 zyAt5FhrSy1G0isjv+>u|eEbHGH)!^jAN;oZVX=M5V$Xy6%nU5~W)0ASM2Ha3Z10?( zw~!(aQvIUOL!j|Zr&#Q96qAsLDJI6nmr>SLeFWtiXa>Yqfh9h2%(7;c)|T*WZW)-& zV!jg16J!>u^2_WYx$pH>t9N?M@cr-dDYnJlQDGtB;HJ))RwEUR7P8p{lWDdf#GzVo zNYok*!#!UzHX5M7_#P5Tq1I+@EY;|W`og3U?`#I^+r$&c+jkqmtA+Aa8HW2m8!slp zuyHl`ZR=o%8>C%bfR7}x%!4WMmhG=d8L~w@w~6`~Wr0edaiEsN|u) zODVIws}N){fnFudvq=jS+#a8ZT%A+dFal{y$EuPoRA4iJ<$1n?x-V<+XRy5m)dpUsU?b6g zd2oVaPG|s?20o<)NSaSv%+boe8dj`Q(JRKzaESE5B1MOlVK_8?=O8Et)n`N9P`41S zPjXoRD!$e|@$%$Q#zuWkyj3k(QiKJmv;DUDS$RX|(x1dw6(9LJ#LQqeett#|4g+CS zIr+!xm_*BL{5P@ER&Kca&04A@=W_+-C;*A3!HTRXKG6Fa=p`%BXf-p!JfV=CNsP4A=gpCU_lKhkvRaLr=zetoy|@5Zx!7Q zSCLT5Tk1fP9683v@~+Yz#D&jg?cEOjWh39UdgoL*7I+OmlP)NI`6D6Jl`$z0bF%9= z<}gfsg5t31qC<8=-bA{X$Ko7Z1Kz30>k0yk;nb&n&kaqo+$>`4%U-K5l;3IfmY(4M(E-r)dqi=nYixWRbeWUFxboawiEoH#NfIU^Qbr`4U*zlHbZ=YIIkK7==5k)(W`5&c8I?q*fD z;dy86#_~3xq8?JKuvMJIP@-cB#nx8Xo#{GFeSC&GXM$c;;1(1qf)4egtSO!QUwMt- zshcfMstWxUIEMW%!4&nUrJRH+jJ5mT#A9b4Nb!LsrO;0UHsOQs%@|nwqC>Doq1)T- z+lFf|Q~4hLE*Qk?tKX1gd4E|M|ARDs9TSiiIb}s)wy=l{&2AfrVKpf)~XQ*E{`-W{^9Hc z+nQCbBwtbwNIx*MVB8x8ggqnQICyT}lw0tA((^xOK70I|PgGc&lBmMRs^FY8_n~FL zN!JWNDwqE$J3Xld<2js>)bZ)BzAz@ahmu6qy{_fuS4M<+v17}HrnhKQ}v*mE^ZgUI@HG6S#DtB(_L~9Z4FYS=-?>-uGEnu|@GiD}(9uC6-GBoW^7FpRaae z9fxRKT38l~jYmfsN(!9Cz{YbmN5J1 z?LG!3qx(WaW`-z&*&u`B{u#Eg2D);MVLzo6%f@oTk~IB@&#vjqR?;KNYT>J&>Sx?> z!Fu7&9FVY&V&H>7U4)8gv%q9D2)mD3??0t|md#^C1L;=RoRu7ONy_MhzpU4JnLaaO z3Vyu^8Ul5@gHu_bsHsixr*CfmOCo#aE;G(2%I%hYpHFVeeQ0I@PRDlt%bHoSzYko;1j5~5K7W`^i7h573T&#MAtJ3WJzySmg z(^67yj_!UNXSz9B5CeCO;&tto;LY}#h7|>#R=0&q_}!4Y9J&4~c!t?NHtKYTNY{fzXv^#YK!Z^7gYx@Yi={$*yI$=1h*W0K7UHa1<1r@>@xb zx3F0LJZ-6G70ho9j&Tq`2X8h_zuI=AVP^`4V+TxQ5=FV%`GNX~N*bJ2L`Pk)IS?$>~+MB1&``jO7oQ|*?)pyth? z2X^P0wvI$DfpEXKbr$fr&wIck=PGMFIMeNcw+tzKZPta3Mbke~_Q0a+ibg>&$r|D` zzCD^+pVkobw%=azr>MlL>x!wvlW*@Kn{_=*#gw-9DJf2>W&s?=wuqNPie|F7aIxZj zz8!)s6pkd*>)X;rxYFCaSESkM4M<@X`;}~y)RtJCVrX4mmgS#>5EFvnRwQ$bFT z)h{4yJ9qFp!%CWIe&n5SW2{s)Q7g@9x*Kf4EBL#!4PK$637*B3+ZX#(RyU=6Te#TU zGG@bLShtN_A+4SGMJIZFf!nVAkJEqn>oQAZ!}q<5)Jo#3JvT|B-gYZyQY0nE3zL{$ zp&6c9{MxjbcCR#FuH>OGXHn=-6tlT-zBx{}wiz)NmvfpIc|(gP#pmd!-r3 zrQ1A`b9f_E46=^7Q%p5Enc$N@_TrGmEnaQ&Dx=9CTv_a}>zV#JD*W?)G{l&$wJ!dQ z4K&!Sp3#w73P`4wI_$Se2UFvxY9*ZPW|)kU6=z-~#n?yl5!b}$EH(|qYtsGAq$>*H zu+eSDB{(_&?TTH7wHd6GZjuBiO4Ez+%^8K zqBnYWxvJR5_qlR^id^T=3hPtfLIbH%NHKBp-@GQu@01RdXBA2aWJ2M(cbg%aQHH>A zh}jsz2GYsNZ{n%=ung&BH1V7)=&o>L zRFznxY(Mcu_k<%E;aA+11@3Kp^=|GjH$cj+rYO}Pl&Um0v>~N2&F&GDiRsU|$Q~MI z334_OX1|ij<6M{df!`4IlgMo=jI}%+1lM?f9B(L7$=zw?h5CtP?GFc}VzQ%Y+`wmze3{z2t{V z!}VB0o;}qIY?w0Ptq_%~TN)mR1C&vz+jO-F7?KZ;UCD`3^0~Q^ad;_`D-qw3){gb% zFchJ;rn-e=S3As0bKkv=Z6|DH@4l!#KVW&j_U*($g3&*xF8TXRWbylO4f5C*c#v;a zx^7jRXGtk~Q-0g@ji=}ZSK9*kY?3|J$Kt-4PR45Vm>Zr@KJL zqA@$5dg}!$`jA*LG~Pewk>ZGl>oCq-%=wwOlrj*PkB8Q7VN1^-W4%c4xYJj_%{w&F zL5_WlO91jC>1plgtujQ@UY*28mCJCvT|mw-MbCC?IufP+seYJ)6I{241G z8yLsHRUrqm(@jr_NBOf4);M+Z`&{plvUKW6a9E;W%a6HNIGF-fzR`H0by40R-YkxJ zseCxcKL@ICpAC9+9mHQVM|#))G+s(xkrl5Dw0hk=E*ljDte{Mzm3dfA>OKD}HE)Fx zUJVK(3FrzH6lHi*&ztWf_|Cg{vO_*bZ<&POn43tS)Pigb441q^+tJc5F0Q@@-XBt} zvglQYGc>?4xcYXI6;F92-jeRMN5X*6TaTm+@Ze&e=wqf194AGGy*lg^uuB| z1(5f=xCJB}W7C zvPu2SL!}((j*yCYr)3l4KznGIE6z%!NbpIN&`x5jv|&onCZsHgV^&!za>~j?xrQfwK(nTB|MW{ zQ(#))|8rBLU@#DcPvtSl!xJ09qSYNmxxZj`d$|(9a7i!eM_sWC#*bXBbDq}wO;lX= zNlTeThKpb;Pj25ZT?0QiU4N$#MO{=t4Ra_RVK!2m`dIhx_P{bElk}ecark*azH0R2 z+%kaUD`qAkxB%39#SZtp1FHjF@z7jAa=L(tu*oMaVp|d2?Bhtx9c%rzclsN3h&N4i z!8Az1y!amtQo=GiVwa0Kq%}!pJ@{Nj#q0=*W4F)!JO~9(`e2Pl*9hDO$6$@_(q9iF zuWzwJ@7H_P*g`JM-PfYK!C#}~r@_{Z1#l-zE7l_>8T|NzRYq-LF+a?bl^*l|nipRm8BU z0(G=YjqWSCo=@LxstTzz0T=H}0v`zO4mN#u$~QHSu?5$e&~rxU=icQ1S7|={&zpwb zK4mZZ-}_mfD%MmJK=uzxA~IUDEQ_SWMLxu89=F3&j!-jJ=c<`J=1yjg1(bQLGO<$! zF~5JPdRob7b+qvz(mV*&!vo6Pm{=f8p$F$mfxnf!Vnnaqe#^nE*m5tvBEJ@JK5_bL z9#41eS#7*>o&LV7UIy+m2!e!r8`W;QYKXZs+;QTkE zHp_vk>Y)%mWI$}Ko?ulH?~RpPS*rn|W*XFEl}#jExEf?tcHP7uf@vW21v^bKUytDJ zU;R_<)2?NI&B-?2gla~lc*hV9CHjbM{6fiR5=%F=Y-osYc}<&I5Nh8ZR!091?aMgC?M^vXm^b}jSy5XZYKiI z47<6j0V>#hoeHM$O5)q_BgGbYAsg_Bw$kO~87h01ooY6Qc3*izJUnmZW^13I=Sq`IM~i)QGW=@UJ)t2?2_my@Yx3Ab+4g=TzNp+4DWwzj@=0F5eH z9HrlMYVWL$F*@sIe6mb^N+YC?n#~PAt?u}eYpq2prd%rN>D_FV4;XU}2D^e)m}GpJ z!dQr+cB(sX=LeI=%igwi%(nvT$J<}`|MSxSx5N4L-)M9_h5o|%zxVlPxD|pmL@Xq< z3XB(}dyyqOd3Rk5@wCP<-~x|{2JgfXyR&(>lzD+BZz_*G5ntM<@&(tXKlVA9FNNd5 z$D6|n8`*CyX~1?o1y7=REz<;0kxIKq#<(N($2lZ|Q~y=$hj=tupsWCX)66j&b&c#& zFDxDf?stV=2+vL0h(Q390-un&sii6gto9mg%Mv*s`ggX+$knmJQ7bix5e{CDy6gKi zIz!|S_I#1e>4OcPB(VlUmB3&Q5x6;4Lj9#C0{DyVu-FXw7L4-h2*U$PoMfrUlPTcI zOaCNGNJRvsnJ9xhiNTKTMI1;^7|ajr=Qj^^|<&4-1K7qp={=h7`frS2npC~L$QRR`pv~i3w3wk4o`=&=V zm9!A(G#$bL!9QMPl)kohav0Bu3E4NZkj`jXBh`#0h>wRdt}F?Bp@A2r*2hjHz`v2w zj*bb|?)QJ{5A@i%mjZ$l#zrwq3KTeGI!+62Ixe;bqBw5+j{%OS@^L?v$+i<$4&!GnLw=DjE7WTq?6V1R>?{D`WH-F&VH? z1t?sm!<0_dbb15}ad~tt>6Z&iA67Hlm3Mss`%m`i7bTSwT_Z5yZ}z?#pBtY)A;$$p zODe|>ivd0_Hr6l519@(8W0f!7g-b0*)*AjFp8m11(zfZ^Ml)mEPDdSE9otFAwr$(C zo$lDSZQJVDcJj{iTKBW&xA_B(s=cbl*v6U^)7l5+V702v(=1Y{taZlmbzHTMhfyp7x8D-x$?JLuz@6_ z>gu_tuj6F6r+wJP820H4bn0|Qh~1VmDdtpqtkgO>fpp%%jDJ@D$Hn$&7Guq1>%zh0 zB;|CCuBjtrJi4s0!HJgKZ2#(IH-GYoAcwn-uF(uD9@}UUcOFQ}y8fIZ?xuFq^eifB z+i?DNElQZ{P_1={3QwCA(v{DV$Kf<#aQQWO2TdcxRC@drRQ=~)Ow)J7uMfNy1IemL zsZCU|g7howqx|2MW0jAEo$0@dde$3|dfeZ*L>_V1tK*u@?NJGpZLck(&mN`Je*e2C z*TYf-w}^!0z`-kVI6q9=ySr!qD|AC2BhoAwkTMi{l)+&Yi9qAQH-@D-5KmXdUtICf zNkyb3Ag-H)6{%`AQn9!4{uRHwr z%Ys+JQVATMDp#vhf|(bb2f;g6%}GP!KsW5j$flp$7F!Nn8l4vZgv0i0tCiWe+y9lA z5}W`&{Z|XI4!`REv-9Ok^<|Lv9M=`cP4D^^5WIDBlRD&B_#RbCBviZYT0_?I1aZ?% zFcR4QT6JLFx#j)7DLZh#yzCk+cb<~j3x4asx%VF-C|+Q@=f0`LK_!m0H!uwO|?*T2M=$aRv)w zk`75tRVV$Y429C9fRAz0R)1t$2ipyOArT~x?6rMLRj`G%gK_QE9OywtW?7SAc~OXb zeLQWoAA$t`dtIUt9bq_`l4MH3+<#^jyVq5cC(`JKWhzGZB3aqk!OAj*o|>`RP`C{= zEk93s>FnmW_g9nXqdIg;BIm%D83bxH904a*4$y|T>WtAqVq}RvW(8c<41hO)gg^kPUfRq_S5ZNRLO#dS9qv|VugyY#~Lo}~}kMC%35 zfd?i1B1)P3Shb<;B+8q(=glzFrr1>ZB+5h9oS?-_LFrH@z)?jLX_UHdMz2k*!9+dz z%XG|&k;M7np@4s`{Nd}(#-?^MN>~t?y@PSI=1|8R40A0jk6lZV1!ZsgT6Ar3LZy== zWKEL(ZMIYG?WRg85SfxL*5DTS5Ck}qTByU)r?L{}E2IdtiUd^nQ&tx^d&qUdYTh^$ zF7?L|xkHD==83R#{xm6Knrh>e>l7$VH|f+goQU)|g4I8L=hFtHvb@+*laJYE_g*%} zvklw#wJ)_ku2(Q^xb2Y>*dP9xi>x(Zla3u>uu_qORL4A!0r*`+db1FxCB#Dr)8Tpg zkcAuBiRB>FHm+>2#QcGAQnus7i4n)ERfh8_K2`((H7P1;*!G=m^tg3Vt-t&)JD&Et z&GOKC6M@jgY$`x%G`|sbys4Kq$VAWE6Gtua1M;wqGG|Y4PPJ6lt6$)JcHV(~5zpJ7 zV_gAlJk;k4c0td+q9xB1x1McvR@^zq>jk`8@E;+28G|>&;x{P_I>I}iSA|(5x>ueB!JGu zXI~85&Q#NK2qg7LyOJy+2ATo6mrs(^p^?%YADNSopf7EDBe+J}mB|wP6bEh1dk(nV z-8?Gx%CxauUc;YVLggM}D}T&2B6~V_ck7J!ehBKAg?H|F?KY1o_obQKxl`D5z^7FH z6uTdQ?{f!hvFXZpIe<8}$=u_#`)0Q?{cibxr3T}FrAD;f@BcZ6^Vd=;dGGy^)L{wZ zfk)MX@?$O*MJ$$hL!5Y9duD5p`ooL^S@{NQ&;8=ZL8p(o*30hBcZ9|lVvRrCMxlrk z>(G!V=#n~%BL0DrVPl% zcw6=pwA?NP#f=$liJ)ximP8#?Vg;DGTP<)#lHwf!R&FXe{$;@g$t*)7=C6`e zMcM5@q;X-&nEvX2Alf!{UNff6@W_B%!zt`E^BiKhj(Bf)ih~?>`wwW>Z9gJY_F+c! zroDx5p&<1`UFyU?`)FXnxa0Da9)1ejB_)d>%>~> zpe`i!d>{wKvI+0^x@Bh=3uzE6t2*z@N}haJFTqliVvzXB{Hb6#j8N^~NNW*Q$ojyPQ1X z6&D#;olc<#$|C}(18MkTR6s9&d1Jfreqf7@%29O4G}10%AEqZh2Gep=eX9Cr9=}Gu zRv*a4=vMPDV!9+j8{kI_f&h-J=)1tvtcwM4Txd5U5h_;r4Gb(zO|JVi{6*QJZKN_` zK5K1$MC3~^POU1O#~T!5Wu|t$`y{q8!Tjbi;2%jJPIsu}*mi`8)&><~p^={CSCS+q zAJy()8>ml$G*>$ZRi{RfhERtkT?75+C<(GH|LSEK;yTR4@o+7riFQ-v4fw&cHO+-mz8IF46B76qPwLf3(aHgSeLy`|2UZhv4?R@MEqhE6UhT;D4djV}A_#`Ry89 zyzD|J1_%OZP1jFU^@zN&9I9fQmqjLHh#f(+Z1g4Wr&ept zb6aobJzDkE%FU%@C{otbqZ>-1724laDIWeORs*-BQ_2I93*35WHfuD4SFL&8Kqa8kiqT2AT&`i9U42_$fZ#f zXPmv^ouJBFFDLYd+Zs%zzuOGo5}|*S&f{aUI7-p)9-b$C&H0x@(oQ_n{5RSbyz6=$ zy$j={XGa6He0QwVBP6BM$}(|?L!3hzY4nJaFCcm_=E!+&=gN?FZ#kZJ&1QHR==VYm zES44Sy}t6El^u*qB2-JraZ~-is<`dtbeL#`HSSwKpd>^Q^8+DycwWb8-2ZVfkX9q) zAB6nP%ec{vZ|Uji+6-qZmSy(kLTW3HRJ91sXA8C4wkM$1uEQtU>K&skyee>xpD$Ob z+21kCZ2?A=7XU0ahbBJx)`o-I^rZ3_M}GwTO@D7giPIX2ecr}E_M5y-2xb#8#y)5E zj_6pHp}Wf3@^9S?+6(^xdvrY|{2H1%x8J~`7bh>033L^25pZSWWAg`w16NJ3KXb9< z8lMt-=>XaI-rvXnfhy5Xl+VrYJ*YU`X%G{$oZ95hIAgPlEhg<$p$$0jRynQMzdmhR za(kfNr!q=e7PeqMd;Y5xz+2Ib;D6?v<9xI?+5i2QX9v}wkO(Y^c1@3k+J>I!8{t-& z8PMN1%uM2v)`p4-uolhqph+OF08C2pm}&m&_^Ni>9`-7UePh+uckBa~hsTh5%np4u z))Pz+I3026npZrR@A51Fg5Z@1@2gNZ8$``P)s^STX<7Itc3J}Rbp=i`wv}YSpd0S8 z|75Io8WIF5XI#an7|5XA4Qi&9W76`Z2vGWlVk%y^J8{UdqMH-z7tP(B^%-Acq&XU{ zMFD8K^qKI;GQdT=oQQAj$BOS2WC_|3)r`PhC*c?RF%}}wu?hO1;`pPd`&t-DRYSqR z?K?mqC;F7Cb<$doFK0nyYPwlhGQe`y^Mlu&z-w^@AqK#NSA|>;`-ec9P6x+wdsuM| zaiQ5#CfkMElL`Y>GJ5Qo4%&y?5Lw&gJv=H08~-KR4EHbv)M6_fo-6vLu`)K1^@FF{ zPTS8%%Wv3d;4d_dp}kYa+TRuF8Ldq8h>agj`dgPwJ4`Yx8%-@OA6T3v!|MvyHwA)7CtqS<;_Hccmz z(I$V~$pS$ntF8!SPtp}^l3L4}0AektOOc60zOAt1zNrD<~wAGK3!pLJ*c#Hqw36B1+0E)L=@QHrS1j z$`n!AkPNiKu?l~54_Bet8T3LoYI+dC{s!RokrpVPn}GnU&FZs^0e*xL{aU?dD&1>f{l>PfyYiH?`-3_p;J_ zroK#T8fpz5A^dAe5O=jnO1@{cj)v2GeOUCH3^ zni3Ov789M}JIqY+sLx0QSLyuvGWggKj{Cc(HaHp@tyEoC-)!DHU+WoT)&9vRJpA?N zLAuw)(i&aOT02gw<^{C;ma|``t?-!q4Y!jOPw45h8r$jY*CYi@#)q}lLdBk$Jb#qg z)0J?^uXH?x!Ezy%Wv@Gq2_g zzwQ6KRWJYVR_%iHFthVN$(%WOz04WXE4Z|4k+~YE!K`_s@C=+)#0H^bl0JxTJFRSk zN$vribiiS3R<#`Sq#Q7zFaS-w_WCm@+^5KZf?UZvBa!e^+nO*u3VcFaild-lRy7;H zwo$O$F;E$jd1g5IgA)K0u>EYAfHYrJYFc)WMxBI(wC~GADCPdc7z5vtNM4`#f=x$R zp{(Q~0ahm;wujD*)D`h8PY6aGkZeqCTI5=?1Y-pVf9#&80!z)*kzD@!K{0_WhJ%SX zIp{&NEXl!yI?yKFY3)#;D3sM0R`NUD7DQnUtzYH=?X#NXN<3Kpi8Vz~x`DN>v+jwxWH;2(#)FA}J229Gpa!)g$jbRM zqSe85TI~mDm~ksA(VtC#MpBG|wqGCSro$~nsoOA0JjwciONTVxbgem?(wM(4tM5rG z8$K>!pI`SoAuA2WAYcN+Vpq``_F>+OEL?mTvq{uS;|F{Z`9kOoWy?+|2^n+)X0|#p zrwdm|YwnwpG_*|fcFM{aOtjbl>jT&Y{~Nt45nu3V32n%_Vm&!*a%?TAB@FI5ar)Xx zzV~1|IZdtgGN{IPtAQZ$AN{~5dP$CRsjIK570lGmNSWX^Z!qd(Jm}DgRH_V+w8LuS z=@3rmJF+X^e-8MBIKrw#s7fi(lhR#;qwcKA3Py-RM%leH2b0&x$&WCq38Hzfh1xrI z9QFfPJ>fRz%YP9;n`}50Xd!9l8OoSX{=8;pY?$1X;?>gni${c*(jMc{wFg0Tquq4k z)lTBX5-Lk}K(@jcI5GFuU#RX_6_l>{c=TsxXAz&Ck4{a&V%APwvfW2MGd*QF!qgnp zZLw~7utU)nq3g*E!?M#K*P3@x)TNs{7+GT~ew^mC7=8Ie)d)LL%X0-mxy)61->m5! zoUXY3!1r{7!&Cow~)Bm(y|?HZ*xK!MDWmC zWR`xeGW=Yel#NQD_enf^7uw#zji(r zKFGSi@86k*pMRzaSl}{aH5Hrr`psufwnepX|E_i6rAqP#=BM3%tf!>?No)@Gp!O`7 z&(Z8rW}#ckRF+sTbx{O63r)zKhpr49CF*4XHYQ4W-XM2-tDZ$D|?Lt%_>r= zk&L7wdW@L$mp*uuR-B z0t&ESm~1p-mCo`7s4q8QHIluk!nCihiItEuIw7=LnZ>85BTi~1z2&U-W&}h;E_&NN z8zvFNY&54*Q&5Yx%~OjqI@8W|uFO;33Lu07j7-H?r|Ca(Ne8cy=Le}cR*z3<>%dO0Z$gI~ zvOZr9b8iq0WH-i8PuRjVo}gNxIW2fgVuy?=q$54HH}cFaAN12d9$qg*AJHHVGb0r= z{bJwxLeT#bGVXSS;3!y(#!L0yxPHW?DJFP&Y3#cg^LGJj#||pJdmbO*UU0Z$R|j$P zxcE=@Vp!y1^sVdvtA1egOqO(TByKO^{aTp&Qh)iZ=+ckbl|4Y75k(Lnw=Gz<-k<4yb)h9JFGy_0 zIAQS517$}|&C)E!We4lxN%eY6nKuER|Ds8`oA(b+R%@{~%X+__gFNHC_>%173P2+(xOqIr&i_1yM%(6it3=iN`fUlzq6 z6XPI+UPf3~UPlndLB>B9$f~$SCk{p3`KVxk*?`%p!!J?SzFAGdF=5ITjVT*4obQW_i_)f)XK` zZx7)&0SNUDOd(WSbO=nb$XQRRK9HGEG;7W>P=KwR&bAIYXs6+}ZFc90fzn%Rq2bMG z-XzPH1$B~DNd!{=w0^iANmEm+u3(|Gn!2nwAfl=DVCO8jJjJ`13(*@W5V~{ z<7#}m8nmvb+Mg~o3IO(wB4$XOv|056vJC5AwoP4@m(Ij?)!#E>Yk zbt>IYFD~rvs1p5aDdiD$OLn2f^?80n=%}8xN8i zycr`HdFljs3qDPSWl4W?nts8q-mXR5dF>x66*Ve{-`;=2tabH(U?LwK&q@82Dccg- zk?RY~aN&dIV1xS$9GpvU$hriCb`25KYMg2-$tkf-O_X}9lbbzStLH^jyV;hvToTQ< z7<*h%sw_ie*u|InJk#g1tDC6dWYO(^kb-A~Xfq~5qPFG&%~IuexJxY-Ock8k>tBkH z=OSh47uGsWgnv%fsAQ#Cd|_p|lp?Lu`^rt8?0-8M8AdI=i(1fTG+$UT_x-Xjd`KhT zedhn9^=)$Es9z`k1uhgw3`KKa&|c1(1_F0%z6-goEpEf=5F|&HWGeNW{H)i8t3hPe`;)uLppYY$LJMii(_73U zz;KE=5y^D3c84;3LE}Pt{}3EmDZyrAwUnDt9EeH&QL9ahg(&{O9%m;R^Cp!1<1p z?#-4qzdZhP3;6F;kexXTFd6*!@9~I;tSnpgTsFpLc zIe^5WR_Zn4PaM;u7n5j2Dz;nUt4NRO=FZMyWW#9f8gg1htqrxJkvgIpn3Yj_47i@< zj6NCW`z)rH@+n?EF2Fwy_7z#R=#Q$HT%tXU$lzcvfKxa+|KamM?I839Sr`drsv1CL zJVdkwvqd#CP)1Ewg;&x&&Cu&Y^JUsFz)8P08gy}>E#2$PvW3K?6T?-F_Xr^C520xS zWJYTFa=Ds7qHLJ3NO{J9;Z?F8%z4j1l3dP=fAcLo%7^a+Gq_O}1ML}-6h(1HgE1&P zcN6b(Qbv=yozNBRIbmx~Si}XPMWzFi+ZOD|!1Pgju^e`T#3S->%YuO8_`>D+`Q+9k z+cxUrr|W6a#|lNlm&q(B@LIA#5jvL0&kPa0Hwm=9QB|^uFh~Wp zD!r&{pIb%c115lzS3vU6k>robK{B&ht`mtwy%9LOu9%J%{t(rwY8-9nqT^fZ43Ix% z#~542G^mw@%~~<*c2y49EzV5czEex3L1W#_whAU^5ZJs1lbZfd#tz9$28BfMN;pzy zK@@o0PnW}C2Ln=X0}PYFlh@-$Qta&7@`jl0^dDrxboUNl?NitfLTj}og9bHtiitG} z5)owv>c{PWOm#C84I7%VWIUZNnSD8*%U;i4P@Mu(h?>`5_85ppHpF8=n*f8xCkX~r z&U6F$w6P@*=@>>!wGNjKLaIgS=d*)FOM@6%482Vo&5|&!;y$|djzjuG$xbx8O>oYr z#~$7MILK>WK3P9cZxu|(2Cvtj4eqb2J(j@Y8~G`d5_^*7^{O`_46kx*21;f(>HIZL z#j6gf^*Ni&(V!GepyMuqbs*1Imx&gqVG~?7W4YE(0s zoAMm%nwsBdH*K}<8>F)jr)@5g%7GoElxmONNZJ{hN|4y(8Md}sA+B+0Fff3++iijl zNO2ZU|5=+zA!Arx*wi%xDJEAuX7fGG_~7;E{^~K#&Q1I5z~OHeyIkc!wUfa0cXL3g zCoqV#;H4yQGb)KHUez4`sE@=*yCM7X)`-Pv3P+@d+vJspW6Y3qipg5N9%P4P_ImJ! zn@;07dfWKf{lZqyS7r_t5!nkPTY~vRkG~U-avwe?|A%HF>zX3G?7+(s`B9s36rLH* z`302&T8XXE{M?C~rGw=gsF%siFJ&_JfzD!7<1NU=wWL?w=epHM4IX8f_-)?zovkLJEP3^FbWBQ z`GHU=7sfs53@i_4EV`p=fWN&k-}BG;&#z#$>n`7><@Z19TZiRmXVraZt+i2OtNM)CjQ3s)y@E z95kS*xL{ZU-18nZ>%PXz9(?VL{)ahne4$~wTM1yP;A-=og$s-)=1-y&s7c14&4ONp zMBLxN_dJ@KLE%nn;>^%tr|tQqdULrW*M`~i15fwsRtMu{*h#Q6DPlt1^eVvoQlvSv zxVa}m%Hl2knB!T+f@z+%Vh%J!pLrI#ky@O$^!?DzTEkY8XZ~%Me|bx^x>{ zQZ%T04Q|ITSMU=|{~>YU27pm$$8K-w6Y8c5im0DEhi`wm^dslILLy_eO@y7|Q3GK@ zB|rm0QA0w11(VrfL3uJ3_Y;vK2p-5B;-&v}Y8;JhDeHCfrUPhnY(O89#Ef}ILekgp zzYZE!9ayG#e(l>xN>8D*9xK}B#rm$&%!F+}&d=n-;*pn$-=`G`!%bzd8$crbgh-sL zL)EvSzW_}Q6i#}@eJ+gi{kGEUKQonP-|2QbM!H$ovICG-AamgM_p3~e1MFw1_ELAU zvQm#ZUOSQjH{AD}9)|FG8=fq3*mziFkf}WPuM~0SCDi%2tlDeTE%+}LW;i^y9+20& zOITomcOQG1XASE?yMAyTrdT&s*E=D?pX%cSFw{;{#EcTEwqKLfTYkQDGziEj(#DmT zNA^#*=eHd#zPMJxdhEu37qO8La1Uv1^CGBZVSJ4Du#1s*zu&KNR(ZMOT9-uRIh$Nb zg_;DUXQ&wNW3)IElOOjC%t$^?GD?Q@dY;hq)=xZk^fZu`#FtP?Nq>v)21N-VsQcR2 z)zoN<=g0OkMfyX~o`#+PXrc0=i;+WUp^!E{6Nr0_6O9jY(_)ItI8NVBGZ&v{uHH{w zG_Z49+>UaVYOb~uo<1h!h34FUL>#Uzm#B1Z9x?2Ui)kB*>Yl=VCImM!f|Y+MXPo}f zj5QWl;I7KkA2_@DOUh4H!KuLi9kb%wM`qIhHav{Ntz zU6xzgW#Dj*s$)Cq$t4zD8l4ny4n&}x+n>oZV-+&qjg?lKTgEu#aA9JNB*p@g-ojA92E zl|mkIq~DXC(rFe4NCzV204*cdd)4AZGk}S|WSbz0R2tpODXEA({mdqyTe$-%W_b}c zde4&4&l~$U6^ARu2%4ZZ)py`_2u{i5;mIWWueW}$PdV;ZrhuGMs`eCJWJ$#u5P3i# zZZr0VNOsWktK0wE98(^aY{s6A%HmJ61A>h&QTftcrW&=`oe~ zZV1!(88nGRGY3>rEDJK*k9o%!%~kLiU0QmdrUHxU3HT48f5w>O`1P43)1mR)D{#9~ zIR6HK=Fge7-Y-L^rbCc&gD7={J6dt|OEI^p`6iL~p-UpHTH2sQ6*bynQv+1vs|NS< zbhcpntz;?ap~u(mv>0i3=6YCODpB^JWgg0COxka%<3!k9bs*qX_xt9#CpHwy`xvMY zc>W5y5f@LGXUjFpfaJl9N8eqNVk@=Cuw;rHu3+o`s|diH$PlYGrD9omRR7&Xj(8n3GQUUdyrl^jp)*I335{iz%33kIh$c zJr;%|Bd%-$G7RaUFWkLuFn`anZm0Jy@mm$jgGJ}SV;{9g`;1aIXnLJuWz-Lt`HT_K ztk|gUr6~?yj{+=TJCiO|A{F4bEE5}RTbv_i;pMWskKsY%* z0<-EhW1ND48Hw6?$^~0j*bbsrq}y$dy7rv`iOI$h5CM~gzjte@ugnn+EOs4W>K%yCcqd5_f1cK(KqSS%a+u2ydMr5X0t@%UqE*$i<~AV*U;{}uD8dM#OQ zw_VL;)}{hr0J!+FnqQUhlXur)L#6O9mXe(OI}jQ%q%VC&J-xt?8NPf2s$$N5Z8ms8 z2N2VFnbe#m)sFp1rQ0UF>XCE^+93+c5v9c@_8M}m5xYPW@Ui1gif*54Fn7;m`iFiB zfdT^X8;dK$J`~-~pI)qodxHew32Y1SvZ?4Za(kf@3?snPs_BFbf>Q+u#Y9bMP-a+E zUv;0a+lrKioA)mZurMz;chijt^LLp2S;mRfM2x9paF}XiRMEI%9KgY;m3{yfMuDgEGx;S#|m^>4+60<7cAKH{dIJ=-6D^7l=! zYX7LHUqcVANC;%45FAG&V2p79XD%C|G!@dWfmU^_9P|mkWT)ppw)Xx40;MH?lV1^| z`tWUVIEEaqaHNswvJHm;W+8r>565s1O!cH~OVid^&>yS}Hq35ngG+5hwHZlIxs(e$ zoV_bHaKV9V3A9f(CM)U&&PD*$O8Cz`Z`9q-Hw6;nWe{4Bw6E}^du$CP!c{<71QM9{ zQ*)L3O&?UBeWT}0yew2H^1#mcMpZ^CtF!qlf_X^qUY(5#jc+>{kg5T>AQ3RizJqY2-Nnx zobOoCy${>sYwd(UGL}2UyVQ!)8vYS5vOeN)W&j)J>bgSrVS-`E1k+&2=gt77kT8%8 zjp9c}%DI>t$w>*c^^e0uMGpiD5w3!uUGl>m(73N1McA)J8U}s(a~SotW4k70db3f2 zitwy^c+KXKFW001%yW(?|nLtq13(hhScxp;-c0Nld6)*tJ;JghzI6Prc>A` zD)t-jku?bO4!3djy^`@4OuUn`&mQk5a@01?C#ld-kv8MNSZs_*3&J&U>1BBI{;?JkDMBSD z8Ca3yCsz$~>1*iYmnqx7c)#x=_iH^Xf+oYQQDEE-ocY1 zURU&KUwoIv`EY)>c}Z9A4kR|)(Gue+fn5p7U4q``-|6$~IJfPTopp59_MJfx)Y+J7 zS8eS}%aeWEByq~9LuiNNG+2V}_MD*Obnq5oH7v`qv7V&uEyu%Xhm6X#@L#pl@3Y$t z047_<5xzsrk2RT>XnbvJDr2UH8!rMJDAh8NKi+42o+iv##jSepBrsFn zR)2xIfb+#eFZv%sp+3F+bpbzv#*czlSF_IoT#|P%6Ni~0cA2h{sAt5C=(1xLp1v*K zQzX2OjP!bF{L|ZcOj^j>xV3z-^@6k@4L4$dNzt%~;gI0wIx^Y26RJ>#lKnBkk7Uqw z3g?3pzo?#ymS8ZFV#E($o1A9z&MXHBbor+S=K0NA^wZxNGV`b#nr0A+X@Yl~NLddk6*`vWj{P+uQPR>`&`TaxY1%U|- zmM)7a&Mw!!gqRh)J-nw0iecLYYnhTaI>`b9k0Of<9@hMjyjk?Mo~-{VRfWcnYq*F) z*jSMcoDcDhgnh@E#{LWkL{`3XIv-9QaoEu0s!Zje7`D?n>AigLa@4cx?S%P+3uIRX zBo`9;XU{seDb>5EzsN5I?4Ssm&w{X@giJ5GE4|)X{xSK~g;@-KBe0 z;!03+QN@bgi#Y~LYd0Z|%h~|WsaZK6XFSrE5j~!WcRgC{R8aeq)_NH;fIjd9PX6!3 zdS-84Nv?mz?hPFX8rB3A8$^^)21yM?xr8fd!D_BO=%Vtz@7PHjoHD1xY{l4^~|k3{WsJ#V95@YNwB-xx1KNW#}N_#;dq>$N>%W_NvYHqP^9 zI$M@36LKuxHBIJ}ERnZ~>K!6Adw-?NZ^Q`F1+;)%IYv*GV;GwBro0w*h^YYnUR^uZ zZUZP_K;g93m`{u=(*bIrYES{px7u#(Lg)glru6t`#_EWmsi6iMJfsiXj~aWiPD};` zzU9e&NMdE3zVyseX!es50PxS;^ug=~2VURfC#||}a8;=fk*qfLk@1=JJ10YO0V5S6 z)c~M$Q5K{3RClI|c(zpe(W@@NI^ZF?SR3#wSvNw=zBCWk{B`)x^KP6har3YKS77Gu z$O0QyqI;q7W7aVeM>Mo%wVlYzo9eIX8bM?R;vf1{irB@;-ueViK+wo+Kbqauu-^9x zp}u2IC0H_0|AzDBP2w)ObU_?7b|yo)ANzgX^N_({OQ@p;mZ}0L{n_!qFIpL;6fl9L z;@rvYlGmSzN0^N);DhhsH?SOR~-%U5BZ{`@^-Za+8MQ`dsxOh+Y= zDUpxM^YpNm(si&;DtM$Vc@h%PvZ)&NUc^u=u9c|09H@PDOd8Sm&aFxWz?7Jf7NXPt zV{+>Q>dZH*FY#s+Om>eu5@ z1d;S3I5xFz=LAf;`7iqWf90UG6Ec}RWzFsSth3ltpx(~+eXHFL*-7S?S1TWa{a5k=JZdOano} zC|C9aH~8RWs>M**D;>fO)XPO9Dfq59Nm~1@n5qbe-5mgbpE-s@{o1KLw68P=5*Nz{ zH2@>o8pb$Io_5her&BVCd{k@6kvBs{qhMKp@j>=JIx=L(-F^^ElU}89Fc@5oVaf?# zwL6vQG<09&KWQfYwHINeFMCPy``gEQQ_}rSr}eQ^cd+>=ZxZjk(WpGxc|+tS*}*ly zus2l`-WTKFbGK`tu8ZYtZ&_uvZiD|Q!|kC-x?i7nMygLWjGh0=RpGUP3=ZdLUoq6d z3yrW+!H!PHG6@k^ZuDJmy28QobR*C#O4Do8__du7}u>$^eY)ZO2gZfkpG_+ zAU1F5VgmIP%mv)<^Rmm`WJRu!4i`M(_c>f^mqf{ObC!(iLazGBUUIj2ajvyxrBzkn z3<4esckysphWz|B<|;0@JnV02_}|7#Bh0k-d#3Nvr~$0&`m{k5?dFECR9D_Nlpz7^ul}Tvhht;oMsf8vbW~%V*D@p0F`8uMM%C2$zT_lepE|uETsWf=sA+t=yX{`}+lLEb;;v*odg2U$#~^tCOSq7) zEiOg@|BUkG+^-d{Vx0KtU}QMlJ&4%R3u5J-zVD2H-#+5Jsg?@b?O#E>*lxY=B_u;IY9;pCkx^XASZ4mw z*dz|8el!+*m3|(n#k@uZmVWrNjo~j?#0Dz;Dos@5?4LW~6e`og&sRUvhwJB|Q0NLR zqt=H_IL1V9Vp4_18GIuY8!cVfbMv-@KC!KFL`9yaOVPBSAzT^U0X3k4sQwjh?a+sjS z2-GFfJ#E!oW!D6w)SXq}K=HG2@zwdl3SZ692J2_49WzcXQp&Lm_P=TI(#yfdI_&8AN8T4VW z7B({$kqV2kNTGvkhE({-Sk;dAz`j*{abf{g_q`0oV@!QQ_TVMHirjdB?|4Nm+diyJJnX`RyNQt7!zX0+$g-Ik$Qwx*+fL3l+Akb9EXAolZLrZP< zjV{eFc|~q)pXFj4U%v<@+5}#b%NC|q`(OJ7qsF-^3}Qa2G+WGg|XdHekfGsCCnu~6}=T($xgr zgA7O62$?4yvYlVsB%80h!mHdILB__L$pQ1)G(`q*d-P5{BqO3d4CitFhs`|MMuBx~ zxXEH?KV;rP6%#I9rpVLwLo2EUXfCC2+>>XivdarYaVFf`t%P4aQow3p$Y6hmb6{_WOZ1u0M)Jsg4i?6H%&xU=pxp` z^`K(eYCK^YaOh+#`dkDDYe8E_oP?{*1a5qH*oM;#sZm;zD7N|SmrL*a-Yi~(L&F7? zZf}(hP^r?(N|{F)B@o`s)dzg@uNK3)Yz-dw#e61j8Y zt{3l6w6fY!85O-vjY0kQ;HthHN$j{$uLZ~~^*%;&C-Y>iiY}mfcQ*z|oUXn(6_&EQ!jdKC_{rs=t>-kNd z(<}q$d3*RD`V)Ppu(nM!q<*kdKQpge0*S5Nb(s@iw!xXRcTM_E9pZg?5I!olvpu~I zChV?cl0sJ`(xcZ8D()Bdt6Tj6z4Eg%MHJ_bMGxa=MEZ>HW}+#;EpDG^m_jvO=lSw_4jq{nIWXRTT)=? zmR3Q!rMtU3hwko15s>b#p}Udp4r%Fn@PGfFxAT2DpL6y;d#!yf@o`)NRD}#7$ztnq z?Q-U9Mu4=FAX{AZGhNL)*l?2D^S1VsaJAkB{c%F^_|odZkB(l?!|cm_wJ+0z5SJKzJ?VN^9wP@(67;m$jl!KEg=xsh;H2x`NtkI4Wzl)7N;(S7Ah23D zt>+7Z_nz|ncDJ&8N=Miic@{j8U`IsQ4BRJfHO92kQ%?om+fd;N6uk!RZoS|0p1!rG zxPZcj9&bIgs+vNvl{m0}%B8iE&9IVWWy4oZ>4vOrg_E1JstN3!6px?G7pfzC??ey} zuG+v%Df`H~q~aGFHz#*5J;JHYqp1GC{vxiYEt0WieR6g_?V*cI7Mn{84)MC@Ima9; z%EyJ{TQN1Z%-k+9v#gLx+jc}FiK(}z&!bw`@TFJRu?S@kg)s zEykRosH4!H#@7p>w-`n(=t7XWCPPnk)3)fwYI#%KOV#NnC-gf>f5_y(e8XDQ45B72 zsX@JC4w|)h!o?vm^zuYE;`La|FFbgU;9;d~2i}f8vy!OjNPArN_udny@2#@hYhc*= zn)8()?v-=mJ^zGsWdn#ut|{!8X*olBDm5vmIyxGxn=1?P%rM=0Zh1LXrpi_bKf156_mGf=C0`r;Se z!M4I|uSV{D+kslbpj0hscR@z4bm?yleF?qtpmKGt7uCyVD#AqOw2hohHzSd=UAm0k zMT3~(71dO0tpKgGJaxbn=o@p7l!&O-#|qmuoG8}3a5$kXCEM^=qXc~jo{2i{QZlXo zPYcPePi1p(+9b`C_dCX%*2JKcRdd)|;YX6wmt7~2BgWwPtV*9lAC3K|$&}sP{8>dU zb*LC!He|hd^J7#Y8<8$buV7DtOdk@QV9i}OLOVq_@tN%AMCT*S=wxumR07+FjPM4| zZ{noF1430qe$5wXgks?MQ?S0#&Sq@KlbE%&xH-m$f~nXfj?zMdt^n1ZVn@!9^3X^q z)d<=y?GSQhXT<8e+xNEcg9wJpN@L!>AC$8T7M|P^h^k^JSoVFhHty<=t9RQXF=*10 zLWpnmIG9)27@gF+3Z~&>82czhTonKop1Db05`hS$K~0a}Loa$qDkF&PE4w<(e++bB z`iuo!|C(0`tN`k(X+xu5N)G1xeDn>-ipV>GdQJDr>cUG{kO<~}*_2mJS9_fE3trwc z?G2v=A_}A52B{X|^~~*g@GZ;ugjG~7x z1nb=&X)oRKFOv=_(z0p!1%fvXjtXfsdnB(8;Ed#SkIvo~bD333w-|r%f;5Jv zO?oPJ-MLXF*jX#Lt!X9#lS!PY1)ae$V*|OXzg!yook=pyLP9(C-}au#8(7ix2qq>v zZs4b9knf7$9Z;I{rgpVt-~e?G{=oF(Ko53;})a7ei|D>~mTt$aWy zZ`}wG7VNreLRa$pPd5Kd?7v$JLARMX4*B=XxjfsObcS>J4XCcvSwkpCg+O%TK6{%# z)hj;`-%nR0dOijIYnyd6uJd=cA(fa^_#zQv;$P^jsC)1y@+UBQ^!WSZweLtmEKZd+ z;`XHkWR&4%4Js&k*q}b0;XDa41H++vBFZf`A)4hnGY}b*_JakOI0qNdcre|%iqhmp z-@Zd^0c~yjE`Ru+m}O(We3qjuUzZ+wj$c`q+Vjt`J8#_-fG=i&?D7%I_Y)G!Y)3o4@ol^7X|4V)80%1ziUpFA;gq?IQ%am^W+Amoe%?&FO~sB~`9jO# z`HfV#_o>%1>mhg@UbXV`=-*lPcRR0AcIbSF!8e3yko$juQ;|X&z!W6D~-=q zOmfL~DNa8F_WE>?Ln3DmPbWI572Yky#T1DaD@X_W-fZoVkEFlU>#VH}nbV3GxzVvF zf~a3b)3yUY4}EG}1cF2K38U)i;)-SCoxD(>`|qC+a%0??%6zS?Jx~y3f1TBGV~ywzq_1RjT74()IBLQUKF4 z%KJra=V{xCt=v@yWM)1G>`znuRBgjt3xMAH6?<-3B=J8KAJU}N>}{%*-kHh}>q|pL z)>CB@ojGIYHPS`PI!MDvj@$3Yo~ga;9tjWg~RK=N#kd0+D(QZwZuwLGWJx zFx;vYvfd`oKZUDeC_K`cOe0tRDP3C4$p`d%VdQ~jVvlzVh-Jo8{LszI4>nb}_dasqu zZ~vt|Y)B!8kf~g{{lb`&NT){LD}a zS4z?yFQ_}q$Ncg5?&Y&EcB*}b_K*o_h}k$GG=(D5-QDsiQCJ3fn=L$hRe#f+0-+xj zmOW>?`19T1z1eo7T?=sAH^8)#aZin8TYSo35Pq4>(VnGphS9^6WW)#uz#_aqY^1?0 zuM!cMk+O+ky17a2d&!!!R*g-N&bh~vG(lmM5h4jwu!lFe*NP?G6#Pv$?ZSSW_Rn?I z$HDXHW!pYJt0p#q*tkp5S^R6CzuW2rDX?I(CyduamKT1s1dCOYAz3%x%96FrziuSR zU@p4ancVcQ{fd@9ug!-E(yzvW`zQP<4&@X7N##bQZ`E924zqkh?Vpxb$q7Pho$gRP zJ6Av8jh-@P{dzJiK5{!#ZM#)|(fy}pVWMM2+fQ(I z84WjGz~vA*Ive@f?sNY|z?uT|S~ijn`6R{apv^kJ>W-bWu1JDX6j}N_=(1yG=4^3P zi&tJLO>?LGk<+SN@F0S%7nkj~`kylr3h zD4Z9cM@`9yin9Hq8?D8}O|xA-aYHkwgAvtHrcS`gA}!%YgbU7EG!$h?ysw_Rq;=F; z6lst3zgR*6DMX!5?gk_Fe=@Kf@T-z~_0C4PA|0h9)6Yj7I@sxCOM~7^sOIF84)18LgKfDNn z=+R}d;*HT`#IZeR>&{gDzs$r3`48WtPy#!9gUJY&bi??`ALz+^HcfuQGCoVxDZSD5 ztw~|dHE&(s2~amiIt7P(?CJ&y`RsnRKgLb{j?1z`6AQ1%@GwWJ25R~S@U{$k{e5^b8uFhF1xJ(+`A{_FkoS{%DWK#TFpqhUqg zPR_09iTq}-gHBb!IipUaD!Um{c`E=x%l#_b?R?Qz+-ZqM zvF&8HN`pRG7aL(I-obw7I~ONZ`^H}^NSNW)5F{|dW6 zvMe*CIs-95C$%t4os^X)nvt3JU2gk}W|XnuR4VDFDL(1;J%hfH_s@xy%c~zx)($+y zLSTr=Z|w@u#$qT)$`jZ!dwu=s>3s9+zf?y6Rp})1=f#6fQ_#L#h9mZ1+2d)e#C?0P z6^iK2FT3;n!cnRIT{N4ywzXYMc3TsL>So;M2OE-evo`MXjWK{r>R{fT1wtXhnwJ zs9g*P-Wq()_#pgIC;(5PpJ=HxJ_?1%5M-3 zDoTK81(o?*o?vwIiQ0z5avn4k*pEaTpCkWhq;I|aQkK!loL}+*ktO+Lsn@Z9n8I@$ z^u%Tafl?EuJogj$UQSEjy==?p3)=#usgU(MR_@1~;uRNu9)2o{zTznxI9qk3QU#hT z=_zrN4OYN<`i5~siq_p>{JbXGCB*wD#VMA;#Vf}G=D<8RHbdY7?4X1U1nupaxS*D> zY&dJ7!!J*3X0`)g#1pe~Sex9n7ynS&)n+7UP}CiK<&f`H5l^#D8KU;ID-kRHUXH+T<2d%4P8~L-7RVCBgb!af`ViH3jl5Fffvmcz_1kfr`JSeN|0Z6G8ai@N) zqG_B~WN$zjJr=9`U`P(DE`dcNAP|`SHUK$$fmv|76C8DIHOKyiLJXo%Zd7^5hW`v3 zX2XAYNjj-*W&k8sEALKyv!87DYlgj-v9gP5koR&Heza(Bp=tLIl15?=NN?%C(|ZEO|+y7As4=&CiA#~>ox9I z5~sRy2&r4D}eJpOmwCji%b#?}i>9 z^UK}DdNX#V>=+fP9?ksLQt3u3(_4hj#*Y9InI9WN@NlMu4l{lGua_yM6;FxEZRd8e zw6@n~|E`L+hW|L?%{TkfLaC!y^v_9mOA16*JtEGw~khZMXs<5E@k)sD~uM z${cc=m7b^5qlOoHXJTPAMN+EocZr|ADJIqLFpr4rok;XyNsOR1qXe3#W{oyybnc9B z;QG36fDt+J%n4rNqOw(l&WWHRfY9gCL^ab6NiSSu{Q@$=l<%Q|&IhYESd&P*W*to< z4#P4-gkH^CDUyIxAUKwr774({mVzb!GB59NSiG-4v!ltWj>G~}@Ih*Pk>B|9d9a@W z@EJe1uOe4CqF#eMf0nHdN|=ZqK$u}(qcSCX?qn|qtg2J-RAZ(w1C3v*(Z9QO_;6+4 zKTs*UJv|c7jRaNFF^^QWI_!XRE~_X|tS3e@9$6>hHc2o3?%<}O@^uFP8^zC@P(Q7o zk{Te@5s1m+Xpc=1vceJXSm)y~q?#tBgw%3-BgTS*)n=>0P9YM&9jT9iU9In0EZq#! z8R@fj_+cm2{b&)=K`tqI*Yg&Gb0&Br?fmttiFceqGeR#0010FqeTM-v$Ook;+Xx?bce130YT}bHs ztaON;3YCVuuR;-ldq|coLXRM^*Zi@Q=~LP!0OM=laoAQn`mwbmzOxwG9K{9l1>e(B z9LK*}>QmQ%TP5u6J&9UxNulHIk5y-r?`0bKfAD1}7S&-6Rbhpm{ftf8eWlBVu&g*D z_w&Igko6W)9rom#796b{@g}=;)hVBwr9iQ8PzSqIq}eL$gYA*#74r`)$cJ2&e`+g~ zbH|(RqOa1Zzfrm&3Q6}?_H-m=}o-0e6A5C&`^Du-B;yG z5h&J*1}jP(Wd*aBVT2fgnRV(wMQI68O;lJ%9gLXS535kCc^_9fQNm=wt!tc36)12f z52HGbD-@+{T&vu)NtL>xtfuq+p&paV+9JYa6`cV%Lj5xYdL8%TS@jlytUrfpcEr_*xF@fO??zhv!uD@F z86gEtLCbj(ulr}pG4!|t$g<-CdffDFhZfpSYw330r%P5Ze^prS|2uj2L zT?7n2G;?eJT?Bd>uqCK&;#3TFOyIv6uwWzMHm;bqiS~z^)7TS%s!&5?->o+Y#+4yY zniSJCgZAO@pv-W9oXKZ04|FD)x=1M!MuJp6iOnk45RRG>9i_+y_0}}3#JYq{Nk|yv za{}c73|)n8EV3Lwc64vVU*dYd#5!KI`fsG=8-Qy@e$f!KH}5t~ZD>3f^2uN-jR7Ms z30X<<;h{vFU;jyuH!a)cBqM!BZsEL4j@Ueq0;`s*TD8W_R$)I-`&?QQQl^6f>yMSR zFSF)fqEtI0Iq2xlCmPoxejC@*u>GJAVUZ2e;X~bEdCT>*BN2-7b;4PV8lNRQ0KWgj zkqRtRmUBjm-bCP9G(W&>FuUMyT#sNJ|5_)hN-PbQgkC&1n|Rf*+iKDwOpX z-3PUP!1bj7z1RBdsX<$Z8;k1+KO``8t;f5FiRR?s)_!ph+5WhGUO(tL%XK3Waoh}V za8jGZEBR`4{Mze+?;EMY_K+;FJ zzrR70u=XkQK-sBhRH*Dr;ABbzvHGe3BGwu%WSQczJI#3q{tLdcdi;&2b}S-T@nDK- z&=Q3IE9Iw2?j^@Z+B?DXe1EfuQDA!Q?0Yyv_Cr{lh6L>UJFe}|(r3-U!dp#?m4XQ` zt2^7=u&fC5v_5{<{MH``Wkm`JK@7rIuaP&F5JCf=c~e0EL0P zUmc|2(QwrGQ&M@^mGk(4Lz?vnFpXBS!7~9jSBoUN060R@PXtmgF)dI~)i{#fpp|38 zjM5*u$2X+DDgbM%D=Qh8Vbc$MSJmyc>G?T5!w$w~`Ah-`{pjR=S+5qPv4}47UDuY< zX%xN*rwdhZ*%4wAClcja!E44b*U*pACqn`sEi~#wBqFYRx4+N&xIpE;-)vSLGm20{ zk!JsaF;&tzcvb|LpeT1}wd*8%2N8>kw5^9Be`m7wPpVIVJ?paD3jH1LWD5uWeT3vx9t@{Pzu#{0tqn(V-a{|trkG58$!F9 z=S7-T?}_DSzpPqTO{)ar^dK{h-^|ey2>z<;j`>{eP=|!MKx5|JN@PqW2_PPQ5$K$GH zGv;5vJj>vqHjjdOs;C4-%zMqPO-5M8{S)lS>1CNDTB;AC*;`B$r*HdslF^~`2b}QW zmhT23X=WPp-sc!0g^^XEx+#VeZ!LZGGZ#sxFR75@X0UmjM)J)jwnPe+d1=_(vi6(j_go;AxC~*u9BDa{mO*#j<$CBEIS_ z(?be?&8Yd>b5zOT8qdmrb*(F!if@?c+1XvmMLE2;yO|Y_wKHw~uN~gjM^yjS#&>Uhm%zm?a5w7_b!m%%qbkvZ-bY^H>-1Dt+sp%aa~j1> zky1oU4KGJNs3XBf{q%Ze()U^=n^xS=G4{CO0enER2kf0QYC-jvEW*{{D`vb}(vJf* zoJ(zA)JnCQ_s6lnX`!tl`={Rg>CFsX7sX1iF*N{|oe$NLC)JE(b5-XM5dgK(kfnEfMb4c(W0-Ct2w(o$Me>5Eho|_SjIvT_R*G7+lEK&si zE4CBChVn}Po428K0o=2-d83D0Dy!}qqje^Ax|}tWqOzBlh77VQ=2`>?`l#5p#%QJX z71#*<__7{zyh573J<<}yqCi*47xJ~SJ*3lbr<$b7n2+s(nj=Q$k?^Z7&)@gVo3LPc zQt}@1HHTJKFe05plXBx_u%OpDk8lh6&;mo9#VMBf)+gz5L5o(VNL&i>zDKb60M<67 zV&jA_tHu=fW#h_Czy9ny;q6ZxZNfpm8^G4H8*lI#bwm3@fGP@lT2zjI6z z)W>7xIBWh`6}bKFMB7ysuRRQ;kbJxY?W3q2x!iI((cr7-^~9!-g$P*L?L3~tjj}Gq zIQl9I8}Jp=)6)b-{)EbyXTl+CpDnhlO_AUD{QW`ed%41R_Y#QyUY#o(&0mQH=UH4B zDF{jY zrd7+_9{2(3uEYPO@pp_Gm*P~-sW)oP9?yMY(CXyi zzHdi*A2%@{>~k5g$Ht2;~G|iCY(8P%2DfkTohhkoY}SCPsm*a_&si(MYaV0&y%Jc14WwY_Zxm+a@PL8 ztukp?_{C)LR!2()B;siIy7|HJ%bo+|Gnf62%as_l7%Qr9BVp7-iEKK#<_cv+b_=Y5 zE`BhKr8F=R6r+9>D}c#-a_*-9*{vBRw;G3XCYnsW$=iH%xgj$=S+Fc}y^1O*gh>`Z z`0vn=>yXS19apb8Mt_9T>H6x*xEXoz^9(FZ*yqa6(DOWegLnDa*@oTEug z5SN}W+|0inoJYQg{8@GmlL+ucJr%1kF1De8x+ftKGHDh!Ta4fJ;&PvJDCSSY^)6lV z^{3X(0hzu0dGqhm+y#(Euxvw%Z46Ztz%Zt^g;!}nC+ha&UEul)ikucl44!lkqTv#j zRip6Awd9-}EU!HDT3{Bv8bVvqCr!yk^-S<)!!&4-Qy%^tjk^|19f6ZU-wpi5Ui zqAVIyUL@7t67cx8g@?9sKa>03wtn)uSWzl3clQ-ZTh&e`!$Z0m{%BO`6V+!4rlnv$ zZLhcDx!33`U1vz{bqe=%jxSGylp}03S35%$X{Q&qpvz-;aA;%e=uWYmOOS!+ zjj>EsO>E+vEp(4#0uzRaol&f8z2=tMp|*?*&We+H_`{SDeeos3o-K}SQ{x#Zf}*eD7bg-| z0n?wFzT{@A%G!)~G8|0k{c==lKel&;*?-kP`I%ct2JsnsSF>LV0-BJhwS8~d7SZXB zeBnCDw~+bPsS=Gz&p|#J#|i#0GrnutK9WDjenrBa(~5-}D4e2M8s%1>ahXQdx2!28 zP%ds|$w}bigDVLla{Fe!Bw6M{vW$#=z*nLe4+q8%p!NN``M(_xsPDTk&fB3?5)vtWLLI}y?SrN8Q6SE?qS_Er`; z7=sH6`(Z$@SBSHc?FT|AnJdok8_q8lEj|ufrbALMK}>6{$hg=;zBAjhtCNC5QQhl| z4wPeg6t!juf!j}?aOvS8@8#8NH~mF;k^I+Kz10}O*O=dYnFqu|J33X zl?IJSZfc&^xQ|_z1^KVeVnDUJ^`e}d`rc10ixIU+NB>1%Uq_1bsm`fi*6a^A%E&Xp z`x5qyU~}hgdds|WofsNqu^95(5BH|>qi3cEn4uvn70H~Ab6~Uo6PU6AM_j*Nx`j0` z*5_}2)6p>F`O$IfMK-x`n7v{5;OC?VC}7W3P{rwU2M^}p-u3>z{mqW~L#W=oclFn7s4TAFByl zzul$7)3$Z=cHWCkGur(3sOLyBi9?crDNP$)xBIx}?#S;vvuP?Or?YhkIAT(QW{Da<5^F_jjw~Xc) zW#02`uc`HRX@E34IGwj8Pa?gX9VKnZg@si1i)>AL73(-G;>V8AbaCX`()rCF<&7|j zR$une)iHa=pOBV$m3AS9QT@4n*d8VJ%p1n@kE`9%egJXh%j4+X1oKJYfnY3I46VyPn z2^*-c3l8DU!hX)ly8WRn*UMwuf=LDD$4yM77MA0BScxWWL|<{=*~rYNQisnuX!&!K z$Oezk1{L#RbTA71rl*qrL$$}Q3OyDNqW8pSQdFx`Vciq)u!crm@exF{d@ZUoQY>z< z7XydPgsfWU%5=4^gY1Yg4|2V~V`A$zkyFH;L9WJVIM1ic#e}kxW{dD3L`QmT5DX(^ zPBG97=2h)|wd^&nkBubAEQ*lAmFTp`ak!GXGlGBG7R=<+it3}ZwRAB`E4VBO%hL%_ zH>}9CIMq}db*hGAC(ZyFYv!%9lQ@~rf-=8B3JliFDI)UapiP?}2eTc-S( z3MQC}3%oRMVMsxboX%9*s{$#fI`c%~H^H~ezjy2(hU%zC1jATk0W5D>eP=IsqCh4u z(VnR~PDm#x#q@3|8VlL?nE%~@dTwvc``NQPhuvNBH`wvO=2vuK*h)TzDC_(a&7Xhw z_zco_=XOGkvbVW@D7$+B=^CE?u6tig`#Ch=zjTE%{Bbe*?|A)FQ06}W_}4PN(oibA zZNTa91y-x(Cq~_B9@d|LpNHQXGQH>1mDykOQBMma-fX#5VC+Sp+c4~|*kp>Mq+`7d z;;kcaIGh(P(40b1AOOo@-p0H6IOVm&aO2x33q?z^Xu|j3FwBB>9COh!(0m{en^CKy zY{)EUMYZNS378ad_!Y~OmzO76@6?;nA+SIxgeiaD5r}+CmNL@-mg*QQ!bzR|lDHdg zyY;4UQZK@ic4J3*VOF$ku(Pcjcsv=-eS?=Lp~0dj9Qb4Kr}K0E|5bmY$9Y!7>zUS?w#V^ziGRX*$&NQghbvmsi59as z$hOifXRKDXr8@;WIo9KDGPXyl0WXLz`?Gp?3T=`Dz{^|6 ze}-N~!uflcP*@kjEShDG65H7!4H&Vt8fEpq^CFG{v)pqcD^QtBco@{K4}@dqzi~iT zVi2HKk8Az6XRY#S$zb3qcHCB4Z7$DHB?lD(H6m+T-lh|(Bu3r7gEmJmx$mLV0s$O? z7pa0Q2UQ}Y*;IN6y;F=S`(^Fhi+0S}IJ)Y%IL4}m&!yHF1H!zp{+mh)XqgFBqqzm0 zTZEUvtvUvnT;Ji%9Y8ZHbv=?*r8 zeY>a_CZ>BR&C_N~3f(u;3xRlo*>8xvhkNX%vv9MxX!$(+Aj5|*{f#L}BV0oP>g3lP@ro=-v3q#+jv zFG6Nyi571#YRCQ37MClx!uCQsr*cD+{^em9oy5_{kn~{oxo|EmA47a+v)3WBvfdy$ z7@=&`&%f!#_CJ3ij6@n_+(-@#*wgjM5|}3#_Ej?`qRj`WWqC0Rb?jo5ArhP0Cq|oW zO6tXI3;gCD@d~OL4W=)lY8w4XqMe(fJ7%*S<302W5LO)22Nx7+w=t&k#d~66tz%Et zL?XOtAWV6?pJqj5icvRTGGu{=e(yHO+hW+{unP_@Z zVf~{#Lk;MoOp|5w#SqzpvYBIIYu--ZNvWTQF2>6hI_uzd!T-Sls;prxp^l3fzu*0b z3V>RwU5<^y+`!MM|F(2#wQ3nV7$&P(_j0(kBIo0MUVES(jeAfMA|@6XrrTmC+$Jp|MNyeq;{PHZjc&j6*EV3^Z~>?-YV`$!$OG9kVmc;FkM zdx{^#3+K5Iu7 zpJ0v04@H4$EA)*(00^ zli`p&Y_DWk<2ODQIN~j#1q{QI;%akGFd_#X^(AW*L*wB}W^vW`I)5uV14k~eKA4$m zLPuEFklP2_$~2ZvkO6C`T6b&mWL+?w-e{Xg^=~_nPRbGL5#+o2`4#Iem=dlPG1=aK zmai3o=KTXr(U~vdgZGJ{^`)tnsye>K#TfyRN?+Fqm|LHZ*8Eh2~ElbXnEwwbzS!8# z)UaMtR;OexV&pbSfdRYdkTk?JpPux@-k(2<&EM~GXKU{wWGjbQ98&G=C9HQxzNJXM zb^Mjq2=wNXtCDat9;nGIALb-y-kD0-XKy{iEmo^C`=23Fj_LF4FcPG&i<4Y&D`Vw! zoq_d9Ywp%x#_?Z|w)WJ=s-DhKJ6pv5J>Z(oLR>ii8%-L-h#1t>Qc=`mAw0V-=lJKk>-yk2|wDOxm*6W+E@3<3LH%)qJxY3L+pzKCI0l;oyHPB_x6eg&zk z>R8>~b17HLL}RfYrEnpOnCqE8&tfYuAKy5v+)+1VIlU(e$2^?q0*Nw3)(AK1$h3I~ z!C|DYNFD7I96S>4)e^|tc_G#kj&wzfE9+M9P^U5LbN%u4coFcxfDZ#2;(QqNk4m;w z*MufiQz%IOOW-pUQ32W1`xHhA!4$6dbdj&ezMRen8xU4mkHl08D7s;3y3{k#VTd`_ z40KbC9S;%lXEWf{^tIC9P8#`gLL9nR)cHv25@UW-GK%G*bZeRK||X z?+Vcv%PT?3IuvC9-gmMM5%1Ma5l{^{83GGn(~gLgZT$$V!07k8So(q;lM1Eq;6!s+ z`BP8qbJ#s}=Q8cr-xRO$=N0w5neuotiiI6z zOA%?KA=(NR+AOI^EG8YO}ncF_Fe+pBO5^ERH>XO&(v{=i%ky)>4|2yBW)^2 z`Fs*A!3kme+Y)6hq4V0bx3`h7NPso>y{t&mV9rFEHyWmTK*|ut{1DuD;~#0yMg&klPm`vsFu>NtFj-gs@Y@} zj6!c#rpVFkh&=#O`N7A;P8;$KAH$r6V)5AE8_vcs^7ue~s$CZYx;f&n#ZdV?jg{S@ z>iRPULedrGyG{Lq2;hoiG+9wZ!9o+qiI?!`=dWnAf5M{xaf3g*`^_%3yz!@z zuStT0*buaggFD%_Dm(I*mz+no{dZycDH84&v$L`=CU^Kz8(Tg_3r*W3^b7#_xoo8m zjyB4V9kepKi&TS?e_Yt+j(@(Mx^-USr*aKgK^=E{ehJn5Z!Adx;_A7CLGb)PEl-uq zKR~CM5g;Dm;l8c$CwCn^aKdi6@z~9^v(}HPYq<9-y6+sTiRxc>=CuST@BWPXB)H*J z7@SnA6UVy=$9>`H3H%ccqAHbsgjjr2L=MRmJj|>pj;MRghAhALwB4BZ>9#f9LP|IN zaYQyuU9Ldse!&Z6hj+P0quZ&K9S~g(fJWt!K3~*(H92a>s!11m`xVW1gfwva#SR+9 zhcQ)n0_cjMw;FjkF0m&)#Df}I((!3Ma6Q()nXv6@`*F&{C)8X$!C)u|D(kInGYp(8 z?~W4DNIvC)E2|QTX}a+Qni9#%$X9fUX$e~aM#Q=FHTq;mw*`!$s4#%i2OA97a#M9Q zX^hlN`L78QDWY69r6a}+Y4O3*mCgOr5R=mqZ6ah3a{ldFlqTdn?j2OCU58@ZTmyA1 zUZR0Ff2i(M_@XvM#nG=g{ZF2Lq=*0*qDw^iWkbb+We z>4^1b!av`#8$lRcRXIl*QUMjae^$&V$4W_L>fOMd`ytWM&156nL2?8z8YsQSye5Eo zK!zd0$4x+rw$b4S>|nsY>EJyf=zFsM9NYT?TsN_%g>$=U1!5m$fYR3_AIgJdEn8jo zO~nwCViXCYJwae@%?4K27Ut);@hVr95Iog6Kjqs|b+Lk>e@gehDo_1)tdTBNzvRn?%wf$@Nj-I+*tibwFjSYYYPP=d=yrTrR-?3b_H}nZ zIVxC+9{#G!fLM%iRfHi2zvm~3BT}6X-e{}*kwZJ$6k6s&!z;zh<$VeR@Lmth0 ziHfjb8AVB>#WgqIPCgE|Lppk(^x{F)5?_e{J;WqIzl=Xt_~jD6!)5pGsD;|QfQ9D& z47c7C|D#z@|G5_X*O98fnNy&ScD#{8Q@)jc-gAStem<{jujdcTUUUhj5_w>*Gvf|B zy(6WGtDW_)S1vyRTaQA%Vd16f0RJ#p*WVrU)VkF&_j1xngMfYZ-WlyV7~pZA~P zp2{`4F5_>{6U(+{QhB z1rn$RG^1rs!AUq7r6Qf556X!j2C-H2(uu&wkeeCx1r-SS=?2~K) zhqqt&C(mid)T1m^DD&d4l1;(_6aZ0$sqdp6y9cGr-e87Hzwi1d97M#^sWtrrg=Vs^r=j|agQxlqW5N=ylW%Cw^%^S-y zHp%|L#ozYb!F62y@3eyj@o=ol*$U@|jmG0$%CXFfKL)rxty0wn# zdo3-G)VF+wd{SeS3k%jNGa|nUzN~PG5V}$ZcbgIJf6O&m<>oFY>fzF2?lRfW2~HwkR11v&SNE(Y{oPV9WV#~z5Pv@H~lxBqphcOsN;fC+Mmh2zM7ORQDX2*&Bg z_+9+&p%$wbK6iS)+fXgNx5Sp5nFR64qX!KMOZ_9S4$J#H`1p9R?E>;2rPd=pk%Kh6 zrW2hQHjR!(lUU(TP$ew9eM)Iy3ZDq|P$)edx)-9H@XMDhk1hJQkTZzflDRBw$%83C zzYYxlGTx@mQ9M3*h3x?AcljINp@}E9Zc}4UHC`cgf+{=+Mm^=R@A+OGCMDq&8Ib-x z-s5h;BNb^T;Q}M3B4TSl!vVeu2mj)Y0xNvUTBz%RdHkVg?ra}L8io>QsbJUZ2Sesh zrqx8+uLRk*ZWC(MF7XiIzs;cgkr188tabZS0`8l@u% zDHlo~qk9K!z``!D%mJGsFr%xh+K?q-f8{Bj~!`Oo7ey@1y4X%*J2 z&*QW&?ET_L_1nNO4=P}+efF_A4KfJS;* z@L`n0`!{n%g}mog28lx&_*I)QPJ_Gu6(awb-ER`ZXtvR9nrau?$s$fZo_ZRp)$BMn^Z9a{0?Pbm^vmM)U zw`k{M_N<^TkwEmG)8{eo1u9mK1YTZ0h?iPGemQ}08MbhImFm; zGRZ3y%2Mp(@Z|a!rDM{$d^TZK|9dt`DF@Zl2}DWMK$>hW%dN`XI7^tK0OP~-dF|@W zwsuD`+0R9x(#~>!PoJM0p{|yQ)og;0VBmt|#9^|tE76rpl+}~Q+JUUFsb2JE{^3FQ zv-p??Tvq&)TZBtWbn8iqRdk2Ix}*yKt`&*>;i@=DeN1ZE9u@orHt9ajcg$!|Q3kFm z+$lu+*5q%Dag|QXGKX)NE2KPgClN1Ijf&uYT4H^;Ir;fz1VW76#uv!(umEO4Roh?t z>-1N&o;>B^X*kF(j8ChuPZ!BH%7epFC#`!%CY!Eyf-!5M25YV9_G7J#mR#r1MOXE{ zawT_)PR&m>6qixV;mp*LMVp2c64K>=;l5`g4jE0R@Ztdrbn||TOw<~bg@&d!IODXj z$%n{V=#{R4C0Fny9*amq^a6_Ssbyi4j)33$D38jLQo)*pzY9X`+use3!0}A&jSZXZ zw0?$UXGw=`54orSlx}!~s;JKpxIajZQ`cs8S5sCGr%M(_qc%s-Sj=GhE*Sp7hq9(K z?>`eiOM?P^2v=!3EV#B&MB&XR|AnTDZ=R3^FM58oL!c?sHk*0CAl7fkteCL_M`*(K zegnB^xVe*hWGfU7p$@xLsWF;nUjf%JQ`i081aWxzlTB2&1L3Yo%cS3Xk2u0$Knm9L zCexAsE$8I#2wGdzrTAMq_|H4q9eI3)wz?&esrnU4`E0`0Peo>7Fy2?TIR7OXHF>)r zseQ6C(+XHCxS~{>nG6-_`wl3&aJgx9P!wMmu)+u>Ev7ah!+L6iEu;U;qVUo$@_ONn-z{EPmqmvf#!I@)$xv#G=2sjx4!iNRDJ~s@3jd9d za)a)h-D$MzU1G69vmEHsA_f!w#Ge6VY0M{{w z9hTPaaet;vgUFqw2u&W;#&XQg>r1V)z4OZ(Vh<9NiFfXS3h>E|j!hQWLq>2^gXU^_ zb=rN*x?{C=Le%1E=L`c-;^iQt|5Yqa==V;DgPQG;ehx??=DNqQy zFN$)p+cgoVv>srwCX{$(cv2~?FQfXeE`9n=M&vROS^qyozPg`vl#ARVQ{j#Gk_n~tKJtVC zdn>9UK?bwqjd4_yQ}k|wFGhz4cyU#w}nM;Lj5)0jgsJm;fq9+ zw_@MO_n#M$H5}ccv&=vf^e)#bec>T&_~ewjU9Q)ETV9VTDkg@>k@w;HCw0gfF13Fl zI)jZ4pWd^+pTOjs!`H{_Ymf$Da@YD%IY@NB=jas5Zo%w$^UhN7A+9r~d?!-upfUSV zZsMasuExn428>n6^ek}^K*tx2cgbh%bX_V1$bpQ^f;+3_0vf@E_ufB$sEa#!`wKQf z(pYSKhjn-(+Dvt%!@CHxRxh$I7@j~irPXZ_3?acbl)*=HLlSI` zBX^g{#lLs|lC;;C6I8kxbyM~tE;%>bfU*Bx2R_h{d&a2mPzI3);0WSVk1;6`I!6zP zZH;;V>_tX~+gHLqQnj>>2ObNpkNZM=9s!ugYjLB~rd})jEk-inqzP&GUy$jqU30ae zYG^w{d$N&Byw~i;g_;x0k37D{ce5ND|j zHxJ?1f-0G(Pr@I;ZB4V)j2;N2RXKjNwOW~OqXx6t6=jBXg6Z}(=2Ts_la&ocY>`Ol0OeH#GRbhuCyQ$3{+0m)It0+wXe)8N?=)=D zBZ{jb<}Zl?(Y&F0X@828wJxVyg)+2=7eVsXqg*V_Ll14=ycf8S(_`stYe65ptYti@0RQPs`FtK&|6 ztX^NA584c^3RTw`hJhFZp^d(nYRMj>a+%OhV}(9YVe~)-aH9pBMmwAlCK~RLm#Fzf9kb zV~HLKLf?t3twuH2EbzhS3dlWtu#2OE$=`1Ey`8{ftrL;d=mYOliA=WUZ4n5eY>(giuo-bKq)Zv}B79Gz(W-a4 zSMexFPpM%|3Uww=2ZyhLH>*60v4<_V%Sn!2r|;pVK7>o+njj$4t0tO9pe8diFS_b@ z#PK=URgl!L++3CON$jp&#odbpTKd>~);Eh8)v*f676gBht1W-a!{PbJ83c&Ppac`0 zwh~w#pJx0y&{%0YH4Mj>le=y)-)z;d@AOpje>`$>Lf2yhvZi)xEcpHysy!~)%Goee z7PBbYzM$I}%G3f27DqQ?YD|I9>!Z??@fShchcgPe=mi=25n|egKiK>GV=gd5^sB+#A9X})~ zK?_#UWrjt;Wg-0S|1cMC;y?x3vh2i%M#Px_O($rE16|#J`DM-RV&}-hG<>0%&?F&w z!hAqt2H7$O8k!bixuS1|;b#O`?IrM!GQU|b2nQy`OikbD&N_`jU&QI&Ltox#OSVW> z3DBy9Tg)_oPx$)Y#c`%U+VkGRd;&v*s*(`8}E&+l}i z@9f{@yY1C3M%=GTFn+kOfbZed*}J(EB-p=lj9H=)$Z3)L$MP6)k2TBhj|)0ZK{DZgmNv+>fo!F| zP~7_LoYqC_EHsu{WP}`{8ZiIVBPO9K?x%U<~)Z;E?3EJR~12e4GG zG~nRLV$=IeA#^z~wP>+Gs29}SXYT%<&%SqWj3{oNO`OZz)*`sPCwo?YzIAuBlyDXZ z56x!J#_Bc+yKGnpr&b8Su5)p13u|wBwevcjB#$3Wl$Gj-)LY8m`=%ZQ#x^pjNVDzw zrid~PZw?c|CYuFg*6!zJDCYyOE61D=M+I1i&L7l48hAnUa8CXR?4B!Lho{iSZ~wj9 z6W_#5tfD{EkC7+Z%yyLnH-5|+Jy$erD_y}~ZFsY>Y!`L9xA~X+)2Dq+fltae(o*2) zpaIAb<&=LDyTH{0BCnON$s|2#h;+Jw3kL>bO3;1a4QlS0k6ON=<;{PVHA8iIEuV96 z{g_(_R*+kOAcwhi4xe4+h6uKDhJ5_F=n4xq{nE? zv2)ojy++>BAWm+)58|6!Zw;S-Pc1=1>o*b)C!+MhCY!`JOwKQ2)no+#>7fq>sOD*I zqew7Y9m428uE9xV&^#L=P^X#^l<7Kz7h{|jgErL0p;0TXVT+VLp zvUv;L$(0WfshGhYM0>+}(%WSh2FJy}cnRhjmgbt*+k(KmaxDu@j=zMz-FLJj!qi=d zcHfW7uE=fPQt|^LxIa>E)Enqog4E{Gk;<&QMk2 zNZLD3f#B$qvIWlZ0QF>CRq9!|^jMES`@UN8k}o#H7UrYwXUas#AHdXWWf0grDsV{? z!#GLe-U|vV-b;|rEP;Jivs~V#M}iyn75B*`q@Q)@PGd`l1`P3=Tr*SKl7+tK<0P@H zW%IHh*ZORZ2I<6V&VzNB&;6+JREPZL6yW6SRv zy;E+-mCtFrVN}UkGGWZ>M5FU_SkrS<0fZ4X+b=pFgCld9aLiTvBDll3m{pRw$gN z=9L?lH*2wb4KNUuhza3n|B#laJ1w2v_m0=My179aUcN=>hNmlM_NDk)byq=JgP-T< z?n-xZ^k&0HV2_5B_1o=i#T|^|u?o~hU=6zbM2r9J`)q$-Vr2ToYp{h)9dAGu?x^Z1 zjWGMjt{PeUzl43i-Rkr&aGzJxt-H70#|jHlS>18GUI8$(+mRJ3CF4MNz71@*0(gnZ z{?WQp=4%|oz2O$^>M;Odll_tc(V5-t(_sDSV$CBKKl~p)@V2WH>&UJWLKW-~AL#G;9u?!B2PFl}c@JQE8(Uz4(%*!2Dc zw)|$53ss&yS>N9(K6~|xK_8OqYD#3wfV**d1P@K)%qq!y!;_8>*N`}w5~gb@Z9ID} z2kHN5CZv!A%co5uz&Vq`lOJr5p7oKo&XaL0s_@+mKOeMwn8I{FbA`v@(L$z2F-tI^ zBT2jj!G72bQ%Ho?gch18n0sYn6Ktuy zLUSWH_%XbZp&0FB*uUNwSlVA$H!U_!18=ApQZ4OIs~5v^szF?iHHQ@+NMCGH*|blh z{H!GZS8l}qr5?WoMf#N{J$; z@X?oSmk6GF<@vrYsb4Yf7iF1tpnb8rS$gZ_c6vTDHCI|D1&oZ zFG)gxiw2)9*=4J2)QQRV zPIpzkV2yc8$CI5+(n(>qFO#azc(+fa+VOc}M54=!Z%myt|8S0`F|Y>t9}&NQp|$DC zys3VV_@fk2@gw}xjCOly3dbxv$hU&7DQ zx^`n<9*N>wU(RXI_~~(yJZ1gx9<{ldw^;MHz=&()mci*zLe}zZLyZ+8`xR9pSkWe- z7&|;qd!QX^yd9<9JL7~6VwjpyPQ`p1nvVRw3Mzt2Bnn6O@!VQ8(;qhBUCR+DE`LR@xzlLgg*klE zRAOC4HW^5H3RkQNh(6>$hE>Msr@UiLMQ0s_?~B`n&7JfSJX6y5BP*2nS`Y5i>N-PQ z2K&Fk?N_rMN&@N~40=iN#Dq_8p0TZhvFH45>VsJ7BiP|a$Ps9bgfkRbZd!(D3o&Sen1eNr4p z4muuD*E)^Ko4c!mJxkmL_x1L9O)}7x?t$FOQgWxVT4?>xx|FCSHf}dHi1`N**3XfV zZ<@_NZddBCP8WUQwPe_&5SYylWa&d!EJi*qKPCk0ZLyl3C;;_`P`Ujl5=WUE2DYTs zf+DO! z(_HFAWV?R3CPt)2ghHchm~~33MuG%VRWF`hm&4ZPf`TQ3b zUSIQq0hNd@Aa%&HTVGoDzv!Oe`#}gcCuq*$*2j{jn-lR67qUJ3cp#m+#1I-({p)L< zUj?LWdVP$?Qp&u8fobvvRrKL4bm!42fb6`Rb~7gia>)yHG#s*^getDdze^I&LLbjV z+8ahZTB*WlcVFw9NK)dlYeU5PlOg!S>2=SPTo^1sO--cWED~*cPko#4ODUB)Gy98xT$R%;`_tfyewD>Fb8WY3MmFBE8y~wS zf5#|Z5{4D9iAkkPX*3?`$fAX|1tW}*Ox||h5)zH7Q~gTxTwws*7|eFgFB#)&ndwRW z82x&Y^k-vcKdS$&)p#NKEvGt8CmEF#iPggIH%*g;-{E6^%dbTY4{p}hs9l%S<`cgg z?Kd}@grn3MU7LD4hN(UK!W%#(@q#R9-gJXQp}&Rx_o`=xGuO_7MK}_ztb~na+({UV zy4>9eUd*Z0s68S3{YkQ@Jv7I3FhU}rPBb5-2hJ1}I`*w1N*_Sne8j!8snqi)-!@9$ z7Q0F4)jgZY1U<1Pj7O!CrEuR>84f-Kz2al2eq|$2yId#6MDne}{s|8pI(o*czo;dN&=*diu?PMJz16V0NQXdJBNZ*a$=LlbjRhL8i>YY8f?7`JC6vm; zS9r9X7J~h8Qm4Uw(=V1De%v;Bc5EwjU&Iz;;*{863A}mHjoKjiT2U!BlnE-oFIP)5 z_LG@2yd+9)nj1k|tZdZ%Y3-#(+SB)Gyfb&o>dg3cg9UP{Ph2Z3-5YcyKO{8zSXfh; z^zG2XS{>x!h$J%ir*~K4S*{#iwD!wklKt-2L74BE#SxXDAMx7#xtX9i2nEHRJ4ANA zF3;|eq~fdgg=DteBaK3DkIM>M;T*0jw~oc0T5;;b9EqL{GpJuyK{v0;Ri)Q#kS^x zy)~MNk{n=R?|h%>$ zpfU69hg*)5(|qflk&O!WY<1na3-sH*n|OXkHa}2xoZuV(71+#?Hxqxx9QEOkVs)Cu z4AG1WaKME)UK5aX{ATiNN7soxAba?h!E=eMHTgX+W%FP}d-EVhJA$1C2yWsQQfj$q zmlwdO% zFqfCaPVU82vrpBFYHjw&Pm)v=UDnwb!)C>)3BC6=2_WPUJtI)el!%x)mjo-qWeQ>M zltUlvD+gpd!r8KV4f>KB)y$^3BZqN{3NnsTLI38D7Go%=o;W$V-j|bB7ffH}3X3m$ z#h~fU()kVnlTUVg+WDnVd;>|1UPvntk=1=UChIbcBzclPhoy#reS;%IC`#h-U~b~D9NR4J`$Ldk z+uqVS`(kMpWc$usgMHYP`wIH{AlBgfYGlAQ@nvr4EvwYLl~Qkd7!9rqzw!zHc?ywp zB*6w<=YmZ#3sU}5E)|YzED12}^EMs)9`IvZFSspLvOhtg;Sp|N%%L8Xw6C7=;Ao`@ zAxsR9Vw6XyR*8}RpTnDUVP`vH1dfzz1@vQ|s^zruXq%Kw9Zw~TZwL1m9@Q2_-^%HH zMX$jY&+{yH*l2rZBzCxXnIXSJqR^vsBaIGB=@ET0`L!Guf-8J)a{PM;VE^#3>ui!R z#_vK9(QOAeIfKj0)Uu0(jq%AiEbXl$%5MVeM-0HfnQ1mFDQ5l-##mgh4F#tS0g`)q z^BjLB`UbD2ba(L#4*>s4bz#Pi4zF{x-FXU1^#4>;G^MWG{xv~WAwN6sdgIsjFYUVq z?7_qZn4X87{d%opHYG)-s!07)QO4|L%Vm@yKiN{|j0+1(Lfjc3;djB5k(o|J0tSAf z)q<0R&7ocjItlnvu|wFo+~gm;<360jMw)rQM493BDKlNwOFmO!5K)mxC(PB zI!+|>y;eBRN0$6dVsNH;vIHc%n|U^5lZM*uFDAJaNqB$OJ||NSEkStGKFke!qA(QEin0JxXp99bJjMjUG-ED8 zfPC8i*#>%!%TmrV*94SImzf{@o+W<=s#~2g6HAbOwTu&S%*21z&|!34(`);(qae|- zGr%|RdF>wl&CL3}kH(g@=ZvbFox!-bl6Z{^@;f6T5c<0~O6ggy!U&d?207tX&| zBg|VDu0RC=B-|QaJhMDaQ=)o9Pds{D+eZ>;A1xB%*UmtcJD%{MAG+lCHz8-n{wQ{n z;&~9jZ&SnRpkI^H$@hJ)^yapa)Byz4JYwFzspNHy=`G2o&Rkq(NSB@0iVM1V9NoXS z(yad#{PiGfzkDr(s`H0&ga>8stPIxgTRm{X2+)7%9>0tRu!cRPHuYwgOnAjQa%?Co&@*UWv6-OuC0Aqn4jgLYN2 zdji{D$sln}d-|TbO`q&KhZRDnvuV`J1mc*e8r*OHdfm2>Rhe$1*46-nfvwR(GQ|A( z(7bY{NX53Kct54P`zJR?tq*0;D3l92COFgh2y41=V+2A%@KiCU{~h*dtA~yob4kns zF43vt36~<{9nO-S$a+=UFF}{>k}?D-qVV7FOHt?A#EaHZ$uOP+KxF0S#a5nUMl{^R zU)mO*Y*aVqbum;3`L!kNeyB3xvzbbB8tG&I(=QyId|fQg>_P}lg8Mb8L8BFxmSZcb zPW|ehVV#~V;vVlMiIOENufu{55@5h^pCSYsesdw_$5U?~TNQiOv_puh3!v@4e{#@Pj)At-5hoFE=~bF*D#t!Xa~BRi}je7|9H}PADrmZ`aZ+?mKEmu z(4Nx+_!Q~-i$!GEu<@bRvqC3CP!WALshHt}V5t+*^n{QPS zK`vW5c7~Y_x3Ug9L2|YT#({KrCa)V@nc=%NYu~suvgO8{ljqX#Um|ho+;;WZ2h~M- z>kqmVj0e4$R!Q37)Sc8vo49^Y*K=(_DY4m7lbM|#R(g_t*gI*>p5ltIk@HQJjPv;fm8n_shddX#!sD=bk|7AMq(!csrgok1 zw9}7#%Xv|d>UT4_&{nEf1gJw80NvaOwZSBnT8Q1T!x66HX}~ai<_oV%&SB706NwGJ zi7uAgn-nbVR5MkPZ@`2N{Iz8;ajb?{@kJkHx%x{TR7Otr8 zg9Zy2d_YEuHRyG}(!3UO?Z}KCqx9j}KQD2I3aXMFYt`j07{$N-(iIYjdKthIym!6NX5V+}n7!lo|&S>TKuF<8A zJ>cNc?O9k1HDi6Ebo($k|H5fk$q(PTc3KQ7HJ4EwaA~%efl$hQQL$&9`Tj<1xR+_C zaavLFTh~l;)&0X^5C&P0){}yml4m#Vhxf@sa|3OcR?E(FA^SEH~=N5&O_+J@_nIA&Mxw*@6P>5Rr$O$*i zSQ3%hqOYPmUVc`eygbY;BKh+ixR2}U)+@es7FDG5O^)()fvHSy@h1=pZsO##ZJ{3noe#6fs@j_2_WJVl8KRMB6m144 z3;s>grg`HM=wQ2`AnE+|y!a|ykM)U4LdXqJyvzQ?HCk9Q9p8Jpg>oqKu7L` zWWw|$XPW!*O8m6Jg+uF}0?6wBqB75e+D3XNa^dG?uG1@qZX_5!NBKlQ%OHlDw zxT>HQwVRu^Y8NdAYuw-eazv*E9l#O~*4{i{hrrZonI$`?G3N##EO+atJQI75 zj6|{%hZ?VCs$UbZC-Q46Xwr$N6>0M0T~UZQOm42b1pW80H}^qmCrT@|TJbqO$We!a z*FQ?yAX+DueHpZ1MrQgs6s?yiHpx3kD#kxZOkwCeX6 z{Xd;WWR8H!WlT4Q;iio#6f)#I?5Z12LX@GMJT4%q<$3zO6Y&!h@fADgUa$SM!IjpN z;cnP3d+#QZ1dhGGoXC2RA8{NKcMhW7d&rX~2tK^B31?Fu!ZHVeq++9+Ut|*Q!Rct` z%xbS5J8%Jq`Bc4`z8?7r!)X0Z=D0_sMkwHDieKpGU(|ABB-08vKca~t2=Yk8IY~Tm zW=UL-T5h2m71L}Vq8dbc6q+H_PLoR3RoX)raLpWlyHai#x6pn`-%)D)GO?&gvXI4?frb2K|0+Cfe`o5FyXsNF8)wliOm%1dh7|6O7sax16{U6OL zq4eLXTO6Tx`Sv4KLRfW1K-S9YNF6|5Xd^O`xG;{*7HNHN6INz}D+!&vlxmKbm@Xd< zQh0ekR6Tv`4`fgAsTwtw=77&hyq)ACj(0Vj=_bFt8)B0qGMAeqhu3M6aKxp{MmZe& zJg(yWoJ=v_Xo~jq@q@hr9N>wI``h4=`8QqDqJheIvxDIV_R#&Z#*RQ#=@ zV-@Xb3I7&b&f+q{Ac0Z7M9tpOOED`w!kzOQN7~9z&Ip2!#?lY?7ccvak3M9(_WK?4 z^=};Xp(k2R$*+GqFWyzDOa7H=qJ(OkDLp2-j9B9SF7!+OmACy!FqOH{T=9;TFW^L! zX2dQi*Favf5O8r{TB@X***AQ-)ad9%jQjP2Lgh?PM?&#=cKZ#+l}JK-cO*Wn6Rx_) z`)IiqNY!+xY|w6lw#Z6Y_*sx7>D#>N%8^AI&A9mrn8khq7re6^q1YcyWo75l(7>^s zgTNd|AsUYMhvH)cXIKgA7m=8Wyl%J{KfzaLbWL;QJz3VYFa)KH7lq{C;Qm=osopXR z^k$Qg)x~YN=#z6{iIRJzmHQR%Qp}_BfVOG!!Cgkfwb;4sodG`$CeFOW@A)&#W;sYu zw=MHogh7DwDCZ&EE+OgZm0!+o7~~qhmfxY{CjI7pft*S+ycFA{-4`O&4!ce#xbXQK zp0S>D-IZvU)N9KpLWv$hvYA)~2gjHiBg0XyH!VK*%@n{!%A|xXXL2s^jOOWVVfUwE zB@AwhDl|{TTG2yM)aw<+o#I$$BwK=qUV9@a&N+aPs_l~B?=DuK3+HxR`JJ`yS5|x) zTDBffW;WMWP5GdL$X24M!tphV@Y*PHmK)$E_%3a6TOjXSjq_Wb5$& z%Uls)l3Q%CjAM|@L&x^!WAoKvBz!M0m+wzqmMCuPl zF$fOh2-tludNcWcOOWGa!*aOP($bya{pA8+gS1ys=T{Xp>yg^qb8UG8k?5 z{2=8ONu6g3B8$fqMPn$3m(ZdT?fk<7?~HrNjF))KDuRh@+Q}6pFO&Z4v*^WP#hs#nOE!Kctv>nAS>y`> z%{P2QJ#F9OUv@p_+xwc&{=RSUppOJ-aCyhNVmUdtiw&Ra?3*!z ztdK<5zkev?f#=kg${CUj3g2<0BM{∨BMntMQ3m$HVz)d}joG?dFajJnqK5$P<&k zp0dr>FU2~zHgC}b7y~C<3_ANVL2l^D4{?t6);7m#)PM6anwLe5x(kO}G{P=QNfL*r z?2RjVP}Aa9wn14a(4XFrkD;TXG4lbJMhHuFK=m>(au^T%M5*p zirSg1w#!y;VC{Z1DzOIXra zoT!V9}+6^gPDBVaGJ|K5*aAut_1^mloT z*e~y5&w{29W(mtFR`h%(_g0S=?Qr% ziyi2n{O+6Adg6?{P;?qe;YmQ2A(Q2&`GDCC78H|(^uE7#T1A0Z`;R+f84C?POP@5u zcZ&bxUg+UY8<2Uy+ai^6X=Jt^&Z4M-cotF#T4`VY!iO0>bX{KZoXYDNr6DOAJ`4Wf znjA=2B+cv+hN&hv2>5t&>j4D`a$JC9Km^_mR}%5$Ag$YbSAd1lH@;6i>ZddKQ5Y%T zcj?OcL6vP_H2R)uDsI_FrrA3BZr-8y{FQnJpfArH)N{Q|=1 zB6LykP?xg}T5to)82cez%G$gB6OFS?6)IF~!K$Yy9VRi&n~&#tJ{mz{z3fJk6uHDf z=e9TcOXypFisMmAtAgps=>3i&+TZvsZ?N4v3M62)Rajuo;>Ha zG@-;@#`Rl7>$QAXG&5}gfg^4+u504E4>w-Lq zXb5b9Pj~2wptvvjkhP>-;m!if-vup#qt9)LwR&g2Gp=GSb>trx&X2iSW+ZzuWP&13=Q6m_anxaHIwIdjYf1sVY&p?0ggSXQ zpUMkz`Dyqbm={A<1zHQZORop4$ibQ-KwFXQ(?UO;oJzfB;qEx%&{`jd;8|0$wO9Lb zENkTFp8bvnzOkj3g@^DHwh#umogs>zwNib^%HfKPgX}O?_gOs_!|SA75=$yBr5>VF zbhIugG&mkgKczp4#}dfm^q}qi#8Cmn-R7Y`w~%%W#vz9oEKM)`gMw1I;Zfj8=?l5u zeGtBWNWHvEp^^wqF_MUEn2{!}{i&w+odC{USINmqgufF8(Z+%PBVYIc zo7I<+MeBKin{{7musa>`nv0n;V@AjM<&t{SqnV*-KM`S+7g^+(F%xrm;8XTy3*=31 zFP3aK-JElR%-pgGsl2>czr0Uk%bQJ4glx|6?9I8ZbNzwl>c)q$romX@o{l}KI%zms z!tHyQNmAf0SnGY0j`=d>hpi1qUQgWroZf4N&Ma07!E~(`q5l|xm@{g00t!E zSObQ#E!@&U0&g_S*T^^NzZGZANvFw)K$ib@&zSMvSQ`rlj#Vxr$&WJ~{KR}Td?_9z z5qb9kC0kark9LO4l}iYncfnfUZiP^G1k)1Y0fKaEOwUbR5@( z4qdRYE8ZD(VE_+yv1y_T7((Pon&eJ6B2IwV-dl;4<$7^Hx1i!{gsHExh4zOl@GPRX z3?;7=q39V=Z(rFhsW&)`jpSJkNO8}lyMaMB72~(xKW>hUnVd|7xjdZrL77Q%hFtZ5 zp4~|Wu0uY?NLQ>`PJ*g)v}@$~eMAa%ro(p)_@RPxX)qIT{_UV=~!PG`B33P`ZmQ?u7SHRIordb#eZnNYB+HcRhYS)+QNDYFORWW?-!`3opnH(R^pGx)t3NVpJ9q5@y_G{IZf#bvnD` zJm!1an>LD|!@BqDij z&VlOGd3PLtRNv{a&!k9S52>z~ppowRV>!VP(|9>MXu^Zm zl(K>z8|))sv>dI@e4&!ehkul(MWDKApI0o!TjvViO~A2J)7J@DXMp0?3?>dH9#@+U z^Bb*gFN;tc2{pIz(*;{J?ot$wq}WS5Za9C?82-%uUiSu2c(u|8bIRSVKOa7nIiGud z%Hf(v`J9@-G`DW6RT@>(BBBFR%T6iZ;$PFWw1R@J<)1os#iNn}=!N6VWghoo(PE~= zX)n1&B5Ig#JG%$Xgzd)zp53M%@9aE9l%Z(vILXbhJ>OixsCK_6+P23n8Rvh)B;BTH zx@B}0eapnAsSSxKQ^z96>xGEeEXVd7d!z4Nv$cxV0TN}OSupqDDlw$YQqYfM@DjY< z?z=9JN&=>zAgS;Z&D-SCC}^=5Onkd(G6P9Z1QQO$a5}?tqk~Un=uZA)T!nKzq7{I( z-}gn~Wl8uY;O={A$8CQN!<|d-{qR9FXneg_*BeazV8_uEqt!9VL3D7n8=f~eDnh{$TGnS z+nm)yRDcp8*bwa+Vd;Vv<2ga6wzl;* z{O)78_3v>7m#&+#Du)pQ5YhrP%FwQ5*~ z2o29^=~dkJWCSD`5L1=N zr&)b6i9iUMp9>DEqnZ}WwyWEMo7D`(#xprcK{S0QqfFjsz|lKusEwvDR4-WwBNH;Q z_RUuJIjReTGdr4E#DwcbtBGpHz|3zbJaALYYLg#?&xAMhj;G z7cX(m7+7xA)&yyWYOQGT6+tO{_8>eOhqgf^wHdk=)RTj`#sQ;m1PO zZKZxJea+}G7WBcF@Ook*7xu>0EoO-xf;0_$+x#crB53yuNq^yIlgH2ZkcF9^aYvBi z_)vd~;8ugx|?j6kGcYAbCrXbcE}F!r=c8PF>${ zTD{IXY6SAJd_Wtc61r-?YK<-*#7uBh=V#*peXFH$Hs6`R5XINl8M>JGKIA{a62EbQ zX0Z}w!nE%RP?C#HHc`B|?3`JsU21r{zsRi#(*1KB8z-_Sg7QWb0a;oF@wC<}F|^;L z(G+J1K`!-mMfgJ4g>=@X?zFzgy!@PTeDH&IJtf4)8)JtXf4w3u}uT(viNkfQClN zpe{YOa>=#jtF_HaCLxknue1TvXCj`cW6OaOv%`j+ksScGx;WYfpRn!t-4;V;mNPZE zrpHuTbJ|7Dpa{J@2ltYxj_d3vbmtyvb2l8iEtRLQ=<{E{`7cOWCx%*N1!~dZvV#^& zg9V0yu=9h`n4L?i5o{N89~=mJEh1&%NR#)ifuW{5#;Nx^j^W7TpCtdN9sEH# z-~Lff={&MawvV&JNLmBmGNNtA9r&tMQI?*NEu)3jA%e2*{#}c3pqG`*ClRjR~^lo zU@N~|tmM`x6Q0;gDBA+{FjO|6#Xsk%k#${vB?nu;qw-y~I45SWX@*7lf7N{S|@OX(!S|j+T<8%Vk)Un-ykxEFx&A>{Cdb)V1DxCYUbl@W?zy& zw575XcFSZ^VKoTDYY#Dtin@zxf0fBFBBplqv`FYj>^R8!9K>t-V1c5i8|ty#_s;~p z_T1gt5HRIa8~^1y!Y;8GH#*`S>kgG})LT7}LUf_Zyv(4s!aiPf^UC~)gy&xp44u9@ zS{U>2d=4G!qW7KG$xPSX#+J&){1tCJ6s>*g3x@&%G|AO-=tMDIan{FJXHN?>30J@_ z=%CnEN@hXwi3&MEXI9Vgr-=(?x>iN!qvKj!dS#>DP8xL~;R!&)s)$Rqt+YvRagf{n zqrNZI=gW{@Giyr5RWdiqsdG)EX5QFqrrJuD&e%jxhSsf5o+$j(Qjfl^;;z+hfnk87QL1p1ITnjyGkVGMj-6V6FyW*J)*6 zg0~cWLxj8cbi=FPqTLf%s$LJsq-x?ZXF8)NaiO9KT+NTvhhIE4)uj+ZQmL=r?I~sV zdA40s&pNI~i>zIWoAat8_s5qQe{ii3+4SochQr!@yu3RPV&Dq*1G0MWH>nl7Ajy(I zTMVG_+Qd}Q>6;zCg$3(6(DegLmG7nI+17mG>=L92nh#Y^Rm3SzYyAEh%m3YrT^Q7M zTtK5cyAJ>F(%J?-Unv?#g%XnuAx=#Hoe|htkp`<4qJbm6K zv12@awM&7$E`&le)U;9aLP|Y7P|D^+%|RDV)=n~9HAjk|mbYD7;Sdcrx%^oZzUIj< zACjiXx-HHYQhk7GKs&#Ec<42v0MSKq$Sg+`q}4IGtKD+*e5Y7>dKmmpEtHx!!rF** zP*=mvo-HISqTK=H@R{v#*44!HL1!Qm7djvmRAm{cymz$k$eW}&Em8B z{@pIbQFI=jz0`No710Rgf~-h&&85wExdYSvgn4C}!wsEAk2u=Xhe=WNwe2w)NV;}i zrW+3fhNsfM;Rb53>}cX8>5xNL_V)E+OBw`YMr(c z9MlKIMsNMmH_IV~(J#w5wiYp1TW~Tx8IL==6c^nTguccc6>@%k(+CcjAl{tlytx z-D{z|U*C64mzwHdYCko1`giq)sfvS<&pCvcP*hL-*4~#@%~sWFO1|FV zKxx0|ijU;)l0ds@^C7BT$~x4zBuRAewjm;GbG)#%<->Llh1w}YC9&q;OKPLyJJe?} z-dq@n?K)n;x*m?9hR#8zCf_s|z*0~Z;bR@(M_~MiH@ws9!07UzEG*8Ieu@zM{_DCm z_;W}IaVK0w7vLC%{dIfeLD64x)($Y@#E`ky{J-1x3MulQ?-jhE<=(XD^@*7Yo(HG|3^3PU;8rsqTSHWgM3jhg zK_DS2IYStOB~?oJVohC+5xS}oP97P5dn!34yohb7S9>In?fu-{L?UB5F3W$>q<6Wn zlqN8+{~0L{6}*iDk=YBJ`sqaOFJc^b2C;2B-1zT0p6zo)t-jo&PZsQtrh+ldViXmn zfaAA&7YAKM@YUvW&8wl-k;{c-2Hgg%AZGE9$90ZpMPTf|8ZI{D!$b-_;~1y4MZ>7q z1^pP=0GS@kirPPD84{EH_{lraj^e0o@h34pb50@TCGP&irBc#CR_hl7?PD_i$~z@* z@jk)d@e)n`OxqwnG^ZNj@MEzjC*y4k+DtYs&QT5uMrYrO_69x6qgK5+o;eSu*gpXk zq~AfPEkh;G=dGOFBpRhK(RY5#BpxI^xvcf|>m0JYCS@>E2oE0)bWT6d`E3cs5c*bs z)UEoZd6=#zc7G8m;< z6;QLTeP$~)H`hi?jcX8DMp3_d?)3$Gylw*P2?%RovQqq|a&N7p{*u9x6IPes1v0g% zXLZ>F9PP2O%9*DA9S9svGkkh_NW0BnzH9^m$C`4$|{+AF*Nwq zmq)=n$6h%Sv0Qt%hdLx9V<@ms_@lh6aSPi&f~9nRTz&n`(dOH>T6C*-=0a#TB2T$k zYYHqZ5hoA%m51cS-0ibx#&?TVX%J>(-+})26b0EqS;G6}*`%gt-;iV{=Q7o}_cBt^ zwB2w;B%=w)4Fc-KOZDwxtBdyuT$qUrryo$hI;|ji@tWTeIzte_mE0w&Pq1 zf4*SL?CCgfg{ACx#rVPfDJ0SGV3$;{)@cMCfq9pzS9&C)yx$UuY_x%OM6Y#MP@f;~ z#J@tCp&yg&5>%Z_GW|T-JVRH0U#1lM+e|`&=;XdFTu(DAZKm$ig+MAW%?*4_6uSogjeqkNs z84pxWrD=Z64DRbG*b;IlF#Eh~KPo(Tk)|qTy(9>6+NW=}T2CG(1)n`}%yyQM{(3AJ z!H0F8kJPILK2_j9y#KxT6?wTTlWk3y)@lV79Y-_Rhg$M00|8!v04M-$~^_SGHNr{MvTZ7pUYwa6ooXixMnt=j-Sn!gotAHwzn*r+-wk zx}CBRt_{6A5%vP*wDv)-RQ4g)v@w_zXC(*P4L#F-=aEN^2!&t~lwQ&>pbmWLju`X! zt~k1}Rb>a6v6Z0@JhgHBgg+f|?x>9LZMFYBy3Sgb#Nf1JS3ipJm@nr9FoNd`9vqdi zmA+q*(^CQA#S2Xg1eLmcsfC=s^IkhZi%@PB%*9EcfRnJ#LMJlYl(qw3K z9Udy=PeOLwawThiXVzIU>ZMJ!Cq`HKeaDRXkQ<`=bo*7#@*gd<1P?^$w0mP zWqYpCw!KRfEGt{IuO)JR2sh6VW9()R>>2jXkUYe!Kyh!mVa_($DQW<_L;-PA;CCXI zqg6<7TAH_{*2(Fq%wI}Wag3Kd$+u&0Au4{jt=afM3dn{wkF{CqSP?g1^ozC{tcLN zJ-R}X)z#nXJz}^yDj){i&PvM8?NN?CPP5=FC_!A?=wyFGbMqpb&;JH3T>p2OYy?lQ zZ5o(ErSOM!M)^5Lo}k&CwPs8Q7)MS0+2amo-F=$^8fIA!lRFZ`K+^af=Dz;kx3&g? zTfWo>A!!W++{w*Vf=?%gg(JrOm-p;fqb?0;H+4o;DZg}Y(l|cubR}O_i9aHbkB|3k z7wXJPPoxFl3nlm1s<&iyAiDR)$Jz1lY7PfoM>y~>>*7qPU8GcQMJ)a`5nXpV7SnIC zOCMFp_}OkwWlRTR)M2wX+hCx&T z(HBM5UGxe7Yei$6rPa{0Ma?!lyN72%L}qP9yrUc&Iej+$gsa3hDe_ZsvgZ9;KTzWM z>`$7a1}`^Vi8-S#ij=wbEzWB5v7W7NgR-B>voB}BzY*~UBIK__*DDaYX_~WLVru#l zMXWuGSPJpq1Anhs>+3Xd>V^#3s`tRx9h6We^b)2~;t)&%CGz=Can8`23zF_b9b~$9E!G-t3ev8!k1VhB=kG>Ub>V~-dw^@B{;M`B{02f}>p{m2y1@^8R$LBi0 z-)LnbToC=7V$K8{c4g;1E~N3l{Ur`@l?$xivd0o+zdVL)vK$aLpP2WNw=AaIZv?)7 zmByu7`uv+D3PXnL<;QW=jq!ejZh`yd@Se8?aL5YIOYJvdVBpndtA8%tzqz8(duvE>*&)l%f=FEv@>@14T5*91HoZTps0NaNgq)G0(vU(d@W0Wu(xbu_W z_&lLS8jo*Y&0X}cggT?KTkv9m%J-Jt!E*BM_FM~z1$GMx>zy#|LfI&ja#XVnzsv&O z1e8bn6K=E{3&K8fq>I6YNmXg&vqzf4-(Kx>&LJ=BCXXTHt;u4EejY){R~G?R()NAY zGXdL)H{i5E-`G=knUEb?tPok&MFuo=VLulO=(Id%lz*kOBK>jFmvu^qOIweq%-)x6 z-j($lUz~*4m&HWo^O4YWs$e%ig)iEM?-pZD8wzYK*3T~oiJHv8Yj>)12_RwIUTP$n z4rzOY)m9rszkO}-Uqgv$1JeFlgZ)-|rYOS#b$FiR%yaix_xC;+nhD+5q`qI86C3P+ zY{Kbj{Y6$p?hNJqbPc7iNWSb4)1!Y=-?RFlRmu#J3RT>J0w~xsA2NlqYj;xLzVc^X zIUNwE;-qqGILrj4QTx*9weoEID>Ol?>iqRFyvD3Yuig8db9vP0TKj(r z_DHxL2ro_Au;$NP3oq_t!~+qtJrE9i<< zOhG->`jsEjCeoqFACDDkXn4=z+d(=iKlVdkgajBgC7986zSyGP`F>TiAU1tn#}OeS z9=Ol^pz%DbsWc3g{pz8-SxCE;Cy$UQjqRog=EYn3N+#`mp8f!<6-vxYAZq+4MpXN( zw-G%(1T&)sC{T zagA+kiX*pJt2_fApbsyBS3R1GwXciw)?IfXGpNu#a+10VE5U?I7e@wLz2maG>`tI# z9&P;B2-7^ux=y5p)YYIBY(WtObewCAYIeHDkU}2$iz+i=y@B#ekfSC&yRTq?qJL_D zvQL_e7g2Ny8J{K)#h7s-f4l;}j^KV>re@%G`-sRd+J-E4FBEfK3_gc0UdlI)YE$0H zQ>yU;Xh{g^&H#|5Kq-u8%$B+=Ef_z9A5jP&Qp?}#unUQ-wX#kTML79bFOp1Il?PNn zJrr=K!qjm`AL0;5U)DK%$#9$~z(xvnaAD=argWozzKO?^{McTD z0~m=42#T`iE3j<-1c@~cw{85`mUZ$4Ca!xr5~PF_BnvSBh7DfiDSk*&3I7s@t~?m) zLgZ?HyBt~7<2Cb`w&1$Jb{7eQ#;As}l$t+ovT3%C>IA)V>^H*8&YH<}TD17oiycy@ z4Zi)*jS2~>PvWg%3b0#YbAv4?Wo({(`E#9KS>!svl)(BPX(tnm%Puj2S^bNT!tV|K zWv25DaM_E3Rty-_v0hn@xAPsCdZT4-hCdC_9dhF34;D@5+uHEEf;Kr*s!j(Js%HhZ z{@L>YEusK+hf5dtCkB3Ym=H$BKQL}j09lR)AjR?_TjxTJKc5+&!YQ!a==hQmd`0{= z0Tg~$RvDSu$@d7Y1DFSz0}qa4HCcwHoB3#l1_P(BE%)>T)w8R^CB<0`3!9BMD`=7q z6n>pycBGY9n^&!6jk(MPcbx4he*5}W$fL(c#*$N}rh^NTun1s&Dc75QMXj}uj*5Qf z(NGszG;(TKGLR+N=$uJrk(1SFD->bjX&mqG(@ovOlbOmd9-u<^H9F@&zSV5QB4x07 z0Ddt1%b|mh_2uu91}K`fgg)c*=RZ^g8q9?z>9xMJwkYEU8f_w(>+Tu!_RWdj*o!|r z#l4^I+$`PG1up#gn`B7_Hf&AJz!N^d`XrKT-cU)~j#e!|62i-VJ{QZ~1rCu&6?1S& z*Ju!g$8!WwC~XDLCd8i`$C@6e-8TO#EyLyX`)CPKNOx zz$UY8UC@a4{@lkjRl-C1C^Rsm-$K5C4XsIa`uJO~@zmBNjka(0gy~0Vimm#@(Uvhq zOveKm!3&<;4l$t_>Y@gajqn%ulf1q4CZBv){f<$?+Hbfb6!SSw>ES5brQ zoQSKHF z1fl!G71v$ibL|hc69Qn5G{!#&dy>gy`QStpshChlP0{HLc?w!w76B7nCUfdJzXmvm z!&gZIgYuqS@B>^MSny{D(W5eM|2!CW(W<>F+98@SEZ>GSD~WyA_z_posYT>eYH~Q< zg%W+#pfH!+_zM3EuS>N@mXfw4z!iq=fh@iMry%{jECET^Pb~#`yB;@0lctFAUOXXO z1E~svWnA1}54uDsv5X#`cqpjqdu+<1DALHxT)gQRR-xt`5@Z8JXb3tPnVpyX`|!a_@)tCL_|{8Xx$ZA41O@MywO}e5t68ls`dcKI4WhQm&==Jxzx8L(B1WG| ziI-me;aFnYBUHyYl?!JD@exe+*HuyW5O!X&vNSSG=9MH8I>C=BjgHm{)7=sjn?Ix0 zO(QNxIyg{hD=TlIX1~(T|HOXrSm%H(=uC_E2WFpo6*nhC{imZ|+X|Li?>?2)`y8kl zzLg{kR=!Of$HuR{WqPF|V5(Mdr-FBl3T_A{^=L1$-$gI3$HUThPHtHjbX%^kf*AQc zqDU?Q?CzOO!QuC8idHhg8U8bmKNU^x?E2$C8-J4Ot+1QV**Aw>j~(ydI+~sKG3u@8 z6K=3{A!-~(1HUFS6FkJY2k>p}<+;9c|Im1mcS#081s~a+`#a}#@WDPMuD(09-^Cs8 zQCqNc0(KWL$$DR*;f{LYaNsVdtuR65RiA~Ga$n=V2E>_3x3}|swBvQGtWsyDIfQ)y z=l?IY4gR;(X1*G8g)X&&qyl!=_MQAj3c9*|*(p44W1umH(`cG{oG0IhKkUc>=LKLv znk%u-5@7YK3ij!EI1L4v2D~~L$JU{s=TCa@?mSCA3_$QQy7kE1L&lF?C13LnAfIwY z|JZ^d)SygbTvE%ck3}^UB$)=Wq|SZ%!ut2-o*-}?rZC$ZC5+;clMg{e#VGzKv3U2^ zGJ5kN4JEFH;c{Up$a*9{MXCf`u=qL*dcdJ53I~9%hX66{4?KHuR2YL~rGPo!*A7S- z7=eX1&Sa?#H9&JM8?|tBx?l@MrvUt?;J!;rU&kgGEpxW#bYVT9;Z)A#>-2#vt`#$GuMF@(6_)T8q|wJ?Xs#lZDeev*;68+r5mb zGtD-pWdmuKI63=37s+3!6>RnogHq%=$5aCBbKp2-P5~@w% zkMmmhy*fyO?%B#zkp(TCaV&KN9#&OyIM+u##kKU$!3}BC>44#u4*jV}QJYwzqNlT+ z=C@W`ATc~E`k)Cvp@+&zUUXjby2|+1I?z5FM0bs2`n>qL#b6?T0Cy25G(T}<+NU=W z+lB1n9FF!?i}ZAe@rQOr?{LHMq+tzAS1+x^z^>cCfzXw|q-8&JpC9**5GQoBp(AYYIJe!`nyUmuK3nBA=kjvBTHbajh7U0-v3S50Wz z_5Rh47k3^JO0~~f%gM+dtG7VVrS=GcIwVU%HfT?6nn%{Wmy89}v55 z_Q7&{^3m#LS6nPWFoeiaX{QE1;Oldwt-Y*V6U-5UPeGO2qET<5Tgb-yXi;^+iX=x$ zvfB52i`S$}Jz^O>H6_3pXooPJ76jpnNe7E80Kf$eeV#F;5qWJuDLEjFi75>2_$J&o zx?^hN0n?e8*CgTQRZS75DLnq{T1ws%=_DV3(*E$R? zP+_UJK;h|yMbC<*a_fAl6O-S*6U6|yq`PZ8Jf!L0R6nl88|gw{04itK)~i}haD&yJ zFc_#VuUs*kh?;&Rx59>2P%ftSx*K}Hzh4AXLcvqBC2zL8Ki94`;hQjEJ?jbd-{I?I zZx1u~cTE*b&29<%V+QlSyompyZU0HOe*fnX_!4N(HY2r3$O$uBCKAlepDEyfcg>zH zaOLf5Y)A0mv{m$n@WINqzR&qawsg;ynw9P@1v`Y7l_Tkv9FOGLUai zB|~&!*e-}!eV6C{gvZlxc0eIqSh$uUOFGqZ#KYfhon&qZy6iu& zMKLLZT}i9=VJYD%7engj;%Xg(nC*ku7AUxQITzT5zoPCN-V5UCI0IE+5tb;dp2O*F z1oMW#<%~0FXvVm~UZLi$pMyqqTsF2{*^=Wv?yFgheY95Py{<1b?!Kvkln3A{cWUtE zl1`X}f|9W9iIcVL8z(CpkoS@LE$yGmBW+11d~!7r9f9Sh#`YKjZfFBpo;chLF@Mrw z3@~$Fr8DJ$c^Ss7fUN55llBTUsCf(=#QSnCJ2`z9Rx0hV-bqfM=#62LQX(i*Fm(Ht z-#Jebk-~nkdJePeWiSVQ`mtrY!zI=mms(0tODKMgO36CU%l4|3gz9-{&E=IVuq2#! zpKf&;?8&>6m;;pwf9WB_n)hHQCfd_t+6@0Lm=@y!vNWeDN5UUE9rV=S)e^3V;7ML5@};W?N240 z;+`_JT@#m$58qUI-5UXJhW}KKe>b9-VY;;x({0@YNHw5wFX?9vY|~r zIV1~qYl**$s|0eijDy&Hj>Pmk+|YB)Rr!#R>Ty?j(3HZ^wd0Ug1Jv}!@1la{>?+x| z`+$?umA(yENO|%-P%l}eq$_}bd{YfR?_@A1ib5pA=wRDBMm%}|^KXFQ-*Z>`fZ||a zpl_;GWFc3ZpKbQQ4QT~I%`r&Hs!gQqbl9S2{>Z=9{~l`5{GwmyihI`b%BNrFgu6`4 zZ;sIKnz32$ipxF$3vVWDRHi>uO=4D6@RY7K&eGH#IrF{jn0uf0DCBO1`C@XUYIEfM z%9oa9e_0g#S!1!H-2#db3>;1Z21Xg2|0fH z%F9EzH%}PJ9;P=;PmQs0gK7C$OZvM&y9X|FYc8p(N3=yrxJOQ=knNu-m_CY#cJ)nm zJ<<0sDAiuPlWACcWdM)H`VA#O*jY{YjtlSuJoQnhgFK!KpOSrkPC-IwNfD?MK3EDE za+L{X;`Ulc+&OwOYMtt~c@bZj0>0x5fbYJ_H(N>&^efy36img^AMdS0inJ#r%2Xl>D z=;lgQ8C^om?Dx_TT3$U$IQPf>^Y+H#FQehVui`AW)Xo;apy%^kxe2$VjKT^AI!q5j-N4^{p4*8uT`!ala$W)LB9=yNN-?qEf&WYb z6aEhw%Q^2ib!g59;&*@Qcv_fhrk|TL)yf-JjoNw zPR8b_r=?8I?^Sx9(8{VS=FrW4#|B4tgj#>p=jAc^Z%EHRTX(vC_+JK;@U1OFJq5BE zp2s#&7D{_&U$@~I+xWKo`7@FIS}Rmi0L!4FE@?iOH8*Vz#WqHVk6aB{6%LPi(|*sj z%=i{Dj`NdLCmO~%zFKe(}DR^Km5zkCVurQz0OuddyFXnE19A3D2Phsw)`uk!xl?t5E=^ z%n^3)`Yn)K!+%{RKjyrMjtfP1m5WHPX20#$v2zD0G$YgLGFPxjKvSF7&=9@TuHFL= zC?~M8PxZGAhe@?@EN!!1W%}WS`A@cBy&tQE@94R@y7+ zw_GHA%N~}Wz{TGL?!#{^oNC8mGW;m|E?J9SQs2g0SWF(st9LZ5E>#!$n zXNrl^<-o)k+bFry3v23wkXL@*Q)$M4P$cHWF<<`=FgU6_mv_}|WFQihd*#kaJ znDg7~m#=1O89`A5S&aYq%H_Dl^`Z+fRi_fMo){oJPQG+%ZbJ5&4&!J(6X z*`I%cbxTjRS$-J3oLu;EvP*geASt767wHSkbRE{QA$OyLqpOZ zAK!eA5f5iD^7|rxddQ?4ECBQV$}>{%J*4JPr%>~}qSXzhy~?=MfP3+Nkm&mtIX?af z%?bcz=d=B4>psbkC)9(d5)DzTjcw=C=m@g>dO|ncoy!2D>%Og9NF0JAS{NJQ5BGhg z9;mq1!S(VYj^<%281s(`M6Ex(F@YqiL=v3DHk3WWkxO$#z&`p9^ZZ%XXRMG}o1gAq zGB-(@+5h$q%>PLP9YFQzHc*d|U<~AV>3EI9YU8@whW(wgsBmH}rLji~$K$p4GwR=D z3wj%LgC<4KsWLD`sB;147lyH_ym^_P^Y}T6Y%6=Dro~+GMuToCdx7}zUMVc>EPe)P z(2%`rJnCDu_Xq~C$I>S@nO<1u*LhQZv(^cdtzujk|eug!1+72)>j6x5=F;aO&M|(N0`<1*wt<__?4YQhH4#p zXwg@9X{?mH;u*rS2n5OD?}>yC4P=WTYmiHmfqjS0b4icFl@FrP(`&xZxN!{M`JC<(+}j+vh1yCY)3n&$9U)hB<8;? zkc3G%5oCw_z%pHV`^G7Un>7x{CDekFqvi)sL_rBXQ6lP*si!np9dxN2lJ9c-L zg9lUOI(-8PGW*dK%Gx_u-@Bn=yg7uRWOL&?xbjgpLrC8?1E=o`(O%I+`qj7bpXTj! zuyC9p83F!yYkA|f=D86cyA>0$F%G4*iH2jxwKaE|RiA>C%jyg&!y2}YSnKz)-i#My1=0D(y?s5Z`guH92m?TP$C4fjy z^VF8}!=^xH@gL8AK3nrEQ*wmZ1+YC*4NiNZX^)H>-^Gf)S(Gv(=1gd3X-hi{x11W< zq6rGay24uZZd^Ui_*HV@v;s3>Em&&4lh&OW1q@*z8DwX4&_Z^v**kwz4Cu{%lHXh| zV$@`E0;^pPxAIt1jK;V$&{q6F1-dY{QQlyUj&0T5w^N>XTq69KhF@yN{<`K#_%RCA zmr#eAZv@y|sF~SfgWU3aHx071Nw}RSNs_aW1xXl)-mdz4sdUrE{H0RjSx(hc?NHmZ zjW}2co~83*3bS{?sHG0Nwy^oNcl|%?ukk->cZJ|-uN#_*^P`p^ys%Pq5a#uMGh^@c zJGhRp^3kR&Q31oH>cI#Jczt6NFk2tc{4uC3NaHv0rDs6EFqI(f>l|kZ8)5Z}m7Y{( z*;$vO-kOwr-*f}5Cb67eS}9AH4?Uk;8zn<``D(i}x&5+hMIucCBcq-RpNYFsO4&W@ z+%M--4K#x3;E8{mpEtS?&WZ9Oq$o^y}3LXnb@**!0>$&HIM%eWP0{#d5rXpnQxa`36Erxo96-67)@>%go5yC zv5kB0Bo{Q~0@%v}_ktLg+1GYtf!HwQEbL~w4;qtQncnsUH*h1gb?XAs_C>_FxaT=&YEw^T@ zSS2M7tB5Y8UCmu-v?NOUKJjnydSY{WYJoGZFl7|SXmoL% z@j)K&lWwV9TO22V7UQ$iah3w^l+SIeLA`cWKloIbqs4?~(hfRJj6I0wO{Jcu4JhKY zaN?yGyysw9h10P=*$L37G%8Mu)Uxlc^xtdw9Yw7A(UAyJs1%`;5osCIo4xNMX(?PK zlm^a|pmKk)8mh5wl1Fa&Xhw3ON|e$rE{}Ed1nGl@XMAyrqAB{jTU@v>8;6dRa20?4 z)m>1NEolxYm7^vJU6n|c302_nNByMlvTvZ{dpid?YM@s76l#Wn5g`?>8>TNYDAMd} z(H3nfPSb7qw#KmpB}3C;Y@ z$jK-g5s7f`qEfW&BAxPqihfEa;_QgWzsBy2iyWlH3aqveIc(K*#$X2;^+*)i!yP`y z2`eMDT%9+jFJj@P%iDL7f2SZ9TXz-U&*4!>=`+Xx6eP}tAvF8kFnGLO9H%gFtXtDy ze5;*WQeUty0dTyg;;`>oj9e4CijUFDn)H9)`*!TCav?xLMRHSvgZy^E?~lGxNRQnQ z7c+Fui>BoU@+25g{JNLP88C9>rQs{?ynWDr?3_}0<~8ieFb*A3W(#_vyr0@^NL6t& zqAA2t)f^YEWZ$&1NsKgv(fwQdDfsdxvrf6;F;|IDO;$9+Kkz@CukiKNG1z1yFpZX zt}{dsrJ4n?kqvMIP(V*qqOySHvo2ja{|PU3Q6WVV8|8)k>mqyq{grZ+go5MH0dE8n zg^C~wQRD09i;ZQmuKk!G*4XR1>ODek8N#{cXE_5=$o2N?GBC^mu6!(J6Eb%pyy#^R zk50WqD4hPgRLpHB->rO{PbwWRCPLXO@U-`gz3La1AL0Vb0;)4cu1aqHlL*5M(V^?U zR}l}two#BB^r2gc>q?zN-`)3tOxtGhLipI#l!5pDeCcPawkA3#M}WEo_&|gMjxNP4 zJ;BNEcsdP2A*LR)uxIW6QUvPD9;4w-J}s+J$)xfTQ@38;c^zc>z!XtFk-6RA_E^#_ z>YM&Z>>B3!#ZhiV0GMY^1IE(HIMPXT|Ekuh*y>;Si!rxi4||l*&4xomA@{nWfA6BB z>nx$EzEVNI3dj>eWLYD+Sm{ZX-U(jxSAp+K+}2fvfpkC6AwS?2Lc+7*rX6Ms z`r$+yykkH2`PH#e>VSpv;6QowhVzEwwqEU4lx_roJEMyF&L2k2d{VP_Oh=J<3x#QB zCS|P&apj&|;v-37La3zNshp7eh`*ZGc z1khfF{Zf>9;LV4sm`m*|wAz)hfOM}#UU|YPQG?|bvgk)6A@`%Mzylt6Aj?Ryi_tW^ zo|3UEam7fo)8pfePKzVP;S&k{HVTR4M??X|rGn_vgFG($bbw~X?%a&RAWr5=Xtu_f7$| z!kb)xLO_}oWddACISch=|`%_1@4k2l|^DtT??n+-lF;L3S!bo~tXCYq|Yt=vl9cAzFYmlm&7cd!8P7z2rNgzi7<;M@W6q7xD14I)rLL#oxOu=7qDq z=<=cm$69>sws&My>eI$eR&;P%Vxe0-skbwBgSSv7u1<`>;G7!D)>i!DRE?6$$^3Ro1z+!vu=jA9!t8O|RK8GO%XcTYOD>#Yi+DVfE{cvAg;G*Lz4K>U^+&<#ZlOH-f+&c=itOfAN;;o$T_97{ zeJ~5*X6f_UFM@fuS@DD~>T+BsX^TN^7?iky^C{!^1aMalJvg$u@E_#^=jcS9wfzIb zcRdy98LhR-6pyB0uE;*4wGT@k&bdKqk6)<(WtEk1Hq<=ZL=h$-Jyk83w<|FUULHn7 z9Oe~Yo+My4`-&Yo)FIqMAdG)u7$hG>p=^DG>8MFOQB z>#B3P3%Ar{9MNANJu|$w;O5ygWjbbhXkD$Id9pZc4b#37ZN`Z2K=Q?nn3_2(4MFRQ z%c}OAB0n{bwU47V4~luNOs>O@0$LN*RKO;SDMW(GNLVt3m`6UeVXRJ(Y@h0bu_~H> zyETM{pRUwAPny^+xdT#)VQ*VS7fs3G2;EB;D8;TEZbkK0C!7CTKc=3-kI>io4txJ@7zp{p#pB zK=e5Tf4m+VZ^y^fYyYtl2pfuHybY0l?QqSv2u12TRu%{xV9yxH_I={E)TW;-G!NxP zyz>4lHgL*x@*yn}pSGBmG1ngVnStT5jM7KktslG8N~WF#^s+1{K^)klRxi^LF^)Q2 z*a)zvn0j1ntu6c9BFr(>=^aKrtuBvnuTT)Ls zQkjfLL}DLh-(uc)#mMsbmjUQ2R{8KxGMJ)x`7t6=K}`KAeL&WARJTm{1fy;SFNp&y z)S+Raf1aw(;SCehR7Lbt=2NLHF6YD0!sgcUZJr$-KP*d?n;oTVCKM6hNb6RQP2H#O_G`NRPRXxktoZ{bl7u%Kp%l`ZLcW2$KDH1s1Z@C>QE?yZ3z2keW zjnQ=8dQ07W^l!#m{*MLNhW>Ih4i3tC4o$1>bTOJ!3y=Lq28Y3h{$H z4#JV6*9&*2>_GP}YWiRSFy~Fh zwX8(`Sb0mI9?1t3z|9jpT)`sFqj!By&IKJeqUl(G167rg*GVSvuvsea zKeumzp-(79suNE;wRvpNq_PV_;};k5p>WF0)GA;2HmuzFV_Bgbvv%FDA)_P}Gt6As z^6JAD0R_au2-UQUV{pxW1wm0CyM1)J{%LohBn+IxSi8y%nYWf#7lQOlbmO!?+i5+fz57N4+#rp~mo1f8jkkbvS`uI+$QA-YCnygy`k#fw!_>tefb;BG z13$o~OdJv*r6+cE`!QMW5F5}xU4&w;adJQ^V*Q(U>s~`Z0oS#80qfi0e`$cYd7rSfWDyA) zzQ6|Fa-;SD)FS0Sf}H^*Gktkq$&Npd=qPWa=6--z~#Qn1_uIToDWipJ5n_83~V#$vIb_0L2o!Sv(p^|;79^C!$t+I_b zHf5uB7#8HkIM5V8ynyC{8M)ODhc zx`~ostId0hEh?1-+&X3+)%nhdxA1=PgD+95yj`!BSL`Wa!P+x`ZJT~xj>^E~e1!gh zT?Mt%?XYN@&8PA}mXT6Ir^|ySh2*znZkPRyDalrNr$ww+cEJuMt5xC^^oG2%9g+Tw zAt>(7e|~I}J;W{#ESbpRSxBE~36OQ=vV&y4i?RoHF5UC?S87IKdOK{WF0zNAim2Cl zN|lAB-){uac;Ef$SNc9pcsdM`@mI(RT%Q2Y`D#2Fn3&j!T#079?k%9{42^kp@`wDf zBEnp)cJD^f3c|Y0<2PIQwh?V>J&d&HUEL zrN@p;uAREhh*9Ucp*BO(q0C!3_<|prZk9(fpHoYuD`QRjMx05bAZwHClJkLjB{ENAvr>Odab~7xa0+xP+jTq5@v6MTgxjRE<4Gc6%ojVB z9IAHLmR22K9P0XrT+={_m7u?6i|b$PfcDYIABvN;R=-*EWNNM?B|DlFp6@gE5TEiFxUR~9-(zjt=A-w~wbKaOz^LQm8=pST=~2}(# z7gqd+7B!P7?`DMBl+7yjzJ9^5L|@Qiq3}T)_tjeN<{BDVMtED~-|mWJ@>AW84?~l~ z$|3Ewq=AI*8$mXF@1F(Qs#*F)ig_WkzIEBvHa}}6TL`}8a3PkDi3ONt+PZ6f4Tf0Q z;Pam^h8lS;v~ec6VHJde7`^hl&702u6;J08gPl8yDs+kV*@C6$#GRw2ui76>7rt_D zOjTRrOjK5Gi@(k>z*W=!7gJ?>@`9M|8J2OLPE43@Ap%QDnLRG%*j3gY2QtkdSABf_ z4^K|$kv5xYbC4J;dBeKeC`z9HJ|Oh-NQ|HEsR{1KD#pbxMpFsJs&_47-z>H0P0VAYmFdH{-^(XJJDK!rNpG0Jx0>WhT( z`F|3po49KT$=S21%yL=q@K54&gzAd(#xGI$1` zQ0c6zG}w*3wUlTI8f7ZLRhdHvuDS_450opXd;X9Be5?YLX^|#MmP0T6l|_#$PLcN} z|Ab%2i2|fWEV2av-x8S z4NGHhp*Fk55U#quaNeRG| zVsZ}$4@OZ{X5zaN=a8qR_w06OZ@K2hjz7jByvsxs)!w zb^$@p#!~zpW?bxzNa)(7yVzFtQ-K_I*um5loLqdWL43J9)q`aHyn9Q>3ukcv-9QZI zDA62$RR$AuLPR#lpN~BqSNKY7ni;AhAv zK~FC2reuG01v${v4p4Rds|V_Ch;%+wz(tls?Pmx-1l*X3Z)73fh&-q^P} z={}9wWO>a*fz}8JpyfQJdUzGe<{dxz%hTVH=vfvzi%}8zEwrK|i z)r29lBR~EDa5hW)t&YKp%@&ES@;`lSg0?cnBZvu+jPB4jp#EaAkO2(0&NQ_0PseL2 zso=`=95b2MAi=JKaMwG`WTYUVHJ(Ey9If;lmA=x(wDYn;_Gpur3#mxEp+GxqUAF-y4<&h}Oh2Jj&$7kv*ZNBZKxP1%sa3MMkxECMPJ@Hx~g+8fJ zK%Sca2L{Xrz*lf!Ht5|#eT9aHiq}b@i;*47<&ohKaC3ACtB1Bbi$zRT zzw;$=;y`C?Ha3_8!VN+~k&}-#inmPQhx+N;T!`XK;o|@$bO}3!0$%pN zL|TMn4B>3nYWFPJI~^@gp${I(LPPK6|4O#lNZK#g<0cXQCKwQiUtdMsKeDu@Q-O5H z%h$DhPsn*D+zc5`9pzPjRAL#oaV5kP7efFQs6%bkVXD~L$VY_b#E$9EY$1k^^Jwqc z>3g<+b@#2CxKn(|`l%z(lFXEp^Q$5%FTP~J5zhjR?}4?j!}kcspuC(M``tAL@{xYz z-9WYuN-|0SH3D8(@MF4jMv<$+iz~}!H;A7~$Y5%g{;VP=jC(>gcN$S6c|zEQe2>}( z3!cQtkB4?yzBt}vtc~Elmuf?*w#znW54!v^#(#Y{hiUs&y(gA1Ss`Yu$k-~HT^Y0GmOc3)qE$X}U zDQjFD6GTbp^>irpkt}0s)%rh&MF`Z;jEH-{Ev5iJn7VlEx}Ti=g)}*0gPUr%;UNsgjg{@j5W`z zu%^1!P=-qS=u~Vx(p#s4nZkg7nftS{!$y0{21Fdg)SLBRTW>*CeH5pyzbRN+?4Z1Nl2XIqIytIktg`o6tE0aX0qj0WB4hlbcW~H~ zP%>Vg+8bSFms~XyEgb4{(m_0OJ`LSN&+#@7Mu$JQJFh^20rz;?`yX%=lm+ETH9b$Y zjob}#RidD2h`a5Uh@;|A?<<>JIO8R1ZjpGv&i_h^gL0)HdA$ai;x~iR)Qvzqng>HJ?uzqpG#IpcE{e5d*q;Wvu7NyAc%E50aPL6o4F5M;6Qg7LieCR&yO0^bHO$&UFS_FskOv{9OmN+sp$)n7_BW^ct2j#g3A z@xO2lt`(*X=eDRKvCqY=pV9;%Q5dsgDOxEyB%#kl<*Uc@8p-EIm)MI_OyK=Y^p2rr zSsBWuv})Dgjq($Lukp&AWdL??KrKg+(i`la z-~m!S?ql9aD7i+UU9FJ!+F_90pQtzT>e8&z6`&6ng@Qmi!us2%1qTKK=L8mK(%jv? zWbh;#Fqu4X4{%TG^EayZ^<$@6lu~LGgbi=ELvss_Q_IHnG%W08k~itAvy6J96oK_8 z>d4}G?ij&lI92kVa5q~x!qE;{7jb};DI$r5o2=I;^J}St^N(uK-dK z33iMzD{+8Z)?fF6F{bw`AKGHb^(XU4>lKLV6_AnM%Cv9~|N7zdA%#fv7gL@W2~F;0 zPKi|0&jfLiY?DG^&K#SA1EHDgr6c-sw%UFD#hD9#+<7h3U9PQ>i2iEF6Ra8r=@5K+ zwCZ$*d5umn#jWJ3u;mIDXsNzh4u8E+Y=H!6dVvo9^zVMF9(zz}m2{-V2*bt`Z+RfO zHDdj&^IR9yVj^_YKaDaPY1GW?_PsIHrk++_3+db$<=C;TktbZm`2*XNLr`V!#r9*f zewSoP(j>0sO*mY2bW_a`k{C26v)nVRUZ`hwP)d&!b9U_9=7>Z%h+P0jII8F?{!Zp) zv&?ZCf5E)q+@a~iWAfC?yZT%dlF=}-uDN#Wk}5~?_1}*|@ujU{CDrH5qTCdKqRYE< zV1bC?_9GeQ(;`-y@#Qv?;_t@}ek;^OWH(HsG0u-CP9#y#j}ifQJ_F zDbf69N+#RdgBw@ebT2A3Vdhv`24K_I-*~R03B@3uoD`VMC0}nygPS$HTR4_20*BTA z$wKuwUil+b>$bZFyj!j`ntQlb>2hN6#|KHtWHAgHxSd=qSLGOH`xut3m0^4tT*x)) z>2Ef^0#G*sYLv*pL4RksQOlFS_?f$AV?Pu93Q`b@Skj)_mL~orI*7`$2>q5eS#$h? zwf^Z=FTgYlDB*vPv7Tm6E11WHV~l{!%}YkQR3~o?@_cl_7V@PhBFWcE2C7LNi20pP zF7b%+gcJ|+*GdU2BL<_L6`q=26+LnBS~7qVrB{dWvtKS`_OraGr{V3~;*_Z+<`hHa z=gZO{sf`jpwn`(3%FX$uwV?g8H@T`Lb}SnfI$$mfm0Tuy&A2s-udA|!dtwr z8Sn)%fJ3=D*^%am1s5p+ZR^cQoOuHWJr#YR<%{%3JgW9Lsg?-FvK@5B^E^Li+#7Lu z73?X;4Wt+*bB7?<}$2uPgBE3*WL!S zlj=*nz<)Q~U_x=nXCYjmJzSco48i~7obpiB`i%-#gl0&mM}`@$pw79w~o>Yj!eAm=gBawTN$`el(99wA5W6&Nx@=&#s%OUCw z1tehNxmC*YsK4U9O(y$Vh<1a_oMz;DdY67%OhA>eM4{nuvW1_r|f zrQ*lYh?s2DrJ17`k)Lp%FH9}LV*}|Q8?9?R;8o64!{Zuz+&FJXhAd678)@c$1l*i> z2t#>~uJJyWC(Q5=UPcil2y>5o8Z^vE9JOf4a;m0i)gn$wwEJz!Kds_D|h7_ClrE@R3o|a)kl9IQ{?u$$5Ux8Pj#-QF8!nL_QBa<^J@dwetZdN9v7()OU zkb9KoBHAgN4kBPH%-0pR(o$rTf z641oB;83rtke9NCKqptBQ&ERqm_t|l!ErIeOd;Qa=liiYE;xtev|O5EjLcetK1lov zRsv4<8HW9v5g(ycfKsfd;$Msp+RY8c5g}p9k7D%>ABjeg0XMC?*QsrL_PIk>Y}WK? z6NQw%cmw=^U}JSAr|+lPVnK>4AlF`o<@}8uJD9MM)jy9{wZ({Bw&^zr@&j7F(&5XOu;LEWm3?fW*I^ z_9vVm=b545YBBYss2IOh6{d^aV;khVK6$Fxz1oZV?I}@L((}4=jdwtP_DvUS%S=7i z`|=Mw?koPx4K$KC{DsJbzhfv@biixISxdnpw96S>{!xJtVb9?p=SSbFvc$FFt9~1>eJ4x8{*J~un*nq@^AjkI-b%jPnbhg9l(H&S_WVhrAl~cg9h$V!vQ*b zT)!7>Ohe^FkZeiy_==Ayq80jj&Fuy3=S$^9Y{#abH&X&;csIv&5zj0!s$H7v<1q{# zni1dc3OCE8udVT0a!Src^MPRafpJ*&2`J!+aLOusyoe2jA0dx`Q8x#4fL~r_1>#HDVBng4u}sj01|6)uw0DM+f6iKD$D^xH{e0KSP-{ z0%C7%ZbY=KDoy=NWuEQom*{Nbv>MG)c0arG^~bRkk4)Mc1~avnwFScvjr4a?#y&_G zjh-fj4BxX7xNy6mLA1Q&7Jt8=uD8Zcs23@um`=^k>~d`jvR1-Na1q{PkM9SM;v*z= zD|(=n=+rbAh?88=5lr*$5^=KuE)o^+;2$NT!<qn_P&Shfy`>hQ%KvChfi`;Ux zcNB)9;k*JS=&8V_%uMTX@ZFcT#e6%WP#8XZo}KhGk!Vc=WgqXguehlDy|7wt!~E7~kFXoL~{d2QSuP(-2`_ti$uXNG$IE{xH>eKV6hw3tb}=dkRqBV#Rm zZq=EH^!Ib)2&OZF4NmdZ-o>i4+CmoKig9}yX*s0k@<>PA7tZ>0y)KgsZT&g%2Z#!w{cnr?k4+CM$gh~3{&%=u(diM3^CEd&F7Zp0h?tal}qLOXMmjUGds zTJEm@x@+o^ryq=%`g@eE`Ssq1+&3EPwLbmQsQS|l)k>v?MByyUtu!<0t6MMN`>k)vQG9p3t6Rnpzv-fR;3r^V2jQ!_63Znf~KTNfl@IIsx>bY9) zV|XCiXeE`)o` z_1BrEIwc-NGyx+j%kHTC+ zG3SHdWYXWl85^f6slfI{s|iJ$5lK~)D>>w~hlm=2TD?=Aq3DBnFu+X5j+7kUZa@>1 z`omQT<8Fvo$6{=Kvm{}kojrVYg)q&b3*q;O_?79G|I-2lf(B|zG9>W!YwHV?y`2&L zAy|)XG`=Z8MqnTI3=J#Tl{8LP3f#+o-QT^nP>g>Ph%~n1{hD9bE%9+hu1qnlk7p5y zWm+ll((_Q`NYtCKR*({Wj`CXwv|3&=qS4i4UN~O_%BEK6piO3~ z9XC@N&D}c@?n{(jVy=pOw>qpW>Ip<9Pvd1rzkEZMgmn9%cTypa^k*&khhn*}3nZVF zK~RV_d3{slC(q-|*oWPTzTgy$S{76=)<3-kEoI zSQS7}(MOoh)5$sf`Eu%4y>jhZ&zn(QRpb4y#h5ErCbu7mVCT=b#+rGdLROesEbQWG zwWFe#k2!Ed+%W|*B;@2$@jJe+Dq@xtO!Z$@Y3?pNp%2s&Fe0*P;3Lj(2eCYIm2whU z^79={rmm7ae9z|5Mj_JtQ}JN*n&7sME=`REHoYrf;RnI9voyZD0TP<`%KV=y-r(+0 z|J9pq3q!^emYkj_nbPS$e!N{zS@n`E$&})+qZU_PS0K508oz&p?05-nG5lsAL{8>}wqEFZfsac!Q(46bqq- zyns;eMJE^RDq(j5iK{|Ea5P!6W?KQNq0s-xq*qBAFnO-2tE#d8&4f|E|U5T=e)4Zw29eGoV?rCykA0w zFC_&V`CoQ5(rpWfJtRK}Vt(TutHk>DL*?UH)R66XR3hFBU363=LepzMD}c6FSSH%!$QqWMZ88u9vR`9d|P zw6BNWHOsd*SeOjTU#W1khU8X7RQi`&ZwMHa#beYApYhETvYWmTAPuWc|48>)wELQ9 zSYIwwp7t>6+Pu>gIeXG3^nBxiI6XbU3OXd!cNOM9mFSNUZebOWtn^q6T`5*+Y7JKlxFs^%8#A>@&rNn^M4R2) z9uam4&n+bD3dj4ofRKFgb?V@9Sl@gyMuK(#WD(to6OHrU^*5;vTD3-n$;TRh}=t9vRA8J$L^szejNn&q^ucyhZ z)@lXyi87(T5b~fCfTZ=^iS6@wgivqZG^nIvJhNvgZBJ!kzJqX@;89#XWT&q=sI%C( z^#JbXyDyekNS-$&e1w;aMSJHbLKCoytzM@5m|7Tx>$AF>o7$Yis|v4H#*_xYbwwrqXqgeo>CyuF&N@yLb8d%AtAzoC5+BYK zgp<>f4|aU@Xg>D+Y33I7d3pv8b`f>WrSM&luRToz&NLzLH!va zAw4yL0NXYzy|3YbBeiq}OtUM}f8{jXa>Xccr(^Q3w<1T`C7TmJlADwfy$c#(NM zpo+O;$AwvWW3vCVk^35T>$#nS%$Jhi!@~3e>q&=s&i^1I=>rpoa@b-|GkkYtRnHtE zf6*tQ+d)(ShX!0}jU2l{_aDAWemb5luwH)=bG+EIoiikjz3S|<+r<(4u(hJry&&^==EIs{lP}LGYy3LFt=DpP!P}r=@g;Y0m`i`d3wOjK z%{WT!@SOZ)e=q6s)5jlL%YSBV+Tlk#%>kgsR>$JunNx0S3uC<7ZhnSZ4drt2KpyO1 z+8Jy!GU(MS4AT!IKo-7ROeFTBFi->45Q&VvNx|a4p)?wo(1OCBHW-FuufkbugtzY2 zE;=6f@TbF%g;6R0t`!1zi(`t}@K^yMd$BHmW%k%v25`WX4sXm?Xi3Bh%ElvKzZHu#)zR^$1nf2mY{3!d(upgV6?Ko z7|BHq>n9#C)YKL9nSXt8wLO()@>JhqGM-XTk~;KUh}Hsjom{sN6(cX3s0;*ehH0a)zLzL(C$nr^Jz|uimZDF{ow5;bTzlW z8yh5BcDhTgGnUm`5jU?6Ha20m?zH6no{BLtb2jdaQrjj_m?}4aI%t88$L*KcS@Y5k z`O=4+@Q>~kSvxNfuR<`#4-!b}B4~&kjg{riI;kI*Pa`FE82T;a(LN16 z8*4$DBjOO}c7SgxWN4a}%MJK*ZvbB;2X6Wk0xAwoZ=|dzj0S-kA4E6_9aTG-oiDL2 zC8|5JzDW$TWH6TFZR*?OiZzkxO_6{{-=Vz=k4%M*6gv}LHNf771B4BktAb1;VZH#Mr< ziI;QG7W}~5J~*@eQdsJCl(`am%(ukN#}k23W%fAV4kmHdhPhG-V=ru=Mj_hkG_;Os zn-lTyzOx2|Gssre2Zh1l`rqp@4BXZZ8OnmgSe%zl`x7&}Thz#wS@{sQh zCVaBF&4+U)TVBUYGVi+R_!t-~avP&R8k#vz`W`;AEjWuV9OUK9D=JvVMb z1gTv`lBXWBQA+c7f7qKDOtS+I;K zglLWwHAT)9Gd?;`(i;cEYH;i|8? zJiLgoS?D@9uX=$=JS?lhTA}TkDAzgz>tqA*?BK2PoPy~jk(<-ym6bnsbEoQ?>n{rt zSu;(Pe4JI^h#B)P@Hvn5Y~1|MN0vhj4fGA$+A?m+;6e%5QTgL((V8Sue_N_mt;9GH zzf*5TwnoxogV(jqiksGFH-dLhh3Qpaj3gW7Bb#i^!(Q8MZ`-Y67T!%o1+o~>^YiKJ zzB8Rh)XrXrvVD4zagDtP;h(W2z4Y=o2dZdP@g6bEH`+1BtT|%64N@rF;|XsR#IgfC zK|8rAl>*ro-LVRM`~dCQ&&w7QTKc`}>1b+-g0n+HGp=s-t)s*Zq-b|6MtvZWE@wiM zUyB{$r@n&F)1Q_D1sW8UPpf9E)q1}vh$E<3XCL%^535h}bQyhy@+A#8j1J~tfdZmo5}LTN!yW&OKh(9;>#9 zPpJKiRZ4n?zoJmH)}n%}Xcc?_M|GMWK0$UrL1tp|s${e+>YE3V-c4c$Z~mO7>-5Xs>Pl#iS^K3Mvo1+Q>$;0jEU~tkpIno{*&SA0F30R*OmGx zuYzisT)V2%zwm2Lmc@uvq0mK*Rj@c~FiQ z=83p7Dacsk;^zdVP%2!Z9i{1cS5|vbXib#okDm#uS~n1=eGhfHlN#PIQ=t}LHb1v);|MBPt>>+B%*8*S zBPQ1vCMcmKB-oq8e`2a97nik=k{ zYFDjGyjXB?XA(_n_@NM(JTq+~ck*Wqd)kmBk@{KW(rM2Ja*S!!U3so! zmA-H!RHhYW>!5?qLz||vV+Z5o!G0(Jc2{8Ck-X}KE=h4OrD{fr<<$(1%!d3 zttCaptEDe>umRRL#9>YgjFyRb2((-AmHpCzX>dWKmvhlTKh!961wzcSL8L}of^OG1 zdal7s!RynaaAF(=cE`Qt^!l|!DfL}*`K3{dc~&F+%2*QMvXy?Z)sKdp!WQSQ8M=rD z>VEo##4G`5UEuXzcJC%;QCxXtZae@~7*FSjB7*<9=qS%@g;?9YaD_cVPd|o(Qm&2X zSwAZ(NkubT%`Zz1hMV;5wDVjOt2~TH)a48PDUUZz&KuU_kpl&9K3rat?)hkwSC<^N8wsFxeBw~< z2HEzAq-Ilz(9ym8*!i~Ds*R&qaDZ)WI`HBm84!?#dJ+Ar*`s>xzz;1lezf zlx)@k1Tp~IUCcP?a-kK#r>ub%0*kLCl|Q8of6SKjM`_I|%YIgbAt2+c-sx=qz{nzT zqcPz8+dNr*F|*y^XEK2w?{ZIQc=sQ%*kCQCWgnuZ%heNDimZeOZr@dEeq(tww~P6y zQL2T_bQYF#$B?a~BOQhkLHGB!&A@tV{vp>=w2cDeLEfs}M$F2ectg)QQ3qipF+mje zu!jA+l>eCw74`np2{PIT9v+-)Cx4xV-b9A%UhGkOJ)Di72t${G*ndPD63^WTu;R%pZH7#a0om$;HXB65wQff_+>b zU_*}LL@x8moBVDeFvH6$3JL3{)qDg#91FOMC8fpR+)Vy@-I>QZn<2DUBk3Nqb(=l} z{(L?BIFSDd(zKI)^Z_%vrH3?BjxK#tDxv+xE3l2BjHxWJK9$nuI+KH7G2Nj5)}$+qREQgzf1U3(I_-y;ycTf6qNigh z2kV-V#|*w_QUJ>I@S2b*5qI$0-&X*>)i{c2BsI7R(@q$11t?KF-m~Ljf4K5)Hnc=M zbA~zH*61WV$4`SD&BpzgTOWUh-=mti^IL{?6)75xULqpmE0Gu&bk*YeQFlHuj!caF znj@Hq2M|@&Z`B!TaNik-e0Kh+R^=2F>+sLmunPdz_@?rW3%pQ=W!s<1#3moz(xUSH z+CMP*ot)nqfKtRCbrb#=J)4^v|GjIUIGOTe8a6G*)q1JQa8XCFq;j!Y5WUZ~u=|A< z?5Q-zio&*IdBfU#tJOoxypn>?jL8CRV4hJ*Hq7Cf*=mhnRi}UP-X&G^9%~UZ<<-<| zg3$+94U7^_rI!MNSVK7GJ?tm^@6~@x2gNQ&4I-R(C2H#HEsZ}tmmlxnTEO+mmf2?& z5(^&qC_5*-eJk{5@Uyt zzU)Ma9+N=6m=gwyT#1A1mV%?T3DaFq-3eS2F`HWJ!ZjM{4_1SJtNc(!Fi}q6<_{Ao zj4rQj{wqg`Vm7e6S)emKKD+)|8b-bFcg%9M$z!<1Y`O+a5&hZZJZ){KAx_!ASpV`7 z0XsgmQu0fHnVbl*ZCzvb?Uy#{4rmr>>L>{XtbG z$$bHBE!?m0R2;)LzJ{sbLnFa{(U_%kp5L!Mi3)md&lO9Mj7F zCYPlBXi5gn_ez3#9UEuwYtg;O=i_J%U3=*%aQ}pCj?kuh5u1G6bJz;PN+U4@|_JY{Y4ENcBzomZ21QG6E}c6>nvy2+`jyWAm>mt?Z!@2GTaxmI`J-11e_D497++KW}ibYHa{-$s@A zS*6eIuM0~&B#oQ{YmKpU(Eo6#38fPJ9|>=xHZBQL{*}Fhp|L93*mCBeEaq{sYru## zSou$l%>btcJH4k(^lKUE_j6bTw?w1qVFRC!`_aRqQpIxw>3Lplsy60M=UHQDM%`M^K0Ov)IU|LkG|P8CPRDSN{n-_ zQAERQ90SZ50u&R?jV^@(0@(e}(vnpO#VE}Z??AR&ObdsIUl!nPrg6p65%4x^QJv5t z9#XFKySvjpKF$@CTFM!_O(uPNa6q@={8Q}{_lUwjjnBPq%PvO(N_}6|8O2jE0-wJf zTY0i{C3Zynocfa(H=?rZ_oX@b5sX?W8|xy?r2YQrZtylmN3cHh+3H_o>ia6UO^1Bs z{lXXssVPS#-Mj~0*y_2xk+qy^Z$&^kWOos}8adB0(v(t6&6RF_yP#Ic?SS&(FCPEa zB*JEIEK1qee?0HN9TrN2cZ?|S4U`-ZRcj|bMu@qlM+2L6@P}>R$Z6U8=XHiF%GAOq z0g&XcJ?i$nY=&M(IJRYAy7-w-uRWd?B|rMPz~EgWJZ8(Wykla=b1~}23lg5c%N^?R zxt|@AL#2-9GOZQ;rg4eGZVulPm17whzKm3H!9NL;^6dz)3mcC(jwrT}>FWTUW(!n? zbWoIz7o+|9?WMo+b2gIpQNC>Lap=Z?JFdWx{Inb*%X%h~25@gjIC(`wZaYhg6|6P- zag)JzPjuW*tJ7AZ+d^rGkHzViGcXvUMa*|?Oji6aDEh4}zzTPtQOZU}ul>&@W~wNh zQh=BQ&wVL%fOjVDch?Q%9x427*${8vMMMqvTJj#f#gK{#|9Q`s!=X9PuivO)$3s%9 z=2DQcd`kNb@L&>I*rJM;eE z;tdZODxyKEaO`0Pf>2ALNOGfg7##E&CRi5q#Q&vW$4w@6FjG{$p<@iBDZ2VmpBdf- zVbVe;p+B??+Vp)^tL0lAu4cZ*7E5j)b8yar7`6x}gi;#^ij02o*0`Y?op>dET-;IJ z?UsTLSXcTEkOpwfcEvbgWP2Ji6MS!YavS;)5*@@*a`i+hq3271kiGO-bd#%1l(9YATbQD@*OpIk@dH0H%dQrnWhSW75aVM)jB;pRDh6v zwHI7I_J>@#g@%AtqU|C0Irk^GXM(YrQd6n#f8Zm#kDg2S__C+-p%>nVRbaQ?%)jxy zi>{K%Ta!}~w?f5mp4p93Yttm_pwf`@`0|U%x8eB-{;ApaRSuuyh|QlyzxrVA9i0E& z;d{bew=&%A4YV!|yX9nX2QtW*rl!jCqttz|$*Cpo`Qh8C&p38?J0+6zp29KT^W%S8 z+^g@>M*qJlNoVd(x$|;hUAOPz<@{)Q!BE`mB&1Ec;{C)g1-bX;r1r~mE<^>cWP+JI zpldWej6-J={6FT6k}$vFBbjK0>$T&Kn4T;N(+%-8YNtn zc9xq?_?8gJ^lMG1W6C0Ypvm!|sd+E~C9->-MnXmlXjLLU>m=BD(xh1}FxFK`htR9OsX-tVKpg9L@OEV4C*#4dDP;yoG`SkaZxIferK+#fiYvOq z9fdc)f@TcL=2%g|-sI518FgyGllHEB%t_f$hA3!Un)59+aHQZ$>tkPxD9YU9s{xh#enN9o23o7jSxzmRIb)zYz%a23{gst}1d%+-ED8o@?(1S)>HwWV zZwIwE%FE`1!-iV)gUG|e~5Frq(HAK2Km5p#}=Z%lQyLRoz1T1a&UF@SZY z>EEVll zq7TQS*2}~=tdJ;{iIhX-;nou#=Fv-W_vE728aeFEQMQ(ok51tj!arxV*bkXZ^!$ivaUo<@?saIE1d6HSr%$+ninU%&pF9LCWxg&p%Q)2~Jx6b`_ zUp;sG-&6(qUxdtP=|j<>^zg!kZRC}pvVrGDfWQAq7iXdS4PD(ddzpnE*C>hzgQwM# z^z?^7MQgMIEBvc(P2|1vygU(hx{c$bW43S)0w*}vjnScYC%uxw#a)>?kT@SpdFx{A z`g-S)I3>m3#~*TE-^PHx1UDe71H*@uDx7ZClCG9h{8CDzuZ>i}Pj zFBshU9dY1Zr`~G>Mf8@dQ${{ah}OmTIoz>}iO#^BQmPNRNfe+OlR8QiQneml5A-$L ztz(4Gz30o1OM+*qO#TE-i8szeF87x*-j!@s5Dok$x`6GN4Ew&A2&*kTd!Qr__d6?& z2SAiX>?b1|Q>wC9f0{kmrQH!4?~Jz?74v{em`a3n8H z#1}j)9VP020yJp_roX`>z0wMJD#tOw7O`wi_yEvIz@hfv472P{LrCtH$}rv;3>xZ$ zrAv*t(A_MHt@tFSCR-!D_imyI-{})==7vd3o@z?)&2z{%jYpka7+qzjjCPyfzJXm}`kb1_ytGB>0 zkt>yifSW8`3|Aal=SjP}ES{OXxZ^UgAeGG(=>#GU=q+O1)w0f!8)Q>T{y4Inls|&5Ed{y{`F$FK$v!#0D zGk$YYen5qr64?cQr6F^Ig(33UT~6bJE-zQjlsAliJhsKC{EzDaef)~~R6XdcQQ9dN|mo@nZrN?`y$@T{IDC|?8aoB4rcifjrI!f6PCK0cav<4wYOV@6P@nxun zB4a%v>3^xTc0toN-|(`8ddRVP$fsYu7C5$T_ryldf^@;8)ZNxtJr_L$@2`W*!Y$gZ_L`N*1WX7`1X5|2?V}14Et=3;G{mB0s_Y_j zLo1xyJx0`;#UR$fYv*+>bI)DffXg&r96ZNmYrR9cUn2vv&vG1A$ETHR!;1=mqcO77 z&a#AQ+%2PU25H99fnOh{KrCB)(dhCdYr=o15>$xd_@gH6l8woELwunxxJSxQ&C;`) zOaAUkpF|V;KE_6gO-h#kCi`$VqS+pKn&qjqxY76R@m!I{6G3+(h0uh0I4*5h5rLF; zQfJX?NrxuT9SVgk?i6~q7?qUOj=gcuc}tMtE(6p=mPYb*BE|%v0-LAJ^-S0?C?euQ0 zgw;_l@u%sV6q(L^=EZ#2%~<1BCEN#(mm@Q*+FqQsrKzmMaJ4~(AJPV|AH#*9RBUVA zp$@JBl63!gk=o&If4%w8p&0Xatlk)#!U;Rzp_ywvjAVDBY)(m}38Iy*J=l;^S<+(Q zsGM9->{7L8S0t!3+r8#_78D>6G4#;`x$_}u(eCN?{|LL5{}FbT=Q;6vZ-idQuwO_A zT#{Zl%`cbBT?PCl-;IgGm?Atf)}oBa zkS41#Q6%06HR1-gV3*pX5wTAaR*rC^XT}AkMA`^sYS0g%#MF3Er+3+r<;ox^HfIA$ z^2bPEay)G1OY8M@CIpX6$K!AL>(9Fhil&lb?s4^ zm^#c@DjfRD-|+}~N)`rsn5=IwQ}-$?%Ee+am$5MF|MlqQ`1|sjpX2&9*i@r?Ttn?G z0#QTpvp9EWODciTbfL+Q$DrH*Lx@dG2d2LGnSL?NP@)_;OWVb+C=bCL12}UkOVtqZ zDWhfDBZUwG;4kr6c#Se~-1xKBHaIjx4PhE4z>Cp<@Rep@O6=h-h@wkRI)o zg!q@%uMtXcbHLWVSF@v1Wa8clm|PHrtFZt^G(I)xzr!gcSbAOkE2&afYWszv1j(h6 zXw7@K3v^tl)o?@|k@Zbv7ZX27?j*kkv2E=|g?{%#_TefcV{v?!k(Hh>lcUaT2>9?I ze(K#3F-tju_NX?h&fm5szOcV7T%_ZZ_4@p3 zBmusJ1>cFNlnMVpp5&Jm%sFJQodB}KpLfP1aQLtL^u02p5!r{8{MxD-K=sewD&h8| zdGZ%`JxK0ru2{8=%P6)>0QE8@WGQ9P4q@P{t3DX=%6g91%63cvu9}m-kts6@6r?`& z^evnDyP;d`avB-i^-`w)^n!p`-B!0-&30uW|0ob*0t?baSM7Wc&ZcPB{!e@@V$GW!apPt)0lf(kwhLaqtBi20So`)XQJdjR~9O>^Vn;oRcCs z-+c(@($ekBuon(RH3*>jw@GY2)ivsy{wn%EVM82}0YeQKjI3^``jT3g&4ssSxfMc| z6}gjJIyR6#kLY)OY(HaU&kwTHVeOtGUE>*MO7Z%6aoA!SbW0v+U1bUwz71#!-A{N7 z4ph6pv(W?oZ_aZ2`rm%oVjXi>#*M^5^8WzWKq$YlxW9+&m!5d;nSXm}bsxeR^i*8E#4SuC4X0xnS; zCZfbIl#@g}9I`YF!_aDqnz9W7D>~cDIzZ1>kAMv}#n`Vg z1t=7#D}PRy8#ZN{Y2TG@hFfnqf&*K3B4WTrS5z-ZRQ7|OfNScoroxf&6%pWQt|NQl zG@knM7qK<%qCwD;`dMW05j@cpMH>j^P;buIFu<%n$ufYf6Lg+xoQuIHQ6%X`3sHpH z(c5t7zBd394WzY-P2)8+Bd7f(%|LOb4+vO>cpDG4x?>SsAsEQd zi;H>5Ov&$)4u7!$y@CZ=ufFTL8BYpEn-pxno|%4Ts#B)-k&~e^Zgj37PlZwfJuv}` z!AZSKh8+)I;ws|C6ehOLU}|8;3a9~L=D7wivNnnMGjCBd|;7u za#P?^07=qN*Z@7#2vX0fIWwfbB*drl^QHB8qzK&`6Gx83l zap(YcC8=@|5}-Ijzeg5^%n3V*C3R6 z*N*Xg&9^Kxw{5WVp|T`xW`L}Z0)kjmBHJjg_Ut@Nf~oXhS)+KW?PU}Q7;NSgnLaHz zC)bksY&B{3Q5SdTUQ9+cSqsTrcW&xKkSzmPUfo{JRimBvd7#C4t{+4QD^*-tSVS1d z*fujQ%xjhC5Mw)=;;R6&X`k}?s;oW(9Oi#@+hkkN;yy;+p`aN#$Z#CiFGuyyVTTo) z)zIL*iwscxtXyXeq;$R?1lvZ!kDC;sxW44Ld9i-oK?P6n!wPzpNt`%$9)I@vFQQYQ zL_3JZ@XNTblV>b&pBv{aE9d$e-RwIp?61f8&7c1uruuEv`qZK3kq6sa51GvP(e=A5 zF;jk_{NP0;Ozh{&_Z#@xvXbriAxS}`C>T1~d6(?bZdAiF^NaW|pZfw9$!L!&=!y2J z*-<#NFg0DYI<-Dv&pib-ENk5k4mE1s-}>PDf94?KZv{cnySh;{zG_?p;~E&(z_qJ^ zt6bi%U4h0=y1r^42QAaTe(c$kcg$@pr)i9&ABOcbqjg_k+@~4mnLv$4n))-@mx%=| zg%wCGDp@r9X%|@*hW#v#qo_MsooH^Em^!_6cFW1>TI2ajwfYqxd3X=@EX(~y?BHmPp^TGFr_0$B2TY9lf@ z%^btcR*|0kdJIVk1eDRD>@+nIvKmne4AA9MH^}^#scHOj1_Bz8+1D;JK8G$4R05W~)~29Y_e@QB<|fo`c?$uNZm_wLRq#pb&1sbxQwv_lbR(YDuS7L$ z%8h;LJ0~8dU+?{FE#Wl&==B)IiDs zY24^r@Ie7f6Byclsal@)UlQqzc)flpgUkHv0-z$Y@2(!nw6ql<>5kNBYbUtn;9(rz zxffB7?L|;R(-*eaKBDgdR zcxU0YeHEZork)R2nsKA<)45in=^rM@^8rggK+ORU^KK0KrHj}D1^JmEHW@}?Bw%(w z4A7&-7}JQ;K00i(xPm^7SgIAo6AeVw3DhShP-`?0S1PF1Ytnv9%XO)@1QQvM(iAR9 z(N7a3-5z?qo|t^JS}pWDZM0h(=(U1`Lsahc5qZ!Moq>M9N zAjrtO%I7*^Z*dF;Oxd+2ESyKJGp#R;q4ng021TQ-qS2FFZ&`Y-KU(cZy_d*kus>Ml zWw{+qTbfC)2t1pvJd#OSnkW5*OhFU@Dd`s$glGj3Ho`!3|90GT6nl=}32fg5)T#nh za=JHjMXoIOZ})(j|C%_TN^li@f_xs7v-ds`jjcp z%7pQ1fi|0}oinEtfHMrIyg!>#2usQSUemzk59;B>egn82IXdl+kqt4S5tulye7 zTIomjb9$(w%uAVwX!qEX+G1`(l7j0(*Q0kE&F#T)FJrhC*KlCxZXDdb z8}%R-FjLHZbiFSf+LX1mbv%^Y)tL{u{$LE5Wc}kyR$>FD(u76UT|Epl^RP6TapN-W ztH85eC{$~dm*w*%S*N-7ra6%CT-FQyuLOWP7x6)+qd(>geP(Fpi9fmqzrR|*Qf&Qn z4`au;pMstPuP-jdERRcfkZ30^$2fHRL;{v%95%3+1^Au+`sY|m12oC%jwnH4>euW& zI2Q7;$fhU5u+~dZZLQ-sf8m3;ajJo8uO$FE532Nl&S{L=1IHHwSlXGyj<+HJ>NB+c z@#v&)?EaS^r08-o`z5U6xy42N*(d)Rt0A*tD(G5sJM$1jc5vz7CG$Sr%Mh^T8faa@ zlV5q@9k(Aq?bLtI*>0PUj`3f|H88G$m$C+4+-mYtmUR5=m#hW|I4)xG)*jN|dF=Gb zH(Xv{situ#s3hZ(jQZ(@8PQF_Lpz^WpvRih)w*GTYMQ13#Kl3#Z;7!aS(3&Q*dyy! z!l+t{VoWtASGP<~ZA?v0KRQvZezP9ezY!vRbO&}UFv0J71Tobp=S=g=&Q8|<&_=uY zb88zLM>krno$YQ1owQd?v&^J)$JE)$kTLk-zoG1gh&@StdYkWdh_IffX=2%x+>5jk zq;&^4y55m9D>v;hgjtSNKj2Ih0~OQX@eC790h-nz}+IF zrJr*^Qb8c|Z~618nW^BK&ye)B#$Qj|Hr3i-Yu&U-~>6O@fsPDohi$+H(Xf4ZvGwF(|V#2bz5Y z1)q_n;DrgCjCluxMKY-RQH=Gtin*|Yd*1ObVDEllYD#o08PNIX2RUoenqXMR`qiw5 zuRr++x)lSA60~&A%mlGVfP;Qj_pOt&GAM_gTNK$Pd7xJWjEW4SGE-3Na}+~I+FQzB z)If90CxVVv3ZRl?c=^qDU|W3>abi-4#o<_8saZs_g&Y`RQo}>mN2diWE#m2i9zw7< zkF7{iBgmHYMaRzv2n{e$?3g*AU;z_#-L}{HfT#b-GJ{zE<^vLCpwb6;@;1mBB@XDZ zse3B(AnD@_G9~4atR^1MR3vRUh(q*}zR~Ft(;<_bC*YgPO*lO+%k&rE&@gHE9-?(< z!6q^#`Fm4!NAvA8OGQW`rY5`Y+A(`Kk;c0oWuKzNYmG{$tQpck* zP(AY%SGQ6q9hb)J$ z|H_gG`gviQ(-B@-)83#`0J1Pbsa;DwU}~nC7dQRLu0uxIfa6+$#^L(E!^XWnN9J+u z#}+WO`!grCQZO&#!HESkHFKdn?!C-+Gj9!$=PYa7nx84_S>`@reX)xeZE7Fr(s8QV zpNd0ie@=N2(d}WfK8eG7_haAoU8qt4mi3hA>|l_1GafaS*J@^48I!!P6&ZYP?X|K1 z0&c0Ho1Ghk;VpX7ITN?652=R(3P297Z3=w56cXE9Hubx}YL{BE3{@RNRto0|k}Acu zFzi#0+{}R^FSF9qZdZ##0~t{FFk}uUO0<> zV++T9&hrY(G&U0pL z`G7S0s*ZbWV5al4#8_6VGpeE!)p2fP9e@0>Phg>!AgMLbi(`=0og0_sdkbZiNOW2#G)6KWn@4aEyXZQ2tx8;UUM+AEb9h=L?d)tkQGyz zgJuyoDN)YQTD1O*ZW>S7qcBWkMBzle-mO<_tBqQ{Q?J!7*6OuqC!*SuaU4A!RjW_e z5nhfFZ){+ry%&2K5ojKWKJ!K8s95k(5)m2ap2lg}WzE6)pb!WRtFYF}=W$g?ANpLdDk}M8{C25(baX(9wEDXq|bOEr9V+7PS z!~+Hh>ij5CApfHwdx9)9%vKxV_^zLuHU22T(XqkGj7wh|&|{=&Fdp*#ths>c2PL+O z$CxyKHd=$EF(}|@0uCFXsf#b0P*}M(#muH~Ie^lg^jqN4nhYp|qWqrx*#|6#{e4uu z`GAc9*A4g~!Bal~fvO{W58{Sh`%tmQE52r>?f24F<>&I=OhF}k64b4{cpguD=?j=x zUB;G_j4-CeSpyMaDyi0I*%8$;2bC36PB|?BS8n`Spu+%b1OlpPh7smc;Mkkqit6om zNLo14dyNiP9vJF-$-V6JOj538KQ|-=b6CB*)FvB{UJ0;N=7Y~% zG1HO(NPWOh-(49p{COm>VF62Jb-F-JNt02oZ5Va?n5|CY<+t5|dOwoXaI$n{LUbvX zxrUrPmyV?z3k0jXUEtCsJo%NsLA1PxEmz*jxc-p2zK6i z53p;uVN`~(TqC42d+_04Aq6Ux!RNI66kOE<76qBjHbMX*0g?K-;hHik*>9o{?JU7_ z7ti7B+(nTZAW%t0CFdUf@|n|qeGC9j3J}->J0Faf^~v_NQ3o2XootiIra>e#a)69& zEMTH#3X=(J>N#hvtCpuQOYMAR4yK9|xepi)Js`Au0-f&&8XD6g-ACAJHj@u3kDlIcH@}TX1C}ptJ|2GcD8SP1_ht`gS>$&AS-xBG zP|%cL`|XtuPpqyrYZm_echF(<1!_PgBk9_eVG5s=b;pPBV$B=*U z&fmpRAZgKYr-d7)Ch;#G{QuZ{(;&-^>rCvs+uLgI>h9`&Z!}g?+$3?4L`fjUof1hc zlw)LjW;_Z_*b@_BNBEy3#y{+_e>fZy*4QC=Jbx4thax3Ilq_)tcLJ0Mf&@Tp-RK>? zqEXegzIQjrIVVrvy!q~{s-`4jiB}2=UG?s}H*e<2%yYi{&UZe7=^{l^q@2y7MSDb( zRpw!>k4C&iwV(P2LhkQ+2xa7(9j2~bXJ{P=Bd}I*+{M*2#sBl!{}(T=tfT03d0nQA z7KQHJ9FkhooT5;44cQ?vEIUPx*2XG+?(UPt2j2P4sj+_LcC2!I&A11~J@88Oz!Kv(Krl~>VkD5)J z0z?bCN$}6M#pqh@ChVp%=hT zfy$~1cD;y96`06W6=$gei-rM8pJBPp-%VHSM;k zTk_YC!VEqYuGoEiwT`zbdQiQyPbI6EDFS#Za->zntA6Kun6KLta&!Nt08||9N~@}A zy3+k<3}Z?iB|A+^9N%{kM;7)m(=sYT(Z1t!L9W$0bQ?ENp=&5T2ULLN<~5xD{O1wf zx`ElUU`-`T2_~>b0|GY%mH%dJkbrS63Niw))X(^^odQU%>12XJ0&+@9;+M4WK^${> z)#S;0u<+ooFkqQSb=^u8byqi>$AW^`fOM5Y{P6M_oO|gi&>~Pn<~z5StH=(gVEUcp zUt9XP^3HrWVk zv}rLMd&p3MW`yYq_?-g%6u5N*&wlNzD6U__&JbK|DM(WgD@4H$S9{a9^Lk$@JJZ#t z9K8|UDS)}1vGkwDHfRFDrg^9~S_`hV7!BE|tq@*kqv?9!l8+TMWJWXt!-gg1Fd=nr z@#h*Kd#ptt(D?fX<)eTDZHvW;%o?W*Q2n0EKxUgTdyGGRZYsD7fYEiDR!&-(V#K2T zM(Y60;k7d2YP7xm`*G+^Zv_^1OR*$AyfFK}DG*Wt)TVfiSzk5-NHk?w`_i3X`abku zUO!^0Mql94t?RgW?FyFrYY18~2kn&cK}@42P@!p@Tu^D$IBqsRuDwt~00RM*xvGaZ z6{E?ue|J|FEnvAWD@ck!ie*?p#(;u;8>MXuAWD`7+dkdgNRW`Pz_zT?D#ReA&>%AVgwP&gq>b{ap4_Wg_M#gM!|g z<}rb{gRIZj4DyWs#(NWi;grHo_a#6lOF5fi*X&LlJ$wYcR>Es$IUuH@<4@XRBf~}B zeET;>KXNft5WWUtJH-ICXd!*hqu>=;bLRv}fY6Wnf(i(-%mnq5l@F;hC1jjMvY`4= zu*(43&iK05RYiRCVAvek>OO2FDD=L15GH5IDz8%Mvswf9i@9v_`{-R}_!BMi#jJM6 z4{Nf0-tUY)cYy?XtA`tDj^F>2|Ad#L7^@+%usL_Z?&&O&9)^v5qR z-Rz+i7ey3=g&=7JT5g~yJ6i>F?1*WKoC#dHhzPN%c=s27=AsTj0k4TWNH8rashx(n0?Oa;Dg~v}ngS-_X z)rz&uBCDskxt&}K3>~HsU8Bs&(QC^;4q&NsqaF-83^-$Gv|e#RoI!zB4%~g{C=Tsd zK$KOiac28zfMbeLFz~?XlT=-ts^aDXUf;NeXTSb6;QBS}K!I+anL<1=O0NC7c0CLL zj_Q};<%a`on@aiPZ(O6m9{sxuO!11~jJ5uo0pn0KG&#^zz|vz1t6l3oE!d+68QyDH zEy)p8pj`%=`_&pKn^zOUY2i&liuw1 ze{JnFj7qAJOOg<4MS=Aoz~sIIIPg<%1@;~QdJ_P#@re2AbQ074i9Kjii*=s32O{nm zYg={g*A!;`yMjupiAxM^+EQ=aewow1mj>&|qktKeWb4YRbPxWT))ap)*$!yAov}?# zRqDhXxK;ay+3hTV;qQJX71SIaS!oWlF8Ba>^FAeqA)K0SG$C!3b7x<(?@Id|b*AK@ zq9g`C6+y}~o9-P45vQ(q;}+Vj7J8j7x~(?4oetV@f>xaHyp(ir`akAa+Fjk%RwZ~y zlHS>KG3AM9^^)K8?+i*KXFDQRHA(2KZ4A)w_p!0DfsM5_6j_RXKSfUZzzA^S$Wa{F zy9Ws+@9-E%r8BWVNwsxq;{7BNXT*TsZgz~;V z@_+x_#q&pATzYAGK-y7Juu+s{r{*9U3)7QL)dCaO)?ti!oie z=9ZNuks2xOnwgzaJT#z6<&t71NBmsL+&nVWA}h|uPs~udhMa+zKMw)|IysfysoFXM z(Goyy2%~aXiR|VZHkvrW#*sBl3%8=QyoZ9K)m^21`WhYpX`j9opeeoJ1AVp#f~HJ* zMJ0R(S^Lf{I^hP!6xax(QbqZl^juSfq^48nbY9INYbeg(0swXJtx8e$8AnY42X_Rg zAy#Q)YL=>1NH$A7noxF~DX??u&`})Rv74)bN13qSEQ?sv+lFr`0iJ@DgA};Ei1QDB z5&6~2m`Mld(q{xZHdSUe=If6G7X`^BE!HjeAVH5*Io7th!MX-wdL7NvJPgqfBU}w5 z-1}?42^>1ej4A>Lni!+nR|+;{A+Q#e_|B8x$7-1&B`QPB=I?y_scgY#3i`mZ-{G4x zD*ZLt1=BddG?A}gG{p@@byEn^~;+a9|Eex)GOAr}wdN>HQ zBzb_vMV$HSSCL=2g!!^ShqO$&N_iU1=^92Q z1C>F5#lb4hUAc^xmTpN3B>x>qsTCIdA0COVn8KUcxb+#fp|$HjS=Vgoh5Fi~2VOij zS|OW&6{FM2UolKOW*{;UHhkj6_Mmn=)J8d!(*s|97e1IZh2W?nH~pIc$WE(^smUHD zJ6%k6d+4@%Xti2MXl^hZX#g|vM8vxdWZ3{~{Q=fiH?X|CjOC>z22YP2I*c7tGX{_nCTIP}uclIl!dRViE+99cW2Ax0SjXPz$+G5M4qH zO@2B%*&f@^4}=t0vOto@0CX9L{9V;^vhU{ukJkM%|7Zp*6$q`Wfa*zo)p6>;@_TFQ zw(2uA{H_kO+x2={tmCW&GF+fXwCOZ;)-a}2?`;Gl(vF3S(wnNe3i!JHtid;ri!x?a zL(TN()_`VnKeL7?r~Q>I_D{n9#HPIa@GDGcu;ZtHhS^ma+lH_Nn*x6JrO87Mfk7PLsq?4t;_X{l4|7iGbV1gwKr!FO zsvz0dWchh@;Krl4r9X<9u6&V0yV7+(DTCXJelY=tpaOJKPS(4L>ip*Oq% ztv!2ysVSh<2BL&h6AAPb$%kgf6RmyR9tS{EP)@Z;x3UXmOp;FJdXeJXwHI;e#x?Yd z3{i)QGfCPv>nG~&H^P(Ec{Yl9*)&F)woLt}jL}hJIcoi~hfI#vsoI$fSn7Rw{pOA_ zGBIYHm|@D&ztwtI2i~jwyfDS4Xb)n4!~kBzZe&|92Pwpuoa*#2)0@KN!~~{#Q)mS- z?@6>DF&k2xA7=h@f7q0FO)ValmS~!@>L2Nt`{0k;$L`!>K&)nCn%R+a<_ZCrl13k5 zeQgD6ORJcho?-^5tQD2Ev$RW4?EsQ`GAxxN#hI#W7-}c^%)!b56NZ><^44k2LJDB=oKqj_fF|$ga#1uQtJgHcF&5Xata-!$a zF@6>YxU_f!Ph2>IJ{82EtUF5MG*wXD^})^j6PC4rW6?H=BE*FES4F{g(UARwT7#Z+>i$MBM=uuIYd81#X)RowWy zM{)f}Php-kx3U4+1bS28UYJxjWvG=#4#3gxa)E2Bhw7x}`~B|%2&2Zyz2;5;PJP$f zJ9|~L`F@Uv!q>WtGAA9*wR2@_*>&A3f}{6gAT9RhlMi~1@Q|9?-_(Epfq)-0`}grd z->P_&?0NMvKhU(&xn<-p(ER#u@PnP>lnCpV{OAZVBBo2amSznk($5Z`Xc7-?f&e7bG7yu zNfFyt^AEO1HAg!5GF7!*f5G0zXbfsmK0ZOBVud89Lvw`I7t+HFLUcPV%=9KPGd+#D z$!WBc7FFyM_8w7wfdm4&Q?svdJ#N#E{MIF zecbqiwiT3;?^Fu+*F|{-L1!j=f}muh|1M)>3K<#ZWmP;;42ll6q^GJ>WqDq;{_#PB z0 zt7ubNxYfpV)o`jRJPVs(5?b3Wi0FM zSG%xUp=3{_4^EFt+8Ce(5|jMXFMSD5T)50&C9y>btW-H59#<`X=7_x_K2XLkPkv3BL1SmyZJaSx1p;FaisF<|*h^!jV~nFg?o zfYT8$-&yLX^9aKS^CbK)uPnXvv(H~VoAtvoCViwU>U?fLjqNi;!?I81^$vB^}&<{4UIQrx2R-I<_CD*o_~ku#K5w3anMg){_D& zw{iYEU&s2H)0m@b(0Pi4082;ROB{3^ZV43pAlwJs{NHPH#(>KT9JuM2dS423xmq}Z z8DWfDy(!%Pfe!+E_Mu4HoTf=&h1$1kGZG*Pi-V33R)QRl{OAW*D>IPfJAsy3G{|-O z-Gc@W&K%7>z0L&?q>uk`0ZVO@_NQg0*y2*=;Kc*F^m^(?F3L2>v14KiZ#a23%L&As z(p{x=4h3OOV9US*T|gSA%gb1O?ngNH=%bh}GxUlAZK@#8m3*ZNFx!VC<@u_-%&^&Q8nEdgQ7qhkkB-+UX)07?8M>w z-iY|f5nyJPSw1wY@JSagjnoJJJH3sLjmDoilUTF*}Q1O8`pN z*5$)80%0W8TkV;dv6>WM4qSK))vwY+Qj!($vN8z`O2?V^ z4l{9e9UMN8su;^#*+6ZtwFg7G_|)g;s3I-yGdNA(OeDFtX6n$G%a!^}_kdRX8}&=f z^Ud#c55ZAoPt^D2Vo;_?8~2MIY+43t#q5-RGY61fr&MECz3<;|UE93Zt94fW;5q;} z{Ou40=~yDbpw+_{AO8XV`jPJ(R+2j(2*mcK99q@A)ums{F<7(me?cIfc`oUkp zM7n`C9sHU8^yPQ_Hq`Cgq>NiVRiAOGex?DwOF29WT5a@^U=;yALty3k3&?ttSO;l< zOB#6XP|Cbj4ajF8vjUV+mV*#w9OPNZ2jPPOb|e8l`Jwm!FZb@+{qJ@K!HZkXf$`_# z9vJt)xCdV89vB0buk;AK2A^-z!eiiE2u!zb_w(Ilh+oL#@ON*lF5P$f;`!ck(dQD} zCOzB%ASFMD&VnR9L5b=z2GP-%QEJ(Mg}-UZ<2!+qf==d33F6i5TjDg<++G+ zg+=PIr#j2s$OpN2Dv(ES|CVs>}*Zo-eV^* z-RdAFohJjvx`8P-*>r=;^OH1iNlRtM-10K6eCJzOe&Km3u#*iC6Dx$BWHZ_hvyb1; zFya(sv_(K1c7|(*?($NNVU%SUH72uPB368 zlY#WTTrJf~R)AAZQ6f!3eE;mTxVn4`WfG96Yz?sV10R3>H2q$!2g+y}#h4idXl}jm zQoEFKWCLS0{GzIO%h##BTTNM0Ov6+ePl^y_KgGTs^SJlWaRw|~#G;}=v!L;)7?Ro_ z?t7E=C5K!jAF#0oT)T#+zVN6mq)0ZVBB31`!~u6s*hiVyLTr-MrR4!7NP#qr^L*|p8xcZ~)fF|WB& zd@7(L7kDdyPOF32i7CuZ&td269NIwyO7iA6C%?$-WpvESMcGs<(db9({_OfD{O$&@ zv|dKT92qsQ-FMk+EvU~QJujB^i2ye7gQ`pdS6aX?V(7NB-sa)Vgzd~**Dl1b&~@4z z44K>dRiX8;O`jo5I@(W+y_gRatn_7${9=HWgoWA9T=8DRupC+=#4_bcM$eSoBApMs zFPME_=POr{q|SB@U&SaZMl(OhIW+bA@lN-XQR5(O8U`$zY>}=l!h|(=)1-s5ORM;U zKm9CLk{&jQMQ;GhCeTq2sYoxJ(!ghf0{`lhpTORBgm#f4;>@g385_Q4sm@{bf7LG* zpx+j0au5HAeMt=AsD%wgoCZ$J$|uiXU}hz)+ZjmLQc73Jo+l;u{4uvnO;!AdmZp&W zf-p_9WYEVHvg|kC|DHd5 z<{0`?cpKB*cE6k(Aib|>NB`~Z!TSAAyzt!g(qJtvlL#dRD|wv}qt5JbAjo_@+1w4o z$}i5NK%Vy&d>E{$QqScb|9%WhA0TZ?#jgVt+Ab{^U_5rM=B^%717Ilt zLREA}+-dp35MZh753LGpe%HXWO35_=p2x^?Kj(h*fWJ`;E@dDXoQGK~F=>Krq*$2Q ziTjTpN0$n!WW;Ez+U`%|Mg=2c29cxMUuUrL{5QXW{)KbcQDkVRDPjuBO$}~Nx6U#P z(o{igMF3W?mYzKdY_ZNg1*Q%QP^*ON_pBxo2R0IXQ`%)1CFn;HZiNwAM~-3tyB`Fm zXE{ZatA#82Y{th8&ROe@(l?_3S65!f<7b{l7LzU}MQ?R2%cggrch+@4-@|3m$ltaC zB)9r}^a9L%qgs<*YO+naQ-DF|zGbioSkJ6l0~iw15`zqf<`;1Hfuqc{Bp{M1%KOY5 zJ1)v_;_C{Q2?(+Qu+ax@-N3m=zJvA4moOKF=;k>hVu?`DY5K<1uC#?^OlqI3E&p&X zfNz7NY|?^t4LXP_S)gsTqvP7H&oXrTyg{?pZHLR`YHHtB+wq-#XRF|SnE6$2fmp~y(kTCdC@SsPNacT-GA{RR531=?u+j#cll-nV-FW@F^0 zd^eAM1z7Sv#u$sCwaMtnKJKM~SD%2}m!_%c))>~Tjw^qc?q`5rIK$7S?hxLX8&!P7o+AvZN6<% zIhvWyq|Fe4o9niTX6Y z7?FIA&W+y&w^E6%3vvLc$CV*cDzkt9Zjn{wXhRCtbtItUh2 za8hF(88(|5?v705Y`b8i>k3u?q&&yEpynU-&siehVKTZ!c1+Kj?`_S%Q~*@^psI{q zKgF#Bq%ISH$I+xz+A%Pf`+>O+P>xDzR)Eq47^$7geDgIan|^ogi~gM?3Gy_Pek14H zfO*=}{Js<*lqF&oc}sD4_g>s{=opA8m)O*IVHWaz90sTO)fEU$tO^2~=zk!ct;I!L z`t~=mcJ3^vfwxmqETZoW#OV(yr8gA)Ijbo}C+u$JZw=t;DdH|;)VEL94pj?JgB1qW zhJs7cxXPjgw@J&g-Nn5h{TQ%oH}W86M_S`aYOg*|*)-G^YBK?r8)1p>KJ^1EW56k% z%&Jm`m6?mKoz+3|ilU@{uiCm5Ai34&o3EQq!HxS34IEr)2l||A0Z@Qs);0no5(CTb%a;N1`lO3 zyWXmeqt+Qe_1o{4t-zTkd#wWl4$W?a$+_3_UP5zSpX4F}^n(ItuU^9C8`pT9U>OQpHwdhB*oGDuGVjfn&C9fr zr7&P2r90R9-LoJ2UQQM1DxJFyYBkw%)3usK!t=@-=E`ogXq#$TR~L%UBfrzW#_NX# zI7?|Jv^uw!}_-B5UxQXq$Qym=3fS$;$g73HUTH|_%iyj3a3x_(PL z_^_$Qrc`FFGM`Ptib^P$gMwkxZU%NNFlnn|%Qu_WTeU-Jcl88bGu+=j)?eLkApJw)Zjb<@kKkCIy@vV9@~xY5vhTW+flv@sHbo?)L={7GTi*@z-4ezJTnZ&%W)O|W>p8<^dSyd4|ca0sj7H+lHj#%dI@Xy-c_5Sz! zmw%^2kRZf*Ni53=eEx4A!xtZa3Td~;THN$m(~tOXw5Fx#1O?)aWxVysVf@y69>hex zhITG#;dUO8KWBB9`oK|wEbK8@`ROly5kEY49$9yq4~n@U zni*FeQ{9i9cPRpc>>Ft(GQyakP4dv6i|FQzXpuma!hj3!= zK}q8+IPp)$m1!>(<58VvIk7iv84$n}09ht!;J24>=G)&yap63tfp_x^QIR4rY#4sL zra5v9VE}0pSn$31#Jw;l?H)tL34(n4bZwg0lWLRoz)sWuh5bVDE@>yFtq!h~0Z#nl zgJ_?+hl_HgT%gl5))ZrM({^>dNY6Hn0z7}^0$#X&8F?!L{fz+<%c5>x3*EV^?_Qsq zTLF?=eSYU_a6Mp9-9zn9*0;56sp^1X%%pz{V@eMgs0fGA8l+&D*pzh05GU9OLaas!TJt+`GS!RtWG`0XWLQtG|O*MfTWS%AhpsUZjQUsyqe0O-`T!6NW%f+ zK_DfseHzTySk1XzkL;R3TD)f8C&m{a@HmB;6zI}1)z37oe6z99wZ;W*YT9w8jeIai zedlQZ4Y7l{jk0djK^ZYBE%Vau?gr2_KqaSr6HrOT+zMc_+r!=+JF#nS9=)WEh~P=u z3vy!R*Yzm!^-zwFguODJo)&Wo6iQOVt4s);Gul4&@^R^CTx^qs~ zSNgB)K~fdoeV+TfJ4U1CE(3;!1u6bwy*Q4h#5<}Jjesq$6;_w9dQ+11V0-hbYFAhI zfbekWrG6~itNM=SkfJJ5DR-oU1-(8eyP}lgP_h<;S1A?E<;;o-_-TO_n()>Br0Wjf zzajnKJhRPgaHbAEwU_K7;U;p-RU=P(DOE*{_TD}vphOV-qx6&;2_ce9y zh*?QMB!QJ_NSc)iKJ&#d;rnOLqU=qHW+h89$Qrk0pO?WP2!fX3?+yU8Xx}H9m{vYO zdwp&2fp@+AtG{sH-T!uPkUYHIeQA8%xCh2PFz$g@OAm|z%U8=tyhht0sp01mpc|3s z)Cvaeq#w=p5!{6+cyAtuzjpD~wS_a6F2wz?Eb^F0Yb6!B;Zwls=zsWhSGvS!2FhO* z;POwY>(?6=rVS?Le(VSv&s6WOPH&~PGz<(j2g$?CMY&EK)F#lVd8nHiIKzxd|J$Ph zOl?PVKh~>$4!3C(BglWA84nNq;1g(*g2Usr383i%32wmUewPCoQ&eZGl<*-9OXme$ z5KRB<7M@|SGA+?A0^D=#I2NYo&?W{27t5)@QN53=&!zafWw4bwV#-w_!1C5joc;E< zuyN@kcA^xpGS8$~iU|OHW-+BI*#OJP8c0B?Y>8%8keweU#gJ`LS${PqMOF1v$4Kl( z8KR5Aauu!L&cz!{K^`5u0ScrMuu`V@?o*FrJt(+( zI63WFtf^^EXi%Z^h^`rCUMnL;Q$>A%(l3f2Px2YQ)$@nHuixO+qk+Yv_UhU}ZQfH5 zYuG*9ClpOkfScwxxMKmw_a8#1j5)X<8{_rKIjHg15CWT|!f(mdZe*ftv5ZZNTJ+qRnI$==U@e}XxVd9l{-zSeo4S8;E%c1N)E)+fF$ zpA%FA0ApWr_#+AmBj5{MwqK<$qQDNBPXW%L2ebL&0)8zyT&k4>@>+GA0)N+>m5jws znQt0?Ixi7m3}c(Pt;+LXHm}bUWIn`i_+|CMyyDQZUoIbaTdd#f2MK@(t?MrL2<2+A zq13-?9-prU5!<;F=kt06$ceL1rALc%(+6ad*3+1!!@uva_A)G4g5n!;B_m4H)yPy< zCV%L}j9bJ>`Cjip0KJgvsH7LmdE6fAYpE#B;{6phQ?uaJ^nY!xR4Rpxh>erJ&hSk? zxSCh9pi(#uDKm4e`y9#*W;F`G;1~NXyJi#E#PWrL64Lxh=jcqQ(mjU9CE5{!l7P(A zZWEWF%@@e?tQ?aNHgNK+)3UOja*L9u`$rpum!qbL1I?rp4L$yFkFv9BSX56JXxdVq z%8R9Q1i0u(RdDvZj@d>wDB~uj1^M_O&V~BMajW+(4;xzi!tX!F4VONRwny~F)}3*)RiAEF-&tO;Xxbgk- z?eMswYlOmyOIBLjl~Sq=3nZRsd^qiK6@YU$92nH8Bn0L9?ohkm_KF&Be1B|$f;{n4 zJNt9y(%@I5C`F2y<(!j&CW@3;DX*{}WUCR9{gC_&Zs#?&8D&6(&SiDSHM4nEc1J~2|B=WM z4})vbbQ)nHs@5K0hoYdmaWM%Ha;dChOI}bdU+qNGFMHp#b}xufFbC+E*(~(XB{J;+ zQeNTk*u6S|Gp&a8l7h-0F+5+4!dZcVst;<1z!QFxf6?Vk=be?wgbWrgsHo^T;4)%1 z>u%>HuJ>f=NaXsYkZdlY?J$6Y7t~QF&=4dN6ND%gO>3&%PI?XR_<-Uez&YB~q)>(` zv)V)v%)*ypkIMMEJWrW95nnSPwosUCfqr3eeoz}UyV`8<%fW30X5AN5O&`Q@V_ z=|Lu1w|u$=z43pep~*9EX)A>U7Q&0O)H@Y-N)G*c-=v%t`#+FX`LKb~hp=jmoD3azB`XnLOiC@~1uqviot*yG#9<#>Q-zc9 zlbv&M4@=*ueU7dd^`lio9DJ|YoP_LkuCfIaeD_@tNLmi;r<>clc$KWwT5L~_*n&tZ zGVo`yFsOp7!~cCucit1v-i5W?hLe_t94%!E9MyRy41eZz_WS#xESi96%;FEZM#J0` z^;l~_5lnS!zblxuiwC8CUbA!JWLDeSMN|3J1*QkGJjTA(#Jcv> zGptq_A1JS9Y?8%{w0(7AE#+H2JG*4P@X1`M_Ua7Oywo&NlmFYLI#6)W1x9NjTO!Q+ z>V_=#Eo{Or?LC{K=W@jZSA97$yfF0lD<8dar+s88hX6Ap-ALE^u8!64^ROwo_2s3*PPx?FxilGFZM@3^ zuJ1eKx8Zzs3DiMB3;xEbIC~x?-Wq5)qBmrfO-~k|YnYSzvSY(%@pvN+A)NR1#&C)? zODK|%fD%A37zbtIL>AJI4_E(t`RWm|wq5L(Ck<=^XYw+_4wYdl&mhPvd1 zfxz{qfhVPP7ojBuAG)ey7GI6^9x6lc(>py-A_g6EL&7WwT2p^WV)VLU{0D81P*a9U zHXY39N6KBMAYxKBKy-1mo?hlQu#ygR&#xMf zE_AUImCZrL7%h}-MF{8JRHR_WV5Bn(_K3h0Uxf}x$?NzgBEO?MjH!$E4tEp{Fg*BNVzX0;(&>S=m+6qaF$EzbO zC@&i0q8)4tUC`!0@&&l2me?)BPAX!uD~DGVdwjSZaNT+vk~N-60aIMk`5*5f!f;fE zR>B-uhSTpHO(qRVw(wAuY$Zq#^X6+TV_;w*4w5lsPxR5GkjRr)nUHzw^!xW%0LpW- zWmeY6x0sFKa6&=JC`_>6Zvyt}$cneU{wHM1b8#U65~+9M)_}-@4sY<1?i*Rur5Xb$ z&UTHM->}3IwSDbJ!OBNpHv={cL_A8VT855rnOMw?)2Gs>B&ubAzNeZ^gVuT2TPDl( zGBbLL5G4A{)vo^29=a_AV@&6sj`@ZU0t4rWJ_&e6NX7wlH5ZwvNZ>Q*VS&{9bDUY5 zGs~C=mB9fV@ZK_s;ijwhRAaTEGQe>PjQMf>qp!{|FHYjN@NmcVCKVb+>!r7#9zJFGc(?}O}T ziUkEmDL8%vQ6cy{xiInvyG%nmG`Y8s0pE&47KNQYZLg_3~5w4=J^*uTG! z<8IXi#-C*+Z7gdp2>%`aJ$1FLxqZdQ+pA&g0Chcr7lZrUMWcH^6Jk}d`}S!35P6U< zD5*bXQd*{&i=*9+zk!nbS0n-og-_}tFa?b)fz|x+YUt-STj`<48XAM{uUJf5 zSN7eTT{P?)N3&H%S&AGXV?c5tUbd)B<kIK(xm(}`-L5Ni5RY*BOxmyD zXSg1He^pfdqkD)L0-mr$afCh4<;HRF&s7SZsEJ9j=GJvF^4ht8)LHZ9 zG*1?-J(45sd%>=`DingL!}Fgp`Yn!s>jzFZP}y=r2E)L#AA}Y2*Trg&c)D9aybn1S zursa)HVL&(j!YwC9oZ|@59PA7%w1(`4Ey=DSHU~iZa*{s1N$Z+lS)-MNl20njOKHO zyCH+^hDp%ZN&nW<&x-B;M!9~j$s|xoY0(ELz)7ijQZ=N&V&@Wcg0UDz!?LL zIPE}FmrJ>3YPrkIJwk&KP#$D1-3x}KhfCsWFGmKicr>1X7A0aS5KEx#ZM{yIH@(EnOQ-}& zY=5jEPPo{R2NfUsk6%zz*^+o!7qxM~)EaZ&0{A++t>$E%`nM8KJi#(BOQW>Z&2&_E z@-sD;tQ)d=KzsCF6mL5BcI^?10-6-x3yDtv8QhCHmU1V}7mZPdCgxoJX$A_jZ|#b3 zx4Ov$^Ue}RZ(Cr-o`|XwSa;~NsLW5nGc-@=fXIlOcs|-VQQAtv-dvv(1MjPsqxdGS zZ@ci)LWpOYq zproNn>6GtcjwQr-_%~+m%dFaUXTuSa%%uI7WMbLC#`(qJ=(tCVXQsu#*rSk~ zl|v=XCX`5I>D49lmd6SuXlttTd}79!3Tb(zW-JOpdJAH+zM>D))_j z^>&`gAMLRQqY8anf>u0;80CDzwL{6W7_9`0VH4E=a`k0h10``E7a>d&*SJ0b zr2$+98fUSrf>I0sRYx_o)gD5i%N0~9yAV}zUwKh!W$S+&WM1v;k7GaP!;YX%D`0ir zAT@y!Qe>&nKCVj8zY5c#C{X&C1J?^75oXIa%&>8?6M#~}faiiKK+ez!m6oZk;eig> zt>RDel&b7lO)s@tT0A|sK%N6?I+8bHl4g{|)+w|}KSbVF$Y`}33~gjbNckp%ns6*waJBh3kx#hv;*z1%@SIy zd9_7(j;xj`rO{4AT{xVTOC&vg;6v%j@bwr;-x6Lzv$wR6H39yo%uh`GAc?P^oSna8 zVt&iyWcYbKq?~qm#Wf)J`_}>E8D2`8KeeKINuD94L0FM9BtMrIcBIu!4oH#=L`0rg%}}lJ^f<)1o8u z!CZZNCi>PT#A5%vkOk=gtZe^>KV_GU3j=)W9s4;(V*mS39|v!zQuwZ~*71q=c4^wx(up z$>*tEYwg+5r2M!x8Woo}kydWuiS53GM7Bo-r2&GuJT8>aAc&?VELm zw~x(Y%sN%@`jc4*P}&VXR`YW~-uRGJniz46sb6!pPtWHFiRWJsdHe%eyrP&gs#=uR zk$|vG;CTsJl6ej79>!HS#?|LZ%O53c!k;OmCQ17iI$}Kxk0yL&i?$}>3Eo7S1scnP zR6^d5)Ovk;X7q$-u5BFHsY3-a=w8z&NMIcsGsJN*AqFLP)sHmVfe(1EEJIP5&OdxPF<6AGI;c+?Bn*+no&OJ;1Kx^R^|j=7(-)xd&<6XvZKCP{;~^GY z`E`J>B2v0nbT_lQ{m9p}#4=|6FS`j8vQM&r08$+R z*86OQK)*;L{#Trd076Tyj#v5GBE$9Ws@}uo+K;rTcTHwHQ*~81S>`n{^Qad>Djgkg zMP3VVsB(3AV)W>dN&$AMYfzn1n$1%jgHyhtrj!S=4|~R6Q~?V}6Iuol%7J`0aY0Iq z0}VZJ-S+UvtxupXpC7!EV1Oh7C`}XTcqa!G!IP?XOia%@qlVq`k8g}2{ zx9>>4@iDOge*|M)Ep+H*C&JF959pp{+Ll>C@^Cm<4_9D0dv6{da{Ycg^iz!6R<@uC z3~~LTC46P#N>G3uZIbZ}_DqHBXFVVb?4$P}G7BsRr%H-9@A^XUZ3I$9=PybVCfMG< zWAtt)q`~_xw`HQI9;EdJFJ81!c7ZJcE4E}GfzS0ulQ3lV^9M_bLl(1|@T+;r*OjrJXUn}GmD zl4li!lTsp_?qx=1-@kQK^uxniBS)~gDoh(kffGRdMhBPw{#9&0&w?BIV_orYwLPwL zc}MC-=S1 zr(IhpdN1>tfQ2;sB;Ab8GS-b{bm}lnKv3zj& z^Ht?KK+Eg>4dDNGo=bco`!jlN_yw^wL3QS(=8VaM>H;7Eb;dD!w%h&sJj5W3*3$3; zf^@|Y^7*fm_>xjrt`s&BgB^*>Z0Ur>b}pmEwuU&&dE8;KY*kCWFfkj;-7Ouayiq!0 zt@eJ9xlP%_;@=qlr?c++OTRXPn|)Twk3UMZsp1IU{VtE;gbL=nC(O;LGNx zAs8O*GUTplxcb%jh|(~VwdgUNEA0Af_8{(2gKJ=)WtO-941($Yr~&AgqB~sB9AW0G z*W80+uBpG{{lltUciNAgKrJbNVIW@yHh_$Ai{d2=VV0T+Z)YAJ%K6e|e`aB88 zr$Q58N~9_$nMAbOXOrKZ4l@A(*M#Uuwb`@bg;hu*%RgJO-wO`Od9PVoc1o;e-orp0 zJ}`I=@3{XRQ&S6?P6x!S=l2!M6LivuM!MvOJ(vzIM>PRZ!PYHDTM%a1))ahfFd->k z05!hSE;Mc3p=n)*RG}IJ`f@+Z_E9lu#gWj-&FoM6x?#pAi0xcx`V?&f{8`yWvwAE& z(N%_I{EzH(dzn+Gz_KB*AmucJ=c6(++fTZxm}od5v;E2qar+a>w2Hs{rK+d2c zI_BC8X)e)0GRiC%Wlt3>LN7Hxd$b$Nr5{X=45?;B{Q(Yi#f1KkL zu;P)o2EO|n%e+uN-65iKncakH8h3_`>DoZO<Go zgKMbz_-p$w^ghd z^}N)_vWW_5^f%MGpK$)9}tMGu;Ydk9Kzjyu*mTGvW{VM#~v@!S*PmzI2YxKxw zb--pglen1=+UGx`?ufmHhGSG8e*tMTy!vr>n;PczdpJW+^j}~=YGLok3cWs~lBxV- zIVPi&9@7$R_n@Y@FbZ0t`E42z#Jh}02I}~s#;E}cXvvHwO`Hn%zMf8;?k|4G_dd@* zxe%A>1MC0LzxLyRs_BTO0qGdMUGg<>GFBd_Nl}nUN5}ff?;SB*QAuvt$Z*1MiLDd} z2ZAptOugJtNSlqSh1@xv_G}$p&&X;Bq|Kz@BxHoU^V;mRz4Yj{KPFl|>CI<=dts|d z_`D7T3ub5qir@vF49dU+-&*SKisqajSkZ?H%=&FAQXGQzj(TUO>As49K0gG4u;irE zVrO=bn!|Yya)@0&Hed>R;clXjM+j7{W@jMxo9>u5cG1vv8T;T_gV8mI!o{ z`z9)4=Z+FlIB}Ajgu9Vjm?i{anN$DP zL6wv2gJmO6uCS9g?~J@wIcLDd?gKTe>QhdsR*g2|Mg>w_H3oZ|ZOl!Rl$!y|#*x)W z^b^}6pAMXxEW1#g$V^Yx2uOsE-39%_r-L9I*E=bY0uxxGuX@x~@nS;>`V@wvTWl#D z&RAxVmd4cX3>=f^4}ZW>WBdiCgKF-;x;xI$B@^s4r{QWnVYnxS-kNIz1&TdIFadAB zOA!>2Kg|Qf$plK{{PP@Jr^u`P9*3*>wT8Tmwmtt+xK(yZz9$HzZjuxP&v{}&SeX1C zN63k42>z#E4AY>1rY-~g;>F{f+ZT)(8Uc)%S)gQ0z(kTVFX=WTP8UyQr&_a;vY8A2 zvL^?&U%9M&v{tRyq=}%_v&R;R1(K#n-b5Ay%EbS`8l(cUtEAPaon4}Qcv~|0#tdfS z3qz$XmOLH~DMrY_IMF(-L!lW6zh+NeHS+7O^ZT$f5Dn;mHju{#&X0AR4^EL|C%`#8 zBfZK|e+BsZaGCi|R5mX2_CuOa&0;>YO43rz7U^d*uao_rB|1V-tm9FYfR;~}w%SV2 z8eJ2)9diR?afq3jZD#@RoQ@8ea1@6LHN(Lwe<~yYx z;|wD(OB@}^iWOQf|F=GY%FZrb?HM1_A3V8?3Q1HnnGHE5s*{~(PqmQ4u0Sp!={!autTaHVDxZ)aK9n~^Bz znQH-mWAJ-7#Nmy69bvxXIXeu(;WPNQ)e zU;QT?as?ruLoGYX}g!wOc#uYKB)Zb#km}M+q^}W zNdo`vkt?XvCTht(EZi@X)t~Bdp82@X5*tDC!98JNeNaUv!a%&bZBCeFwK;Pxu$#J(kU|%Jp zv6i5Lqx)KP=DsGN0hHyGBUq7+g2micpqrg9<3hfIos?_>FGL0nyRi!7 zUb#P0ZhkzV`ro=mrDWyK^1?hnhq6S%pvHYL?o5V>voQav$P>Yxr^u6Pav6l^9t)#t zqI0JN`a3vh6LEVR1wqupil0=HUE3=)vG|jynv_J@8ygV7)1MoKe4UcoKy7ujf0x46 zM5%4$lN@?VaA_)7@!)^map?KqQRzOn9nQl*`X<)*G#UhBkGg1ii`IP*5^TJyz{~y| zwx#!mcbiuwAoA1IDnwT*`jh>vpU!kTtPjOo>u5=8<0V6nBi7HgPXD0p*E106%{_$x z!+?Q%$M5eInhw=BUvnWLl$>7oZ;WbPF|h$RqB1e73a3m|RqheGM*WJFqmZp8qHR9+9Ps%x)cLTRp5vso?X^nJ|^`}3m*uT6sM~kf*v^ zW~KG7_XKgcpW^>^Y7od=PC{a)Y};0x7cx~p%y*y!P+))<&MK-65+*b6v`c)%K=|qW z8APMOvRSNi3)dBEZrR`Eosy5Jd4tsd{Is`vV9W8`@u(y|+T{wazUVNOzQLsqFc{zD zrD+&&$-*0|L7%L_2~k~3UM_&Cq^ezk5PG}Xt^Rq*Ex!5 zYNbFv6~UDx3|*cfYqs1&cS%c(t3hkS)1y#nREqyD$^F2^4~r2Ipu);54!4B?4YE|h z+_Ri2-K@-~;VY$GX{J}DxkOhq*5dUT8i%6HYrJvJ7xUHR&NgYa<5Jip>QL9yeR@M* z-z541m7|5hNrOQ-SN=K+d>W|bpRr&zLSuumylwVDfXqdgGRmK_QS_(()-p2!S_{t{e|7gwCHbp z>?iMk*#X`EvIDwew<^#0#f}_|w8HxBP4%;u;z&iI7@hgw+){qEQBwpJn8bZ51G7f^ zw$%~E8>nCM9?pX?>}^g|e<`3j%(f^Gc@|3e4Op{O$Dbe(*fYzBWin~|Ov1+lSyn9A z^Zsz4J(TT&C!lj|fYstA*W08=VKZ|17x6dR64v1XoS60VEZrbUS_4AH)~%W9;ulhE zigZu(V3Jt%#>E!CL7M_hPq9_pXW;7XvYW%**IoVVS`yP+TGur-U73-NL z40GU(a<~m55VXJNVD4suDReIndn^ZB^$7w=*^39VtAe1ztF1ORoDoW14F5R&Qr0P?V}j^!P{LhjF<>}ToA6Qz8!Qqr1_f4=V@75wy^zC2uYCMVM$(d zp-1{Av77?2Yobhz#yOR73X%mn05wnG2>Yd%^n@~s)tB2q%>!sjvXF*LF|^OSMaJ#=Ea1#6uc)$0 zZ}|WWJ6)sBQ9*U0qHV5tsSYl`(%k;ZqGgzs0PfslI~eubE!mr9bc|s{hDAu0DxSdI z3F5`5L6`M90)lm1Nb6VsA~?4uGbXD9K#t6EqF3>e!@q5V#R3yf#J+nY8{-DLSKk5C zw?>_6ROPvcWPhYZiu-?wNd85Sp|S;T5pYs}C6I++O54tmJ~NA~3Q1weRE;8_dV?}G{*&GayF1u`bE3tEqZGfe-SCioDsd0gC#4cX_*i zw`{uLZdilUHVl3@)ku}ZWWD4ehXa)fRCYM1uPDE{X; z{-MY-$X5qgN=ZJ3KSmy}b><_4q6>3A1!u*DMC zL4O1TX-I0B;w&%eJN&&3VQ?C46Mg?q@GO=N^bzt~Y(Z2EhRMw!5gh{BFNQ?OCmDP`f(Xj+w3?0 z+`59#U(v?%L{eF()00!A6zFDh0SX!TW@)A{VP89NfL7V=Sm*W5s}mPr?BcB{;bxn3 zHLx)C387h){f}xeri@Sx#67YAiy^t)YkK}~&g7iaB!D2Lo@7Wn6^}xEXzm{ZhadRcxJozVctMZ`#9R82ELSW19 z02kA&x9SYJ5kj97$6zl>488v7Bl*2U*b=1b>OY~lHpq`9yFBwn&UhuAig7lWLqFh# z{|J!}PP*urgZcY#+i3=|Yo$`_%Yr)sRW=APy!YC~D%Fj)`m^#~QTh@BJr$Ky4|6g{ zFTm^9mta1oGmH-b;7JgdiaS~PBK&z$TL7{W#wIuZM_lfZ#~dGlnxO?ykqqMmKa6$J z!sub*L%rd_e1L01ES4!!P|>fjg=iJ+uZ}E6JUU#?kl*lf$J4#Qwk8hj4Fl%wF|bGsw{weCQN}uB?HQgYJ_l@%gy6cP4^k=q% zD<|2D+Pev(y4cshk0@Jf{-Q~*vib+sc3|X>1ih1GoWE&m2z^FFVSlWK6|5R9Ihf%k zcYlAD)lZPJZheDX3mehC{+#U2gt;}8p6n8SG9lB(b9WBL7!T@k*@H`y7^l~8q86)f zR0KL$G+jt*T1sqkSQ;QU)#8$T$uGM7$?5cjH-Gx{e8ZXv!BNcIc)OlwSg8Knm|8=- zbA;cDK;XSJm7i&sCyNDNd13jwL0QUrdM#|0$uq=In{61;^u*ww>qS^3(D+INqX-jb z0T44{hHyn*i`NSRxf?D97rI~?AvQZ)J`(Z0E$Q_xG`Z|wz@OpW1`zV9H4x8Bbo8NJ z`hktS#5LYJt!DsTcmq8f+>8MLo7-;`u)+6qScs`XdLD+%iS=A@)k5>wpwl<$rrw0ZY)aE_rToN| z1<~D}`+uKLm8O1PJQl{qH;D2$l?2lC}0g$ z(Isb` zjJOv0Rgo#47JlnZ?hpPgXiwm}nTaCi$fT_zs_#TlJp>goyVLutyFvFTqPK8O6o|wu zlN4NP4eeS>61EM;4TPaS31mwUg>MNDTPj3u{RH4&UcLwpOI;liD@DEiDO3)KM|LoSQNEE_P^ z(O;d;nZ@G{k@#x}BJNLE|JmJp`7&1){+)t+6K5Xruh#{Y?NwHumxr4Crmm{Pnf_+Ft8F*q z+;0F!pScrtJ+u7)M=-;EAvPzP((u5b-Ikc^1q>bE8yuVM#(u=^1))G_po%3*$-kLS zX;cv`lIvMztfej|tZrepquSQ>7sEaKShRzfaM=6Sr>Yz?e>^*$j72e1$pl%)4VXT4 zY9j*%MVQ(#F!sBi&e*r=ga}L`QhlM^!&ONkVwCVGy9qr$^CuN;=Tx@+ z780F?f$fTKtJo*=z2gRu|MN}g>cmuh`{6}(^c)*mZ;3^Ozd4m+v5RP~<`}5tIYxRFDK#tm05_+{G+9NaA zaoE@zKz$S#0raCG(`6hJd20U8?fP-1{>INzZ{#Z3#?a$cV-v1@ zTA%LFp&qcDoWaf;Qb|`rSffG%oT-i0;Lo!^-Thx)l`?}iHLo^&2+9g&c#ASj9Uk?e zKO^uT1LS-mCypSCoyH@}cz&?Va4boG(k*sNqcXUgmF^cXU*`{^h;Yh9T-mZf?y81z zzf<#WW~C*tDU}&=Pkt#N0$5*#2|)uzV07~Jb9Hu*eAiWvyiBu~9v3FWnXK2^v)%tP zF#7*yVD!WwFGD#FVm`Zj-|gX4Mb(snVW1(N7wpnM|OC0Jr|fw~(&xNo>l zMsBZXHSrhtW~nO~q{FWJib6k2WqN!Y8i4Cxo87my++EhtHUAzh3-xlYf?7HXP*r#6 z1@yx?r%kbTvgUDxurwMONEv86X=bP@i z`_hz?iP~%n2~;tZ`#j1zTS*v@S~)nMF?pu7YP1#R=%T|usDM!4eekd`m& z3ev~V{gNgKHS;W-Za)X~Ei<=);{}RR_+&-8e`6m0(fE+_Dszc&#PF5Az$0@Y8tJG> zp~(Yh!wUix$Hv@G{&}o(_`#pdB73d?I`ae~50JtpQ2z}7s7MQ#sw`eowJ5eoUOsfd zgW?b+j}!RyB>uMcWBSAia?J;-a5Gf^zs@MMY?xeo;U{>*bYMT&BHTPy8xTjC3S@>& zs`#zHnzB|GN<9(keVWQ zYj10vhhgL_b3LH7*&B8$$1eaYrGFol6PozS0O5Pl)^lg8AMr~k93oEVVsBJM3`P@a zjLJ~;B#&DIb<#?yf5}*e(}gyusJ*M;FCF~aTIs`JN2o!_K)Ph#1k8EPMgR(QY(B;S z5vw80CB=*~fB^FOG*4;ni+s0G)NBi6sM<)%ANwq5$`cTI9fq%1;k~v5)AO00&6Qrr zQLfqLIpu=9KPHhl%wG{7=dlD^8XlEsNC1uc&JVJGDHFb8@0MROaz;S`GK=)v;+n3_ zoE-H7n%_8>DMux9@{_BL{w(!g`TjFnP8JD+>MtU`9x_%?#~a^y53~^ukhUTYS1#= z^T!a+@uvLyqSZ9}%w@in;-YdwkwC`XV^;o~|ANi<+nuqH7^y9yORUOLSs*uPcrI~O zb0vp<{Rs6fu_G6akJlHft7rLw_>Y>lKCa(Bx0Tml^oAf^GdhZIw*E^-ENLNF6HK^w zcD_9Od`W7Q>?1rhfqfTGh5jMf~A+Wb#uPNT6wHqxj zA1lhl*l=~COvTfu7u0M5=YV8V?f0En`}g1@hE!8?@;d8|GBwF&e=@DndBQspDXE2t zjrQ&KaA%!BkH0}mHGll0yZFr>C7B?dKN^7e#s&Byikt1rmOGbE zeu&G>D7D3a-G6IgamtuF^YW-mYFh1F_a{+VJ458Upoz^0#%-jyx^Z_3WG^2kNJp4$ zNkhBP*h8amSrU~*1p#T@EhgOs$W|CfWkC~28XW;^UWR^~zlJi&D3jjnDrx!uIx9{z z@md_;ly_p8=6Q5}(=m0Iw{DC= zB#Fj_=q>X&S) zjjpaXiT`a2+fU2S*3qs zNRJ-X2~C?+rWe~&IW!jAVmdHrncuxsxN}ZfYRBi^lUKXj`MVq5axA*6;J5mfp&F+9 zjoiDpnneAXr=#=vnY6_kQP(Xa;8@^^1$UvUHQIQC}}FiH1*JQp}vvMzp)>p3sTZE;W(7pW$-V4ce4^b(ceq-7&>L zsUZCha%<{{;b8BfXm22m4Zws@h^dE>ccc>H8>Y(<(CvFG>HYTno#aB2vinFotHH!% zlAeI$ZkkSC@2q+^+s+$r^Ksk99EGBUk9^8E4oe{&irWC3+9E90_qDaLSR4rf2YRn@_GDkhP@_g=@(bKVZIve4w^*$Own z0b&s8xh?jKdHwoi+s&TVOj-^n~t zf5IWHl~DPtE5RmCAnEzI3;8)ZafYcG0H zuvSNT>qyz0_6e$O740Ewp&`#?N=d;~7t0v^z>M^Y;G$=|C30 zuL6j-ynp)wHTzJ&QcY4^urUX9bEB(*m7~hAAjw$z0Kw@F%Crxw-CNVAv`?!?-*>drr zbr@%2*y{jGOSp0V6c#RBL4RQ$;h>L324Oih{J@k7isqSdCnsx#2Lbvyjj8%yh6FYAPWr-t<>AR?KuWO>ol!j^WMe+?){ zrVgbL1{sZWd;ubx)03FpwjEn`?nbzEt6@<_kzr2?W6Tr<6QniH+RCyqH|nP0b#uOg zdgfkD+Rl3!`lOQw=*9zFy?Fzdu3W}yN7ONq#xaf^Jc_&b9Ta9`Kmdr0Da(ec_B{od zbPh5XF)rYekTZ#o0vV^zo;r>jEAz;jWW-taSE zw$X%-1`XYYJvjhi)ruSh$ezr`GneMD>wwj5t?uKbl1cO`1H{o3p1E-YfA*QrBA%K; z*Q4x*nfn#AEY^88l0CGhK4)(Yhqlh*58wA5Ol1Q!vsfw$xPGwIJB?ac$pZZO6JHdt zGHp#tiNCOh?U%dG7UkciRvo}gdEV#y2nIf~20_~pS*MvK6Y0t#j_%xk@wa~c;s5=P zaPm)QJn!aa_d(;=jUzCQz&HXg_Xvyu%a?l#zD(~w%d!x-+*rnBE9?wfL6A+RX|OE| zvR!lz_dM_QzL!zYa=+($(^-}^QlLToEN<*sp4J)mapvMVTw9zM6A!wHGl`42NhI^q zf^%l$S-)`*Hbf1usIPYcCce%EEKTUkjL71B^(Egd4U=oy+o=MU|2)kpThd!xsvg)J zeUo{e$(Pr^rho?4WT6xn4SNNktTOjp03`=ioKk-&1S8YYYKIYw6i_nBFgrOdraD`v zX2giZIIxjw59K>k&9$J1NENF4=C3{9R&kQgoK$`rY z)elqFspEnxppat*Rb{+NsXV`&lKp&)TNb7EdpT4PK!Cu2Vdc_9ica@5Opqj#PQ_^= z<$ejrLJ*=cIfa?+J21O_7g}?3#>so44KyMHQi>FSIXKJL4Sr4zE^st#Jco|-ZMtxi z>1g#`86mP7SNa|B!0zJpt61r*q0wkad#8g~9D9%K*>4!P1mUIPUm&+U#@hFFJi0FH zIew%o*|K;&OtN<<>kQYFIK^~x zLRghMC$<{qy|BWH(sg%^tFD*se3NVD5-cHL&gaWK416E`AjINefTxe2z|$|hh|4!` zN*`!NA=)1BM~{CP`zKmx{(k^i=J#w59*ZfY;+kz2ZDk1Svl}2bKpHgBL5NlO_`g5? z37lM5LfW20LWZFo6H>lxB?_u*lC0xthA4~CVxL}J!rLBt5bu1$Ytc;lXp_a8A2t^t zdENT4-g@HB-7a9M`w{7>mBUVV9J%wjjw_Ys5G`ZQBLt*9mIzZB;WE>7^$8xGM9 zeeBz@P0GgaIY{HGDYW2;?j_m8Jn%9{Rs=w;Nt9((3SjF@tp%uT^jFFA9EMFSdJ+DQ zkNp*{#W9ABwz#e*3XbP}xG3Gv__=hTkNar$d-$&&|4rO#Vx#InnigHskot2xEV}5qiE!P$IqDJg#0ng4rf{k2rHv$S2uq(7D&8zkITH36pI-`sPo0w_&bk*rG=Y^Js zKkiH^xj=$<-- z)8GFNX5yi+Dy0NgfB;vI&*6$unu>NL1b9N@Vy+2HHCb|ht^=-Ih7tc>l#Z5@GPmzm zlV*vf)n?7LG&S+cSrg^bx!fd3wK((AxWeQ?l9kL8Ez6rJu>VbOM&tFb2O2F5L@>Xs z2P%mblO}O>S6w-C_#h@eSs&kh?nmg+yn~>n!ai{I&0%opmWuEkom^ziE2MW;Gl+{tokH1sP2{r3cjm8m1r+pryw`JUj zsbfhXpW}f5{!BC>*CWs(+O8(!F3L!SIa7=VO+F<}*24nTI8>7nri^Kd$O4?8`>W_( zCXkEqXKG(k_p;kVZ(#w8*REl8ejfdm6=dBWs67`Ysj!!s^pQmpHGl!U~XR ze{p9=0spODXbwi&gAVXzJpyy~vI#rUN$G^|!}_LXKYnEP^lQ?G14x>!3* zIkNe+0V^|HU7p8_=T4*VlVzF;vr?1sDkxWV{T1CxJO3Hxn;n03mB+%gv#W>$m{a=2 zHI=Gc&s-4G1u7L7tRB+j%x67FbATv06k~5BQ&|)YGWdf;q(-QVER16{yJCAfrRl}N z-RQwY^}g8`Sr#07zPLcMtaP~_4yqDgu1`Yu~{4 zPn<$LF)5mV!f1BJ1lf4HUg22Ns26L3Ok(A3`U~I966aaQ(CE#V2 zG{{Be91|i7n@N%ey*2;PwypDzKlZNwZd=s+i*25_yxzDS|9%{SaRkN@_<4=M7_j_# zZP}mk=gQ0uG62qmz*Mx1Mii{|8)4|pq^Z9(%lzF4vORv5y#~Nx_}RfM%eDbhg%$FM zlE5WTjmhNdA;D^|gEN;dr1L9Fz8V2Axnja6TUTXVTKD2=5>pLX`NX_9>DJeo_nY7Q zIU~&`hIx&1g@JjSfaT_!QA^kh&7prD(6Y=xt!|v_O?isVwkA$m5?quBE7R06Cly9< z9Kp4HgdxNdIx>bb57SXglE>X!w@dj@$o;hiGG+6sV2)FA>6W7FX4;LdXtoUoXg28l zLI7p}EZoG!@BJf|PoKn=EJ1VF7qC(V;Y}V_luRqgDhZ4vGUi4~C1N!{*Y!JP1C>Bq zf(Q;H$!%XZktqYp_2vQjMmaEI`Kn8q8`Ibi&J)m_rKSm)H7jDW69mGXS)H1~k>8+} z5he3w3H?QEu3#XAZL&RQUGz3i)Ylp(E0L#%g6@cYP zQdN0z4%(HHE%W>Y5E(a@O~TjL0hVg=Qk`#1JLR*9P4EnJMxWQ!%*@AwH8C*leKDoML9U;T~>IyDgzltjh z*U@3D0>7A7k`!~z3EY49D7H<{B1$|&A_*X{n@otgDVl;15@VGsvFt6*z1Hj{4vZ<* z>1!8p>e6{64Wq@zx(I?nBc>yHIcYKXYroIu8bd3Wm?dOL*)MG~JzOMPGNFd01(oIC zhu>@6W=$YnkW&9#_q&m~)@s5kXhyJ74hJkC$+ci2Y~Y^#hp=sGHaFD~IT8n~96h)* zpH!Wf<#!TeUqIh}1|(4nYw+-c=U>2ge)u%5uB;+$wS;YHIBC9=pSHaWt)!1Xc>jBG z-8$@ICS5mIIG#YpZzS^cg&N>I^R5oR`CzkOwgY*95pT`r`7h%o_%@R7Scm z1Le?xU*&zR_+*RbXwDog#nXMYIWRGRuqkCH?8l7=@6-U1JP zYMj_*9k(oNYGTT|%4(Wk7e}^l#qYfDJ(#fbL=PVR;)%b-vlp)blQS3ufr+Os&BW$e2?Hsi9y{#V7 z!+?J`klknWEwLp7_X}Q2zznH~@UKZmuoi+@*_H>30!RGe{UJh8a?2PK|s@C_Zl^;IVq*UQGc0>7eB(U|CMY z>~q@$rbu+``&gR4L84gzAx_e)Y!}f#GRz@A&M?=S#C`&mTehP`784mT%pa0btv(l6 zm3DCi<*S25ct5czT@?h0rWF^@;>5R~gtst{S&v!-v6Nep0pb{J=9iM@lK!XhDj1OB zG;SFRWHazyv{cG10bdV5cY!lu+__y)ON6x_*#xWVvR+%};KY5LrF}HN)az|qlrTy9*U>P={B++D* zSBO8wyj&bK$$CaGPYilObP?mm$^tH2yMin8H^qt84}DqZB&lW$;zTriAG-Th*wUJW zH%!p*A_ENSnzW9Thug2@_O0W15_;$(#S7<7;==rOpy>;sF|_TQ&+!rulbMrIF5rfp zy9IQXIJv!=k^%&DspD6GrEUqgYKsz}WP2+>qOT#C$=`4EwH{DeH>O-bSwcW;gTOuy zoZ=KaX1C#Jc+%m46goV`Ae zBnpighyX0UNM;C#4{A3KF`2~p{r9{Z4{V*=6kxd?U|y`#^^q(AmKLBi^Gx}GF2_!| z_6eXx?8VIK1mFf0=bbo3*6(8?3b1Q-8b|i;7tPB%w$6#^Ax(S8?w9w7M1#>uCVqQg9-zcSOk^?s;PJMyM))j&7uxb48leiPL4cB5*M+qeoWFJj^J`1!aZ}fb@)Kj-SdH$h z>;}4$e7Gg7RRF>KTmUlWRJv+vQw1dJ+Q0yBlHAx#LkUkKOax~Gt5>RfO%<@beE`$k zx2s|4g1%-$?6^y^4aAk(Zfq+q;O@O@9CJrYO?WMXEg8lFR#1~~-;O=lN9{N-w2Vrd znpmS0jhkLyuf0*+gu6n3gKr{uLKBgG7Yon+1ebsO6sCqlOvghhIpe0;fKa0*G;T}k zrrpHMvc;q%*(5ZKj7c{12!8#sSOr=0grEkn%%9u-t*#{AjY&23!Wgvg6_$ZAZfr?) z;WBDINnO18I$?#dT-6V;Fg1(&AA65DhG$VEdRZKoWFSid=rD{WO|bogsfn0)1i(_< z!&5IlBcwriKR_k1&$zC zWy?ehhNPAVDig^BxTODQK{y#v4kM#FzN?26@_N;tWU5KP)FWrbGl^cDU^;9`sjz_k zL1apZStf1ebaQ|^2R>~(Ti{jui8w)7|ib_%=HLBTkTLgB!e&cxVLXOsVm@r{|Eau8C;n1W6k#-NNz z?b8Z0ItLtJ^3LCzl2=6lOq&-aur#$X^S0Ydw9LKCP;*T@q-0tWeWU5hzRtDk%KSXe zU%Q0G-kLddlX7-~2Uh2kKy8v@+r%_pd+b%130hL7Ol>zQX%=jlZMFXVB7Y$xNd}@X zfMga1SdIJm$*C93XH%LXAY5kj*s5bfO|0}lrkH=t{Kk2Lj5})j8Yl1s1b4*j$SM0) zU~;3wt-2?vyT+!NmAbyGma7FTsew#DpDfkGckIL79edFr@DUd*E9s#|hh$(Pzo;08zJt-+$~;JhXc^ z8iO@qo^sBq0+X8uB3YihuK$e}%;~LpN*+NT()4 z`N5r)PziX*Wwokh?(kAE7i!Y5tGkBZee_}c!oEAvOuFLUY;-FVneSv~Q5SIQZml4? z_OE>ZW7p%tNTvW=2{=)!(agivye2ml(s)@DFfnYg2dm}|49Jyw$Ox{(16g>~tGw3h z3cz*x`VDxKv*H9EQ_{kyIhGnCU#o4YwYzTS3g@m}$J$^ZI@Dah2`cmTlqE2X zbx&BBQsOT2QTaGS0kIa*?G z08BPiNC29ZL@k#2uP)=By}Rc>^4KH)^Q7PWvpdHIJGYu*$A2G3U>t#;^9YOq%b)WG zez`oJ1uY}sdbF|658tn3vp`8CK=Z)g@fIc^&ghi^f%)+FX)!6d(Z=>xy3NZfD`oPB5(-PlY8&N<2EzeGxh?REBvuUZKZlH{-Uky?^|%f3UmPU^;HsW4}*dm;8d_z7RpsR*~ zkp)Hra2R+vbK^3OUp#|BNaGKaXw`j<0+Jg8%EfrKWqlj0fNmSPF5{APS+=%lt6Bz* zUfYaWTk>WTvz&n@38-pnO6DnV<9kf}^Q)BR13r}it@;9$ps*!yRfed>g-f5IgbE}!W^@}r`tBU)I||}Ni;XRbFlHTaGrT4CZxFul`%;%HFTUj z42zN~f&vZ-uxVXQ%&CcGUs>Q$5Mi=5EfvdxVdg!y--&UH9yOTtfw5%bz0p_B(w1i} z0}3unS6-*aM%pjzh3f!woip>bDqzWGDir~jZd}9Vo7d5g6QmSS4CqQ88^Djx7E@C# z6DH-I+jrqrhmVLXMihJI_rl)ZY~vW6SkaIbtxO*`))w*nnUh!}vz10aa#%1@5xnk! zK^@4irQQq)0SEK7d5H&=JkW6`eaNz0*E^7M$Kpn>k@=N(lbZNZt8%K*!o3F$O9?oo z6oj#C$4Rxnvh!FqbZ$E>R zHx@8#O$azNB%EZKW}Fw@IXTS?$i_z()yFh3)eW6hJo2X3kXJcNnC7O28>CgLX=a3kGF*9@G-#yuS%cn(_&z+&?C(&kJzk>J4MALokr- z?Xe7;z^DNN$pKXGy>zfxVOR>ZnI>p?KB8U+(`kbDzwIq};NT%~P7jj-yksb>)S@og zj9+1aTb4N2Qyzkd`HmdQka?NmW`||tXN7rr{`yU<4hP5@Eh%Xq5}fs|Gz0-jF|#pq z4dY8=Cb59L#@Q@1Ih9{cJwHcZWkv{pAtRLm~&)6>5!uOC9kBBfo0ct7U$$8iM45qO0}VDmfMS4cp{kNN*!1PZ3* z)o2mTC|nJrFpMX&jI2uUpyy>rQ{Ow{W!`=t*_~NNrX}Wt9O0$D=Xp{wDjU8`k}auK zf%#%9P9kVH^mW(Sv zX%ja+(RVh?8?{6=F)V)8B>bOeXcs0!Md^AT-XYjp|JNYEh5h8s+ln_IQ2ypLEmj@fWp6B*lnnt-_sJ^>Nd!A9zm zrxKXyFnEOF;JAv{^$+cz%9yLZCXbiZTPz%;Rr_>(N><+HE^|V-45t4N$jQ~6eZ!K z(JVkjmSw6rj`{p5P_O;1h*6;>D+!%t^pHs>fVsM^R^}VmEJog!k1JcQXk&jXz|ieO zrU=zaDDd`^3~evO)~Oln*|8H_Cua;GBfzFHYLUKODR*`6r_2(xI+_&&oIHuQT4nh~ z?f5BTWx-Mb5p7wyV}@dN14JIuW$m~vre%0Jkj&)N9!$u*uFNZ2>+W7h!AG6f>c*9n zEIJ9M!k>9LvocY0jaJn*woD?c4XKA@yiX?kgLEkC(Ulw5F~7QOWDpo10$0>|V~IF_ zlcY%F1OXHGB*Wo7cjCyM2aI_US&Wj8L$4ad)&#()jqpoXkr zP-#rvLkt0RSem{L!?3hISu-60;aKS)1VzJ$GgGl;?P@uvU1!~E)B(Sx_P6ViUdvsh z+}CM)>GcGxBv=r$3_Qb*saf26_z0%F29kIPo`#zB!i?MMS1~?EP0-xAT>w_;hdu@@ z4-O{ru`hfHKe>1ry;ci-YW_-v2qg_FMr{(81WmdncYFA)x4#9yc;^8$haJPBb{Ljb zNsn6ry7?F#+1G3iuvE6Suq%aSPD8{Hi7j{k#5eyQPd@t`Ftb(6WrVRO&gf>G>%3?N zR{>CokyMg^JU|-By!ya?eE8vaVk+$k+fu+PIq0=*VowsaudX*wRQFBpnr048tdxOs zNLk(mx?p*wKiG1AKI0%GU`uCoA3u2EIG*_WKVXghx-}ss;9Spy?Wh$Dxk-<+|Fi&? z&c%jln2J-p*IC0I(^L4+!|%X>S(Zf)MDLO8bl)?~`SN_FhctHmBCFG(Aojzlyynl8629~?4Mw^T7#bh?v5usaAcSB1s ztfX&~gKh=uM?H95u*2P?a6iNT(%h3W6N>W4Dllr2B>nq(L%jevOF)^j zSajpAiK$d?7Fu;VaH0T%P1s$=r6f&OYf&j=bT^e`k8T(QN@8Hw_8r*0eFwHo&Daxk zQ(B`SihizpJq3<5S+72W#*v#<0g9L;knA%9`u z7;P+5>Z9>8+uu5ALIo)0WZrJr)ucuHLN#&H`z$5t7411?Y`Q~GU+)6xF0h`*eVuVz zO{&*R^r{kYpMOiYUlb1bZhg28Fp$$$V-D9>b9&;z5N z$0UF(uYY|0Y2zC1rzT0VuTy3RnX=V;c7bIDR21NLfi^w^HTd{?HEYlWDKB}?Mf|nB zi$vZ{uoS3{Ekj6uR)CU;xr|3O8?Zl{HdP&1CSm0R65gXpYRcnRB`5UR3qZ$xohiq5 z`yBr&#+{K&a6oR+*fdNP9Z$R`0nkEwKw+f@+yfId$Sod3+ed&z97QD)x zku9@K=gq{Tfd1dfrrMhCx$F6*>?A4>#a|9Yh`UaIVHNX9@u_fx3*Z2 z$y~;^l^iAt0O?uB_X#oA`NEUm7IPhMat;HIh0y517VBpL#L0D)ahe~@iF&3S=86$Z zmLlw|;@&+w@sUR##f+B;sEYyh8%rEs2F)=rx+iXrSagzcAMYkY!t&&mdE|cHfelfE5Q-hJ|R> z-_J$_g!C|3MPJUpXc#5$6w2{>N)r&&2aZ}cTFn_Ebklifhnn<=}M>bIg8gPsU%B&0BIGU4a zb8!J|a+8K~1Gt;zQW9vkfcd7b@-A~1b|>YU_tZCUJtNcI+`1Zd0+hJr&3S3Ny^bW? z<>%s)3UFk9&qOdA#B$<3Ju!(LTee}()*YA#s20Z37zjuyV2Gnbq(EF<9POADeW$<( z`gdCsxbBB|^*bK{?${@+41yw}eMPOdTp!l@d{xS1_?RPnlXh9H8t+w1%flHe5%uSMY zxdU6oiPf;nQo4eZ#V4h8g==ok;t|H2W%pS_7-i$ReMRRK?bCLo*^>IoT$!H>C=H=zpFt973nsZ^NMKhBIu`4P0Uo*DEOZueV`%}` zmlm+x>tHA>B?Ib7Ojtf?6a0XHwsaWdx}No70hV8N*S(l)O<7%C!@w1@6Ke*f&#m|8 zmLR&U%EZs)^U3Oa?(_*sG6)T0$8%&l5HF`aBTl!?3msrrX4hSb7er>`BJ~n*T;3F zWJaIZ=w2$rvg=RaQ{VU|zH{OflF4bKU1`B;X)`Kg%e#;ZzrF_W(>{AS_(tY%R=6f|Y&~ zFI>Hb|MZ#9Vy)RmpN2Sep%zkJfFaGJbk3-hJgebKBLQ0RPyow6{NVd=cy1QWq%R;Q zkD;pvBl$e3l^->1*!9*kJNRE40|K)_8#j{_pZL;O z@!a`y$Y`#^l55e`HLoxFuVrAXS(P&H{X*}TeUU6`KD*yt!7sh)K0NxSH(^>BmwkB2 zKq^n<&^F)i>OGq4i_)p1{aY75)1GD>l+tbj(c&J?O<4;)r0A!>Qg474PMtDk<5zEB zjb-E9yM>Y21MvOA*5o0i(H73fi*t}u+Bd12wlYI)fp&49b{8H{%R@+R7;fi zZw|RxV3i7Z8N>sGX@<#m8@qPx!u`MSYFt@d!WaJjNd#>ImgXRaEK6nDnvbySnjRkK z{mN>UuG7ync_7)Axrk+0iouQmmwb+twpx^WNaA2^CA??P&XXU0CiYH zaeICO-uovh6bp_!U8V*@CR5q zcMem@0FwkOlUSG=3N*Tjse(r(Aj2jqu1=-<9tFx2&~!_}^L~;$>J}i73ouAOaY{_< z5@)xu-TSMS25P%2Lndb$YKi@b6oR?(C{SK}q8k?lCs+AD81N_8fW5^asxlmlLjcqF^{XH@ zT7Wy(Rx2|q-Fbijo(n+oT2<{&1vsk=OEo#l4=<>pr^%Yz7ODYe$5)jhy16-|+?LX= zPGV?CIT=FHNHhjit!5M3X6CSCb_-@Fr_kgmwR)1`zA7@k#sH^IexR&rJ8IVA_GNAR za$i%IX%l}kFf~>Rkh`yU0c7`}r#g;{v8Ui*!BCfeR{=b#2O%y4M>We#IV6EcrpIQE zCDSU#5R2VaTwl0>n=4CL>2*Z%zzdAIj@y@bmf{OrlyjDkkY+1n{&7AWCfGi`6?Y#z zjL9H`7ZXr3EZN*Fslvu~V`DVoYzeqEr6aR)Iqu>4lgF{x?;wkUl3r!qxUV}vTTdHs zWS+5OUB^h?E|vMa3Zzt-mF|I#UQ1t7Hx`ruod0b|Mw2Jt1=phOQ**fY&=E{UO)=Jy zm=gS<92dGsr)&=DW zTvmZU!w( zeCQo-!)xz4gjU)|BOAgeI4^^;kUwzmw^&o?kZs3hrFqQji+O#tUt8B|J0IAE&}!=z z)g?0iQ^Shocpv%=41yLeu66JipZfwXEG{E!PY8>dTFzpwV-NTg_^no`xa&3_lkH1$ zp(Yu&@c{3B^Dp6z_uPXPA<#6IGH6OXvqai0ryl8%E}(sjtS{f6+Cw!u)e~&znOVl? z5Zy~3efU`HtmSUwXD?mFQm>CB3AtNTVD>w zbzD?}n+mIvl2uamMPN0{lGykC&`aW!jM-pq#lLIo+{NF1&%6JZ?alPBc6n1*H#eh< zUp0=vI0EAcymBHi1}tAWZT+%+Qlo8orU6WbD=RHwTWX1T=4B57xDy!;c)qtgOS4JO z_Zn%Ik#QOFT$vjNeR#_}lN3$>OxbK^v>O-W1C}@Sk^xJ`aeeV77S~pCfJ967WRuCn z!T~R3lQg17C)~nP-0M#6H*S~$uvCVNY{YXQD^LCGH&q0$3|Qs~ch2zBjiO$s!nn*$ zhRokfj3WE|q8sUeeN0wGblqxF@($=syme+4JGX4Z%)~TWq77DdDt5tv5jI3#a#@b2 zTMBMAs@Cj;_aM%+OhRL0s?+VDbM8D&{pcyQ*H$r^#HIv1p_ivM0?FN$D{-{RYqhP` zD+$*UaJPiD#CCFkkt$%5x2P=AN(Bc z6FGyIj^2)QxZ9Wb`Rlo+uQ#{o!2m%5y>Ipfy1PngH}8iG=On{atBtLbvvSBVJ2{QW z4+Qj)zGTO}j$I+=SqX@dzG0cRe*a^S!10!SPnp>>L4==yUsEmdE@Kx$bAo>6?aV;u%lsFUElSdOH~o2 z;3^Z(FzeiQEoqD9uE~XqvOXIDso0A!3|m-5fPeq-PvQLX8oCk9g$!UN`ok3o9kY+I z`lX2>8}oTuSqy(=6>omvK0NwMZ$dlmqsb9A=$QjZYjRdSFe~rDbY3eT80%}aWk_!gbH0ZUJp2yaPd&!0kAP0u8Oy-+=F9Wgh?eOHBe{@HaQCgF zu`q9x(|*eW7qY(}B4&F=O{uCFz{gUrhZoPC$MYvn;2gorZWoB!@S08IplzMB*&pOu z&ithYJJoTo0B_x)L1~_|1OfMe!!0dWfZSBNFjj_y;A&L5fTaO|i?tx1t98AsYEoM_ z`9iA69`>4by_b0@^AQ17g=m{52-76>IxDz)=QjNAyB_(-Y@_|eShMnWX8!SO#}ODu z;FT1CF<|*hYVDW#vt}8sZqGyoaM|CQW!dh`5BGX09ssfjGY>~Scv~`LWL7qKNG*V+ zfS!&}n&-uy=Y<3ujP{zvG+7BHqEVgznBCz(SeG}K7O>J?Lq9Pka|AC1w6vgSF6nKJ z3Zw)?KC(K*DuAH6BaWChvF=L1Eb1(v=<<- zp$rrOy{KzxcDB~M!$-T(6p)hIcym+JQeY|$-+Zn87&l2YmsV`K>_X~p^j!c?^9^Y- z5q3|E5dqC|qy)SNtgQhz=W+JwA7S;z07KfThNsf2Zqj-okcsTm>|9*@2pkv)d=k=3liAZSPQ^z+vsV8}MpB*a)OB ziJGHZx=xa=*N`$YFTipXVQpp$?s@oKz}9WzHY$$c4)CU>i`7y?CX)Oc^<||LJV^16 z&;0~TaTmjo3^n5<>LwiSGg*s^(vYuvW{F*mzLuM;uNNx=U|J2#iuR-enK@A7nDz+p zmiMP}$*jxRlzw7?9~OyfGHr~p%O+U2p4v6xv?f_P%-1tbu`&+IG*j?$B5Yuy)y9_T zS=2fknQWEHSn#oJ!{Ab$N99z=841-WjjVQH36D@o(@k2*jq2QuybxnQNvGx{8| z{(0u0K-WlJZ$z`w=YLIf{3bs6^>5&NFCIraH78n*bPE^8v91{oYu~J4rk^p6QJSK` z1H^s@cWvK--+Rw{OxbhXL6Dg;eS(R?@b*KILdc)jiF3K{nLyM7lg0$8ScuIUr_WiP zM|7=Fz>>$DEQ4ps`x<((*1?X|z@fyu4*km_j8$|D1n8$(Mi z1yB4)bT8KiL!7yE2``3TVn7is3iP=+F{u$Jn9NQlm_ujT-V+31qb-Jijx$*;7|W-Y3Bp{+#Ql*<>&Z zLbMu90W-GEZNXH_lzWTr9g{9GOEgy|gTPG)3Z|8QvT2uUiEUoWT0H?LBaa(=0>vyn zAJSB(3#_i<+KbQQ>hsT|+3#XD2+>GsUO~eOCO-!F%Z=<9N~(F%}rtAzTe8T{)3;re?4QT!0SAi@nl!md|8h}nl; z2TaWfyGuYzb@jDCT;7+ghMxl0yq1iy$j9QKgC87!M$CB9hAFF+aa+?SCY3T;d>a9^ z@{)D*MD=f_#HbRms@ts#rWbmbg)4ZKVd0o*9%8w(DzYKV zYpduCy66oD0+7)F$P?W*!`$bgmoO@&)UX2jJAGEzt`;B`Ey`hvZLKLhaO56L2W^CM z4#O0`DCJip7zK{By^3ct>k`eb1_XH;0XkWX7f+wWm8E&45shEW!JCdB&PhB}GUKI? z0CDHTYM-l)osGtl``+6DETz9%>4>`aECDc?Ie?Vc3!cT5-)I05oSbU3aR1?>*w&tf zPqR12R7Ye_awcJg5m+7f>h8P_;IA)tIHr$^-Y~eGk1J&A}=np-vhQ&+J>&ki^_UzAo4sa)C(!B3T{b$i3w6 za?7_x62@vFT4@2(rzs=gW8eqqdlUHPGtb~N-}*MvsTuSGhuyWZ1O{$U`dw-Gg_uj8fTAHv~J4eb$XY^&wsu892MJNrH zhr8dW4)^Aq?tG~gme}()YmrC-Ju=RAEJnVtzUf!WT}A0z#g~?rGyCnrD~^j_3zc{K z3QO2~)FS&gT4Z+fR%|km`T7)IAR+m%xUJbIS@-u*+RPSrTyFJ+xaW9PV^B*t|wWZ zgyv0Kyg4?YOZv(oEoP`N}h?y~a28d}62yD7f@VK;%deaN3i7 zi3}qWsK&15FgisRtn94e=Cdp|^do=K&q&NEW9GA3yMEy&w`_6n8;)E~;mSnQrUosu z8P`hhs&liC|2{&+K?eDvXzKSb_eJvD+{X5)Ifrb@pyv%i(T4A7-Ifj?#+UMm6j1!S zzxjH&1m{pjb=FjZ$VCX%_V z-$No&G}S%!H~fXjhr7y|dNN(y*dJ5cdtUl=&Ek`&LlHJP5dBW)>;9^B`vw(02jqBb z1$ACSso1Ymr59$66iU1Fe9I{)NohaS=Cpx@LaY}Bqf2d8GNm!Gc=$8iu! z`+q1wIJX~+6cZH;{BUO4f{I~LV!&5HQD>M*tyEF3r?8*j%W#I#>!sGQm8+*_Vdtg9 ztB2y?rN%*n>-nxBzxry>)5T&Q3^ij17Abs~djCWnn&!u)hKMWzM7a4LNl#1~uaSDg zo768+m#pcRgr`9nq}_ddPVVx`Qha87!8{#wBG}Kt;LN-ke&I(f2xyLKmPY0|z-HU` z^af*DG>_ByKCQQ2`Ez%`j~Pwyi1ULAYMvq>MQUTz5AfZ5Xe(i%M<#6*tuBVV%JQKB zB*$zC(u#>$7EA_xk~xn8p5^e4q6Zs?e0p!0-<;KCC)qh5iH$N117O3g3B7l#B^@pZ zTc@PFV9MmQe%b8me}S0C!HK`xZ^&t#pQ{fpZEkVQnfIvs8>W=YbT0t-87hjvJ%i|X z7tFl5I^7r>T)9J#KKx@Plu1-IO;-}Y_mZ*!tX1|rRVupG$T~VHuoB1i6oP`&DCJUVq^Xi#{!XIbUkO362$b9v4oFT>h}r zh3QzDGkb0MZ>YFaN^WvBgjMdcMCZI&@B)KNAA<)WQifFVDEQkKS8_bo1G5j>@dnQU zEE#~rYyjia++e+P(i8Qb7glPL@)eLLVNA;^$~|8cvSM%vW2*~6~Mu+ko(1PQYCkG-YP!g}@4A*0_=66*1UF_fum+WtlI^xE+R6HT#* zf0FCU!%3m0Oc^`M7>Ri65+m&aM~{?dQhzWTD}&6!lWyO7R_`=Ks#VEv>GZk& zKV9)Vz>@&ByL$E58(2XKw|-F2f#LWd;E% z7&dxmEMiP>^8ZAeq4(C1sz_(3Bo==$JPK$%_i3swmA&VdiI^jh5FMe*)%;vnYrk|( zM-}OzlijSG5Q!AxcLtaF0M~!*r zBa0f8D!a9x0aRTj<8=CdyCg!d`v%`X*Ckmw!Otr6eiuQ*=68ZZkeSkv;d71=;mog4 zC6!60-$QamfqoC%Io;ome-m?lZ>%m;JUsN$$(7HEJaL4C*Pb=i)9E6&_a^tf>w;ww z_32MKUm##FJPxkpoG|H90K;p@fmV6f^mGxV|3*}2C?^7D)_r8 z-Eh8gwwJ}g0$uWkzr9nOqlBgScS>%;O=y-{Zqax822yQ{oV>!XoH0A5Dxb6<;4Or1 z6K`xz+bzrqPWWms4Qac{L)uApDg8#ib{6{)-UO;B zC#RabE!H5To29v&`fsxN)Vj8x)rB&*Q!`M}3^DEXdzXXbgem{ul-)CKxy|hAzCL_5 zO1`rY^S{oa+5aC(u^Rax5OUk1^X1jnN=py>aYx;!Mt-lBiudQcVkHfkEf0inc)Lj!lv%= zJ1g1&Fxv!goXw-&x$}vxK0#M|a4R@)1L0sl5&+=#k~Ua03dEMhRa2!o@uKkkt>%6k zr0A-3ogI6XfC?Q)8@MG!s3|qO^Lr#Kr+V8qy1NX#F|;4nL(8Tx$37#QiBK4*HQ_aJ z3#yW3QdoPwgQFCan9n?JZ*2NJelqB9WnEAM3}*GxQO-AwY(;3O;sXc+Y-W(xY%b3B z^^`{Ef+?xh!B2q{LR|5zg+efhiQ#=fEniHsuOfZk@kd1vh29N}tn(hCD1@oK_$*T_ z{89L{qN`3nFQkOa?g?Q%X2jP<+4p=4<%&y^f}+%5Ii{cbt5|G#xesuj3t#|b%;I@d zBCT`ZFuz+$#1lj~WN+O}es<5&;q-Y(E{8>tt!yAtPrp*6Tiy%bOEzp6@`w`*07a0u zNnrv3&w>6)P6I=zVlR!ESyXbeCzxkdJkFcb@PDEp;bY7a_xR~@?K_-)YDtoeP?-n445LY0$0-0S#2>{wm8r0!2N*xLafa~xlZ>-l9pRbvc&v3h zA=zZc@REu17sc1IG>)Ics`BB|HK`UnL8G2GP=1Og1Px+qDZWCo=}YE~K)quKg){_@ z*jWh5P8K;-MD|smP1Jk)e~_e{3}$C6QEH5Ug)4?K4N2LSbD@*{zOnvpL^%{O=3W-d zoq+@9O_PmBEYEVR7G)V793MDmg{Z>OcJw-Hw9J$8EzXzoUi*K03oK}E1ySkRAy4jQ zmewt2Wjn@tEb=lw@}cx{lBdsqyk(J&!(4ZAe6MGC+N+|B@}hYngC{!mA)t*siMdCE^M2w)Rx$6YYg(>3MD1)3 zQIokuI?3S!YZ~Ge{)Ia|k-#t1@;Yi8+5WH>zI$6}hREhzLy-E5`OyL1Om~Zq*x6>; zYYoA~_RU`PFTvWc6Vy{}0s z6!ef=MT+#c=gfQbOvy|7$Syi}Rp}E?kalJ!g2PvlL3|Os4fQd)gTrT+hn}28`Ui^V ze6~2phaAaHyiptddPB+RkEQ5pC z0%0|yg~7}67SEe5Q%yEd=wjH)m#vBqLkHh_TnDXEaa|*J*9?mM`-(=BcPR7_$!)he zT&rmEJ_|2*-@yMuYo*MuJvW~5(CZV8A}V?D}oRq-noRb@f@;v7j3RKkw> zHT?Q)1m&V#8gZK#|@eE2scSw{N3L6*Q{Kl4>kXD_;PX%p7lUuM3 zKLSVCd8clB5@Xg3DKB!yStsyg90kf{FtTw>Hy1{^x%c~3_%kB9HRDf;K~ofYD5;uJ zbD+r|VVWYTkS<%0X-k=QmXL)$Rcpo-=jafy4pWblXp{z^8epx(lW>WJ=fG>d49F?H zDq$;h#t1=UoC8)R`EEjpIw3$641FUs<*7PRF!~5baJM<2ckyK+b!H--T-8#Nu+2?X&Ofh%keJ>wT0)st^nz5 z9TxQ=QNw=jJXtSXPBHr+wck_lCFwPUDzl+1HkG8T?#ZgvKHx9YW!fPHyhkSrGs5m zL|O0W2-rv*jx5q>tPk);{*-1@3i{a=xR@?95c}-v*?$nnauy2jc<8?k$@|0&{3HCI z7a9F4w8h?r)33yNju84GXjL|{_ZO&5>zX<}syLptTKj8h9_yuo6JC1hW+e_-s?Q`zbI9G59}f5LCFN?w7-RH0LaR437V zVW%M){%2ZnX#li+)&CMG9y!LBX?@gaKg`pdMg~Xmu>|$+D`)W5pHWJ%$@PV|NQVT3 zyiPX7MA4ruthilHHDQ_!5I8mIKEL&NU310_ipM2QHyyJUa}}uC(xpZfMUgP;oCvGv zZwH&2GteE$yxm`%2=`*0daZ1v?nHk|{ffw#@e3ex)Ab$t#1i8=&+Ko73^^k&u>eWVjiB5jZC6jb@RCtiX$BD)$$+6N+!=svczo_v z^?>aMpPD)>+e4`}8`N)P*_2Zhm|zk{PJ$$W=<~^Mv2fVh=cGV$=}QIRx$1TWaYDrT zPU*RlhveU%ML&zw>mg3YZ@7nFX+sMoZm5qr`(LkNYQHO2u&19FhF@1V-40W_TZNpNM%(LsmoN5upUUrrkJs4!mV zCmNZ>RbdRiG-0UxbEfqF*7N64X*J28q+-$y<7N7L(!z1Loi;O2J*mUKmuwtZju&(y zoCqAc{-Rez7fSuK>X+e}43yAG#01cr^!!G|bb`kX#XjIjI#{7s>aRgE?{A9D) znv=ZflXC{@XXeu0(uRs^&3QL~P6tvGu?MJ{ zhFQJ&qN9ou4r$UP_7hpA0~H4=Ft3oM*9(fda-$Q0T4Af8_Q27{?5o)LiYVky9Jo|D zXX5_v`(Ix=gS8Y~-su1A17CT?)~E1XUq;||7TMlx+h!QTeV*2d=0k+Ws1h}CCap)d zw#s#K-Y@e|J`4uZF+QlB!{p`3cCi09w7&zli?*l>!-l-b17zNT!|C5kgJD_G)rnkD z+MVc2^ILqlJZ9(%yliXr|CIyH0Iyxb8hctJ4>Y{TVZx!n$E5eH@Mxy!vSKkwBr&>O zOc>7e_*uLEM_emS*fc{c5RTSmH)PxrJXC*UtfT_Pt8BGPaiG24AId`KZuSXTOasdW zD9|N*D%#N2!~FC7oH=1!@+w7?tB=M}rB9^~WI*n4w=JE~Crz*2 zH<~WPwEjYJO$>;8Y^u%Tbar=51hQ2`Ac5D( z3MxS}J{-QXz7#lHnyAe8r@TkKS414T7fI(uVto8oZ*VP-+s&66b*N)qD`o13eSFvI zRg=EVA|@ETnqzi5j<>WY(KOD+5_$TfDs2ho5DxY8PH6f0nKsp8XihXdXofM;hy>Kg z-Wf3Fi+|ceiQG*3N+o3SF~mj7d(J?u$Tz3^ChI>csg6mF6vKz6-G>2@P8Xb^$z9D{id}3YrCG@-H58*ebB(c05?R#!9T0~t%PnD{9z3nnCb;dpxCN8-hqsb zQm;l9;Mr^5g90zqwWl#kQ*}eo!FXn`HW%t;f?V<_0SBqP3IpKBT654=i%mOaRJruNfBrzH2C(Ggf=SHMzN zPGqy@pSUt0>%2z=7#ZvDC1`S&%7f25`E^B|;|q+`2Z=6X&JaR_5en<}w2P{f{9fKT z2~yL#{od9eKDPyGG4W+$D;%($4fLiPg~INRjld1Bz8Rv^oArn~mHWZVq1WtYW+%#o zUZ;WgIl^{?V7_|wX>fq@OZr=cAY7HXiW5kt{KPbjtLt~N8Q@so>ku_vYoo<*#&Q_s zIg6YmWGY(+zd7Xmm)EGk)U6H$e6Vty8Q<#9Pvy6o7yl%Azut+!dYRG0inD2w^CF<% z;;lF8GDt;PJMFXI@;)I3V|GJlIQ|v|F}XTZR<`-O^`hIpbo|JC67cdaqH zBO`#US^vKC_w|Vq)VgRWlneoTk#OB>p(9Vld*?|lc@&$C`<+jX`_YeY-coqWBfGkl z`2VOKJ8-M&Q2n4i+hFV0?G$S$U6bs-zGCw{G{qpJDn<#<6qJiOt?hr~Hx6)8ZZ+bo zY>c))n^nc>Yn2t0^&k>6(yh|A%N6pSllgSewHa4tl{w7{X8q~wbN$z_@yJ(zS|i-~ z3g+rqy9^>ciQY%EKm2fXDJ=IAa?ux!k!c6Dg&Au#P&9ePKNeIuMYBRnWFPO|&Yhbb|F))lwxV3PU6{2m>@BD|f2lQM9%?(b z?hOMC862AQAx!2h1>{}4yBONhqq33G<|CyZO0_P%(@QhIpb=s(+Pw;PAp~#`v4}_b z!wPsE^HfwYkrft)#Q>o3k7Z8RQq>pL@y8tJG}SQ-x%D#utg16LG-%v8=WOC0@_8OZ z&cWN8`Kz%KdUEx#_%jU7dW3Fv+f;8wz`D1(8*e@D5>Wd~t?#(!#mGudZf&j3lAO)R zgrVq(OR5VVO5;V!ez?m{qL@%Y>EgB-ukf+gx8+GeM*7n-Y1kM&COcP;o;mpop1=x@K zO5xx=224StV{RhmR_eJx2dt%-`wt`*XO#(I8 z&@c7y;x&H8nL4<4dE@Ia{O>SL)jhMmww-)FdwzZdY554%hz)h0wrjX;bR!<#;eqlz z-A~849&)tBEBE--pfSc4a{?_qbl9kLutU>UJI(sc+Yn+=ZgGc06oZ2qZqHB%0f;Eb zH|1=jd5M#qdWYK9Rz2O<=zEE&ki~B=(+4SdTUzNplcw5y>WdV4MNamVw7*hi#DZ0m?6|BARH19I3)Lg>-2@tfjF)DCR(<=lX5)H68&RZ zqa%^Y-|$#hw+A9_ESFe&et{-WsggWA(CTq1t`e5yE2FYPk{p@cA|=(8^RNA+C3P>` zoeCY;)6YBQ-_d@RKyuS5ZL@`>WDRl^34&;9Zizf?*#wlPPT%ILmag}^)@_Ae->>d5 z!zh^WUkoVnvta`)1GlBe-FEqN(Gs~fELl9NfR>hZy(XSmyl=DqS@_X$begIU%T`Wt z)bwBbCp^QyBde-1>BIK7oE@!ay!hy4Nc&9(Mc7gr+=Wvn^0QHe{Ep4YjvewM?#wpu zX8(+DMHRS|(3;8T!^wd~US%XG%S`qKp=^LCKKbtV6rv;0Q>Tg7mznySEY{ro&U4YV zI+UGr)(iT1pGzv!25_9#-TH+(n1rGvW`FOaXWa`%+BHif;9zN#VPX9RX429p_WCbJcayE7v#Y z=woEe;PD|DV#LU9>aL^eK2tTVw}};xt1$_piVa{LrTE4K&8C4TyXPV!k@jR>ld-y> zPA9E@?{Na;hoDGiP{4)X%S$$-bhfAJE~ZskZ#H7DSaJJomjTm8Mz*lOS$|{Hmh+Qfoq5U ztQ;eG=4myMm{1XlW;LeJ}h_frHrY|r#6fYzUw(JWj;A{AChbUfqIC&pM zoK4}j`Y(0&_`NcY%W}MsK~i) z=f|c>20)XRiehDtTb%Rh^*bYl>@7Pt#HUU|YP0=#@t&_DCAmgX%-z%_=9zG=FtjzrwjGUPWfyNT^095WsxX`fx~ADStoJxEPMj1 zxyz4F+vFN8CEyoF=kXQig^Gmgo0=rM432ByD5viz3&}8JqTp72fy#;HC?IYm-D1{% zs%ym3WA7Nd(q3~!{*MdX{zZ_Q`}Fu&$vX}V;@yDdpG1guCKm3{@hg+m_altK0}ECd z1N8Ke)WhBWqfDhr!0%UNGlZXBmfhsi8D@XeGS_QF%S3{8T2qoZ5RNr%>!{0E%>%*eKz5B6-Me(Evxy4SG zNHCl#=G@i-#m#*RUML``|{3W6piyuO11*U_iC_`0pUW?mbA9L1Uh-^@N9;Fv>MtCbmYhbj?HQZvH4_tJme4>4Kd>*?aL8E7fNRu?{tJlMrx2C-3Pi}X{u zn5`V|&9}ut&Mc;bc_bY=93H!nAGEhEVgqMv4tgujKl~csSI^rcnq!jM28?NL>xuE4 z30rbz4w@%AhMH$kC_4%vSCT~^zH*xX=4%W6=}&Ufb;4kL(NEiUQPJw?umi&Zuf88X zxH!mQIFM2E(%YvkqQ!|;;+fIxmUU4IlFmS3jn)3?V=(VG$6-JCyRUP-`o3)6*PZz5 zCG)?UOoMt!gtZ)8E(+OWkIrwoJVplB~1{s^_sJTC9Rm@M8+!kK1ZXA2`xqR9%C}8bKXQt_^W?I5)DFnos>_8pI7-se8$Il@{YYfNX9S{a` z=$X+ScK4WaGeN}+Q}^`o?$VYF4NzQ8yX^+J)tEg_8&U(;5)e)OJq8+NX znph1ah75n&T50a-KPkm9e5=1yygO=xwpV#S8;jsabNB-P)&|j5qQ)hhLeai8;2S_& z8m1{eza<}qtX~*z|A9{%60?Pm=gB~{XqnHEtt{!JK8sjA(4&>+d$-a%UK&U2I z%r!U>Fg_75dDXRTsAaaVb!h$bOQGeTtQdiSSZiAm@6{h{cY|zzI(!ydHi~-c$FXa9 zu`@hDCb5girlcJOW?LVnn+)!Phf>~Vs74uR^Z3~~8FGZ2(WxgC$*EVOGU0cy>KWi& z6V;+c`#1=o7?%8=-UurB8uT8vpAU2oyP|()4gEzN{sO8qnF0T($w~7y1jHtIaN``5 zWR*f9ACYsxBRIYIvMCd){JE=uWHRI}GnlW;0~NG2!iXS3&T0UcU=PR+bTX&=yDo^J zqUI38MarA8p|8Ng&7%WYsoDr4E-TeZwT8rbrho&AV`X#bd%L9=>wCh^RBU_}`KI{n zq^F8ILZ?v5o5@>)aplvAw2Q6Lf*be!RoY*NHu%$hO?)&n)h)|za4c#jOV({8hl_W= z?bd55yc)5UR1*Cif5_*v#VTF-`-Cvfep9E@8+wbN9GYOUgEE2B+!sM4<|b%^S>d+B zVw?=yY$Gz}HCycw;UK)}Hw-4xBB}n$X@Rgjw3WKEc&;9Ta#H&>SA`kA5<+%L;+9-j zG)%bT1CXhIoJ-s&zWnFe+f3QySv2M^#Gm{~B&`5H*NxWdqPd`jBGE{b`TUj&g`e*2 zMGFUlMHxf>*&R$}+_BoxXv?CID8kQz-tYVyVq8MBd(-`wrl>SC?>pns0;_wex#lKG zx2(a4ACH3n0wD3Z$Q1a)$}%I5I<(=HOum&`KexDKrC4jsKS-LD1uupxKKCrBN>hK zo9=|`ACQ#R&At)z4R0T(-F{+7C9? zCN1AK$j62c7NaGI@xL2l`YS0Hgb`{Q5Dd?thSY?_BpXF-r9!mQ{+%zyk}>( zV;{2<(8%jZtYU_eX;Gnep=h!Fn@2SHd z(=!IX_lHPDf0?fBboruZ&qjgS&~!E>ODe6;HK+zr2j0zs4`)+&YqR-64|3&uCS zn3F|MQL&S}ZLB*zY=p_uYDDwJ^M9y6Z3J*K04>x%IaHQYSA6nN6sEYfl@zosMkBL) zWi}o-d2?(WS&-j>j9QDGZ5y9X?fqJw=BgltSyXN-+^)=FhCuBD4e*y6eLO8J`=?mhNVFPjJ})MVXc^TtDw0zrx6B9W zS$|HQa{%}Qp$HZ&zi~2Orn*f|vqR2qvYV6(!wc7%>?9EI-tA)EmMj|2#mNvqcK*#N zr_B0EVyV5Ni}o|st0!DzrDrpH#Rb+#u0*<@F>-HLEOfwN_{RL*5Ig+}CbWidUK<(W z+Zi=z36b@ykY<@RQ3noUX)&iYs?+7JA|x*~zNy@!22OE0i*X7E8-+zhnS1$p8tkjI z$0S03Or22vJRr~pK22HSGY1Yvc33xOF{jaw>m-O~(RU0y ztyt5DqkmzjMX}hDau73LK!hYO7?Ir4yceT?Fl>??#fWt?^KbP=QuA5q51& zxI7*vE^{qE6I7@b|5t}CXlBMZ;=4#=10~EA;6xa4*d|Zk=C${=86cB6op@(#sbWofeT-kWhT8-bG6a=OC)z zQU2b#-a@aHdolHh3vxuQ!gPkgQ;ah^@@T?@;<+8xFxlQuiO4RXh~c__Z3A;5Q_Pwv zYcd)DFR$OA?BN6+p_!OQwt~Yf*@KXbwy^oQZ58;Wk}@@q9) z5nsTu zQtSYB$n-Ow6Mr%RiKzT%w1=OL9aKA!hGrllpn%IU6w%W>_f>kzKOi@O5CcgDLHb_* zYU6P&{X|CKjc;Gw>_mbV7)GAqk@bDIEA+CrU_|PuK|Z%XAovT2mR+usdHmuhar8O* z`%(Kv|8IHx&4-jIFP=d1P>*DeflZ)^?2nZ=xCU@Eqa1IEh z)J|lPe(My7$#}y%=p4`pWt?k_`d25zPX-zp-PW#Vk!VLbpK-nagB6NRyor>;H<1GF zs4||epvSSlu(`d*_k8oMZ0C+K^QEKMiCNB+#&&CeR&yU4;|^o@%%md0pQtJ?pA`vr zOFwY&mw49~7Qw{|F$%?~z$$!cVPhG4PeN2g;NfR5__Y4EYW9&|?P6)6R54b`VHFsG z@aGw?rUTxfGIIuV5vvw2zKD1jd*Xef(sxcz5wV`(pUbGiGA@5+?qK9|oL>?canSxT zX>ArPijaD+81D+Tsu=Msf@mVoG6q`rcNyxIXca49{>|3gVAn+ZiYb*xhLGpWy@vJt zUb*J8P)WR4cT~F~Xn$pOFh2DniH7q(i-~$nCtMZ5U5pYG*miqQlM5mSwo|;WY0)Oc zX!`oiyNsp^`_c|FV&Splp@m_j@j5BFjJvfOb6|mNQcSfQI7Vnn`<%@qs#cHPo_-;#Q>tmIt_u4BpGd)4Q zP(}hl)ui09IAP;BJJfc6;rP*0ud&UxUfc)*s+_ehRV?j=v*6VT>EeF|Yn% zojHPx>NOrVJLgICA5t$Nvk0t_--|!wn{5WyL(bcJUIuj=(8Oc@VJ3(IIRiosA_E{p zCpmgYC4By(i<*BfqXQeriR&Wz)iDbR$nZq89{^+5c5>Fa4kEz+vC+Ho#pE(qC)3I`u@3dUNSXBc?PxA{I2P*+1se0C@1Zxi{KGyJYG?0!z zBI}_1Ory7!B3YTPUwB>Z)+5MYP=1qMaT-=nJSC=(Jr-C~AH;k1EY*W=Y>m@g!ioO` zLKwW?8|<_SzXIA=;y6#T>{|&N3HWFVTDzez#c;4dg(LGDcNNTR_D`lv{i!G>uYEnA zV79^uX=!&XnC?Tc2PL_tiM)N#88U}AaWsW!6G!Yu$0xqO#fLw@R1{)l`fd@RR-kye z6>|o0=cvqPJV&Ij?n5lSl}F6YdeQedk@JX|ou2l6*;1)kv2(cPXCx63Si-`zshW;V z9?hKgk1K2=!FufHInkOFR9h#A#n>5vTXYh5ZktU%c@CUUcFa{NVW0*8lz|DpK^54W zv!{qcubX12M_(HRHn6F!j)cTAJK>}7I@?R)Sa&jE>m@2@P8zEzFE1+^+o8EHmZBx5 zur(gSNSa2uL-v}dyT+=-x;=$U{ahDM*d&t@Snf$?`SMW+;+krx72rFjxb`&JLZiEd zc15!#`^`c!PguL8b8Zq?$G1KXmI*W4f58uoE9}fRM>e%pg%$XcMXASQZ1P}8I_O<^ zh-i&r>5kBT{T=#xZWLt|8o3&jY!&ZAGZD-Hscqq0tFLj=5*7K&L61qhg(bf6$4?ZtgTMZY<0D{wPT98NP;9J38 zikl;$Zz=)+gC{F8*~;WHYwb7b>bX{>j>5ENTm>2To2tJS=~@G0hF=7;t_? zdyMzZ6tOy=6FfUR<1j1t{){l!UyN>**Jd!>TvE<<3bb9bKH0u^*)(g1->G_-<33A2 zP1%%?pY$t$>Uq;$n&;{W_$~uyo3MSXuuKtONynbMZl6lB2WSrtWI+n7JA2wfe|2T8 z-@@Q-JKf9aEwmN>9=|4dlSthDah<5RTK9V}eT-x1?a!qTm_Aq=*$#t|R9clLS19rQ zqXcmEwf~3U`g1D_|6%{JN2(d)3z(4Sx?#niaYd=Onu5txTgKRilktuoQA8g=aPIaO zhExlTc~fM50ivj3r1ZW+EUAa+!~H6KLV(;=03N?BvUOO*Ff>)EkbV z`{Rm5cGtnbCnN?Bz_TdA8t`ysIOv&F9ksJ?(Tw0# z*grM;0yh&xy|fLbz@ojqKe|C*KENkz`Oa$K9dn-0Mm)7zcaUfB=wxf!`JN5c9o|i`t8w&0Xcq;dyucK&* z(?}R@#wmD3z2H;-1>6@zrt)(~_od;=s6zoT{WGAVTJCQ0&uz*M4UxQhKT4 zA0%tR-9h!4X_U6&391;9^aL`xx2$3%Z1;jQ$AiS_q+8}ehS+t z2gaK{a*`}Y&&Zqx)EjRKanI1VX4R5Ol>X`~#$Be%lI8hL2St zt9a-Q>WN}LIW4A7o7>*DhVp^itWIhO1yVE1MUtY3jxv<}`lu7v#gKq5F|~msQp5_4 zcVIDbMme~#zm&J zS(2A(nraoZHQDjDC?FI}Wg!Okvnb^F^l#ND&Vk^e8Gco0V5xMw1f-XWk}?d}rcCTS zF3lV|y@t#yakEgC^zG%x2>a=g?akuebw7tL;#q@lB5*3CjJ8PL-JrwBbOYh|*$xK~)*h9yY{G%i+3W-7r zDw;yg=x9rL&b@GBLJwEi680jbbj=dn?w7}29B7lrdkn>G6N7iYYxscVeFtfw9x-vw zR(xRG5GSwtUqJeORTrf1gWg52D7dP~mUY7@Seyh_#GJ|HCBNTB@NM=i^h+up$*7dk zrAvjZKouo_T){bQ&WyD&fVboF%zkT%I$NCCDpg47$IPsy6uwCHG@Xm+2f8lB44dqC zjbs)Az_O_$awCxd=wbaz-6k3`Tr}{K2se55s*dlkOJ~h$7ZU4W>45Pb`c>B#TtQHA zYMtwcC1=S2^A^arnW#^Jq#KEC~6tb*Kdt<1KU7Sqv$x)MKXCJg0J0_Hr?B{fh~9 zR&sdz`IBmLw2eR5n^!E%MFr`Om98HM4y#7J&}t7ROwPM`f0-YE?t!nqv4f1-%HtL? zQiC;jnVPg3cJSYGY0#O0}?Y&Rwj1TO~0%2$Lv*XZ@HHXJzkc08E5 zT_W(*kBf!w*Z6&J`(QuT`t!ZU+^5CFqD)F^x_(y^bT7JWxN!zLmUB5^n0Ig^ko|f^ z#TF#?@;j_6O1Rr>a*xz_!W)mCFQ#|0U0LbU7oh3zA1K2+A2 zMU?nV3hQp>l|Gtym3XfQ zrmCgFdr!DPpI!U=IZvw%KAt<8&jRK##*B%JPC+On=%#Q-QU4wqdhdsMD2ms^%;k1` zjzElFK`>FYbP#4MbAnY%`dxi zAgVdUjHo^e%bjp`?CZmvXM&XqnY%t^KRO4`M!&eEGCAR@ac?I*{IO1B_unp?jd7=hcb4`;|U%YUT0aG3mX=mS)D#>dSbb`LDf_< zI8tU3%c-yQ{F_h}b)jr7>>#KX)(Zv(Ik2m?ADP zT7kFbQ{%b7{VyJJk-g=26)`xtN%DK+zJpN#egLfxnvslUzcTd{Yg4&h>_z=Skj#pz zdU&dWq!T88dDB0C#`Qm~_@DkL?YkkmA^lW{&Wz)H)4Ztyzls`}NI2boPyLurx|n%S z5C%9JjSWPTqG~12FWtn$vwDaI(FZRo4pDu)_h{R`*M&!W=G@t~hO?=N`$jAqfzv|X9yel_8h6vJ|vhD2XLjIvFGZeWUHAHMNxc-s=^MzJi8b~(AY-ddFRv`Z)5D3L@0n&Yt4tnl^Dc;ZMzg~trAKp@A)L{W1^LWeNMuHSZKBn$g;b~{uApSy7wV6R4oQo)dwW~Dx=Mg_AzoUcTKP1oUy zlnO3Jc&F81GeRATPVBGc$L}QQ5tV81@(DJXe~(Q!?;SI9+Jej`v&ER@wxwYHm13GL zbyi}n)(Wu(?YFNO%D*?{Nbu7d9BXSFya;}&n~%|AH0H+42QaVJP*Xly#Hgc3Vm=ku z$j~-DVgkF1sU-Jt7NJo}Q1)td%#MQ&Yvy@H@Dj8RcYEx-;WuBlW8=Ap;zKyrSpiS^ z{#K%rmAWR-k}|~jf?M2|-td*{Ji#>QBv^jaNtvzb?qi(_l5L?}nWWbaTj z-99UBiq`PiL*y-m!JIT=@4M1a0!{~)n!wMy?O0=`$2iWRX=52?G;izAFVEw&iw-~s z|DcLuas}1)sPxKgh%(oX8$k2!cV?+1COjYsKDWk)DK5fsI#q@7Uz`VbLLkN%2~4}P z>`Kbk_FXcs*BSVp#E4m922Y%t`A40aQ`R}pE}LNWMj3s3?P-z8rU1P&t+v40Im6C* zl`o^-YeGgTDdYI)F!{931j-jLl^6d-*NH~?IP|2{?t0=Qm=X`sXt2C9M2cQJEQ0{2#CN(8T)p6iqIdzxdL0<8X}BM#Q-nPLP( zR|UgMfPwu99?{43n?kFJdXWxO(EF{{W;~FXM$*7n2BS z_t#+Pj^PraIx1%}&mQ9$#tYi2{g&mhs|dH%#Yj8aA4p z8xTPb2%O-Ole{d+lbD;w^QSSk0(dxIMd-_;gd7^EEhb3nxcduUZ{fuoM_kgl4FiWP1g}#{ z?E8yD5WQ&7#h&Dv+mLQ|BOzQfVAcRLGGV6ZeVmQ{)OPZ|Kj@KEo*vZnJWoS!?|%Fx zo6nixwqp`vwRRE@pXl;SHOG`W20rY9?v=(%RfjZTy$(_}0V)uWMAEOpnGF%XIFR%# z;or-=?0KiU@LNl}UgD%CVz-Wu9q9Q8!j$ZGT8Z^5ca7C4>e*^164c9FG#@;|S`spK ziJ>mU!1?Eah{ZJgAkT{UQkRCu{9i9D;JE}OGh!jOe0Oke3A{?B#UN7bxEfz0(smVJ z)Lb7iV=%U9K6wSno(k;|Q&7B;>3+xpk1dZIV=Uo1LZDzwtgboyl=k8nC#&&h6{Q&d*8pN-(LXYQ{INb@za`4qjoyKzM zrJgu7mj0+Gkw|W1Hci_T%xx$Y<)ND6ukt#7B^-H3IKEXQ5A)r{2chUFBE5}J3rAz7 zRFQ;hOYdqP=zk20=2Z+T7pIr53~#Z%@s4F3@9A}0F>e7v(RR2m-0%k-Yu_*-FE|t{ z)vL8n%Fc`7qx+0oU&ixuSjS~!3J^-oqLMp(-?Y{~ldJNvIpPNyosPV0<+>->O|f$S z;*2{Awl&RKA!Vc^Z?>a#t%}ksU1v9Sd~YJ;Bbizm#-RO5{inkF-#Dx!IWN8Hag#^f z*~8+?+&@SyZpjxrttQxCWe=eq5JQK%oe4#2P&~Ctqk4fCM?^0Y{=gKb-T7q;f()V1 z<4E@AS&M=c+%yQN&hqK)6)l)SLZPCs`D_7%5MIjovy)mTZI%*2$t2Ux6YEVQT_eTy z@CDGo67V0yWB%5!rmLf}$kB%g4#Do)(YXi|f)wNl71ShAkr=OLTiF1XcX=%?+CUW82L4rT_E! zOv&di{o`!kMnN{_Fjxsnfk1LX^4os2TF6$NERi2O1|C~y;7AY!CnXgqB0_;gKVh}Q=w?^bmw zvbZAj(6karlrRlZSe{Ac5`HVs+mQ3f5Heb-$co8qyET!7ZYps3zh_kMz}-;)J75Ex zq>m?9&~`^!dRKI>sSRe7juXuP(+M}MLFV|&%B^nXGJLT49MhnM+V={2&vb}R$+wpI zi_$f-CZ`y}^qG}^bm)VAsDimpBCkcZe%(h+{c!Oa3C-Mb{&|hs;dEi?QO)9;^ZvmZ z>-+E#YkkxZt5_(*E}I~5^`(pE3%wjOLY)bZbDYGdI=sdDQfeq`G49JQ-yK+bclrvu zM{W!s%wo`*Z+2YZyaj>UxjoUnM`b8dGsVtHDq@(5Ir^itesmAF-uI7IQeQK}GJCt7 zYP2`8oCF!3KQ*(0+%e;rL(!$Beoc<6KeJ3RyCn@TkRuP@xXo-mA4eo0^Rj5_twyR{5T8})u}=91v2m6_NNH7_0S zG7~)sD2`7Stl~&5wtl~jlRhAsndMmAUo(IZ2NkQ8F^SJ#8a--aem-ej^h`=d(b%LY zF5bIWpV)mjC1~PYZ>!)o04Fp(DFIy zSRwg0n90(*Ge7Qstp=;4atZ+0AthHt&yvAwPhvN;MmM6JdglE)BW3m=!4?dImK6cD z4pV;v&hLqkh7BU#Y-S_5c1B-$J8wEJcda%*{hPDYx_;i%4eX5$md(#JE&|IK#=Tb# z3&#~Uun{wLca7a1!P<6C?ItpLu;On@wSA{NCVyN;5m9g`@XnXtF8M$kl^t}>bQ?VU zpvg|~%4?$yd3Dx-5k;<@AiUGaIBt{w#L8GOE#*(d<}`;4&O^4Jj_06MRefvMm8Q3Q zoR=V)v6F9X$<#@e$hyVS9D52@CtEQnoSBl-J&-nzecPrX$Z2SSJ9szaGR83LV2 z6b~A70$C_HSKQy^ScE53rW>-x&m_f1mpoyj@8f1>Q~}?UjG&^L6-fE-^>c{PFk>e9Rg)Y_!r^anR+{J7 zKnZw&YspyHUBTD(AsqxSJbCM~hiy!h2t19-0+WRvJ{O*yGxSLQGy%j7j{WhMYQ$ko zkqj*dpOl7;8Emu~q`;me$}c=J1@Roo3h76xH%{p!6b;BN+a`!jhmn;Sm?BhoPOk?c zN)44yu8dO2(K##ze`l!@Y098f?wRAunkx4^di4WT)wQFlkm@G!kmoVW4Dr=iUuehn z%eGD>mlbSB`$uUF7ozW~FK5e8pbt+b1Q^2cKtYV2A-$KfiWi4>B%^{;r)Cl}9OLj7 zns)&#HlBK&d!b6)O&3r7oXj2f%nrLtYR*}s_ozbYS9_Z+Z`#7|qZ+=xz=bYn0l8OE z&_D{oOX~l!0DAF7+1NqBhxbS(?K%EG98=vD-`!6TBQ_F|7^*&n7$fD!(Y@;X5bE^& zvYIv`7@2)Jr!B)Da4>0Lp`z5WF_BJV=!?|@J@lF6O7v7s<*C{e)?HfoV@nhX`jRIT zAX0u)b!L9$U3whtR40orBbTN`*MktshD&9R>(+!afg+QC}&ysDSVZX3z`O-1X z=fxGe@BoVJ8>yCW_xP+pwtzD-umd$>7~;|0Ang##oG>*dicieq>Rt?vVf`cDFB|A% zAa}`#G&i^*=XkbK|9$aNI0$a+d~Pji-&1m0Y!QPNK-Bxq1yYXo^1x6T-JmgJyUXj& zm}hRJ|Js8VAwGl^qpkAiX;=$!hdw~QP7o0b?i`k2qaBji<8XaRQR=$fYGK~%`8_oP z3RYxXfO0*0{}`+K?P6r^X(^>Mpb$K(cj~CA3e6CG>S8E=I$VENe_i_ye_hGlQ3Jy; za3^v1fIO!ra!rZ>S=*`IWMH|vN-UK?d`(zVt`Ydi(IkINwqRHsPWsPP1{R)>w>Y@;gruC9 zlpHv3WdKQZ@p;qt)Kh~gy;F>{NEu8Uilr0V-2gZOo#F(jBK^8d)*ryI6S5kgeY?Ow zwOJ2%y=W&~h3V!hmN$$aZKd$8`Dp*WV~4gd?JRT zU2ZlWT=<0=YOD=&uerWD=r+aF{_TuIFMHh)+jwTCU$7cDv6{?e9lXr8b_)oUp$sV{&j@w3-Jiup05A`j(LjSh} zkK^5FoD6Q8-bzCL8sH*Pl+8wjlJ_=JbNf8VtZHieum4}~@mnM!I^e~jXw@C}MNt98 z826sci|1wQ>m!=o#`U&Mc2jjWr|n&~3e5kWd$9gH_pI%3z089vD3T(YwznnRMi$!# zkyApz!{Z^}n~afx$hO}MxHl03bYF6;iDumQ;1m#&M1^lPEwF;tL`G$r7(Y4m&C{gi z(_31|3wmB=L3oy(9}`D%*HU*&vheoMpwUfA;o6aA&X}3SB#9s0j<~?tR$f`HLRdC< zOleEpcsZUwrKRC{+CpamG07<&$wz66S^cpq(v>`I#>2Iy1xwuJ^^mT@T^Kya0nS$5 z#QSqFxg53G@?KezBdjV`1%-vb#Xgl0@=@pM;gUP_~Wd82ZV5MmYN$y7rlFG7A>l(*0<|0|S; zR2F6Q?p_xjL%i(;+BwSD3>l0+l^A1>1rKQHkoZx0-4ny=toJw0e<|a!FM`CHkoSTb zbOL0z_+cJS8j&88pp{-qnEec$(Z-|ym0r}B(Ck61#jI9q9r|7UAcs=Tv7+vA>E(#{>|eJzJ?b|4M=#H$ zo(fl$tr~GOm~=%Vtn2Lz+U;yD3Ic>=^8E(Azg0?OH~CiizJ8_34Q+3DD3iYpiKo_? zUdZio9cr>cGdQlW_(Vhpl;!ztdRAwO*avw<_i`_0$2Kie>z}n@LPW}K@HwOs~+fvp5bzTWw(Hs3i%r;0>HMoKnkZdvCTg34d(GR?(&v;I3hUlC>AeHe-3wt=)5#8tJ zEXqctFwAc8Nt8cw2J$FNY)akx(}I##jSwU{;ute_d&|S?O;p0H(|ruYb})3o%A&xY zt7M7zVH&I|?s&!sETY9_MoY~SOpvX)B3fU8)JTTPaLN#Ob6`FO6)O;%Xl z&0TYkwJFwc>+tPbfZ1dR%&W`VTlqyBxKvRsn?9o_+CarO2FY`|_lA|j3{kQcV*X`R zVjEGTWUJ(pU9&nV{-uliU1WZOd;)3D#~K$y;(;>HK{h*xGyxwZrE8^%@Ap^^*kxiV zj^fpLEt7xTo7cs4{$jvnR^JkS!(#7n^-3q=%xF4PJz0kprwXF+%ue#HE6iElU_kzp z1#>uzU+kx9-WPDBSS4*O&8}<@7TD*$9b?C$oZV zuCG+S=FmcDGHHH%Z$G#tJ5_OU@$#+#t(mT;h2SQxaVH_%pKOZR)P9d@uqpb@Fh~Fy z1-hRK$1-7CsER^pg}4vG{Uq$X4AJkw7|C`xw%DS z5Lj379UE|+WPVGLX=X-&Lc}ymh!bYPWOL-8HF`5w@3Ni9-#erIaIZA&t(lU;qDQp zHA0de6PQ40+EF2Yy+rIGLF2cdm~wcf4}S0oL9}R)4By{&dPpG0B;)gDPIq1ySFe0d z#7zZh5wUNvp#Tfx1F$8@MEf;`a zUn0e#K>6jNrXcWdcVaQOUGT|=k@LxIl)`88P%$TuNA%}}e%A_NDI_XnwIs`!;uK5i zN=b$;&JXeUwF)iipVj@_$f~E2D5QUg}j-e|N;IkN%Y=BB!4R(@u#gvvYSIQ}`tX$!zNE zitoG`RyevtAW9r$c^HP&MfilHHe=7bZ7Rd5g;r^+k@i!ga!!@555;7p7{|*=fh&Yk zeJr4U)H#F_&#_xk9d0DYE3hLJP&o#oEjgP3j7X#4{Gnz(r4>}4mc?Xv#Ji|N>z|te z>O>y*OjWoMgB;=tq!C3D6-EeX$Z_DA^=|?`5)K*RGPnh+FOMcZO}%cnhctj6K0;X< z5Vs}1R|(V~CqQ40MwZ726s_6VNHV4uiwhYktqtK2K~F-M-^{7?FCA6^`e_6R(+ULS zmIjqqGpp5*i<|Cf)qt{@-CAQ248HGut|IcsF5oG1-!_QIJNVU4xY7C4pB`~+d@%kH zDJ7FiyKiRz2WiUPgc(`dB9Q_#>~1GN{bxm3vh&ST%HxDxEj1KZf|fG|Zri^an(z?| zP%)r}b9B?Se|Hs!P$yL`-j5#|P)yJ6uwEyNvp43^W?C5K42-CJGF%F(bf1bqTJDV& z3nUlY^%#^2L|z|KH(I=KEQ-0yzf<3f*PV#e=in2sKr8+`?bayHOC@h&OG#7Ng24lY z)WE_~)Uf4938qZw7|7djKC36o73(F>#S`wL)uwaiQ$qxN7Fp-L@ut7>fkY8sn<;K1 zAhe}*N9*Gnc|4BztTWbN^rP0zv(+F6XCF8R&;FTP9ti11i&OYuV!tAO?9Js2jtp=m z9N@G%N=qpZW3q%tY2Nm6GB7KMJhhJBON4}ZBYhWxm!40R(T!z1>Xm6F{hWdL6D^L< zwcXWrc#cyC$9$6&GOoYgRqgz1cLqYu(Ln57GB)Op6zdJa_bonlyMGO1_8^soo>oN+ z7Y}+3_q?msv^rG*ksZ`pK>IccmN(R461P7R$?qZV6jDFpHNW^-IjuAtfDEgU3VwoE zwz(o|RE5pAsORRMjt;xR+ylxT#z5N|?=|!3&d7RB+1sDIU1bgiQ3MK5RUMSpR-6u( z+8a?;jpuQyuCh?}T~wCw>cl?}{qtTqbZz8NRLZg}7wKoPl6`M$5ZW<33aq18C$wZS zGc)o&_OuB2z2VHv%xad-wNhS1=p0Qd5TJNT47-xeik35h28_t6ggJU)?JVy;TiNRs zJzqR!oQs-X)}R^B!1@)AU-Zpu`u2Dh7XN`WvGYm%4z| z50@L^y4sBfwaY(D#(EKt3JjQ8JORt>b3cbm#J!@r1YHPxpM#l6EuvL^Z6x)*9&}jJ zNRTwH5&tx={YN?l#KV5cq9n+~%XrOl?K;)Bj;i!BDQN;8KTi(*Kgel05%}Q3dqP@( z@J2vJLNa?+r^e%b@(?+c)A8OLo@ghZa`4yyB@Cv^a7qx3aB6&IelQfwmLU3{l~;ri zk72nd*hmaKs}dq;onMD!QMLKbq0n;IQ`Y61%91zjW)8|BnpkR4E!0a@9kbcV=6RNl za9wpg*o|SRwdIv(TrsR#FA{m+(D42#{poGMNhKwy2jv&GYbuePcxY5wfUYp{S&EYE zrnkY=Zi~+&+zC9)x#abjliIP7%D$^?t9*1_hb=V=A4*7hzsj5P0{qQeCFdt~xoiVGm$F5%}-!$3V9$q{REH;~LNy z97Ca9G;<>|Ldum0s6%qHUa^$RJRk+A)n&m7cpgwNF;5^J*{p$+dX96(Tjh0%QFrMy zea#5#iP5!>cO5j81u#W_Us*b3>9^l48uy@Grcr)wyeAz-o$7*)Lp8xl6tjImF2#x) zjdZn}7+h@qgOl5lL)tmpbya#OkYA(a@dsC$eQkiINb6h{>c^g1PVazTgwMQ4>=w#b z43KEqaSUURnL7<(e4yVnL7*7=z>$!YR)moQR#nR}&Bpy8cdo^HHDYC0WfAP}Xev_N zV0GfT+TG!5fw_wvVc!>@@#oA_qXIiRU^*b|_aK$8)w57QeXKgj(5%jX(*wK*Jgyay z1qSt(veE!oS|K@rcK;iOHA(dDTk^YigmaSK9N{N)AGaNjgoPw?4~+78@U&o zH^^;IN_bFw^CJdg)+FkoRLbHtsj;W_S(W}L0w_LhxC8su9F#W_0eXOXu-gdJ_kLYp znl&fEP^Z-G#bs3qB!DS({zV6=asOzQphXDW)51^Ngt~U_6g{6aw;gZU)+>H8lo@`2mj`D4H&5-7{;zS{C0)VUy69{F z6@BaG?wIN&<&iO4*!eZhEXTvx&gJ0OgpcnW4$I8&tU&G`K_wT;?dt)Gb`x?|f4VHS z2Cp(l7oBiO9tOiWlipVU+N9Ma7v{xOyaj}OjXiUjm1sTp-xUU_NY->#rVX`*+}pU) zlfM$>ikJ%Zx;7h939Kl8G40A$DHv#An!(b&>iH){jY3(p4bNp0XXPxl9TqIE z+28sOgaj(>tXRGJ=49yBdcOL4@1PNR?wt~^Wfpl-&6VTsFMQVELm%PeMcNIv=9v;F#+}q{^*fF{4EmQ4>_oz~QU^Q6 zDtG0yZ()g>s}&iaA>EccChNEVPcaPKaC45c$GK`F^0OA)mAhO8u#X76% zGf^Fs9sQ%zq-_mtoNqoV2@6Em=Cba;U|g-SaQqSv%UU!(J!z+UaS{;VvQoxgbJq!v z8#^z^AwucA^P_AQ9+W^7qm(oF+%mQ!H049l1VA&U6YLsA(KSA7RcsTPv^3cFcnN6i z`ifNwaqs_tQ%kQCy?pwR!3R<0VZVbXpX(1bAwilR3+~Ae#zQaWNpwv2<8;fkbgHAo zg-eqt9=b@OshX(htn>QB2%{FX5IhPYOxHO;3;p`Jq?PL?r*v~=35t1{HQ4<}uqc^R z7;oD{LNRFqBHZ~{qzXTW1AcbTww7woWwD$Wq?s<^!9G!6dNb2pe+3u!a>J)f*%-dl z)LI*|4Qak)zi`3z;B;Os`bJOr9;$r9RD;?xU6@vKreAkfMtcm32F(InMN+Bq50^$? zpUz)5g30+CNfr5^)Mu~yqY#?*R>oWU{6_eyk1vQvJGGc`;inJTVLMrlmMYN%ZV)=( z3x-tWZAEhMvKaC}G6Ouqb;2h&e;>8q+wfYdVZ1UY59H*UeGZx0FV9^KBNXSnY#nl` z%uQvle~p@^Nt(GgTj4xAK9Pi`X8Xl3TvK_+z9cfJP~o`0thoz#pK!$F2*$Pq|F_GQ zo9Kx?xbJZ|^HUmau^%Y4Yt8wS5XiLd*>XP8gdd*!*Qi9OsqHpg79x4E7UF=7+a4*d zEC^Qg_c?!W=H52?sy~&K1=*QQKuk1C_d+y&jpr*C!A2Xlr|Vhw14<&$B}kyNuxSZz(y0M1 z*}Q@Yf9e~Pi$`JmP3Tqa#IB~PTfWmYSE){Fl=OLoN`DWc-V z+b?g0jQzr#<#TfE3SM-1hO3(EY|;8=?w(D&PkZWt9>LS%Km`amI#H1=1w{;MH^y84 zKx|!NOM{%QhD)s$OU1f~EO1Vc>36%1Er}lS1 zZUY2DU?~?9$A&Xpu}}S^i#BsXEz#>e%V|i%;w|i``U&(X!~C-mWw$zRO=h2_DIbJ7 zqTCQgW${|-I5nDDg##pAlifzdHA`_~8pXKjB!|sbZ8(`7=!BO>43U~vTfPl-Xsm_bQ44fMddV`p{ZWIA zhAFRQQ;#rS=9*%RB6#CPun~Fzrwf#oqPz6FIADP9fNz#MNmt95j=rkZW8xB^c z&(%__38aPSc;yI&g#gx+sz=~`Ue_-N7lyIB6Rd?9!4n8o=l%smdwWE3#?W_xtX_}H zyH8i*`ajqf#HM27!o=%gWs)H^692{d3^|DE_7pPXB@gs|Z$Gr@idtNEBHt-zyjX_H z{ymrnN?1f=J%Nuu<=vwh%BlACPJ8rZct#-`#}*QLTb|mknKRo`vKA4ewr?a|MB`lA zLC}$*8X|g;)u;5t;{DCxs+B%OZ_5+W)OB&6S`B!HS&EwrT!l)0NM}nKCe6`ehuPcZ z8|10g#WiNsJ@oDy+K0v4liw}Hq_($wAvjniWT5mWvCT)ozuH~8Bb=$^2({n&!jzTi zy~4t;du|82d2)KYm2KV~$L;BT2*#wg<$oNX`z&;Q zo-GTo_1tYf42~v~>mx%G2eBVWTzqfIt}5|A4CO+k)*>&@SZYRQZu3umJg!Tc3}a(o zs^wyvXAn8!17%EQDCl=j@Fwz>6#iO*5kN_4t3!r3+-o(lvES8a4%Y2iGb$?VkiAea z`m?Fwk8a8KlQJL6TETm>q$d80p9RBcGkE3c`|h2WT8Mfap-fINM+qy&oP@A6W)%W| za47xoA*+zH$LqG$$9}d|tPie~KJTm8@*0*IF~9u_{^pagX|~4>cN3f*kECErlsD}q zJh~V(CezGDdCEi^Wj5351gz)$nnkyudvuTLy_^F0a?>P=Sd$ChDF6ZJA1lc0PD^F$ zjW~^$n9v+lT9Ast;=q0b!$dv|%Q76qacyUAPqOBa%I zi=Pf}_R0ACO0asXhPU>+^T!2IDoX|Tqd`8q^3H@y?JazwFB?gbX5#)ImrnF1&q;9$ zQknCR8dmGHm9Pr@CId@rg8K2eG-_7)j@Dg{P{B*uYOqkOdkNMGUNgR37^M2bu+1?2 z=$QFUJPSxhP7)=H^s^P7u+NChdwz1qHiu?aT`RP`_@7YngLA6W{ZorV1D0~f!f7Q} zKI9bqtrIIPxQj6Vz7)_8Lm@8PSKJ*B9a?QQr~a(FGRsD<#q^@%|Fr+EjcfLI+Z-op z{)v1z`H7TIFNipYFFMw1+wg9O)v2FwHNAF87TQ8T^t^1dPZp*%0Dpb%n_*Y1>2AL1PPiL43f@(>X5@8p8b>Lr++m!r=H23Xbaq zaqva(Z3Arn)^(s*Nh9S%2ws*}E~neCd?C~d;U2#lKyhjCfw zZG+-)_6m{o3H;s5a1VG@IVQE<_G*h3?)n6#X6yghAYks%sKj;D1fakJ+BN;E)}%BK zx$$}bmjx*9Ab_YtUJHeoo)8dUJKw6P^5WgFU$w!(1`*%$v47JdvsT|6<^3ao7Hxq% zmtM3nh?{{dtHyDWCm!$F(|gfR6>;-H8$N*>(U zodSiN4B>{x2p7>jK?P3C;NGxc7zZm0{92%Ja!o#D51|F7^987MhyWukgH?n}Dyuw= z3?B^7KP->X^v5V4S!^t*|pVgULEW<4A}xw`p3C7&_Fiv`EyE=#uq z?_25_o~NL{&30w*_yCA}pK6qhXl?WH2cf=|*|I>YKR%Cf{u9DdD0YkW(YxENCyu3D ze-poKKlSIfS@pi$G;L_%%Cn zDCYfTcHmYjU1G5AQD73diSUm7o;z%8h!$S1HrZj!blib(2w$}7rh*fEpHCwedb~yV z5PC9LG0xG~&e3huvh14r&EISN2BWVP(a)3RBA_6o=qPDIOESWz>ou#7d*k0vE@X7& zcN&0KQe|gML7C-uUU`@qCZqNnu_$0_(A{k}l3K_zCP1Jo2is?d)DGJ{8{V)Be2JCf zs_WU;1y;R0Hr1RGJL`qvukI{M>4Pz+uDhxm;GtdvFU?8?1=21MN`?uU_I++JEz(+x14sMRlVqmG>j{(%1R@@R##UgOKzf=@peS7_&EbQ{KtL<*v5b#j;*+M#TSZ9QK2C z8oT3%q26Dt{L>9(MuD&D16|Y3WRjEQ$!nz1f1pCh!J0J3gzvKZU+~*$`a46!qgZJg3I(!`FG9mp%9Vjd`M&M71Mf_gV@{ zDwp3#BCLm`xG#3T4wyQ{2d&|J5 zC2{@keG2%&!I`FUxtO@TSr)8nhB5^Ky5e+CDosh6G{D8KR>Z5VFTL$rn?|>+@L2Hs z5JG7W@}o2qaJF7%fd*RfW^>KJ`BsO68cYZO7oQlt%mQ>S8ev%qJ}7n-A#_n~&hj!H zSgHzHWf|4}_0}_8U3joI!)g3Z{wFkY_exhBsAIRT{p+;$`1Qso9#?tjR$jJtAWC)z z06=4-v-KFB6^f(SY2jJ6G&~(d;i)%^eQWZ(rIH`0@#}<&R-;!>{kw1$3Z$%}{oQmn zBq{gPW?myB2kw#JKns>3MqBfW%=|OBna6U6j?Ts7F~jm|Izl=Np||0 zjjdM2dD7_6*BE_E>@l?N>xOZX}JO$_=@;3cXVJ7+YNg@pVVe=+oUNj>ZLuMd? zLkt`$v47r)#?Iz-*<4FgW0qmkZ*7dfYx?1i)!0sE8i3>2rDKw(@y(k!*e_K+$=rS% zMh?f{*;`Z!b9Na5HA0!ULYcc``*Cz|fz9s(McvHt+BrpLFMZ(%ZW-X!l5ql=OI?Sk z1YkS2t_B!0O(`#;GH~C@lSJlglUVWPDsBe26q7C8_C_^mh)Q=b2;Y+1PV~(`s)D?m zfb!TC3v(F{=O45F-mfg9`GbLPg{4|$ z$8Z@)?;l?l?b;#bid^MX#ixW&Xg&6Yckvs;eldaMSCTtu>&(>tMg@FUULpT*Q#n&^ ze!#wr`q1#4fIOoXv%?yzhmg7uoDgNmaVed(F?EwK>AM^ zQrF2+U9l!cs!_iYZsJ)>Zhs=etkft&(IUsC`oy`XM)E%-p+RCkf|oWXTg}lCjCnn@ z#OS%mdu?_6Vq*o~D81}Fe#;wqKxzJhy$spcBuR2~x+O%h{BY)f+Tz~~CkJz6vV379 zF!-uFbRYkAxYm2~vA2|n-5`|Y^+h()F-(gLuaB3u3VwEjz^ksdH)6809rdx*bwPrd zjBgjOmwm0OV679v85OFsm&LSjV1v=^{~}N`V-*7cVi=(4BrQqd)$`x^S)Oia6hR z^zU~-kflr`=P$Y<3{VYu#T@spCE-q))*rm>lW|@BP&tG3#z;O}HMT((l4v+Uk7WRd ze-1^MmwvrJ_zWEs=;;c+@LgYOb->tFq1eZRp&57x>lS+DMzagbE4`6$K{I=esR?Oq zC3c8xDq5?x*lpq(v^OfHx85nDg0$6#uwk?N)wkkh`^=^BTJsaPMt=_$oWwpb6KnnABaUfTVi1NL892*CTQG=Mu=e!e_Mo{63K)aS&M;o4J>aO= z%g!8*g|0}&*XWj!Wvy7q{m>T4`)-;9HayDlo*F8L6pcmLr$IZ1Xu6Zx%00^JQrbpW z6g+pkE?qVlB(YHMe<1Q*J8hCwH%4WLhxsAXUh7kda#%}uRBs{$G$VCw25(BTznsiZ z>gsPgY;EatH=RErK5VjGx@U_v`#}2i^S+h7{S)?jx+S)o&|hwblvt9VZPYI`7Mi|S z;Yf}@5t@|Ejd+NKlr&|`!()K2td|?fWzS&`4>kAWUD5GcZ+3V+dLdd74I3k}-$Stz zyWKK0u!b_{h{J+`MWp&GSNV);sHxKT53Ev2J?^laRdiLsMWYl^_5YK8)QZ@N`W56i z_RiW3mJJS7d$cwawqLtv z-n`+VWyQAiJa@AxdkSB2=?25@nE3ugyd&2eixpt-TMmo9ffkA60Xm9vsM^5`BZG?D zsFpKE5(hCc0nu4f1{w(gu|j|o5dT(bDOCHoi<5LMSeaq7+&PHb$k0TkOp9C}hD?{6 zc<=Di%PX(n>gTe;?n{jumfq!Wk@DrKlmazmU_dZQ3vP$MM=#Q_CELZF=~4@+ngX*` zua^k|eL2x0hKAAJ;FXDBr6aZ#R~4hu0MPN-dh7j;-v4hg+1)cat8Df2K29{X@#H7N z`Wz;CqTXaUlL)uo7~f3-TT0p#5Rey_yORGscS&E}dI$}`VJFmlo25%KKOYGK!k zO_BPZuUXCRhl)SmxzaiKYi{87SIi-0@K74w;VwR37O#hml}K`y{IdOgi{&?P$d+d} z@$9LlEE-@+g>mlqm(agGWj$iVWuconZJsMb z8Y`rkT<(oQB4Z%wJ|uqT>ih^K&d15%DAy$VQ*EuQSrbxP;w^pP4WRmq-^`iDKCMnF z!onBHBTs$hx)wMgN@G)O#HD&-&5e)=jRA2E#&TOCd3+wgj?=8c0eTIV&C~oTt{A-G z;p}h44L}I$-$`B@qJ9Y%8!%?@Tsl}zNrEXT@yp%6_O}9one4syyf!0s)m&K(nyC{j z*jc<}IUzY{ppEny(Jl;jN#7Z}0LI&9R7b$V;r@PZ?+^c#ET%Q3TO zS6sm~NO*uPkt*!9&OwRev%Su^n)+)H13YnG3E0u#bz7o@=-EQ==VDGrxVNW7Nki9N zo5?@?1r3>Br!d7M%WLXK-zV|rnikpcZdB>{Ng=wUFjL~*^wZTrnCK+kndGoEvZmQ3G`mpgHfmIhrU76Bv* z^ehQav0X~K&WFf{ijcSegE*k(2uVV~D8fxc?;lMMtJtNH2D;x{hwT~Xq*IW!6+x7y zO{IG*GDt~>y!Jt);VBw{%2w$cX8?1BjES;vI0`R1BbyNcNdDtZ%aMeCylu;^M^E$EbtTz{{E2ma{j7))$Hk2FhT-O zwUsPPW>}S5qi+IOo`kFbL#-C#U?}_1=DAmOc4&o*pNs#XTJh+)EjoTz&w9hT50VzW zLQh%XTU9`Z@g*wzYIGM{AC3nQNREnWx*Iz{8>>L$4^p#mveGihLM6$2xR>{~ghCWS z)(nSp{!V!-Wf#@;oA=Y9!!?lA)zz@zhfk%Zuu{L56X$zANWE-uIGA;ppVIju2Bg%B zJrwD$a+<6mse3raKqrP`LbED!EdRr$W7Gbxr~E$prp$NIk}{R8i!SL9LgZj;3<_vL zYOp`!9a?R7iVAK7{!GX41`4wYo6EF8|3QXIizsH;{EK>;(w9B|{}J^LewBdT+HmdK z*>+8~?IyR=)MVSXCTk{RXH0G~C)>7dbF#jk^PKa3f5ZJ->t5Gd7yenD6T&=gKVSg% z1g9;MZRYyO0ls_NEW6VdF%+aZNeOnds7JQ?dS1EfnF&q+chjez@JdzTP#N4ie>d(F zZK0s5?cD>aT4)Hey!q zFC{sSbLo&o4-I=iiK$l~bq~~CV7>X|t(INF_f(NXucWCiBpd)DT7*^rGM;=x@%_|( z&-8X><)5R7%9x{zpUfW8uCA`%rQMeID-ldu1r6g;#~4KeM2*qtL2#TPx_v>5_m55g z-mI1dVT(><(2RfBBhn9ymzp$5kew!-n{7;v(=Li3Q$u+OyfMkYH7PH|K zb|xoR1vS&hjOKnmYHCicAXdUJy2sl>4ju3;tDU$N-*)*QnR)td(EACwP4muuv&QqsMCP}I6Zq@-R zRp0mGBa;F>PYG=^WxS9*{ zZhA=qA;GT%cm~lXwt@Hr+gg(2EulPNn=mq^5W3IYdYo0B%E1l2)f&5A7)VC5^4oXw zerV^QyE6qRrOD8Skw@?YozIQ;OU~0t{WD?S{ICnIDW>#LFLz88(zSz#GXppXPId`H zl_^hEl7tE7JXpjA-r-?PrA=M*8Y+1si&i;VUKX%^{>3=>XtOY$j+ z+{ms%>0C%g79sUVhY35lucwOQ)`^JZSdLIunjnd_{U|aiFB1B7+l$Bxi=_0 zvm{QmccryLAzNV9SlZNVMtH-+KMZZ$Q%i7F6NP<(&-bjFh?l@@SUBjEC~RgngnE_^ zHhY3v1<)b<@%$V$qZH(g3op}1`&xVyH57Vva|7Bba|?C&wK(y*Z%s(TMa*x>#^_F! ziuCCT+=O?UpH>fe9A1=e1w1wa$2n+n%36{p-9NiR^M}pHtIyx{d)T`C?u}zX;(jgk z@bd2QNEu$UWrQK#n4M2!Hpbx}1Yu_F?eItkX(m_Uu>$o&>x8x>l!)&}DSeo^(7UpnJ0wXT!YApIe3 zFTDNIq!^Y;#_h2iaxg4|=0%mEget6peJGddL7Hqm(l4chIJXfT>O7#2E^Cv#DHCly zPMs6e)ZuY}5|9Kmkk*?zO^OYmtn~wHX$5gL_Hq7`_u}auIxeLfb1McGWLmqS`RvVU zQ=^zzjrNY${7HCADDA_LqfuOWPY^2&9=10uSuOW@o6(4B2ByPHVMqwqxr)`xK6zkZ zu{T0rCA7G?G!zE2LCp#3g#$}d_=LV*jj$W{1&JJJkcZ{ao&McaBM^K ze>8H|Eu^Za zEb&g@CrEFI4&Tl8$Fueaa10~^JvIiva1_#O89pJli_qTqjb_76ig|Pr~W9dF0oPNI?+JtZ26FX(sdajmNlwW z@A}E_)|MJI2ts~%8H>`W#|T40r@F(AtYuZsNt~!AU7P)drx8 zKd0=Gb>J;E6_m#MDZt-5z7ncrgt(Iz=2`h}s>oledCX4r14uK9U^gGgJXQ&;kkkuT zwq6m{I^)#Y7~|<&EYUCs*hHI!B>Jtxqi^6vt|D1Yz4KI(oWz3G!p)1O-4?SwQb8j* zxtuO>7Q&sqr#fEuXJ{N)h9RKg&dXX~NouCHv+iaD+n9F#J}uSkpJh#k@wxk!D0l0X zcsAzq?=X3l{WxRqPF9-2>)yp%n9J&>-@NmQGAtIiPugKw_oV^)E#Ap@yT^Dcw-obu z$uE+})k55$bIdFUw`j;)Z{molmlyz@N-GN_jyS&-2u3oH^J3?F!cH*h2Ua$JC*^Y! z1tP?V#|gC{7qV0qqVZObk7Z}LO{0DPXXN~NYR!Hvbl6fSAi(sV6n6vK8FmXr{ zW(3kF{ zp6vc2)0_A~tI~F_R@H-ee>t$6NaJ*}=*S`ls^#Eyx@XpOPSw%Rt7bm=)e++{Z*zKfGdgC2n%5g|m^3 z?;|&!%L}gDYmy&Hi^pCO@uO0rO|^Yq+}@*iEIz~PRd>O~I+z&`8UNQcZvJPa@R!e^ z;70eHknE~vLEq_%#A7Hfa(bUrV<=YKaNORDEK{GzUQS1Pe#sNuB!D=NNX=Hz^z{r9 zxEZ9s^R2Hhp+7Ij1DJFr3>m5C1_wnZ^xVSc{oJzAA`<~|<9iIL-O4PRHc%S;NKJu2 zSA_~HlgB_37zs#}I?6Ug*CIe9D4 zF92w00x=QBl+_K_{CtAk)AH-Mjx|T>3%KXyOjE&&|8W*5s!$=(hi2Rd2GQcI|M^8F z##{#qa2j1w6O~J}YF`*hDrQGv-BrQ--plBW$>4(IGuiRY-YFP>3Sp&@xT{fkPLQmMsZxSCfzrr@rvEs0r81xM!y9-Yk4 zjZ_Sz8FGv0@7f|9suia`!v4}+>Y3#(; zX8JIOY8Ss-G*)sQ=!KOfeO*`!-}h;(&mOa36Wwu{9)`k^X5e){p;XKkporPTC(aK2 zX0?D*n$U2jwOFbLp#+Fd8m&G>HDJ&xbnlf6Om=>Au6J|OQ$A_e%MG12PyW;b1}n>7lOik(osM&tMT(iMn3^HoSZ9JbOX< zL5@pMV*Nuri;{6H@GE(-PoMd3tcvAGeMw>3pRk{ol2F0G?hFNUWpmCph;OsjOcZ|sSNnPpyuLDZaZ7tj>p3Fe@Q$?vSEz16r- z$d3J#?cvsKnTMJcIFGZULaBpK3(fv$GhNMGN&Vr^{H}-|n z@CT`Fl?R1kh@ca4cQzy3E1|IUZ?`a>4Tq4KPv{_-7sDyr_f6R8)c6dl+h%5TyNbdT zGF?w7@bQi|_dBe{z&0cKRMBxMjoz=^Jp5X#%Q=3&kr$rClf&;I%_FV9YJer}p}Bm7 zky^)B?oHlmw5?r4`IB)HHg3Jv3#?`wsxRzTOPHYSy?WSutAzEK7;wmV;8zk%bmz*k z`yb39iWxYH83)FA^Rm^Fzep*{Te86SV zjeiK|5KEAkmfVKbT8Qyef-@p_aNtCF>#EaviBXS#oid-Ufb{EwrPxwB&V!?Ygj2a6 zAzkT^y_&aD*OFtlq(qVhBAr#4X9o-H&h3ts2+5KK2?8b`p2WGXS$C8k7^5`W6d$fF z?eAeZ5Kk{N)|;-D{)OE=K%K^#OlF%c=YH@3XiuTk2l{4e?t)=|Ty2WH-c`NMx_J>O zS>&^h%m}vm75*O=fODu;xeK=w&4hfknpm1O3##Sw*5g%Qv{*<=3Ni%|+Lqas*e{i> znKB>?22JOa4XB_Qnt9`BbRINxnFoRO0#@P@Z0T3s?Y3rsYwJsMX(8zW1YPv?15C+I zA&7mnya(38^t+09Jc>O^tP*&Mjb~ZIHPD(E+8it;S+p(?_z9fYqCYqZzQ|%g;yigI z6v`wbS%HUiWpCG5Ir*q6uH)+Cooc9ssa7S&V7_n8CYefqGgWo9mc=8hU$_1=wMmUK z2}*p$9QF|ZAyto%_NrA9zes*Ya+@d~QRutWJm%lRIR3m;!hxW7UL#xhyq|6aI>Dx; z#G@>i%Qlef;84By+ZOz!U`E%C{@Q_NHoJFyX*8D!lRRY~l1Drxd!&SkKZTgMywbD= zP=$fO@F(euNz@!Tx_T!8{Y^ z$77k&4+4;6K>0jQwUTcPaZx5qj1msnB6cf|a~)Y>pi+#llk7;{rti92Ph;i{2a1L2Gl9g8 z?~hZvId5Nt)a>p!QEEuxn7=(T@~K>1?6-57p=S!=+V zfNGgB>R>Zz^;=Ob_hdIDEl&i^6?hRC-IiRDL#oR~DCNIJ(fa5+qnpiy22Tqf@%1Y^ zN1vW#fOK;xvo%b|3S>H5G-9Z7-s0xtMEn68Q!#~Lr{^#`y%Ts1d0CF|acb$e4eo&j zLLD7Fw3JR4dC=Zp#V$Sc-l3Eq8foy&^D%@q-$-lQ3|7PjJIDxelJX5xsG+8_CDQZu zy);v^x+@hj`iU$K7Zfx_M1r}$rmC$&q~%JLt{bBF5?N&@?>JtraxrbJHR>?fpPb&) z7uCH5yxMYRER=(C_AX1pwas3SCK{aIRm?+^5shGfWygM8n&fo-?*AOfbPk|iKwH+~ z?Tk*`+QrQpj~x(0FxB$G03!hx%j8>EsAsfM%v6Gca?b_#m+h37^ah7?(6w=5W<+VCzq{4(wNKJ^G zNGMDuYF$9bQUb@5$Csbrp0}Mz2bQR>EI%jz78T{3OpFPxX=y%QjGgtorRMO3l=u*B z`~#jXi>JioXxC+u?&Hxb{_t;knA-J!9pbrc^MbkwW!5vCb;ZK+VuVXH{n^1=5nCrZ zkR@|!M+9}a0KqcqMCL9%P#_)_4s1#7TTJ_9rA~$_juZ@mJMpt9;`#gUzLUWLiW#b=`s2I~HHjG}n(K#GCozd9WWYkxd5znqu#z(A#2=!w zs=RZvCIlr?(b6WiN>GlgS>~N4FdGB!<+ z_FkZy;$@%SN^dRtyR&TN0Lh_Dvl@oIOl%&j$0Mpu0U4RA1M@(+e|IfX`;=V?n^jQt zlhARQ&}|~?52GC=kbWb8S)2IlT<>Hg`vjB9dF*Ou>I<^kayk4>_sXLhDrST(e#6+8 zAAB@+TIR>x1_J6_lhhoHZa`d_PE!3xzIX--jCe-C5wJzn+L9rf6R zaxg~`2eZ3yNQ`)nw|!U8jm|%za;ErVNav`Zn;bobU%BD zZ$zXBgH&<913hjiRBWwWOZ4Li(rRh|`1rVFXYiRoD~RM^txoh;GMF{0;CziX!L5J& zD$P-8ThN(DurT^Q?|k4BZDq({44Y~4H0I5CI)hYfhJ+~ z+$P=~MXODw{XujaL~qkCpB%xE&ZnM~IQem7nNl(%Yz-s>yY%Ivws6`ajDFk-%J@Vq5a7SuDt1iFeBo zRTp&cb~H_^{LRgU%dF1YzfJ#c-VL8dOszcUE$eIfi2tp~sOHF{(UHm}CW+#LMvvVw zVIb4M9Vxf~cdh8QHs=H+=*;>eoIdj5RLUx{!m#fH7;e@Zl1?Xs+q4(;(oP=Fj;*tP zzSiE)mFgZB8QZUC=+N6hq#Y6zBAKS(_gSpV!>>BOZj~x^+Acdj-nOy~KE~Z77qaL| za;W|2oN1nOGC5Uo(8E_`gah?Q zp*NE-PfisB-LWUx^!O6D*jccqX3;1$gif5Q2(M-KbE%&`RctBS(#k)nCSJ>RVk+2C zoh7+%dGJn}xU*K3W~gBo@nN|KK+>RqKYapRiU%qn^*>C@9wxbrGHjeR5bT4|G71r1 z2-OLwPOoEhnZo`kxa#1-*NtBiMD@n{D>ii9W{BibX@+xhJa^vM#q-$GGJ-J!7NuHR z9P_1<$hb1COWtHHMT*OAR!iTZ9I{cW=XU2N&FB>>ey5qK&1eoPA%<85+I`AoyJCcT zmTqQ{j9W}VRi|$k`uW=^gjeej>uMh-O9UAfW|2)O1=ro3*6mKZ3*%nnhuGESW3@-fSr9&AQC|cp#b8l$cq2QX=8C5gzwWJ9va-V4DC;YO0vZZ5xS5;OEEaJg{oyG0 zvw6M3M?p>Rz{MS=stY2lwP<;=Mg1#VS5WnpHL98&pMV&Htx!9i~9 zX{9&G#?y)hqM__AVeQbc#Oubc(vEn}lVDJ@o8~q;xiyPAk_)C)3ef6K9bWxew9Jr> zk+}P%=}B#IH(A3VSTUQxoC*?TjSB|emHo7}%Vb(j$Ys>>oJ+6&90)dMV4-PcuP~9n zfp0Qbdm0A}$uQy<7jUM>jmx9GxYYE&inFOxYG*lOZZ`!QmiXhUJ$W5(@8i>;fjmT6O zX6(_NB5#JE_uUHNZ>E(g0Y$4!pD3X>`&_4=(qc61&NyAXE1DtOmO#~C#Zd+->JM?; zgiWb*4Ivy=Ye$i+ZhFRYs07|GbL73t<{DuSu|0U1Lyk8l_itf^GJ;s9lEpjF8;Amr zN;$8bB>ZkDk(NE_H$T?l^cukG_BW;9A-)_c5q1J|IS7gtZFN0%csB1B{5!GVKRyE~ zC+lOL`Y$-feg0Gboam3T|5-Hn&j?Lrc1Ct7c|juy+%iD^s2(1C=Im(}BP218zc-)# z)-erw<``zxF`#_%O`xo^63(P^W?`GFF&a_$bQKGR zx6|taGOK>_U+toXc>$`?nJT$mt&w%}$cGyYd(YutS1YIb)VF1* zTT1rrKpI%_y>B{Qtr(1d%`Ixv60ollu-Ld@V8z>By)<_8c-AAxi18Bts7KLEW)J3U z-h5-69d4QJj|=d_8&0Qrvjo)-;=90zBM6a=aL6Z`H`hm;RyF}T|5<~pE#T_VeF3pO z&vk^<;2I%r2RXwfyo;eG+~@3bgEF;o;X*I$3m71l>TuOH{tI$I}!^0JRB`MG~kaG&`L~uT6r)da7nqsBe{jIi}l{#B$ zL-+Bx=n8A*s~0jDb9(Hs-NdF7jB#>rhr7YsQe7Z+L(l1w-+##?wdcwlJhW}7F&$~r z*o`nbok`_*t$R08QQs^rwoL`nAgI_{M;KwvJk_BZ_6xcVlVS3XzIJ zrU^Cnk3d&>SofD~SLk_fBYN&PIi!>ry%@ z33m5|(t2{>rbA7le~vq2R+$}+C~u~w|6U?;rq0qPrsO9oo>cXezF#3j5-HHV(YFgq z6NpaOr6nOe&Q~o*+Ywj04yOK-&1&HKvQ)Nn#)^=GZ0J?TQG3(mo~=GWg8;z4iiRljRWJG@ zJtjqhumml}Y0gif?!e_I&;Hk$z#aMvjDFNhwqQi;nmb|b1*du&o_LJNw=F^o$i+e4 z)h`5*J@Uj~_vO@`u zPNby&u3y)EuF4#IIDFH~pdr1r0!7}YHn-+RAo4xsQj?(>qR&Jt!inRO2Q6NVtGck1 zn%W|u5D_2mJUXE2pc!1)UnHU`Q4Cq{$+hCov~eXE6pTQ1&+ap(Ep*ZYDXtl zFsz&-RM1`vZxOdmr&9GW8PM3V)-w2CNPz3{x$w()t= z|6H$814OdnI0N=$X(qead7}ZS&#OGYzyQ=6Y_qhtkPn~3^OIB4h-k)1N5Bh_!fxxOD zm{FLeo)XiNWRZf?FM7B(;{{1~j3JdzPH+TwX4%e>1gX~Ih?!I^I+pqr4XlNuYZ{{m zI;&xRkLqQt{?^ItccE{iMefj9j6~YrVFBRJxHj~@S_X$Go%nMtpo_9I^}+j27BT(D zeRoBY^#Gv1SXNG|^gIH-PWO`IHq$)$L!c)t{o#TPAINX0#m;^VVH=1W`FlcqnvpA@ z@L#N1DqXMkU^=2Ef#^UvRbH$#wHa1^EZnB|qnPjWsmE{91~w!LrziEP+79*TJE}zn zYnB7(3}F;{jpi-s;`XGCDX{iPz*j57JOCwEMQy}gewr+zD z?#9PLMyS?c!&!dLJ7b5(6};gm_jqWCRzt(Z?U;FxI%1_v(J^r80ZoO}g|q9K7Ar9e zxdN`jH-bz73C>(&sm7_i7Gq>fHIdW8u5hDZ0~Q(@1r8b(?|@GVIv9P&=3(s@%d~Fs zx)i~{&It3E++qGqYOSlosFGe^wVY37mg>w1NH`gxmX-5B+g2Q@Q+~dkakaocb zI;a6QG*gkkow7cd>Xu0a<2B(?5NGA&0U1d+@Qv@0N0)E=n!M_eMBu9Jt>vPe+#Ob_ zMfby)A>{%bDLO}uJ<&uD?h3Gmw%-O)&yU)thE!HglWEi>1Dm-!|AGE2h`4P2$vAB4 z`rY2We9Gp*GtkY7Ro0+;;o)9saQ5yW?=z7ePp z2C)&Q#pqof^Q*PhtM8p!+v>YjD9WX(L!*%9$mZ=c;iX@H_S=Vf-Qn9ls*AsE$Ljy9 zAzx^MuAA^!BbSi>g`8tjqN1byq~)We0?*P9n?GIz%%yrOT;{yUQ^AFp(9eq~p&5>F zvt!i0RGsd)>`Tm{e&(rQayGKHNNkZF;AHQ?VJE~bix)mChiVX*vuUc<)o59Z ztX^(}>2`$ugng8ieJd`!o3?*9JiCt!-*C5aJT)gvoFPiynPq@t!IzRJN)!-1qRS~? z=M5n5JENHXDDnR$zOUiiuZ{*fq+(S`N*>zz%X&+yNjdY5dgnvn$_I)(6{M zm57tvT^A)pmw5 z-TQl;>~q~wp7o|;)!*8<<@n}EQd)x?)-FhD+ z30*XHsiDAPk(Zi|p|Q7SzjB1P z_|(Vsi?OYiDI1pmpoU4WC9SG_YjN|MTerq35t6}0^(SLoPOZ9){P$xxhCepOhR8M} z$e05ImdbMe;qldS$1oe>fs^ak$i9cLs1%nI5eHgB0l(cHaA0CLk52eE#K_emDQuW*74xo#9!h zH>{^v!Cu;^X;l8lJ-y97jgAM1nTh&Mj=E4b3crLv_?`t{AnRgAwjGqH1bb9zws7m} z$9??9e`=-j@m)S={$iPm_QW!Z4pZumc0S-&5iVnue4J4nlGF2;R;FHCKz}zvn3r`b z*mMS7FHD|4--G6ipJX$gM;5=C>m=^WVE_+?)+(~wRF!ExI6zEoXVN{gm<3P_lNPT-)mJN+mr3H!;FWnI#mN5yt;k5PHdUSx%eO=(Dt`J`4>En z7-ZJ3OkW>Kq;fgrMPwrOhY6{!Fa8;jJNr zXC@=Rc7*^zk!LJ8d8Rt)jFWa$1{7LTsNX7qY=lPZZB4c$AICn!?y5F?NNoyTrV1uh8~Sk2G;oY%{4Xenio8?rICi)~bwk+1SRln7Di`bHV3KyG)A)u^M_V z$VGjocF=niMupZQmD(m1SV8zNrnR91PwAME)I5!j&14|#B%7w!5@397w_Fr zyspogM{2eIgtt9w$nyUs0(NpY)4 z6C0aL7f};{E#=ebka+T#+^|@g#K?Rb2YkwtvljacQa(du(dms`7<|qPzPs(UzL_X@J^2=uCp6XE=#Qx76NyOS5;M%=qqn1x6b(FD|x4d!? zZiz>JJy`sq5XCRWPV1y|C6cmmK?PvlKx_wJWdkrZFPlFZMa-8?+q3k7tX!%g)LC@E zdg~7`+iuitF`dwc@Xs@AS2P`A*~6b9>p~NrA?tCz*uFJ*$GCXE@E;&Fc+qYI3q(~hk|b$9%Bz^sx+}DjfsX&!+|L0EDFO%b!zM!!wD!lg@GcuwxnVA>yXOwtd?QsnA$JR=Lvah3O=jRH|*S`XiCW5=p?A! z!UO&uYf49(={#j@X43&y7cW0QdHr^v{W(eoGRohtHGx!+Q>F!`n-W{aM<^j9sS9F&THN&*Spn zefV})V)$Xt@Hk%lQMN3(JO||}#vMT`G&LeamE%2VR(|3JdNBgSyZSQwVym{X<*SE*XTfqmvYdR*EGQDMR)yNDO7y zPh@ja)h9zLFIU#G*dTo~hQJ}>g0}@;wVcUXg&gq`@ECx-P-oG>8s5B}Z6UYql8mS? zcvSGB;pSf^G6}H%zk~w`&L4f*6@(3?YT+`0B?*fvo$Qm=Gdn@^bRjuX!za zhFTUX!WsQL#}FVs^HM8|3FS>RvdM@=c@tum;p(aEZnYN_L*Z z!>Ip7PFV{1c3Q5)fl4243Dp+$5Ix~N&0jyp&9;UPG0b{dz*0w4S66ohKt04^iQqS$ zvNw^zb>3td*MK9bh0ZddiJ;UJl6{XNC9Umqx( z(UlwLpzs%Uud!4nm~=}=t09G>P88$gneSf+bzADR5pZ`{{;6<#YxyDy+7g0NL==@> zDCbk3Ab?OWT}(lCQOFzp^sA`Qoxbt%`j~F;oZ)p36h|~%!QS`_yl^)Wr9vM$39E@_ zD7(>Y9R z&f|7O{aEXdFv&ED^rO@+YWY%3?a}a(v^`KP+}4&%W7Jf8)_D)qVELF~(Erg(E%D%G z7X4PIm6mb&|I`utKSiN5^($rm?=S6|l!g8&9?mbL)VWjY;QMB9hbr`x{va`s% zNnf70HmU)kNkfo$)*3l{Qeb*hQ34%c9WeUm5|Q6B3jT2lX)1u4dK;aU?24)s-xHD< z8XcwWL@nZ;6r#mtC|G&qrzF%gVpLjynJAkR_AWf4{`bSbqL~g|-29v7f#JP}irX)- zf|eu6R+i{tg<+&z(=QQ$0m&J&2eTcn0Lq$`j)UhS5_R8<`tC6)8KA9=4!LSKVtzkS16Qb17SSOLW~inTy7p8rnbZ zw^BguA|~=ke=^B)FQk-~@pGJ6A#~$#$+WXh1!^Te^)Z(MfVDo$dz`s<3P^bxHJV^_ z$of9FX2JwfhoY>HRQ+m{@=KQBcOpdKPd2kaAYzk?g_x+!L=~s>_s6AHKN@$3wU|^g zXfA8XUv6v7t->s)i(jZ|0WzU@*ur7@5n^cD?=+~}vj$HD6aQYWcENNkq?KELe;lVc zo=-4Y()iVz7GdCN+tC)9cr#Hq+LIr7fc&&yfLXnjVTD#NP3k$|S@0MaECf|GbvoE6 z;`W0|-~W18X*<5|*APGPo_>G*_ecn08g}7OHrb*a;DNOv;h~zFBBJ8YkOD|uIQ^4N zZF)u|y;>wK40|yXCp#X?65t;4kmWxlIVrEwAv9hNr|>9QWpM69lc3X< z9b2o#o>RhXW5rGOur|Hy=n*3rAC^@ktnhRH>W+||k^rqv^7UE{_e+ujGo)CwdU3N7 zzH(9k?Rx8z^hnz+x@gJ+dGGTwo$aTcD6IQ=PvyBzwUNDIYI2QC)|CL~{1-fHPMYO# zF|<8r-Bx#(L{ZH`_wv&fv&;#%;E<0-Lb=xiJc=J{niY))De|EBb615@Yl52nw%Xhl zqxy8OFD@f7s~EYAIkBxMYBO}n?p60@^Wip=HtCPDuUX@w9^wOQJc4^h5np&jr0Cvz znMnI1`+BEh1LDfU85asETD4xvWpnI8j4nso(9Lb&CS${(#&GkKH{bUEZZ_Cd-q~cd zA7hG~J+Jw8{udl7Kr<`-Xa923SHmaw-=qJJN^&xDLFtZ>q77k}Q{F4PU4jKd$W zX`~#b$$gX4kH*cp3SgQVT1gi>z?LS@r83!X63bX!>=R!t=wf516=x|8(B#Qcg(t}b z@C&!@Ex^`$*{y4i6D>4vP`dlaF0W!&C4f*er+ki%(!_iXD=e2v℞rKiv7>!Li#z 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
eo@ED&LL`>|?y^CV9kLup^8G=wfB%p)fM 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%0D+$8y`V1*<231IBM?8=Jvw4&BnuyTTow=M3S&UMG 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 literal 0 HcmV?d00001 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..b8ba7b2 --- /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 str(path.resolve().relative_to(start.resolve())) + except ValueError: + return str(path) + + +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'

' + + +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..77917c4 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,891 @@ +""" +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 os +import subprocess +import sys +import textwrap +import types +from pathlib import Path +from types import SimpleNamespace +from typing import 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)) + + +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..c0f22be --- /dev/null +++ b/tests/test_critical_fixes.py @@ -0,0 +1,1593 @@ +""" +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 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=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..a14336a --- /dev/null +++ b/tests/test_refactoring_contracts.py @@ -0,0 +1,807 @@ +import hashlib +import json +import re +import sys +from datetime import datetime +from pathlib import Path +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: + normalized = text.replace(str(root), "") + 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 _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..f66e407 --- /dev/null +++ b/tests/test_unified_estimation.py @@ -0,0 +1,313 @@ +""" +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 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=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=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 }, +] From 73a4b8eb603aa3fd696b47240a076ae74f1f7150 Mon Sep 17 00:00:00 2001 From: marcotag93 Date: Thu, 20 Aug 2026 17:18:14 +0200 Subject: [PATCH 2/3] fix: make Windows CI path handling portable --- src/tide/core/_reporting.py | 4 ++-- tests/test_critical_fixes.py | 3 ++- tests/test_refactoring_contracts.py | 35 +++++++++++++++++++++++++++-- tests/test_unified_estimation.py | 5 +++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/tide/core/_reporting.py b/src/tide/core/_reporting.py index b8ba7b2..806bbb8 100644 --- a/src/tide/core/_reporting.py +++ b/src/tide/core/_reporting.py @@ -71,9 +71,9 @@ def _relative_path(path: Optional[Path], start: Path) -> str: if path is None: return "N/A" try: - return str(path.resolve().relative_to(start.resolve())) + return path.resolve().relative_to(start.resolve()).as_posix() except ValueError: - return str(path) + return path.as_posix() def _human_report_type(report_type: str) -> str: diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py index c0f22be..0248d5d 100644 --- a/tests/test_critical_fixes.py +++ b/tests/test_critical_fixes.py @@ -13,6 +13,7 @@ import importlib import json +import re import sys import textwrap import types @@ -634,7 +635,7 @@ def test_dose_workflows_require_both_configured_weights(self, tmp_path, workflow missing = tmp_path / "missing.txt" config = self._config(tmp_path, weights_target=missing) - with pytest.raises(FileNotFoundError, match=str(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): diff --git a/tests/test_refactoring_contracts.py b/tests/test_refactoring_contracts.py index a14336a..d67f543 100644 --- a/tests/test_refactoring_contracts.py +++ b/tests/test_refactoring_contracts.py @@ -3,7 +3,7 @@ import re import sys from datetime import datetime -from pathlib import Path +from pathlib import Path, PureWindowsPath from types import SimpleNamespace from typing import Any, Optional @@ -53,7 +53,11 @@ def _fake_aggregates(base: float) -> dict: def _normalize_artifact(text: str, root: Path) -> str: - normalized = text.replace(str(root), "") + root_text = str(root) + normalized = text.replace(root_text.replace("\\", "\\\\"), "") + normalized = normalized.replace(root_text, "") + normalized = normalized.replace("\\\\", "/") + normalized = normalized.replace("\\", "/") 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+)?", @@ -64,6 +68,33 @@ def _normalize_artifact(text: str, root: Path) -> str: 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\out.txt" + "\n" + r"json: C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-0\\test_contract\\out.txt" + ) + + assert _normalize_artifact(text, root) == ("plain: /out.txt\njson: /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( diff --git a/tests/test_unified_estimation.py b/tests/test_unified_estimation.py index f66e407..6015b76 100644 --- a/tests/test_unified_estimation.py +++ b/tests/test_unified_estimation.py @@ -6,6 +6,7 @@ aggregation) and ``run_unified_estimation`` (SEI, multiplier, ΔMSO identity). """ +import re import sys from pathlib import Path @@ -164,7 +165,7 @@ def test_missing_explicit_weight_path_raises(self, tmp_path): cfg = _config(trk, trk) missing = tmp_path / "missing_weights.txt" - with pytest.raises(FileNotFoundError, match=str(missing)): + with pytest.raises(FileNotFoundError, match=re.escape(str(missing))): analyze_bundle("B", str(trk), np.zeros(3), cfg, weight_path=str(missing)) @@ -266,7 +267,7 @@ def test_uniform_weights_file_matches_no_weights(self, tmp_path): 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=str(missing)): + 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): From 25915a5ea17f83ac1fe97a4fc0443c12bff23d2a Mon Sep 17 00:00:00 2001 From: marcotag93 Date: Thu, 20 Aug 2026 17:27:47 +0200 Subject: [PATCH 3/3] fix: stabilize Windows CI contract tests --- tests/test_cli.py | 18 +++++++++++++++++- tests/test_refactoring_contracts.py | 15 ++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 77917c4..90e28e1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ mutate the caller's environment. """ +import logging import os import subprocess import sys @@ -15,7 +16,7 @@ import types from pathlib import Path from types import SimpleNamespace -from typing import Optional +from typing import Iterator, Optional import pytest import yaml @@ -27,6 +28,21 @@ 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 diff --git a/tests/test_refactoring_contracts.py b/tests/test_refactoring_contracts.py index d67f543..795fe4d 100644 --- a/tests/test_refactoring_contracts.py +++ b/tests/test_refactoring_contracts.py @@ -56,8 +56,11 @@ def _normalize_artifact(text: str, root: Path) -> str: root_text = str(root) normalized = text.replace(root_text.replace("\\", "\\\\"), "") normalized = normalized.replace(root_text, "") - normalized = normalized.replace("\\\\", "/") - normalized = normalized.replace("\\", "/") + 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+)?", @@ -71,12 +74,14 @@ def _normalize_artifact(text: str, root: Path) -> str: 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\out.txt" + 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\\out.txt" + r"json: C:\\Users\\runneradmin\\AppData\\Local\\Temp\\pytest-0\\test_contract\\nested\\out.txt" ) - assert _normalize_artifact(text, root) == ("plain: /out.txt\njson: /out.txt") + assert _normalize_artifact(text, root) == ( + "plain: /nested/out.txt\njson: /nested/out.txt" + ) def test_report_relative_paths_use_url_separators() -> None:

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