From 30feb0c250cc503d96f3e7fd1944c93a12264e26 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Sun, 7 Jun 2026 18:58:46 -0400 Subject: [PATCH 01/15] Add local-pipeline verification script Drives the real osipy CLI against the local datasets (DCE, IVIM brain/abdomen, ASL ExploreASL/OSIPI-1) plus GE/Siemens/Philips DICOM-load checks, validating every output NIfTI. Reproduces the 8 passing localdata checks used as the dead-code-removal regression baseline so they can be verified independently. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/verify_local_pipelines.sh | 274 ++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100755 scripts/verify_local_pipelines.sh diff --git a/scripts/verify_local_pipelines.sh b/scripts/verify_local_pipelines.sh new file mode 100755 index 0000000..d39e1d4 --- /dev/null +++ b/scripts/verify_local_pipelines.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# +# verify_local_pipelines.sh — run the osipy CLI against the local datasets and +# verify every pipeline still produces valid output maps. +# +# This reproduces the 8 passing "localdata" checks used as the dead-code-removal +# regression baseline, but drives them through the *real* `osipy` command-line +# entry point (config YAML + data path) so you can verify them yourself: +# +# 5 end-to-end CLI pipelines 3 DICOM vendor-load checks +# --------------------------- -------------------------- +# 1. DCE (Clinical_P1, DICOM) 6. DICOM load: GE +# 2. IVIM (brain, NIfTI) 7. DICOM load: Siemens +# 3. IVIM (abdomen, NIfTI) 8. DICOM load: Philips +# 4. ASL (ExploreASL, BIDS) +# 5. ASL (OSIPI Dataset1, BIDS) +# +# (DSC has no local dataset, so it is covered only by the unit/integration suite.) +# +# Usage: +# scripts/verify_local_pipelines.sh +# +# Environment overrides: +# OSIPY_DATA Path to the data directory (default: /home/ltorres/projects/osipy/data) +# OUTPUT_DIR Where to write outputs (default: a fresh temp dir) +# PYTHON Python interpreter (default: .venv/bin/python) +# OSIPY osipy CLI executable (default: .venv/bin/osipy) +# +set -u # (intentionally NOT -e: we want to run every pipeline and tally failures) + +# --- locations ------------------------------------------------------------- +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OSIPY_DATA="${OSIPY_DATA:-/home/ltorres/projects/osipy/data}" +PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}" +OSIPY="${OSIPY:-$REPO_ROOT/.venv/bin/osipy}" +OUTPUT_DIR="${OUTPUT_DIR:-$(mktemp -d -t osipy_verify.XXXXXX)}" +CONFIG_DIR="$OUTPUT_DIR/configs" +mkdir -p "$CONFIG_DIR" + +# Fall back to PATH executables if the venv ones are absent. +[ -x "$PYTHON" ] || PYTHON="python3" +[ -x "$OSIPY" ] || OSIPY="osipy" + +PASS=0 +FAIL=0 +declare -a RESULTS + +echo "==================================================================" +echo " osipy local pipeline verification" +echo " repo: $REPO_ROOT" +echo " data: $OSIPY_DATA" +echo " python: $PYTHON" +echo " osipy: $OSIPY" +echo " output: $OUTPUT_DIR" +echo "==================================================================" + +if [ ! -d "$OSIPY_DATA" ]; then + echo "ERROR: data directory not found: $OSIPY_DATA" >&2 + echo "Set OSIPY_DATA to point at your data checkout." >&2 + exit 2 +fi + +# --- helpers --------------------------------------------------------------- + +# assert_valid_nifti -> 0 if a loadable, non-empty, not-all-NaN NIfTI +assert_valid_nifti() { + "$PYTHON" - "$1" <<'PY' +import sys +import numpy as np +import nibabel as nib +path = sys.argv[1] +img = nib.load(path) +data = np.asarray(img.dataobj) +assert data.size > 0, f"{path}: empty array" +assert np.isfinite(data).any(), f"{path}: entirely NaN/Inf" +PY +} + +# record +record() { + local name="$1" status="$2" detail="$3" + if [ "$status" -eq 0 ]; then + PASS=$((PASS + 1)) + RESULTS+=("PASS $name") + echo " -> PASS: $name" + else + FAIL=$((FAIL + 1)) + RESULTS+=("FAIL $name ($detail)") + echo " -> FAIL: $name ($detail)" + fi +} + +# run_cli_pipeline +run_cli_pipeline() { + local name="$1" config="$2" data_path="$3"; shift 3 + local expected=("$@") + local out="$OUTPUT_DIR/${name}" + rm -rf "$out" + + echo + echo "------------------------------------------------------------------" + echo "[$name] osipy $(basename "$config") $data_path -o $out" + echo "------------------------------------------------------------------" + + if [ ! -e "$data_path" ]; then + record "$name" 1 "data not found: $data_path" + return + fi + + "$OSIPY" "$config" "$data_path" -o "$out" + local rc=$? + if [ $rc -ne 0 ]; then + record "$name" 1 "CLI exited $rc" + return + fi + + local missing="" + for f in "${expected[@]}"; do + if [ ! -f "$out/$f" ]; then + missing="$missing $f" + continue + fi + if [[ "$f" == *.nii.gz ]] && ! assert_valid_nifti "$out/$f"; then + missing="$missing $f(invalid)" + fi + done + + if [ -n "$missing" ]; then + record "$name" 1 "missing/invalid outputs:$missing" + else + echo " outputs: ${expected[*]}" + record "$name" 0 "" + fi +} + +# --- 1. DCE (Clinical_P1 DICOM) ------------------------------------------- +cat > "$CONFIG_DIR/dce.yaml" <<'YAML' +modality: dce +pipeline: + model: extended_tofts + t1_mapping_method: vfa + aif_source: population + population_aif: parker + acquisition: + tr: 5.0 + flip_angles: [5, 10, 15, 20, 25, 30] + baseline_frames: 5 + relaxivity: 4.5 +data: + format: auto +YAML +run_cli_pipeline "1_dce_clinical_p1" "$CONFIG_DIR/dce.yaml" \ + "$OSIPY_DATA/dce/Clinical_P1/Visit1/09-15-1904-BRAINRESEARCH-89964" \ + osipy_run.json ktrans.nii.gz ve.nii.gz quality_mask.nii.gz + +# --- 2 & 3. IVIM (brain + abdomen NIfTI) ---------------------------------- +for region in brain abdomen; do + cat > "$CONFIG_DIR/ivim_${region}.yaml" < ms) +cat > "$CONFIG_DIR/asl_explore.yaml" <<'YAML' +modality: asl +pipeline: + labeling_scheme: pcasl + pld: 2025.0 + label_duration: 1650.0 + m0_method: single + label_control_order: label_first +data: + format: bids + subject: Sub1 +YAML +run_cli_pipeline "4_asl_exploreasl" "$CONFIG_DIR/asl_explore.yaml" \ + "$OSIPY_DATA/asl/ExploreASL_TestDataSet/rawdata" \ + osipy_run.json cbf.nii.gz + +# OSIPI Dataset1: PCASL, PLD 2.025 s, LD 1.8 s +cat > "$CONFIG_DIR/asl_osipi1.yaml" <<'YAML' +modality: asl +pipeline: + labeling_scheme: pcasl + pld: 2025.0 + label_duration: 1800.0 + m0_method: single + label_control_order: label_first +data: + format: bids + subject: "001" +YAML +run_cli_pipeline "5_asl_osipi_dataset1" "$CONFIG_DIR/asl_osipi1.yaml" \ + "$OSIPY_DATA/asl/OSIPI_TESTING/OSIPI_Dataset1/rawdata" \ + osipy_run.json cbf.nii.gz + +# --- 6, 7, 8. DICOM vendor-load checks (GE / Siemens / Philips) ----------- +# These exercise the DICOM loader (discover + load_dicom_series) directly, +# matching tests/unit/common/test_dicom.py::test_load_vendor_dce. +verify_vendor_dicom() { + local vendor="$1" + local vendor_dir="$OSIPY_DATA/test_dicom/${vendor}/dce" + echo + echo "------------------------------------------------------------------" + echo "[${vendor}] DICOM load check: $vendor_dir" + echo "------------------------------------------------------------------" + if [ ! -d "$vendor_dir" ]; then + record "${vendor}_dicom_load" 1 "data not found: $vendor_dir" + return + fi + if OSIPY_VENDOR_DIR="$vendor_dir" "$PYTHON" - <<'PY' +import os +import sys +import numpy as np +from pathlib import Path +from osipy.common.dataset import PerfusionDataset +from osipy.common.io.discovery import discover_dicom, load_dicom_series + +vendor_dir = Path(os.environ["OSIPY_VENDOR_DIR"]) +# leaf = first directory under vendor_dir that contains .dcm files +leaf = None +for p in sorted(vendor_dir.rglob("*.dcm")): + leaf = p.parent + break +if leaf is None: + print(f" no .dcm files under {vendor_dir}", file=sys.stderr) + sys.exit(1) + +series_list = discover_dicom(leaf) +assert series_list, f"no series discovered under {leaf}" +ds = load_dicom_series(series_list[0]) +assert isinstance(ds, PerfusionDataset), type(ds) +data = np.asarray(ds.data) +assert data.size > 0, "empty volume" +assert data.ndim >= 3, f"ndim={data.ndim}" +assert np.isfinite(data).any(), "all NaN/Inf" +assert ds.acquisition_params is not None, "no acquisition params" +print(f" loaded {leaf.name}: shape={data.shape}") +PY + then + record "${vendor}_dicom_load" 0 "" + else + record "${vendor}_dicom_load" 1 "loader raised / empty volume" + fi +} +verify_vendor_dicom ge +verify_vendor_dicom siemens +verify_vendor_dicom philips + +# --- summary --------------------------------------------------------------- +echo +echo "==================================================================" +echo " SUMMARY" +echo "==================================================================" +for r in "${RESULTS[@]}"; do echo " $r"; done +echo "------------------------------------------------------------------" +echo " $PASS passed, $FAIL failed (outputs under $OUTPUT_DIR)" +echo "==================================================================" + +[ "$FAIL" -eq 0 ] From 3a1e6530d2dbbb307c8f274f05bf1f6b731c6b6b Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Sun, 7 Jun 2026 23:07:03 -0400 Subject: [PATCH 02/15] Registry-driven config: core + DSC vertical slice Invert the config dependency so the registry is the single source of truth. Each component declares its own pydantic config model (a MethodConfig with a 'method' discriminator + its knobs); the CLI config is generated from registry x schema, and components are constructed straight from a validated config instance. Core (osipy/common/config/): - MethodConfig base (extra='forbid') - method_union(configs): build a discriminated union from {name: config} - construct_from_config(registry, cfg): instantiate the selected component from its config (validation and construction share one schema -> a knob can't be 'collected but ignored') DSC slice (proves the pattern end-to-end): - SSVDConfig/CSVDConfig/OSVDConfig + DECONVOLVER_CONFIGS/_REGISTRY - DSCPipelineYAML.deconvolution is the generated discriminated union; te/baseline_frames/hematocrit_ratio are real knobs now - compute_perfusion_maps accepts an injected fitter; DSCPipeline auto-builds it from config (per-method params actually take effect; previously the fitter was default-constructed and svd_threshold was dropped) - dump_defaults renders the nested config automatically (recurses models) - wizard introspects the chosen method's config model to prompt its params Gate: 825 passed, 0 failed (core incl. GPU); nested YAML round-trips via load_config; unknown per-method keys rejected. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/cli/config.py | 30 ++++---- osipy/cli/runner.py | 5 +- osipy/cli/wizard.py | 31 ++++---- osipy/common/config/__init__.py | 115 ++++++++++++++++++++++++++++++ osipy/dsc/deconvolution/config.py | 74 +++++++++++++++++++ osipy/dsc/parameters/maps.py | 6 +- osipy/pipeline/dsc_pipeline.py | 36 +++++++--- tests/unit/cli/test_config.py | 29 +++++--- tests/unit/cli/test_wizard.py | 8 ++- 9 files changed, 279 insertions(+), 55 deletions(-) create mode 100644 osipy/common/config/__init__.py create mode 100644 osipy/dsc/deconvolution/config.py diff --git a/osipy/cli/config.py b/osipy/cli/config.py index 5ff69ef..65a69e4 100644 --- a/osipy/cli/config.py +++ b/osipy/cli/config.py @@ -14,6 +14,9 @@ import yaml from pydantic import BaseModel, Field, field_validator +from osipy.common.config import method_union +from osipy.dsc.deconvolution.config import DECONVOLVER_CONFIGS, OSVDConfig + logger = logging.getLogger(__name__) @@ -311,33 +314,26 @@ def validate_aif_source(cls, v: str) -> str: # --------------------------------------------------------------------------- +# Discriminated union of DSC deconvolution method configs, generated from the +# registry: selecting ``method`` pulls in that method's parameters. +_DeconvolverConfig = method_union(DECONVOLVER_CONFIGS) + + class DSCPipelineYAML(BaseModel): """DSC pipeline settings from YAML.""" te: float = Field(default=30.0, description="ms, echo time") - deconvolution_method: str = Field(default="oSVD", description="oSVD | cSVD | sSVD") - apply_leakage_correction: bool = Field(default=True) - svd_threshold: float = Field( - default=0.2, description="truncation threshold for SVD" - ) baseline_frames: int = Field( default=10, description="number of pre-bolus frames for baseline" ) hematocrit_ratio: float = Field( default=0.73, description="large-to-small vessel hematocrit ratio" ) - - @field_validator("deconvolution_method") - @classmethod - def validate_deconv(cls, v: str) -> str: - """Validate deconvolution method against registry.""" - from osipy.dsc import list_deconvolvers - - valid = list_deconvolvers() - if v not in valid: - msg = f"Invalid deconvolution method '{v}'. Valid: {valid}" - raise ValueError(msg) - return v + apply_leakage_correction: bool = Field(default=True) + deconvolution: _DeconvolverConfig = Field( + default_factory=OSVDConfig, + description="deconvolution method + parameters (method: oSVD | cSVD | sSVD)", + ) # --------------------------------------------------------------------------- diff --git a/osipy/cli/runner.py b/osipy/cli/runner.py index e811f6b..42cbdd0 100644 --- a/osipy/cli/runner.py +++ b/osipy/cli/runner.py @@ -622,9 +622,10 @@ def _run_dsc(config: PipelineConfig, data_path: Path, output_dir: Path) -> None: pipeline_cfg = DSCPipelineConfig( te=mc.te, # type: ignore[attr-defined] - deconvolution_method=mc.deconvolution_method, # type: ignore[attr-defined] + baseline_frames=mc.baseline_frames, # type: ignore[attr-defined] + hematocrit_ratio=mc.hematocrit_ratio, # type: ignore[attr-defined] apply_leakage_correction=mc.apply_leakage_correction, # type: ignore[attr-defined] - svd_threshold=mc.svd_threshold, # type: ignore[attr-defined] + deconvolution=mc.deconvolution, # type: ignore[attr-defined] ) time_array = dataset.time_points diff --git a/osipy/cli/wizard.py b/osipy/cli/wizard.py index 6233cfd..b3a103c 100644 --- a/osipy/cli/wizard.py +++ b/osipy/cli/wizard.py @@ -332,29 +332,36 @@ def _collect_dce_config() -> dict[str, Any]: def _collect_dsc_config() -> dict[str, Any]: """Collect DSC pipeline settings.""" - from osipy.dsc import list_deconvolvers + from osipy.dsc.deconvolution.config import DECONVOLVER_CONFIGS print("\n--- DSC Pipeline Settings ---") cfg: dict[str, Any] = {} - methods = list_deconvolvers() - cfg["deconvolution_method"] = _prompt_choice( - "Deconvolution method:", methods, default="oSVD" - ) - cfg["te"] = _prompt_value("Echo time TE (ms)", default=30.0, expected_type=float) - cfg["apply_leakage_correction"] = _prompt_yes_no( - "Apply leakage correction?", default=True - ) - cfg["svd_threshold"] = _prompt_value( - "SVD truncation threshold", default=0.2, expected_type=float - ) cfg["baseline_frames"] = _prompt_value( "Number of baseline frames", default=10, expected_type=int ) cfg["hematocrit_ratio"] = _prompt_value( "Hematocrit ratio", default=0.73, expected_type=float ) + cfg["apply_leakage_correction"] = _prompt_yes_no( + "Apply leakage correction?", default=True + ) + + # Deconvolution method + its parameters, derived from the registry config + # models (selecting a method surfaces exactly that method's knobs). + methods = sorted(DECONVOLVER_CONFIGS) + method = _prompt_choice("Deconvolution method:", methods, default="oSVD") + deconvolution: dict[str, Any] = {"method": method} + for fname, finfo in DECONVOLVER_CONFIGS[method].model_fields.items(): + if fname == "method": + continue + deconvolution[fname] = _prompt_value( + finfo.description or fname, + default=finfo.default, + expected_type=type(finfo.default), + ) + cfg["deconvolution"] = deconvolution return cfg diff --git a/osipy/common/config/__init__.py b/osipy/common/config/__init__.py new file mode 100644 index 0000000..d554911 --- /dev/null +++ b/osipy/common/config/__init__.py @@ -0,0 +1,115 @@ +"""Registry-driven configuration. + +The CLI/YAML configuration is *generated* from the component registries rather +than hand-written: each registered component declares its own pydantic config +model (a ``MethodConfig`` carrying a ``method`` discriminator literal plus its +tunable knobs), and the helpers here compose those models into discriminated +unions for the CLI config and construct the component straight from a validated +config instance. + +This makes the registry the single source of truth: adding ``@register_x("foo")`` +with a config model automatically surfaces ``foo`` (and its parameters) as a CLI +toggle, and the same schema both validates input *and* builds the component — so +an option can never be "collected but silently ignored". + +Example +------- +>>> from osipy.common.config import method_union, construct_from_config +>>> Union = method_union(DECONVOLVER_CONFIGS) # discriminated union type +>>> deconvolver = construct_from_config(REGISTRY, cfg) # cfg.method -> instance +""" + +from __future__ import annotations + +import functools +import operator +from typing import Annotated, Any + +from pydantic import BaseModel, Field + +DISCRIMINATOR = "method" + + +class MethodConfig(BaseModel): + """Base class for a component's configuration model. + + Subclasses set the discriminator field to a ``Literal`` of their registry + name, then list their tunable knobs as ordinary pydantic fields:: + + class OSVDConfig(MethodConfig): + method: Literal["oSVD"] = "oSVD" + oscillation_index: float = Field(0.035, gt=0.0) + + Unknown keys are rejected (``extra="forbid"``) so typos in YAML surface as + validation errors rather than being silently dropped. + """ + + model_config = {"extra": "forbid"} + + +def method_union( + configs: dict[str, type[BaseModel]], + discriminator: str = DISCRIMINATOR, +) -> Any: + """Build a discriminated-union type from a mapping of name -> config model. + + Parameters + ---------- + configs : dict[str, type[BaseModel]] + Mapping of registry name to its pydantic config model. Each model must + carry a ``Literal`` *discriminator* field equal to its key. + discriminator : str + Name of the discriminator field (default ``"method"``). + + Returns + ------- + type + A single model (if only one) or + ``Annotated[Union[...], Field(discriminator=...)]`` suitable as a + pydantic field type. + """ + models = [configs[name] for name in sorted(configs)] + if not models: + msg = "Cannot build a config union from an empty registry." + raise ValueError(msg) + if len(models) == 1: + return models[0] + union = functools.reduce(operator.or_, models) # A | B | C + return Annotated[union, Field(discriminator=discriminator)] + + +def construct_from_config( + registry: dict[str, Any], + config: BaseModel, + discriminator: str = DISCRIMINATOR, +) -> Any: + """Instantiate the registered component selected by *config*. + + ``config.`` selects the registry entry; the remaining fields + are passed as constructor keyword arguments. + """ + name = getattr(config, discriminator) + if name not in registry: + from osipy.common.exceptions import DataValidationError + + valid = ", ".join(sorted(registry)) + msg = f"Unknown '{name}'. Valid: {valid}" + raise DataValidationError(msg) + kwargs = config.model_dump(exclude={discriminator}) + return registry[name](**kwargs) + + +def config_params( + config: BaseModel, discriminator: str = DISCRIMINATOR +) -> dict[str, Any]: + """Return *config*'s fields excluding the discriminator (the construct kwargs).""" + return config.model_dump(exclude={discriminator}) + + +__all__ = [ + "DISCRIMINATOR", + "MethodConfig", + "config_params", + "construct_from_config", + "method_union", +] diff --git a/osipy/dsc/deconvolution/config.py b/osipy/dsc/deconvolution/config.py new file mode 100644 index 0000000..13ca44f --- /dev/null +++ b/osipy/dsc/deconvolution/config.py @@ -0,0 +1,74 @@ +"""Config models for DSC deconvolution methods. + +Each SVD deconvolution method (sSVD, cSVD, oSVD) declares its tunable knobs as a +:class:`~osipy.common.config.MethodConfig`. These compose into a discriminated +union for the CLI config, and :func:`~osipy.common.config.construct_from_config` +builds the corresponding fitter directly from a validated config instance. + +References +---------- +.. [1] Ostergaard L et al. MRM 1996;36(5):715-725. +.. [2] Wu O et al. MRM 2003;50(1):164-174. +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field + +from osipy.common.config import MethodConfig +from osipy.dsc.deconvolution.svd_fitters import CSVDFitter, OSVDFitter, SSVDFitter + + +class SSVDConfig(MethodConfig): + """Standard truncated-SVD deconvolution (single global threshold).""" + + method: Literal["sSVD"] = "sSVD" + threshold: float = Field( + 0.2, gt=0.0, lt=1.0, description="SVD truncation threshold (fraction of max)" + ) + + +class CSVDConfig(MethodConfig): + """Block-circulant SVD deconvolution (delay-insensitive).""" + + method: Literal["cSVD"] = "cSVD" + threshold: float = Field( + 0.2, gt=0.0, lt=1.0, description="SVD truncation threshold (fraction of max)" + ) + + +class OSVDConfig(MethodConfig): + """Oscillation-index SVD deconvolution (per-voxel adaptive threshold).""" + + method: Literal["oSVD"] = "oSVD" + oscillation_index: float = Field( + 0.035, gt=0.0, description="target oscillation index (Wu et al. 2003)" + ) + default_threshold: float = Field( + 0.2, gt=0.0, lt=1.0, description="fallback truncation threshold" + ) + + +#: name -> config model (source for the discriminated union) +DECONVOLVER_CONFIGS: dict[str, type[MethodConfig]] = { + "sSVD": SSVDConfig, + "cSVD": CSVDConfig, + "oSVD": OSVDConfig, +} + +#: name -> fitter class (the live implementation, constructed from config) +DECONVOLVER_REGISTRY = { + "sSVD": SSVDFitter, + "cSVD": CSVDFitter, + "oSVD": OSVDFitter, +} + +__all__ = [ + "DECONVOLVER_CONFIGS", + "DECONVOLVER_REGISTRY", + "CSVDConfig", + "OSVDConfig", + "SSVDConfig", +] diff --git a/osipy/dsc/parameters/maps.py b/osipy/dsc/parameters/maps.py index 31bd86c..158c9db 100644 --- a/osipy/dsc/parameters/maps.py +++ b/osipy/dsc/parameters/maps.py @@ -78,6 +78,7 @@ def compute_perfusion_maps( deconvolve: bool = True, deconvolution_method: str = "oSVD", svd_threshold: float = 0.2, + fitter: Any = None, density: float = 1.04, hematocrit_ratio: float = 0.73, ) -> DSCPerfusionMaps: @@ -155,7 +156,10 @@ def compute_perfusion_maps( dsc_model = DSCConvolutionModel() matrix_type = "toeplitz" if deconvolution_method == "sSVD" else "circulant" bound = BoundDSCModel(dsc_model, aif, time, matrix_type=matrix_type) - fitter = FITTER_REGISTRY[deconvolution_method]() + # Use an explicitly-constructed fitter (carrying its config) when + # provided; otherwise fall back to a default-constructed one. + if fitter is None: + fitter = FITTER_REGISTRY[deconvolution_method]() # Reshape to 4D for fit_image: (x, y, z, t) original_shape = delta_r2.shape diff --git a/osipy/pipeline/dsc_pipeline.py b/osipy/pipeline/dsc_pipeline.py index bd27fe6..fb40558 100644 --- a/osipy/pipeline/dsc_pipeline.py +++ b/osipy/pipeline/dsc_pipeline.py @@ -18,11 +18,12 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any import numpy as np +from osipy.common.config import MethodConfig, construct_from_config from osipy.common.dataset import PerfusionDataset from osipy.dsc import ( DSCPerfusionMaps, @@ -30,6 +31,7 @@ correct_leakage, signal_to_delta_r2, ) +from osipy.dsc.deconvolution.config import DECONVOLVER_REGISTRY, OSVDConfig if TYPE_CHECKING: from collections.abc import Callable @@ -46,20 +48,24 @@ class DSCPipelineConfig: ---------- te : float Echo time in milliseconds. - deconvolution_method : str - Deconvolution method: 'oSVD', 'cSVD', or 'sSVD'. + baseline_frames : int + Number of pre-bolus baseline frames for signal-to-ΔR2* conversion. + hematocrit_ratio : float + Large-to-small-vessel hematocrit ratio for CBV correction. apply_leakage_correction : bool Whether to apply leakage correction. - svd_threshold : float - SVD truncation threshold. + deconvolution : MethodConfig + Deconvolution method config (discriminated by ``method``: + ``sSVD``/``cSVD``/``oSVD``), carrying that method's parameters. output_dir : Path | None Output directory for results. """ te: float = 30.0 - deconvolution_method: str = "oSVD" + baseline_frames: int = 10 + hematocrit_ratio: float = 0.73 apply_leakage_correction: bool = True - svd_threshold: float = 0.2 + deconvolution: MethodConfig = field(default_factory=OSVDConfig) output_dir: Path | None = None @@ -154,7 +160,9 @@ def run( if progress_callback: progress_callback("Signal Conversion", 0.0) - delta_r2 = signal_to_delta_r2(signal, self.config.te) + delta_r2 = signal_to_delta_r2( + signal, self.config.te, baseline_frames=self.config.baseline_frames + ) if progress_callback: progress_callback("Signal Conversion", 1.0) @@ -164,7 +172,9 @@ def run( progress_callback("AIF Processing", 0.0) if aif_signal is not None: - aif = signal_to_delta_r2(aif_signal, self.config.te) + aif = signal_to_delta_r2( + aif_signal, self.config.te, baseline_frames=self.config.baseline_frames + ) elif aif_voxels is not None: # Extract AIF from specified voxels aif = np.mean(delta_r2[aif_voxels], axis=0) @@ -204,14 +214,18 @@ def run( if progress_callback: progress_callback("Perfusion Computation", 0.0) + deconvolver = construct_from_config( + DECONVOLVER_REGISTRY, self.config.deconvolution + ) perfusion_maps = compute_perfusion_maps( delta_r2=delta_r2_corrected, aif=aif, time=time, mask=mask, deconvolve=True, - deconvolution_method=self.config.deconvolution_method, - svd_threshold=self.config.svd_threshold, + deconvolution_method=self.config.deconvolution.method, + fitter=deconvolver, + hematocrit_ratio=self.config.hematocrit_ratio, ) if progress_callback: diff --git a/tests/unit/cli/test_config.py b/tests/unit/cli/test_config.py index 6af21d5..82a3175 100644 --- a/tests/unit/cli/test_config.py +++ b/tests/unit/cli/test_config.py @@ -249,24 +249,33 @@ def test_defaults(self) -> None: """Default DSC config values match expected.""" cfg = DSCPipelineYAML() assert cfg.te == 30.0 - assert cfg.deconvolution_method == "oSVD" + assert cfg.deconvolution.method == "oSVD" + assert cfg.deconvolution.oscillation_index == 0.035 assert cfg.apply_leakage_correction is True - assert cfg.svd_threshold == 0.2 assert cfg.baseline_frames == 10 assert cfg.hematocrit_ratio == 0.73 def test_invalid_deconvolution_method(self) -> None: """Invalid deconvolution method raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid deconvolution method"): - DSCPipelineYAML(deconvolution_method="invalid_method") + with pytest.raises(ValidationError): + DSCPipelineYAML(deconvolution={"method": "invalid_method"}) def test_valid_deconvolution_methods(self) -> None: - """All registered deconvolution methods are accepted.""" - from osipy.dsc import list_deconvolvers - - for name in list_deconvolvers(): - cfg = DSCPipelineYAML(deconvolution_method=name) - assert cfg.deconvolution_method == name + """All registered deconvolution methods are accepted (nested config).""" + from osipy.dsc.deconvolution.config import DECONVOLVER_CONFIGS + + for name in DECONVOLVER_CONFIGS: + cfg = DSCPipelineYAML(deconvolution={"method": name}) + assert cfg.deconvolution.method == name + + def test_method_params_surface_and_validate(self) -> None: + """Selecting a method exposes its params; unknown keys are rejected.""" + cfg = DSCPipelineYAML(deconvolution={"method": "cSVD", "threshold": 0.35}) + assert cfg.deconvolution.method == "cSVD" + assert cfg.deconvolution.threshold == 0.35 + # A knob from a different method must not be accepted (extra=forbid). + with pytest.raises(ValidationError): + DSCPipelineYAML(deconvolution={"method": "cSVD", "oscillation_index": 0.05}) # --------------------------------------------------------------------------- diff --git a/tests/unit/cli/test_wizard.py b/tests/unit/cli/test_wizard.py index d9a15ab..fe8489a 100644 --- a/tests/unit/cli/test_wizard.py +++ b/tests/unit/cli/test_wizard.py @@ -523,12 +523,16 @@ class TestCollectDSCConfig: def test_defaults(self) -> None: """Pressing Enter for all prompts returns defaults.""" - inputs = _make_input_fn(["", "", "", "", "", ""]) + # Prompts: te, baseline_frames, hematocrit_ratio, leakage, method, + # then oSVD's two params (oscillation_index, default_threshold). + inputs = _make_input_fn([""] * 7) with patch("builtins.input", side_effect=inputs): cfg = _collect_dsc_config() - assert cfg["deconvolution_method"] == "oSVD" assert cfg["te"] == 30.0 assert cfg["apply_leakage_correction"] is True + assert cfg["deconvolution"]["method"] == "oSVD" + assert cfg["deconvolution"]["oscillation_index"] == 0.035 + assert cfg["deconvolution"]["default_threshold"] == 0.2 class TestCollectASLConfig: From ccaad6205ff109223f5beecc8c56d7f5d62ebcf1 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 00:00:11 -0400 Subject: [PATCH 03/15] Registry-driven config: DCE rollout Apply the registry-driven config pattern to DCE (osipy/dce/config.py): PK model, T1 method (VFA fit_method linear/nonlinear now selectable), concentration (spgr/linear now selectable), and population AIF become generated discriminated unions in DCEPipelineYAML; DCEPipeline auto-constructs from the validated config. aif_source (pipeline branch) and fitter (in DCEFittingConfig) stay flat. dump_defaults renders nested; wizard introspects per-method params. Gate: 834 passed, 0 failed (core incl. GPU); nested DCE YAML round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/cli/config.py | 71 +++++++----- osipy/cli/runner.py | 21 +++- osipy/cli/wizard.py | 59 ++++++++-- osipy/dce/config.py | 205 +++++++++++++++++++++++++++++++++ osipy/pipeline/dce_pipeline.py | 106 ++++++++++++++--- tests/unit/cli/test_config.py | 205 ++++++++++++++++++++++++++++++--- tests/unit/cli/test_wizard.py | 54 +++++++-- 7 files changed, 634 insertions(+), 87 deletions(-) create mode 100644 osipy/dce/config.py diff --git a/osipy/cli/config.py b/osipy/cli/config.py index 65a69e4..69d9444 100644 --- a/osipy/cli/config.py +++ b/osipy/cli/config.py @@ -15,6 +15,15 @@ from pydantic import BaseModel, Field, field_validator from osipy.common.config import method_union +from osipy.dce.config import ( + CONCENTRATION_CONFIGS, + DCE_MODEL_CONFIGS, + POPULATION_AIF_CONFIGS, + T1_METHOD_CONFIGS, + ExtendedToftsConfig, + SPGRConcentrationConfig, + VFAConfig, +) from osipy.dsc.deconvolution.config import DECONVOLVER_CONFIGS, OSVDConfig logger = logging.getLogger(__name__) @@ -257,47 +266,51 @@ class DCEAcquisitionYAML(BaseModel): ) +# Discriminated unions of DCE selection-point configs, generated from the +# registries: selecting a method/name pulls in exactly that option's params. +_DCEModelConfig = method_union(DCE_MODEL_CONFIGS) +_T1MethodConfig = method_union(T1_METHOD_CONFIGS) +_ConcentrationConfig = method_union(CONCENTRATION_CONFIGS) +_PopulationAIFConfig = method_union(POPULATION_AIF_CONFIGS, discriminator="name") + + +def _default_population_aif() -> Any: + """Default population AIF config (Parker), from the registry.""" + return POPULATION_AIF_CONFIGS["parker"]() + + class DCEPipelineYAML(BaseModel): """DCE pipeline settings from YAML.""" - model: str = Field( - default="extended_tofts", - description="tofts | extended_tofts | patlak | 2cxm | 2cum", + model: _DCEModelConfig = Field( + default_factory=ExtendedToftsConfig, + description=( + "pharmacokinetic model + parameters " + "(method: tofts | extended_tofts | patlak | 2cxm | 2cum)" + ), + ) + t1_mapping_method: _T1MethodConfig = Field( + default_factory=VFAConfig, + description="T1 mapping method + parameters (method: vfa | look_locker)", + ) + concentration: _ConcentrationConfig = Field( + default_factory=SPGRConcentrationConfig, + description="signal-to-concentration model (method: spgr | linear)", ) - t1_mapping_method: str = Field(default="vfa", description="vfa | look_locker") aif_source: str = Field( default="population", description="population | detect | manual" ) - population_aif: str = Field( - default="parker", - description="parker | georgiou | fritz_hansen | weinmann | mcgrath", + population_aif: _PopulationAIFConfig = Field( + default_factory=_default_population_aif, + description=( + "population AIF (name: parker | georgiou | fritz_hansen | " + "weinmann | mcgrath); used when aif_source: population" + ), ) save_intermediate: bool = Field(default=False) acquisition: DCEAcquisitionYAML = DCEAcquisitionYAML() fitting: DCEFittingConfig = DCEFittingConfig() - @field_validator("model") - @classmethod - def validate_model(cls, v: str) -> str: - """Validate DCE model name against registry.""" - from osipy.dce import list_models - - valid = list_models() - if v not in valid: - msg = f"Invalid DCE model '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - - @field_validator("t1_mapping_method") - @classmethod - def validate_t1_method(cls, v: str) -> str: - """Validate T1 mapping method.""" - valid = ["vfa", "look_locker"] - if v not in valid: - msg = f"Invalid T1 mapping method '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - @field_validator("aif_source") @classmethod def validate_aif_source(cls, v: str) -> str: diff --git a/osipy/cli/runner.py b/osipy/cli/runner.py index 42cbdd0..8cf87c1 100644 --- a/osipy/cli/runner.py +++ b/osipy/cli/runner.py @@ -437,8 +437,9 @@ def _run_dce(config: PipelineConfig, data_path: Path, output_dir: Path) -> None: ) pipeline_cfg = DCEPipelineConfig( - model=mc.model, # type: ignore[attr-defined] + model=mc.model, # type: ignore[attr-defined] # validated MethodConfig t1_mapping_method=mc.t1_mapping_method, # type: ignore[attr-defined] + concentration_method=mc.concentration, # type: ignore[attr-defined] aif_source=mc.aif_source, # type: ignore[attr-defined] population_aif=mc.population_aif, # type: ignore[attr-defined] acquisition_params=acq_params, @@ -560,9 +561,18 @@ def _run_dce_from_dicom( {k: tuple(v) for k, v in fitting.bounds.items()} if fitting.bounds else None ) + # DICOM VFA stacks are always VFA T1 mapping; honor the configured VFA + # fit_method (linear/nonlinear) when the user selected VFA, else default. + t1_cfg = mc.t1_mapping_method # type: ignore[attr-defined] # validated MethodConfig + if t1_cfg.method != "vfa": + from osipy.dce.config import VFAConfig + + t1_cfg = VFAConfig() + pipeline_cfg = DCEPipelineConfig( - model=mc.model, # type: ignore[attr-defined] - t1_mapping_method="vfa", + model=mc.model, # type: ignore[attr-defined] # validated MethodConfig + t1_mapping_method=t1_cfg, + concentration_method=mc.concentration, # type: ignore[attr-defined] aif_source=mc.aif_source, # type: ignore[attr-defined] population_aif=mc.population_aif, # type: ignore[attr-defined] acquisition_params=dce_acq_params, @@ -576,7 +586,10 @@ def _run_dce_from_dicom( fit_delay=fitting.fit_delay, ) - logger.info("[Step 3-6] Running DCE pipeline (%s model)...", mc.model) # type: ignore[attr-defined] + logger.info( + "[Step 3-6] Running DCE pipeline (%s model)...", + mc.model.method, # type: ignore[attr-defined] + ) pipeline = DCEPipeline(pipeline_cfg) t_fit = time.perf_counter() result = pipeline.run( diff --git a/osipy/cli/wizard.py b/osipy/cli/wizard.py index b3a103c..6177700 100644 --- a/osipy/cli/wizard.py +++ b/osipy/cli/wizard.py @@ -271,18 +271,46 @@ def _collect_data_config( return cfg +def _collect_method_config( + label: str, + configs: dict[str, Any], + default: str, + discriminator: str = "method", +) -> dict[str, Any]: + """Prompt for a registry-derived selection plus its config model's params. + + Selecting an option surfaces exactly that option's knobs (introspected + from its :class:`MethodConfig` fields), mirroring ``_collect_dsc_config``. + """ + names = sorted(configs) + chosen = _prompt_choice(label, names, default=default) + selection: dict[str, Any] = {discriminator: chosen} + for fname, finfo in configs[chosen].model_fields.items(): + if fname == discriminator: + continue + selection[fname] = _prompt_value( + finfo.description or fname, + default=finfo.default, + expected_type=type(finfo.default), + ) + return selection + + def _collect_dce_config() -> dict[str, Any]: """Collect DCE pipeline settings.""" - from osipy.common.aif.population import list_aifs - from osipy.dce import list_models - from osipy.dce.t1_mapping.registry import list_t1_methods + from osipy.dce.config import ( + CONCENTRATION_CONFIGS, + DCE_MODEL_CONFIGS, + POPULATION_AIF_CONFIGS, + T1_METHOD_CONFIGS, + ) print("\n--- DCE Pipeline Settings ---") cfg: dict[str, Any] = {} - models = list_models() - cfg["model"] = _prompt_choice( - "Pharmacokinetic model:", models, default="extended_tofts" + # Pharmacokinetic model (+ any params it exposes). + cfg["model"] = _collect_method_config( + "Pharmacokinetic model:", DCE_MODEL_CONFIGS, default="extended_tofts" ) # T1 data availability determines whether we do T1 mapping or use @@ -295,9 +323,9 @@ def _collect_dce_config() -> dict[str, Any]: acquisition: dict[str, Any] = {} if has_t1_data: - t1_methods = list_t1_methods() - cfg["t1_mapping_method"] = _prompt_choice( - "T1 mapping method:", t1_methods, default="vfa" + # T1 mapping method (+ its params, e.g. VFA linear/nonlinear). + cfg["t1_mapping_method"] = _collect_method_config( + "T1 mapping method:", T1_METHOD_CONFIGS, default="vfa" ) else: # No T1 data — use an assumed value and skip T1 mapping config @@ -305,14 +333,21 @@ def _collect_dce_config() -> dict[str, Any]: "Assumed T1 value (ms)", default=1400.0, expected_type=float ) + # Signal-to-concentration model (+ its params). + cfg["concentration"] = _collect_method_config( + "Signal-to-concentration model:", CONCENTRATION_CONFIGS, default="spgr" + ) + # AIF source aif_sources = ["population", "detect", "manual"] cfg["aif_source"] = _prompt_choice("AIF source:", aif_sources, default="population") if cfg["aif_source"] == "population": - aifs = list_aifs() - cfg["population_aif"] = _prompt_choice( - "Population AIF:", aifs, default="parker" + cfg["population_aif"] = _collect_method_config( + "Population AIF:", + POPULATION_AIF_CONFIGS, + default="parker", + discriminator="name", ) # Acquisition parameters diff --git a/osipy/dce/config.py b/osipy/dce/config.py new file mode 100644 index 0000000..c6a1912 --- /dev/null +++ b/osipy/dce/config.py @@ -0,0 +1,205 @@ +"""Registry-driven config models for DCE selection points. + +Each DCE selection point — pharmacokinetic model, T1 mapping method, +signal-to-concentration model, and population AIF — declares its tunable +knobs as a :class:`~osipy.common.config.MethodConfig`. These compose into +discriminated unions for the CLI config (so selecting a method surfaces +exactly that method's parameters and rejects cross-method keys), and the +matching ``*_REGISTRY`` maps build the live component from a validated +config instance via :func:`~osipy.common.config.construct_from_config`. + +Mirrors :mod:`osipy.dsc.deconvolution.config`. Adding ``@register_model`` +(or ``@register_t1_method`` / ``@register_concentration_model`` / +``@register_aif``) plus an entry here automatically surfaces the new option +(and its knobs) as a CLI toggle that both validates input and builds the +component — an option can never be "collected but silently ignored". + +References +---------- +.. [1] OSIPI CAPLEX, https://osipi.github.io/OSIPI_CAPLEX/ +.. [2] Dickie BR et al. MRM 2024;91(5):1761-1773. doi:10.1002/mrm.29840 +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field, create_model + +from osipy.common.aif.population import AIF_REGISTRY, list_aifs +from osipy.common.config import MethodConfig +from osipy.dce.models.registry import MODEL_REGISTRY + +# --------------------------------------------------------------------------- +# Pharmacokinetic model selection (@register_model) +# +# The registered PK models take no constructor arguments, so each config is a +# single-field MethodConfig carrying only its ``method`` discriminator. The +# registry maps the name to the model class (constructed with no kwargs). +# --------------------------------------------------------------------------- + + +class ToftsConfig(MethodConfig): + """Standard Tofts model (OSIPI: M.IC1.004).""" + + method: Literal["tofts"] = "tofts" + + +class ExtendedToftsConfig(MethodConfig): + """Extended Tofts model with plasma term (OSIPI: M.IC1.005).""" + + method: Literal["extended_tofts"] = "extended_tofts" + + +class PatlakConfig(MethodConfig): + """Patlak model (OSIPI: M.IC1.006).""" + + method: Literal["patlak"] = "patlak" + + +class TwoCXMConfig(MethodConfig): + """Two-compartment exchange model (OSIPI: M.IC1.009).""" + + method: Literal["2cxm"] = "2cxm" + + +class TwoCUMConfig(MethodConfig): + """Two-compartment uptake model (2CUM).""" + + method: Literal["2cum"] = "2cum" + + +#: name -> config model (source for the discriminated union) +DCE_MODEL_CONFIGS: dict[str, type[MethodConfig]] = { + "tofts": ToftsConfig, + "extended_tofts": ExtendedToftsConfig, + "patlak": PatlakConfig, + "2cxm": TwoCXMConfig, + "2cum": TwoCUMConfig, +} + +#: name -> model class (constructed from config; see ``MODEL_REGISTRY``) +DCE_MODEL_REGISTRY: dict[str, Any] = dict(MODEL_REGISTRY) + + +# --------------------------------------------------------------------------- +# T1 mapping method selection (@register_t1_method) +# +# VFA exposes a real ``fit_method`` knob (linear vs nonlinear refinement); +# Look-Locker has no extra knobs. Modeled as a discriminated union so the +# nonlinear-VFA path is a first-class, selectable option. +# --------------------------------------------------------------------------- + + +class VFAConfig(MethodConfig): + """Variable Flip Angle T1 mapping (OSIPI: P.NR2.002).""" + + method: Literal["vfa"] = "vfa" + fit_method: Literal["linear", "nonlinear"] = Field( + "linear", + description="VFA fit: linear (fast) or nonlinear (LM refinement)", + ) + + +class LookLockerConfig(MethodConfig): + """Look-Locker inversion-recovery T1 mapping (OSIPI: P.NR2.004).""" + + method: Literal["look_locker"] = "look_locker" + + +#: name -> config model (source for the discriminated union) +T1_METHOD_CONFIGS: dict[str, type[MethodConfig]] = { + "vfa": VFAConfig, + "look_locker": LookLockerConfig, +} + + +# --------------------------------------------------------------------------- +# Signal-to-concentration model selection (@register_concentration_model) +# +# Both SPGR and linear conversions take no extra knobs (the relaxivity / TR / +# flip angle come from the acquisition block), so each is a single-field +# MethodConfig. Exposing both makes the linear path a selectable option. +# --------------------------------------------------------------------------- + + +class SPGRConcentrationConfig(MethodConfig): + """Spoiled gradient-echo signal-to-concentration model (OSIPI: P.SC1.001).""" + + method: Literal["spgr"] = "spgr" + + +class LinearConcentrationConfig(MethodConfig): + """Linear signal-to-concentration approximation (small enhancement).""" + + method: Literal["linear"] = "linear" + + +#: name -> config model (source for the discriminated union) +CONCENTRATION_CONFIGS: dict[str, type[MethodConfig]] = { + "spgr": SPGRConcentrationConfig, + "linear": LinearConcentrationConfig, +} + + +# --------------------------------------------------------------------------- +# Population AIF selection (@register_aif) +# +# Population AIFs take no constructor arguments (defaults from each model's +# published parameters), so their configs are single-field MethodConfigs +# generated directly from the registry — adding ``@register_aif("foo")`` +# surfaces ``foo`` as a selectable population AIF automatically. The +# discriminator field is ``name`` (an AIF is identified by its name, not a +# "method"). +# --------------------------------------------------------------------------- + +AIF_DISCRIMINATOR = "name" + + +def _make_aif_configs() -> dict[str, type[MethodConfig]]: + """Generate single-field AIF config models from the AIF registry. + + Each canonical AIF name (aliases excluded by ``list_aifs()``) becomes a + :class:`MethodConfig` subclass whose ``name`` discriminator literal equals + that name. Generated dynamically so new ``@register_aif`` entries surface + without editing this module. + """ + configs: dict[str, type[MethodConfig]] = {} + for aif_name in list_aifs(): + model = create_model( + f"{aif_name.title().replace('_', '')}AIFConfig", + __base__=MethodConfig, + **{AIF_DISCRIMINATOR: (Literal[aif_name], aif_name)}, # type: ignore[call-overload] + ) + model.__doc__ = f"Population AIF: {aif_name}." + configs[aif_name] = model + return configs + + +#: name -> config model (source for the discriminated union) +POPULATION_AIF_CONFIGS: dict[str, type[MethodConfig]] = _make_aif_configs() + +#: name -> AIF class (constructed from config; canonical names only) +POPULATION_AIF_REGISTRY: dict[str, Any] = { + name: AIF_REGISTRY[name] for name in list_aifs() +} + + +__all__ = [ + "AIF_DISCRIMINATOR", + "CONCENTRATION_CONFIGS", + "DCE_MODEL_CONFIGS", + "DCE_MODEL_REGISTRY", + "POPULATION_AIF_CONFIGS", + "POPULATION_AIF_REGISTRY", + "T1_METHOD_CONFIGS", + "ExtendedToftsConfig", + "LinearConcentrationConfig", + "LookLockerConfig", + "PatlakConfig", + "SPGRConcentrationConfig", + "ToftsConfig", + "TwoCUMConfig", + "TwoCXMConfig", + "VFAConfig", +] diff --git a/osipy/pipeline/dce_pipeline.py b/osipy/pipeline/dce_pipeline.py index 764b6c7..830c195 100644 --- a/osipy/pipeline/dce_pipeline.py +++ b/osipy/pipeline/dce_pipeline.py @@ -19,7 +19,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any import numpy as np @@ -27,9 +27,9 @@ from osipy.common.aif import ( ArterialInputFunction, detect_aif, - get_population_aif, ) from osipy.common.backend.array_module import get_array_module +from osipy.common.config import MethodConfig, config_params, construct_from_config from osipy.common.dataset import PerfusionDataset from osipy.common.exceptions import DataValidationError from osipy.common.types import Modality @@ -39,6 +39,13 @@ compute_t1_map, signal_to_concentration, ) +from osipy.dce.config import ( + CONCENTRATION_CONFIGS, + DCE_MODEL_CONFIGS, + POPULATION_AIF_CONFIGS, + POPULATION_AIF_REGISTRY, + T1_METHOD_CONFIGS, +) from osipy.dce.fitting import fit_model if TYPE_CHECKING: @@ -73,7 +80,9 @@ class DCEPipelineConfig: fitter : str | None Fitter registry name (e.g., 'lm', 'bayesian'). concentration_method : str - Signal-to-concentration conversion method. + Signal-to-concentration conversion method ('spgr' or 'linear'). + vfa_fit_method : str + VFA T1 fit when t1_mapping_method='vfa': 'linear' or 'nonlinear'. bounds_override : dict[str, tuple[float, float]] | None Per-parameter bound overrides for fitting. aif_detection_method : str @@ -89,17 +98,27 @@ class DCEPipelineConfig: fit_delay : bool If True, jointly fit an arterial delay parameter with the DCE model (adds one parameter per voxel). Defaults to False. + + Notes + ----- + Selection points (model, T1 method, concentration model, population AIF) + are registry-driven: their names map to :class:`MethodConfig` models in + :mod:`osipy.dce.config`, and the pipeline builds each component from a + validated config via :func:`construct_from_config`. The string fields here + are normalized to those configs in :meth:`__post_init__`, so callers may + pass either a name (e.g. ``model="tofts"``) or a ``MethodConfig`` instance. """ - t1_mapping_method: str = "vfa" - model: str = "extended_tofts" + t1_mapping_method: str | MethodConfig = "vfa" + model: str | MethodConfig = "extended_tofts" aif_source: str = "population" - population_aif: str = "parker" + population_aif: str | MethodConfig = "parker" acquisition_params: DCEAcquisitionParams | None = None output_dir: Path | None = None save_intermediate: bool = False fitter: str | None = None - concentration_method: str = "spgr" + concentration_method: str | MethodConfig = "spgr" + vfa_fit_method: str = "linear" bounds_override: dict[str, tuple[float, float]] | None = None aif_detection_method: str = "multi_criteria" initial_guess_override: dict[str, float] | None = None @@ -108,6 +127,59 @@ class DCEPipelineConfig: r2_threshold: float | None = None fit_delay: bool = False + # Validated MethodConfig instances (built in __post_init__). + model_config_obj: MethodConfig = field(init=False, repr=False) + t1_config_obj: MethodConfig = field(init=False, repr=False) + concentration_config_obj: MethodConfig = field(init=False, repr=False) + population_aif_config_obj: MethodConfig = field(init=False, repr=False) + + def __post_init__(self) -> None: + """Normalize name/MethodConfig fields into validated config objects. + + Each selection point accepts either a registry name (str) or an + already-validated :class:`MethodConfig`. Names are looked up in the + corresponding ``*_CONFIGS`` map; the VFA ``fit_method`` knob is folded + into the VFA config so the nonlinear path is selectable via the flat + ``vfa_fit_method`` field as well as a nested config. + """ + self.model_config_obj = self._resolve( + self.model, DCE_MODEL_CONFIGS, "DCE model" + ) + self.t1_config_obj = self._resolve( + self.t1_mapping_method, T1_METHOD_CONFIGS, "T1 mapping method" + ) + # Fold the flat vfa_fit_method into the VFA config (unless a nested + # config already specified it explicitly). + if self.t1_config_obj.method == "vfa" and not isinstance( + self.t1_mapping_method, MethodConfig + ): + self.t1_config_obj = T1_METHOD_CONFIGS["vfa"]( + fit_method=self.vfa_fit_method + ) + self.concentration_config_obj = self._resolve( + self.concentration_method, CONCENTRATION_CONFIGS, "concentration model" + ) + self.population_aif_config_obj = self._resolve( + self.population_aif, + POPULATION_AIF_CONFIGS, + "population AIF", + ) + + @staticmethod + def _resolve( + value: str | MethodConfig, + configs: dict[str, type[MethodConfig]], + label: str, + ) -> MethodConfig: + """Coerce a name or MethodConfig into a validated config instance.""" + if isinstance(value, MethodConfig): + return value + if value not in configs: + valid = ", ".join(sorted(configs)) + msg = f"Unknown {label} '{value}'. Valid: {valid}" + raise DataValidationError(msg) + return configs[value]() + @dataclass class DCEPipelineResult: @@ -225,7 +297,7 @@ def run( signal=signal, t1_map=t1_map, acquisition_params=acq_params, - method=self.config.concentration_method, + method=self.config.concentration_config_obj.method, ) else: # Assume input is already concentration or use direct signal @@ -251,7 +323,7 @@ def run( fit_mask = self._build_fit_mask(concentration, t1_map, mask) fit_result = fit_model( - model_name=self.config.model, + model_name=self.config.model_config_obj.method, concentration=concentration, aif=aif, time=time, @@ -312,15 +384,19 @@ def _compute_t1_map( signal = t1_data.data if isinstance(t1_data, PerfusionDataset) else t1_data affine = t1_data.affine if isinstance(t1_data, PerfusionDataset) else np.eye(4) - if self.config.t1_mapping_method == "vfa": + t1_cfg = self.config.t1_config_obj + if t1_cfg.method == "vfa": if flip_angles is None or tr is None: msg = "flip_angles and tr required for VFA T1 mapping" raise DataValidationError(msg) + # config_params(t1_cfg) carries the VFA knobs (fit_method); rename + # to the compute_t1_vfa keyword so the nonlinear path is selectable. + fit_method = config_params(t1_cfg).get("fit_method", "linear") t1_result = compute_t1_vfa( signal=signal, flip_angles=flip_angles, tr=tr, - method="linear", + method=fit_method, ) return ParameterMap( name="T1", @@ -334,7 +410,7 @@ def _compute_t1_map( # Look-Locker branch if not isinstance(t1_data, PerfusionDataset): t1_data = PerfusionDataset(data=signal, modality=Modality.DCE) - ll_result = compute_t1_map(t1_data, method="look_locker") + ll_result = compute_t1_map(t1_data, method=t1_cfg.method) return ParameterMap( name="T1", symbol="T1", @@ -352,7 +428,11 @@ def _get_aif( ) -> ArterialInputFunction: """Get AIF based on configuration.""" if self.config.aif_source == "population": - aif_model = get_population_aif(self.config.population_aif) + aif_model = construct_from_config( + POPULATION_AIF_REGISTRY, + self.config.population_aif_config_obj, + discriminator="name", + ) return aif_model(time) elif self.config.aif_source == "detect": diff --git a/tests/unit/cli/test_config.py b/tests/unit/cli/test_config.py index 82a3175..8e9be21 100644 --- a/tests/unit/cli/test_config.py +++ b/tests/unit/cli/test_config.py @@ -66,10 +66,16 @@ def test_load_full_dce_config(self, tmp_config) -> None: path = tmp_config("""\ modality: dce pipeline: - model: extended_tofts - t1_mapping_method: vfa + model: + method: extended_tofts + t1_mapping_method: + method: vfa + fit_method: linear + concentration: + method: spgr aif_source: population - population_aif: parker + population_aif: + name: parker save_intermediate: true acquisition: tr: 5.0 @@ -93,6 +99,30 @@ def test_load_full_dce_config(self, tmp_config) -> None: assert config.backend.force_cpu is True assert config.logging.level == "DEBUG" + def test_load_dce_nested_method_configs_round_trip(self, tmp_config) -> None: + """Nested DCE selection-point configs round-trip through load_config.""" + path = tmp_config("""\ + modality: dce + pipeline: + model: + method: tofts + t1_mapping_method: + method: vfa + fit_method: nonlinear + concentration: + method: linear + aif_source: population + population_aif: + name: georgiou + """) + config = load_config(path) + mc = config.get_modality_config() + assert mc.model.method == "tofts" + assert mc.t1_mapping_method.method == "vfa" + assert mc.t1_mapping_method.fit_method == "nonlinear" + assert mc.concentration.method == "linear" + assert mc.population_aif.name == "georgiou" + def test_load_dsc_config(self, tmp_config) -> None: """Valid DSC config loads successfully.""" path = tmp_config("""\ @@ -196,23 +226,25 @@ class TestDCEPipelineYAML: """Tests for DCE pipeline config validation.""" def test_defaults(self) -> None: - """Default DCE config values match expected.""" + """Default DCE config values match expected (nested registry configs).""" cfg = DCEPipelineYAML() - assert cfg.model == "extended_tofts" - assert cfg.t1_mapping_method == "vfa" + assert cfg.model.method == "extended_tofts" + assert cfg.t1_mapping_method.method == "vfa" + assert cfg.t1_mapping_method.fit_method == "linear" + assert cfg.concentration.method == "spgr" assert cfg.aif_source == "population" - assert cfg.population_aif == "parker" + assert cfg.population_aif.name == "parker" assert cfg.save_intermediate is False def test_invalid_model(self) -> None: - """Invalid model name raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid DCE model"): - DCEPipelineYAML(model="nonexistent_model") + """Invalid model name raises ValidationError (no matching union member).""" + with pytest.raises(ValidationError): + DCEPipelineYAML(model={"method": "nonexistent_model"}) def test_invalid_t1_method(self) -> None: """Invalid T1 mapping method raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid T1 mapping method"): - DCEPipelineYAML(t1_mapping_method="invalid_method") + with pytest.raises(ValidationError): + DCEPipelineYAML(t1_mapping_method={"method": "invalid_method"}) def test_invalid_aif_source(self) -> None: """Invalid AIF source raises ValidationError.""" @@ -220,12 +252,39 @@ def test_invalid_aif_source(self) -> None: DCEPipelineYAML(aif_source="invalid_source") def test_valid_models(self) -> None: - """All registered DCE model names are accepted.""" - from osipy.dce import list_models + """All registered DCE model names are accepted (nested config).""" + from osipy.dce.config import DCE_MODEL_CONFIGS - for name in list_models(): - cfg = DCEPipelineYAML(model=name) - assert cfg.model == name + for name in DCE_MODEL_CONFIGS: + cfg = DCEPipelineYAML(model={"method": name}) + assert cfg.model.method == name + + def test_vfa_fit_method_surfaces_and_validates(self) -> None: + """Selecting VFA exposes its fit_method knob; cross-method keys rejected.""" + cfg = DCEPipelineYAML( + t1_mapping_method={"method": "vfa", "fit_method": "nonlinear"} + ) + assert cfg.t1_mapping_method.method == "vfa" + assert cfg.t1_mapping_method.fit_method == "nonlinear" + # Look-Locker has no fit_method knob (extra=forbid). + with pytest.raises(ValidationError): + DCEPipelineYAML( + t1_mapping_method={"method": "look_locker", "fit_method": "linear"} + ) + + def test_concentration_method_selectable(self) -> None: + """Both spgr and linear concentration models are selectable.""" + for name in ("spgr", "linear"): + cfg = DCEPipelineYAML(concentration={"method": name}) + assert cfg.concentration.method == name + + def test_population_aif_selectable(self) -> None: + """All registered population AIFs are selectable via the nested config.""" + from osipy.dce.config import POPULATION_AIF_CONFIGS + + for name in POPULATION_AIF_CONFIGS: + cfg = DCEPipelineYAML(population_aif={"name": name}) + assert cfg.population_aif.name == name def test_acquisition_defaults(self) -> None: """Acquisition sub-model has correct defaults.""" @@ -422,7 +481,8 @@ def test_dce_config_from_yaml(self, tmp_config) -> None: path = tmp_config("""\ modality: dce pipeline: - model: tofts + model: + method: tofts fitting: fitter: lm max_iterations: 200 @@ -437,6 +497,7 @@ def test_dce_config_from_yaml(self, tmp_config) -> None: """) config = load_config(path) mc = config.get_modality_config() + assert mc.model.method == "tofts" assert mc.fitting.fitter == "lm" assert mc.fitting.max_iterations == 200 assert mc.fitting.tolerance == 1e-8 @@ -705,6 +766,114 @@ def __init__(self) -> None: assert captured.get("fit_delay") is True +# --------------------------------------------------------------------------- +# TestDCERegistryConfigWiring — non-default knobs reach the components +# --------------------------------------------------------------------------- + + +class TestDCERegistryConfigWiring: + """A non-default selection-point knob auto-constructs the right component.""" + + def test_yaml_model_name_reaches_fit_model(self, tmp_config, monkeypatch) -> None: + """A model selected in YAML drives fit_model's model_name (auto-construct).""" + import numpy as np + + from osipy.cli.config import load_config + from osipy.common.aif import ArterialInputFunction + from osipy.common.dataset import PerfusionDataset + from osipy.common.types import AIFType, Modality + from osipy.pipeline import dce_pipeline + from osipy.pipeline.dce_pipeline import DCEPipeline, DCEPipelineConfig + + path = tmp_config("""\ + modality: dce + pipeline: + model: + method: patlak + """) + mc = load_config(path).get_modality_config() + + captured: dict[str, object] = {} + + def _fake_fit_model(**kwargs): + captured.update(kwargs) + + class _Result: + def __init__(self) -> None: + self.parameter_maps: dict = {} + self.quality_mask = np.ones((2, 2, 2), dtype=bool) + + return _Result() + + monkeypatch.setattr(dce_pipeline, "fit_model", _fake_fit_model) + + time = np.linspace(0, 60, 10) + dataset = PerfusionDataset( + data=np.random.rand(2, 2, 2, 10), + affine=np.eye(4), + modality=Modality.DCE, + time_points=time, + ) + # Runner passes the validated nested config straight through. + cfg = DCEPipelineConfig(model=mc.model) + DCEPipeline(cfg).run( + dce_data=dataset, + time=time, + t1_map=None, + aif=ArterialInputFunction( + time=time, + concentration=np.abs(np.random.rand(10)), + aif_type=AIFType.POPULATION, + ), + ) + assert captured.get("model_name") == "patlak" + + def test_nonlinear_vfa_knob_reaches_compute_t1_vfa(self, monkeypatch) -> None: + """vfa_fit_method=nonlinear propagates to compute_t1_vfa(method=...).""" + import numpy as np + + from osipy.pipeline.dce_pipeline import DCEPipeline, DCEPipelineConfig + + captured: dict[str, object] = {} + + class _T1Result: + def __init__(self) -> None: + from osipy.common.parameter_map import ParameterMap + + vals = np.full((2, 2, 1), 1200.0) + self.t1_map = ParameterMap( + name="T1", symbol="T1", units="ms", values=vals, affine=np.eye(4) + ) + self.quality_mask = np.ones((2, 2, 1), dtype=bool) + + def _fake_compute_t1_vfa(**kwargs): + captured.update(kwargs) + return _T1Result() + + monkeypatch.setattr("osipy.dce.t1_mapping.compute_t1_vfa", _fake_compute_t1_vfa) + + cfg = DCEPipelineConfig(t1_mapping_method="vfa", vfa_fit_method="nonlinear") + pipeline = DCEPipeline(cfg) + pipeline._compute_t1_map( + np.random.rand(2, 2, 1, 4), + flip_angles=np.array([2.0, 5.0, 10.0, 15.0]), + tr=5.0, + ) + assert captured.get("method") == "nonlinear" + + def test_population_aif_knob_constructs_selected_aif(self) -> None: + """A non-default population AIF name builds that AIF in the pipeline.""" + import numpy as np + + from osipy.pipeline.dce_pipeline import DCEPipeline, DCEPipelineConfig + + cfg = DCEPipelineConfig(aif_source="population", population_aif="georgiou") + pipeline = DCEPipeline(cfg) + time = np.linspace(0, 60, 20) + aif = pipeline._get_aif(np.zeros((2, 2, 1, 20)), time, mask=None) + assert aif.population_model == "Georgiou" + + # --------------------------------------------------------------------------- # TestCLIParser # --------------------------------------------------------------------------- diff --git a/tests/unit/cli/test_wizard.py b/tests/unit/cli/test_wizard.py index fe8489a..9b267ce 100644 --- a/tests/unit/cli/test_wizard.py +++ b/tests/unit/cli/test_wizard.py @@ -321,9 +321,9 @@ def test_valid_dce_with_assumed_t1(self) -> None: yaml_str = _generate_yaml( "dce", { - "model": "extended_tofts", + "model": {"method": "extended_tofts"}, "aif_source": "population", - "population_aif": "parker", + "population_aif": {"name": "parker"}, "acquisition": { "t1_assumed": 1400.0, "baseline_frames": 5, @@ -340,7 +340,7 @@ def test_invalid_raises(self) -> None: yaml_str = _generate_yaml( "dce", - {"model": "nonexistent_model"}, + {"model": {"method": "nonexistent_model"}}, {"format": "auto"}, ) with pytest.raises(ValidationError): @@ -460,12 +460,16 @@ class TestCollectDCEConfig: """Tests for _collect_dce_config().""" def test_defaults_with_t1_data(self) -> None: - """Defaults with T1 data: model, has_t1=yes, t1_method, aif, pop_aif, baseline, relaxivity.""" + """Defaults with T1 data: model, has_t1, t1_method (+ fit_method), + concentration, aif, pop_aif, baseline, relaxivity. Selection points are + now nested registry configs.""" inputs = _make_input_fn( [ "", # model: extended_tofts "", # has T1 data: yes (default) "", # T1 method: vfa + "", # VFA fit_method: linear + "", # concentration model: spgr "", # AIF source: population "", # population AIF: parker "", # baseline frames: 5 @@ -474,10 +478,11 @@ def test_defaults_with_t1_data(self) -> None: ) with patch("builtins.input", side_effect=inputs): cfg = _collect_dce_config() - assert cfg["model"] == "extended_tofts" - assert cfg["t1_mapping_method"] == "vfa" + assert cfg["model"] == {"method": "extended_tofts"} + assert cfg["t1_mapping_method"] == {"method": "vfa", "fit_method": "linear"} + assert cfg["concentration"] == {"method": "spgr"} assert cfg["aif_source"] == "population" - assert cfg["population_aif"] == "parker" + assert cfg["population_aif"] == {"name": "parker"} assert cfg["acquisition"]["baseline_frames"] == 5 assert cfg["acquisition"]["relaxivity"] == 4.5 assert "t1_assumed" not in cfg["acquisition"] @@ -489,6 +494,7 @@ def test_no_t1_data_uses_assumed(self) -> None: "", # model: extended_tofts "n", # has T1 data: no "", # t1_assumed: 1400.0 + "", # concentration model: spgr "", # AIF source: population "", # population AIF: parker "", # baseline frames: 5 @@ -507,6 +513,8 @@ def test_detect_aif_skips_population(self) -> None: "", # model "", # has T1 data: yes "", # T1 method + "", # VFA fit_method: linear + "", # concentration model: spgr "detect", # AIF source "", # baseline frames "", # relaxivity @@ -517,6 +525,27 @@ def test_detect_aif_skips_population(self) -> None: assert cfg["aif_source"] == "detect" assert "population_aif" not in cfg + def test_nonlinear_vfa_selectable(self) -> None: + """The VFA nonlinear fit_method is collectable as a knob.""" + inputs = _make_input_fn( + [ + "", # model: extended_tofts + "", # has T1 data: yes + "vfa", # T1 method + "nonlinear", # VFA fit_method + "linear", # concentration model + "", # AIF source: population + "georgiou", # population AIF + "", # baseline frames + "", # relaxivity + ] + ) + with patch("builtins.input", side_effect=inputs): + cfg = _collect_dce_config() + assert cfg["t1_mapping_method"] == {"method": "vfa", "fit_method": "nonlinear"} + assert cfg["concentration"] == {"method": "linear"} + assert cfg["population_aif"] == {"name": "georgiou"} + class TestCollectDSCConfig: """Tests for _collect_dsc_config().""" @@ -577,10 +606,11 @@ def test_defaults_round_trip(self, modality: str) -> None: # Map of default pipeline configs matching what defaults produce defaults = { "dce": { - "model": "extended_tofts", - "t1_mapping_method": "vfa", + "model": {"method": "extended_tofts"}, + "t1_mapping_method": {"method": "vfa", "fit_method": "linear"}, + "concentration": {"method": "spgr"}, "aif_source": "population", - "population_aif": "parker", + "population_aif": {"name": "parker"}, "acquisition": {"baseline_frames": 5, "relaxivity": 4.5}, }, "dsc": { @@ -660,6 +690,8 @@ def test_full_dce_wizard(self, tmp_path: Path) -> None: "", # model: extended_tofts "", # has T1 data: yes "", # T1 method: vfa + "", # VFA fit_method: linear + "", # concentration model: spgr "", # AIF source: population "", # population AIF: parker "", # baseline frames: 5 @@ -682,7 +714,7 @@ def test_full_dce_wizard(self, tmp_path: Path) -> None: assert out_file.exists() parsed = yaml.safe_load(out_file.read_text()) assert parsed["modality"] == "dce" - assert parsed["pipeline"]["model"] == "extended_tofts" + assert parsed["pipeline"]["model"]["method"] == "extended_tofts" assert parsed["pipeline"]["acquisition"]["baseline_frames"] == 5 def test_full_ivim_wizard(self, tmp_path: Path) -> None: From 2aad9104f9d7fb6d2f8dd1ad51dd1c491be1ddbc Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 00:14:32 -0400 Subject: [PATCH 04/15] Registry-driven config: ASL rollout (+ multi-PLD mode) Apply the registry-driven config pattern to ASL (osipy/asl/config.py): m0 calibration, difference method, and a new quantification *mode* (single_pld vs multi_pld) become generated discriminated unions in ASLPipelineYAML. Multi-PLD (Buxton + ATT) is now a selectable mode producing CBF + ATT maps. The ASL physiological knobs (t1_tissue, partition_coefficient, t1_blood, labeling_efficiency, pld, label_duration) and difference_method now actually flow into quantification (previously collected but ignored). ASLPipeline auto-constructs m0/difference/quant from the validated config. Gate: 845 passed, 0 failed (core incl. GPU); partition_coefficient doubling doubles CBF; multi_pld yields CBF+ATT; nested ASL YAML round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/asl/config.py | 197 +++++++++++++++++++++++++ osipy/cli/config.py | 56 ++++--- osipy/cli/runner.py | 10 +- osipy/cli/wizard.py | 63 ++++++-- osipy/pipeline/asl_pipeline.py | 155 ++++++++++++++++--- tests/integration/test_asl_pipeline.py | 132 +++++++++++++++++ tests/unit/cli/test_config.py | 96 +++++++++++- tests/unit/cli/test_wizard.py | 59 ++++++-- 8 files changed, 700 insertions(+), 68 deletions(-) create mode 100644 osipy/asl/config.py diff --git a/osipy/asl/config.py b/osipy/asl/config.py new file mode 100644 index 0000000..bc2569d --- /dev/null +++ b/osipy/asl/config.py @@ -0,0 +1,197 @@ +"""Registry-driven config models for ASL selection points. + +Each ASL selection point — M0 calibration method, label/control difference +method, and the CBF quantification *mode* (single-PLD vs multi-PLD) — declares +its tunable knobs as a :class:`~osipy.common.config.MethodConfig`. These compose +into discriminated unions for the CLI config (so selecting a method/mode +surfaces exactly that option's parameters and rejects cross-method keys). + +Mirrors :mod:`osipy.dsc.deconvolution.config` and :mod:`osipy.dce.config`. + +Notes +----- +* **M0 calibration** strategy classes are stateless (their tunable knobs flow + into :class:`~osipy.asl.calibration.m0.M0CalibrationParams`, which is passed + to ``calibrate()``), so the config carries the knobs but the live component is + selected by name via :class:`M0CalibrationParams.method`. ``M0_REGISTRY`` + therefore maps to the strategy classes, and the helper + :func:`m0_params_from_config` builds the params dataclass from a config. +* **Difference methods** are plain functions selected by name (no extra knobs), + so each config is a single-field selection. +* The **quantification mode** discriminates single-PLD vs multi-PLD analysis; + single-PLD takes no extra knobs (timing comes from the pipeline block), while + multi-PLD adds the PLD schedule and the ATT model. + +References +---------- +.. [1] OSIPI ASL Lexicon, https://osipi.github.io/ASL-Lexicon/ +.. [2] Suzuki Y et al. MRM 2024;91(5):1743-1760. doi:10.1002/mrm.29815 +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field + +from osipy.asl.calibration.registry import M0_CALIBRATION_REGISTRY +from osipy.asl.quantification.att_registry import ATT_MODEL_REGISTRY +from osipy.common.config import MethodConfig + +# --------------------------------------------------------------------------- +# M0 calibration method selection (@register_m0_calibration) +# +# Discriminated by ``method``. The knobs map to ``M0CalibrationParams`` fields; +# ``reference_region`` exposes the extra ``reference_region`` knob. +# --------------------------------------------------------------------------- + + +class SingleM0Config(MethodConfig): + """Single (mean) M0 calibration (OSIPI ASL Lexicon).""" + + method: Literal["single"] = "single" + t1_tissue: float = Field( + 1330.0, gt=0.0, description="ms, T1 of calibration tissue (M0 recovery)" + ) + tr_m0: float = Field(6000.0, gt=0.0, description="ms, TR of the M0 acquisition") + te_m0: float = Field(13.0, ge=0.0, description="ms, TE of the M0 acquisition") + + +class VoxelwiseM0Config(MethodConfig): + """Voxel-by-voxel M0 calibration (OSIPI ASL Lexicon).""" + + method: Literal["voxelwise"] = "voxelwise" + t1_tissue: float = Field( + 1330.0, gt=0.0, description="ms, T1 of calibration tissue (M0 recovery)" + ) + tr_m0: float = Field(6000.0, gt=0.0, description="ms, TR of the M0 acquisition") + te_m0: float = Field(13.0, ge=0.0, description="ms, TE of the M0 acquisition") + + +class ReferenceRegionM0Config(MethodConfig): + """Reference-region M0 calibration (OSIPI ASL Lexicon).""" + + method: Literal["reference_region"] = "reference_region" + reference_region: Literal["csf", "white_matter", "custom"] = Field( + "csf", description="reference tissue for the single M0 value" + ) + t1_tissue: float = Field( + 1330.0, gt=0.0, description="ms, T1 of calibration tissue (M0 recovery)" + ) + tr_m0: float = Field(6000.0, gt=0.0, description="ms, TR of the M0 acquisition") + te_m0: float = Field(13.0, ge=0.0, description="ms, TE of the M0 acquisition") + + +#: name -> config model (source for the discriminated union) +M0_CONFIGS: dict[str, type[MethodConfig]] = { + "single": SingleM0Config, + "voxelwise": VoxelwiseM0Config, + "reference_region": ReferenceRegionM0Config, +} + +#: name -> calibration strategy class (selected by ``M0CalibrationParams.method``) +M0_REGISTRY: dict[str, type] = dict(M0_CALIBRATION_REGISTRY) + + +def m0_params_from_config(config: MethodConfig) -> object: + """Build an :class:`M0CalibrationParams` from a validated M0 config. + + The config's ``method`` discriminator plus its knobs map directly onto the + :class:`~osipy.asl.calibration.m0.M0CalibrationParams` dataclass fields. + """ + from osipy.asl.calibration.m0 import M0CalibrationParams + + kwargs = config.model_dump() + return M0CalibrationParams(**kwargs) + + +# --------------------------------------------------------------------------- +# Label/control difference method selection (@register_difference_method) +# +# Difference methods are plain functions with no extra knobs, so each config is +# a single-field selection. Exposing them as a union makes the difference method +# a first-class, validated, wired option (previously collected but ignored). +# --------------------------------------------------------------------------- + + +class PairwiseDifferenceConfig(MethodConfig): + """Pair-wise control-label subtraction, then average.""" + + method: Literal["pairwise"] = "pairwise" + + +class SurroundDifferenceConfig(MethodConfig): + """Surround subtraction (average adjacent controls per label).""" + + method: Literal["surround"] = "surround" + + +class MeanDifferenceConfig(MethodConfig): + """Mean subtraction (average controls and labels separately).""" + + method: Literal["mean"] = "mean" + + +#: name -> config model (source for the discriminated union) +DIFFERENCE_CONFIGS: dict[str, type[MethodConfig]] = { + "pairwise": PairwiseDifferenceConfig, + "surround": SurroundDifferenceConfig, + "mean": MeanDifferenceConfig, +} + + +# --------------------------------------------------------------------------- +# Quantification mode selection (single-PLD vs multi-PLD) +# +# Discriminated by ``mode``. Single-PLD takes no extra knobs (timing comes from +# the pipeline block). Multi-PLD (Buxton + ATT estimation) adds the PLD schedule +# and the ATT model, routing the pipeline to the multi-PLD path. +# --------------------------------------------------------------------------- + +QUANT_DISCRIMINATOR = "mode" + + +class SinglePLDConfig(MethodConfig): + """Single-PLD CBF quantification (one delay per voxel).""" + + mode: Literal["single_pld"] = "single_pld" + + +class MultiPLDConfig(MethodConfig): + """Multi-PLD CBF + ATT estimation via the Buxton general kinetic model.""" + + mode: Literal["multi_pld"] = "multi_pld" + plds: list[float] = Field( + default_factory=lambda: [500.0, 1000.0, 1500.0, 2000.0, 2500.0], + description="ms, post-labeling delay schedule (one volume per PLD)", + ) + att_model: str = Field("buxton", description="ATT estimation model (registry name)") + + +#: name -> config model (source for the discriminated union) +QUANTIFICATION_CONFIGS: dict[str, type[MethodConfig]] = { + "single_pld": SinglePLDConfig, + "multi_pld": MultiPLDConfig, +} + +#: name -> ATT model class (constructed from config; see ``ATT_MODEL_REGISTRY``) +ATT_REGISTRY: dict[str, type] = dict(ATT_MODEL_REGISTRY) + + +__all__ = [ + "ATT_REGISTRY", + "DIFFERENCE_CONFIGS", + "M0_CONFIGS", + "M0_REGISTRY", + "QUANTIFICATION_CONFIGS", + "QUANT_DISCRIMINATOR", + "MeanDifferenceConfig", + "MultiPLDConfig", + "PairwiseDifferenceConfig", + "ReferenceRegionM0Config", + "SingleM0Config", + "SinglePLDConfig", + "SurroundDifferenceConfig", + "VoxelwiseM0Config", + "m0_params_from_config", +] diff --git a/osipy/cli/config.py b/osipy/cli/config.py index 69d9444..580b780 100644 --- a/osipy/cli/config.py +++ b/osipy/cli/config.py @@ -14,6 +14,15 @@ import yaml from pydantic import BaseModel, Field, field_validator +from osipy.asl.config import ( + DIFFERENCE_CONFIGS, + M0_CONFIGS, + QUANT_DISCRIMINATOR, + QUANTIFICATION_CONFIGS, + PairwiseDifferenceConfig, + SingleM0Config, + SinglePLDConfig, +) from osipy.common.config import method_union from osipy.dce.config import ( CONCENTRATION_CONFIGS, @@ -354,6 +363,15 @@ class DSCPipelineYAML(BaseModel): # --------------------------------------------------------------------------- +# Discriminated unions of ASL selection-point configs, generated from the +# registries: selecting a method/mode pulls in exactly that option's params. +_M0Config = method_union(M0_CONFIGS) +_DifferenceConfig = method_union(DIFFERENCE_CONFIGS) +_QuantificationConfig = method_union( + QUANTIFICATION_CONFIGS, discriminator=QUANT_DISCRIMINATOR +) + + class ASLPipelineYAML(BaseModel): """ASL pipeline settings from YAML.""" @@ -363,20 +381,32 @@ class ASLPipelineYAML(BaseModel): t1_blood: float = Field( default=1650.0, description="ms, longitudinal relaxation time of blood" ) - labeling_efficiency: float = Field( - default=0.85, description="labeling efficiency (0 to 1)" - ) - m0_method: str = Field( - default="single", description="single | voxelwise | reference_region" - ) t1_tissue: float = Field( default=1330.0, description="ms, longitudinal relaxation time of tissue" ) + labeling_efficiency: float = Field( + default=0.85, description="labeling efficiency (0 to 1)" + ) partition_coefficient: float = Field( default=0.9, description="blood-brain partition coefficient (mL/g)" ) - difference_method: str = Field( - default="pairwise", description="pairwise | surround | mean" + m0: _M0Config = Field( + default_factory=SingleM0Config, + description=( + "M0 calibration method + parameters " + "(method: single | voxelwise | reference_region)" + ), + ) + difference: _DifferenceConfig = Field( + default_factory=PairwiseDifferenceConfig, + description="label-control subtraction method (method: pairwise | surround | mean)", + ) + quantification: _QuantificationConfig = Field( + default_factory=SinglePLDConfig, + description=( + "CBF quantification mode + parameters " + "(mode: single_pld | multi_pld); multi_pld adds plds + ATT estimation" + ), ) label_control_order: str = Field( default="label_first", description="label_first | control_first" @@ -392,16 +422,6 @@ def validate_labeling(cls, v: str) -> str: raise ValueError(msg) return v - @field_validator("m0_method") - @classmethod - def validate_m0(cls, v: str) -> str: - """Validate M0 calibration method.""" - valid = ["single", "voxelwise", "reference_region"] - if v not in valid: - msg = f"Invalid M0 method '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - @field_validator("label_control_order") @classmethod def validate_order(cls, v: str) -> str: diff --git a/osipy/cli/runner.py b/osipy/cli/runner.py index 8cf87c1..c1c2b38 100644 --- a/osipy/cli/runner.py +++ b/osipy/cli/runner.py @@ -686,13 +686,19 @@ def _run_asl(config: PipelineConfig, data_path: Path, output_dir: Path) -> None: } labeling_scheme = scheme_map[mc.labeling_scheme] # type: ignore[attr-defined] + # Pass the validated nested configs straight through (no per-knob mapping): + # m0 / difference / quantification carry their own method+params. pipeline_cfg = ASLPipelineConfig( labeling_scheme=labeling_scheme, pld=mc.pld, # type: ignore[attr-defined] label_duration=mc.label_duration, # type: ignore[attr-defined] t1_blood=mc.t1_blood, # type: ignore[attr-defined] + t1_tissue=mc.t1_tissue, # type: ignore[attr-defined] labeling_efficiency=mc.labeling_efficiency, # type: ignore[attr-defined] - m0_method=mc.m0_method, # type: ignore[attr-defined] + partition_coefficient=mc.partition_coefficient, # type: ignore[attr-defined] + m0=mc.m0, # type: ignore[attr-defined] + difference=mc.difference, # type: ignore[attr-defined] + quantification=mc.quantification, # type: ignore[attr-defined] ) # Load M0 calibration data @@ -719,6 +725,8 @@ def _run_asl(config: PipelineConfig, data_path: Path, output_dir: Path) -> None: elapsed_fit = time.perf_counter() - t_fit maps: dict[str, Any] = {"cbf": result.cbf_result.cbf_map} + if result.att_map is not None: + maps["att"] = result.att_map _log_parameter_stats(maps, result.cbf_result.quality_mask, elapsed_fit) _save_results(maps, result.cbf_result.quality_mask, output_dir, affine) diff --git a/osipy/cli/wizard.py b/osipy/cli/wizard.py index 6177700..7ca6dc3 100644 --- a/osipy/cli/wizard.py +++ b/osipy/cli/wizard.py @@ -39,10 +39,15 @@ "label_duration": "ms, labeling duration", "t1_blood": "ms, longitudinal relaxation of blood", "labeling_efficiency": "labeling efficiency (0 to 1)", - "m0_method": "M0 calibration method", + "m0": "M0 calibration method + parameters", + "method": "selection (registry name)", + "mode": "quantification mode (single_pld | multi_pld)", + "plds": "ms, multi-PLD schedule", + "att_model": "ATT estimation model", "t1_tissue": "ms, longitudinal relaxation of tissue", "partition_coefficient": "blood-brain partition coefficient (mL/g)", - "difference_method": "label-control subtraction method", + "difference": "label-control subtraction method", + "quantification": "CBF quantification mode + parameters", "label_control_order": "label/control ordering", "fitting_method": "IVIM fitting method", "b_threshold": "s/mm^2, threshold separating D and D* regimes", @@ -403,6 +408,12 @@ def _collect_dsc_config() -> dict[str, Any]: def _collect_asl_config() -> dict[str, Any]: """Collect ASL pipeline settings.""" + from osipy.asl.config import ( + DIFFERENCE_CONFIGS, + M0_CONFIGS, + QUANTIFICATION_CONFIGS, + ) + print("\n--- ASL Pipeline Settings ---") cfg: dict[str, Any] = {} @@ -421,27 +432,29 @@ def _collect_asl_config() -> dict[str, Any]: cfg["t1_blood"] = _prompt_value( "T1 of blood (ms)", default=1650.0, expected_type=float ) - cfg["labeling_efficiency"] = _prompt_value( - "Labeling efficiency (0-1)", default=0.85, expected_type=float - ) - - m0_methods = ["single", "voxelwise", "reference_region"] - cfg["m0_method"] = _prompt_choice( - "M0 calibration method:", m0_methods, default="single" - ) - cfg["t1_tissue"] = _prompt_value( "T1 of tissue (ms)", default=1330.0, expected_type=float ) + cfg["labeling_efficiency"] = _prompt_value( + "Labeling efficiency (0-1)", default=0.85, expected_type=float + ) cfg["partition_coefficient"] = _prompt_value( "Partition coefficient (mL/g)", default=0.9, expected_type=float ) - diff_methods = ["pairwise", "surround", "mean"] - cfg["difference_method"] = _prompt_choice( - "Difference method:", diff_methods, default="pairwise" + # M0 calibration method + its params (e.g. tr_m0, t1_tissue, reference_region). + cfg["m0"] = _collect_method_config( + "M0 calibration method:", M0_CONFIGS, default="single" ) + # Label/control difference method (selection only, no extra knobs). + cfg["difference"] = _collect_method_config( + "Difference method:", DIFFERENCE_CONFIGS, default="pairwise" + ) + + # Quantification mode: single-PLD vs multi-PLD (Buxton + ATT estimation). + cfg["quantification"] = _collect_quantification_config(QUANTIFICATION_CONFIGS) + orders = ["label_first", "control_first"] cfg["label_control_order"] = _prompt_choice( "Label/control order:", orders, default="label_first" @@ -450,6 +463,28 @@ def _collect_asl_config() -> dict[str, Any]: return cfg +def _collect_quantification_config(configs: dict[str, Any]) -> dict[str, Any]: + """Prompt for the ASL quantification mode and its parameters. + + Single-PLD has no extra knobs; multi-PLD prompts for the PLD schedule + (comma-separated) and the ATT model. + """ + names = sorted(configs) + mode = _prompt_choice("Quantification mode:", names, default="single_pld") + selection: dict[str, Any] = {"mode": mode} + if mode == "multi_pld": + raw = _prompt_value( + "PLD schedule (comma-separated ms)", + default="500, 1000, 1500, 2000, 2500", + expected_type=str, + ) + selection["plds"] = [float(x) for x in str(raw).split(",")] + selection["att_model"] = _prompt_value( + "ATT model", default="buxton", expected_type=str + ) + return selection + + def _collect_ivim_config() -> dict[str, Any]: """Collect IVIM pipeline settings.""" print("\n--- IVIM Pipeline Settings ---") diff --git a/osipy/pipeline/asl_pipeline.py b/osipy/pipeline/asl_pipeline.py index 661102c..6c2315b 100644 --- a/osipy/pipeline/asl_pipeline.py +++ b/osipy/pipeline/asl_pipeline.py @@ -11,7 +11,7 @@ """ from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any @@ -21,10 +21,16 @@ ASLQuantificationParams, ASLQuantificationResult, LabelingScheme, - M0CalibrationParams, apply_m0_calibration, quantify_cbf, ) +from osipy.asl.config import ( + PairwiseDifferenceConfig, + SingleM0Config, + SinglePLDConfig, + m0_params_from_config, +) +from osipy.common.config import MethodConfig if TYPE_CHECKING: from numpy.typing import NDArray @@ -51,10 +57,23 @@ class ASLPipelineConfig: Default 1800 ms (Alsop 2015 recommendation). t1_blood : float Blood T1 in milliseconds. Default 1650 ms (Alsop 2015 at 3T). + t1_tissue : float + Tissue T1 in milliseconds. Default 1330 ms (gray matter at 3T). labeling_efficiency : float Labeling efficiency. Default 0.85 for PCASL (Alsop 2015). - m0_method : str - M0 calibration method: 'single', 'voxelwise', or 'reference_region'. + partition_coefficient : float + Blood-brain partition coefficient (lambda). Default 0.9 mL/g. + m0 : MethodConfig + M0 calibration config (discriminated by ``method``: + ``single``/``voxelwise``/``reference_region``), carrying its knobs + (``t1_tissue``, ``tr_m0``, ``te_m0``, ``reference_region``). + difference : MethodConfig + Label/control difference config (discriminated by ``method``: + ``pairwise``/``surround``/``mean``). + quantification : MethodConfig + Quantification mode config (discriminated by ``mode``: + ``single_pld``/``multi_pld``); ``multi_pld`` carries the PLD schedule + and the ATT model and routes to the multi-PLD path. output_dir : Path | None Output directory for results. """ @@ -63,8 +82,12 @@ class ASLPipelineConfig: pld: float = 1800.0 label_duration: float = 1800.0 t1_blood: float = 1650.0 + t1_tissue: float = 1330.0 labeling_efficiency: float = 0.85 - m0_method: str = "single" + partition_coefficient: float = 0.9 + m0: MethodConfig = field(default_factory=SingleM0Config) + difference: MethodConfig = field(default_factory=PairwiseDifferenceConfig) + quantification: MethodConfig = field(default_factory=SinglePLDConfig) output_dir: Path | None = None @@ -75,16 +98,20 @@ class ASLPipelineResult: Attributes ---------- cbf_result : ASLQuantificationResult - CBF quantification results. + CBF quantification results (single-PLD path). m0_map : NDArray | None M0 values used. config : ASLPipelineConfig Pipeline configuration used. + att_map : ParameterMap | None + Arterial transit time (ATT) map in ms. Populated only when the + ``multi_pld`` quantification mode is selected. """ cbf_result: ASLQuantificationResult m0_map: "NDArray[np.floating[Any]] | None" config: ASLPipelineConfig + att_map: Any = None class ASLPipeline: @@ -145,20 +172,20 @@ def run( ASLPipelineResult Pipeline results. """ - # Step 1: Compute ASL difference (label - control) + is_multi_pld = self.config.quantification.mode == "multi_pld" # type: ignore[attr-defined] + + # Step 1: Compute ASL difference (control - label). if progress_callback: progress_callback("Computing Difference", 0.0) - # Average if multiple acquisitions - if label_data.ndim > 3: - label_mean = np.mean(label_data, axis=-1) - control_mean = np.mean(control_data, axis=-1) + if is_multi_pld: + # Keep the PLD axis: one control-label difference per PLD volume. + delta_m = control_data - label_data + if mask is not None: + delta_m = np.where(mask[..., np.newaxis], delta_m, 0) else: - label_mean = label_data - control_mean = control_data - - # ASL difference: control - label (label has lower signal) - delta_m = control_mean - label_mean + # Collapse to 3D via the configured difference method. + delta_m = self._compute_difference(label_data, control_data, mask) if progress_callback: progress_callback("Computing Difference", 1.0) @@ -168,8 +195,10 @@ def run( progress_callback("M0 Calibration", 0.0) if isinstance(m0_data, np.ndarray): - m0_params = M0CalibrationParams(method=self.config.m0_method) - _, m0_corrected = apply_m0_calibration(delta_m, m0_data, m0_params, mask) + m0_params = m0_params_from_config(self.config.m0) + # For multi-PLD, calibrate per PLD volume using a 3D M0 image. + cal_target = delta_m[..., 0] if is_multi_pld else delta_m + _, m0_corrected = apply_m0_calibration(cal_target, m0_data, m0_params, mask) m0_value = m0_corrected else: m0_value = m0_data @@ -178,7 +207,11 @@ def run( if progress_callback: progress_callback("M0 Calibration", 1.0) - # Step 3: CBF quantification + # Multi-PLD path: route to Buxton CBF + ATT estimation. + if is_multi_pld: + return self._run_multi_pld(delta_m, m0_value, m0_corrected, mask) + + # Step 3: CBF quantification (single-PLD path) if progress_callback: progress_callback("CBF Quantification", 0.0) @@ -187,7 +220,9 @@ def run( pld=self.config.pld, label_duration=self.config.label_duration, t1_blood=self.config.t1_blood, + t1_tissue=self.config.t1_tissue, labeling_efficiency=self.config.labeling_efficiency, + partition_coefficient=self.config.partition_coefficient, ) cbf_result = quantify_cbf( @@ -206,6 +241,88 @@ def run( config=self.config, ) + def _compute_difference( + self, + label_data: "NDArray[np.floating[Any]]", + control_data: "NDArray[np.floating[Any]]", + mask: "NDArray[np.bool_] | None", + ) -> "NDArray[np.floating[Any]]": + """Compute delta-M from separated label/control via the difference registry. + + Re-interleaves the label and control volumes into a single 4D stack with + a matching aslcontext list, then dispatches to the configured difference + method (``pairwise``/``surround``/``mean``) via + :func:`compute_control_label_difference`. For 3D (single-pair) inputs the + registry is bypassed with a direct ``control - label`` subtraction. + """ + from osipy.asl import compute_control_label_difference + + method = self.config.difference.method # type: ignore[attr-defined] + + # Single pair (no averaging dimension): direct subtraction. + if label_data.ndim <= 3: + delta_m = control_data - label_data + if mask is not None: + delta_m = np.where(mask, delta_m, 0) + return delta_m + + # Interleave control/label into a 4D timeseries with aslcontext so the + # registered difference method can operate on the raw volumes. + n_pairs = min(label_data.shape[-1], control_data.shape[-1]) + spatial = label_data.shape[:-1] + interleaved = np.empty((*spatial, 2 * n_pairs), dtype=float) + interleaved[..., 0::2] = control_data[..., :n_pairs] + interleaved[..., 1::2] = label_data[..., :n_pairs] + context = ["control", "label"] * n_pairs + return compute_control_label_difference( + interleaved, context, method=method, mask=mask + ) + + def _run_multi_pld( + self, + delta_m: "NDArray[np.floating[Any]]", + m0_value: "NDArray[np.floating[Any]] | float", + m0_corrected: "NDArray[np.floating[Any]] | None", + mask: "NDArray[np.bool_] | None", + ) -> ASLPipelineResult: + """Run the multi-PLD path (Buxton + ATT estimation). + + Builds :class:`MultiPLDParams` from the pipeline config and the selected + ``multi_pld`` quantification config (PLD schedule), fits CBF + ATT via + :func:`quantify_multi_pld`, and packs the result so ``cbf_result`` + carries CBF and ``att_map`` carries ATT. + """ + from osipy.asl.quantification.multi_pld import ( + MultiPLDParams, + quantify_multi_pld, + ) + + quant_cfg = self.config.quantification + params = MultiPLDParams( + labeling_scheme=self.config.labeling_scheme, + plds=np.asarray(quant_cfg.plds, dtype=float), # type: ignore[attr-defined] + label_duration=self.config.label_duration, + t1_blood=self.config.t1_blood, + t1_tissue=self.config.t1_tissue, + partition_coefficient=self.config.partition_coefficient, + labeling_efficiency=self.config.labeling_efficiency, + ) + result = quantify_multi_pld( + delta_m=delta_m, m0=m0_value, params=params, mask=mask + ) + cbf_result = ASLQuantificationResult( + cbf_map=result.cbf_map, + quality_mask=result.quality_mask, + m0_used=m0_corrected, + scaling_factor=6000.0, + ) + return ASLPipelineResult( + cbf_result=cbf_result, + m0_map=m0_corrected, + config=self.config, + att_map=result.att_map, + ) + def run_from_alternating( self, asl_data: "NDArray[np.floating[Any]]", diff --git a/tests/integration/test_asl_pipeline.py b/tests/integration/test_asl_pipeline.py index cff00b1..0f8a195 100644 --- a/tests/integration/test_asl_pipeline.py +++ b/tests/integration/test_asl_pipeline.py @@ -305,6 +305,138 @@ def test_full_asl_pipeline_multi_pld(self, synthetic_asl_data: dict) -> None: assert result.att_map is not None, "ATT estimation failed" +class TestASLPipelineRegistryConfig: + """Tests for the registry-driven ASLPipelineConfig wiring.""" + + @staticmethod + def _make_label_control(seed: int = 0): + """Build averaged label/control 4D stacks from a known pCASL forward model.""" + rng = np.random.default_rng(seed) + nx, ny, nz = 6, 6, 2 + m0 = rng.uniform(900, 1100, (nx, ny, nz)) + cbf_true = rng.uniform(40, 80, (nx, ny, nz)) + t1b, tau, pld = 1.65, 1.8, 1.8 + dm = ( + 2 + * m0 + * cbf_true + * t1b + * 0.85 + * (1 - np.exp(-tau / t1b)) + * np.exp(-pld / t1b) + / 0.9 + / 6000.0 + ) + control = np.repeat((m0 + dm)[..., None], 4, axis=-1) + label = np.repeat(m0[..., None], 4, axis=-1) + return label, control, m0 + + def test_partition_coefficient_scales_cbf(self) -> None: + """Doubling the partition coefficient ~doubles CBF (config flows to params).""" + from osipy.asl import LabelingScheme + from osipy.pipeline.asl_pipeline import ASLPipeline, ASLPipelineConfig + + label, control, m0 = self._make_label_control() + + cfg_a = ASLPipelineConfig( + labeling_scheme=LabelingScheme.PCASL, + pld=1800.0, + label_duration=1800.0, + partition_coefficient=0.9, + ) + res_a = ASLPipeline(cfg_a).run(label, control, m0) + mask_a = res_a.cbf_result.quality_mask + cbf_a = float(np.mean(res_a.cbf_result.cbf_map.values[mask_a])) + + cfg_b = ASLPipelineConfig( + labeling_scheme=LabelingScheme.PCASL, + pld=1800.0, + label_duration=1800.0, + partition_coefficient=1.8, + ) + res_b = ASLPipeline(cfg_b).run(label, control, m0) + mask_b = res_b.cbf_result.quality_mask + cbf_b = float(np.mean(res_b.cbf_result.cbf_map.values[mask_b])) + + assert cbf_a > 0 + assert cbf_b == pytest.approx(2.0 * cbf_a, rel=1e-3) + assert res_a.att_map is None + + def test_difference_method_routes_through_registry(self) -> None: + """The configured difference method dispatches via the difference registry.""" + from unittest.mock import patch + + import osipy.asl.quantification.cbf as cbf_module + from osipy.asl import LabelingScheme + from osipy.asl.config import SurroundDifferenceConfig + from osipy.pipeline.asl_pipeline import ASLPipeline, ASLPipelineConfig + + label, control, m0 = self._make_label_control(seed=1) + cfg = ASLPipelineConfig( + labeling_scheme=LabelingScheme.PCASL, + difference=SurroundDifferenceConfig(), + ) + + original = cbf_module.get_difference_method + seen: dict[str, str] = {} + + def spy(name: str): + seen["name"] = name + return original(name) + + with patch.object(cbf_module, "get_difference_method", spy): + ASLPipeline(cfg).run(label, control, m0) + + assert seen.get("name") == "surround" + + def test_multi_pld_mode_produces_cbf_and_att(self) -> None: + """Selecting multi_pld routes to Buxton fitting, yielding CBF + ATT maps.""" + from osipy.asl import LabelingScheme + from osipy.asl.config import MultiPLDConfig + from osipy.pipeline.asl_pipeline import ASLPipeline, ASLPipelineConfig + + rng = np.random.default_rng(42) + nx, ny, nz = 4, 4, 2 + m0 = rng.uniform(900, 1100, (nx, ny, nz)) + cbf_true = rng.uniform(40, 80, (nx, ny, nz)) + att_true = rng.uniform(800, 1500, (nx, ny, nz)) + plds = [500.0, 1000.0, 1500.0, 2000.0, 2500.0] + t1b, tau = 1.65, 1.8 + + # Interleave control/label per PLD: [control, label] * n_plds. + alt = np.zeros((nx, ny, nz, 2 * len(plds))) + for i, p in enumerate(plds): + ps = p / 1000.0 + dm = ( + 2 + * m0 + * cbf_true + * t1b + * 0.85 + * (1 - np.exp(-tau / t1b)) + * np.exp(-ps / t1b) + / 0.9 + / 6000.0 + ) + att_factor = np.clip((p - att_true) / 500.0, 0, 1) + alt[..., 2 * i] = m0 + dm * att_factor # control + alt[..., 2 * i + 1] = m0 # label + + cfg = ASLPipelineConfig( + labeling_scheme=LabelingScheme.PCASL, + label_duration=1800.0, + quantification=MultiPLDConfig(plds=plds, att_model="buxton"), + ) + result = ASLPipeline(cfg).run_from_alternating( + alt, m0, label_control_order="control_first" + ) + + assert result.cbf_result.cbf_map is not None + assert result.cbf_result.cbf_map.units == "mL/100g/min" + assert result.att_map is not None + assert "ms" in result.att_map.units.lower() + + class TestASLOutputValidation: """Test ASL output format and physiological validity.""" diff --git a/tests/unit/cli/test_config.py b/tests/unit/cli/test_config.py index 8e9be21..05f1742 100644 --- a/tests/unit/cli/test_config.py +++ b/tests/unit/cli/test_config.py @@ -145,6 +145,34 @@ def test_load_asl_config(self, tmp_config) -> None: config = load_config(path) assert config.modality == "asl" + def test_load_asl_nested_multi_pld_config(self, tmp_config) -> None: + """Nested ASL config (M0 knobs + multi-PLD mode) round-trips via load_config.""" + path = tmp_config("""\ + modality: asl + pipeline: + labeling_scheme: pcasl + partition_coefficient: 0.95 + m0: + method: reference_region + reference_region: white_matter + tr_m0: 5000.0 + difference: + method: mean + quantification: + mode: multi_pld + plds: [500.0, 1000.0, 1500.0, 2000.0, 2500.0] + att_model: buxton + """) + config = load_config(path) + mc = config.get_modality_config() + assert mc.partition_coefficient == 0.95 + assert mc.m0.method == "reference_region" + assert mc.m0.reference_region == "white_matter" + assert mc.m0.tr_m0 == 5000.0 + assert mc.difference.method == "mean" + assert mc.quantification.mode == "multi_pld" + assert mc.quantification.plds == [500.0, 1000.0, 1500.0, 2000.0, 2500.0] + def test_load_ivim_config(self, tmp_config) -> None: """Valid IVIM config loads successfully.""" path = tmp_config("""\ @@ -346,17 +374,19 @@ class TestASLPipelineYAML: """Tests for ASL pipeline config validation.""" def test_defaults(self) -> None: - """Default ASL config values match expected.""" + """Default ASL config values match expected (nested registry configs).""" cfg = ASLPipelineYAML() assert cfg.labeling_scheme == "pcasl" assert cfg.pld == 1800.0 assert cfg.label_duration == 1800.0 assert cfg.t1_blood == 1650.0 - assert cfg.labeling_efficiency == 0.85 - assert cfg.m0_method == "single" assert cfg.t1_tissue == 1330.0 + assert cfg.labeling_efficiency == 0.85 assert cfg.partition_coefficient == 0.9 - assert cfg.difference_method == "pairwise" + assert cfg.m0.method == "single" + assert cfg.m0.t1_tissue == 1330.0 + assert cfg.difference.method == "pairwise" + assert cfg.quantification.mode == "single_pld" assert cfg.label_control_order == "label_first" def test_invalid_labeling_scheme(self) -> None: @@ -365,9 +395,9 @@ def test_invalid_labeling_scheme(self) -> None: ASLPipelineYAML(labeling_scheme="invalid") def test_invalid_m0_method(self) -> None: - """Invalid M0 method raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid M0 method"): - ASLPipelineYAML(m0_method="invalid") + """Invalid M0 method raises ValidationError (no matching union member).""" + with pytest.raises(ValidationError): + ASLPipelineYAML(m0={"method": "invalid"}) def test_invalid_label_control_order(self) -> None: """Invalid label/control order raises ValidationError.""" @@ -380,6 +410,58 @@ def test_valid_labeling_schemes(self) -> None: cfg = ASLPipelineYAML(labeling_scheme=scheme) assert cfg.labeling_scheme == scheme + def test_valid_m0_methods(self) -> None: + """All registered M0 calibration methods are accepted (nested config).""" + from osipy.asl.config import M0_CONFIGS + + for name in M0_CONFIGS: + cfg = ASLPipelineYAML(m0={"method": name}) + assert cfg.m0.method == name + + def test_m0_params_surface_and_validate(self) -> None: + """Selecting an M0 method exposes its knobs; cross-method keys rejected.""" + cfg = ASLPipelineYAML( + m0={"method": "reference_region", "reference_region": "white_matter"} + ) + assert cfg.m0.method == "reference_region" + assert cfg.m0.reference_region == "white_matter" + # 'single' has no reference_region knob (extra=forbid). + with pytest.raises(ValidationError): + ASLPipelineYAML(m0={"method": "single", "reference_region": "csf"}) + + def test_valid_difference_methods(self) -> None: + """All registered difference methods are selectable via the nested config.""" + from osipy.asl.config import DIFFERENCE_CONFIGS + + for name in DIFFERENCE_CONFIGS: + cfg = ASLPipelineYAML(difference={"method": name}) + assert cfg.difference.method == name + + def test_difference_rejects_cross_method_keys(self) -> None: + """Difference selection rejects unknown/cross-method keys (extra=forbid).""" + with pytest.raises(ValidationError): + ASLPipelineYAML(difference={"method": "pairwise", "threshold": 0.2}) + + def test_quantification_modes_selectable(self) -> None: + """Both single_pld and multi_pld modes are selectable.""" + cfg_single = ASLPipelineYAML(quantification={"mode": "single_pld"}) + assert cfg_single.quantification.mode == "single_pld" + cfg_multi = ASLPipelineYAML( + quantification={ + "mode": "multi_pld", + "plds": [500.0, 1000.0, 1500.0], + "att_model": "buxton", + } + ) + assert cfg_multi.quantification.mode == "multi_pld" + assert cfg_multi.quantification.plds == [500.0, 1000.0, 1500.0] + assert cfg_multi.quantification.att_model == "buxton" + + def test_single_pld_rejects_multi_pld_keys(self) -> None: + """single_pld mode rejects multi-PLD-only keys (extra=forbid).""" + with pytest.raises(ValidationError): + ASLPipelineYAML(quantification={"mode": "single_pld", "plds": [500.0]}) + # --------------------------------------------------------------------------- # TestIVIMPipelineYAML diff --git a/tests/unit/cli/test_wizard.py b/tests/unit/cli/test_wizard.py index 9b267ce..3924085 100644 --- a/tests/unit/cli/test_wizard.py +++ b/tests/unit/cli/test_wizard.py @@ -568,16 +568,52 @@ class TestCollectASLConfig: """Tests for _collect_asl_config().""" def test_defaults(self) -> None: - """Pressing Enter for all prompts returns defaults.""" - inputs = _make_input_fn([""] * 10) + """Pressing Enter for all prompts returns defaults (nested configs).""" + # Prompts: labeling_scheme, pld, label_duration, t1_blood, t1_tissue, + # labeling_efficiency, partition_coefficient (7), then m0 method + + # its 3 knobs (4), difference method (1), quantification mode (1), + # label/control order (1) = 14. + inputs = _make_input_fn([""] * 14) with patch("builtins.input", side_effect=inputs): cfg = _collect_asl_config() assert cfg["labeling_scheme"] == "pcasl" assert cfg["pld"] == 1800.0 - assert cfg["m0_method"] == "single" - assert cfg["difference_method"] == "pairwise" + assert cfg["t1_tissue"] == 1330.0 + assert cfg["partition_coefficient"] == 0.9 + assert cfg["m0"]["method"] == "single" + assert cfg["m0"]["tr_m0"] == 6000.0 + assert cfg["difference"]["method"] == "pairwise" + assert cfg["quantification"]["mode"] == "single_pld" assert cfg["label_control_order"] == "label_first" + def test_multi_pld_mode_collects_plds(self) -> None: + """Selecting multi_pld surfaces the PLD schedule and ATT model prompts.""" + inputs = _make_input_fn( + [ + "", # labeling_scheme: default + "", # pld + "", # label_duration + "", # t1_blood + "", # t1_tissue + "", # labeling_efficiency + "", # partition_coefficient + "single", # m0 method + "", # m0 t1_tissue + "", # m0 tr_m0 + "", # m0 te_m0 + "pairwise", # difference method + "multi_pld", # quantification mode + "500, 1000, 1500", # plds + "buxton", # att_model + "", # label/control order + ] + ) + with patch("builtins.input", side_effect=inputs): + cfg = _collect_asl_config() + assert cfg["quantification"]["mode"] == "multi_pld" + assert cfg["quantification"]["plds"] == [500.0, 1000.0, 1500.0] + assert cfg["quantification"]["att_model"] == "buxton" + class TestCollectIVIMConfig: """Tests for _collect_ivim_config().""" @@ -626,11 +662,12 @@ def test_defaults_round_trip(self, modality: str) -> None: "pld": 1800.0, "label_duration": 1800.0, "t1_blood": 1650.0, - "labeling_efficiency": 0.85, - "m0_method": "single", "t1_tissue": 1330.0, + "labeling_efficiency": 0.85, "partition_coefficient": 0.9, - "difference_method": "pairwise", + "m0": {"method": "single"}, + "difference": {"method": "pairwise"}, + "quantification": {"mode": "single_pld"}, "label_control_order": "label_first", }, "ivim": { @@ -758,11 +795,15 @@ def test_overwrite_declined(self, tmp_path: Path) -> None: "", # pld: default "", # label duration: default "", # t1 blood: default - "", # labeling efficiency: default - "", # m0 method: default "", # t1 tissue: default + "", # labeling efficiency: default "", # partition coeff: default + "", # m0 method: default + "", # m0 t1_tissue: default + "", # m0 tr_m0: default + "", # m0 te_m0: default "", # difference method: default + "", # quantification mode: default "", # label/control order: default # -- data (second) -- "", # data format: auto From 25fc00c5613301dfc90d16b1128f3bed7cf760fa Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 00:31:29 -0400 Subject: [PATCH 05/15] Registry-driven config: IVIM rollout (+ simplified model, initial_guess) Apply the registry-driven config pattern to IVIM (osipy/ivim/config.py): the fitting strategy (segmented/full/bayesian, per-method knobs incl. bayesian-only prior_scale/noise_std) and the signal model (biexponential/simplified) become generated discriminated unions in IVIMPipelineYAML. The simplified model is now selectable (and a latent dS/df Jacobian bug in it, previously dead, is fixed). initial_guess is now a real IVIMFitParams field threaded into BoundIVIMModel (previously set on the config but never forwarded). IVIMPipeline auto-constructs fitter + model from the validated config. Gate: 874 passed, 0 failed (core incl. GPU); nested IVIM YAML round-trips; bayesian surfaces prior_scale, segmented rejects it; initial_guess seeds the fit. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/cli/config.py | 95 +++------- osipy/cli/runner.py | 35 ++-- osipy/cli/wizard.py | 30 ++-- osipy/ivim/config.py | 219 ++++++++++++++++++++++++ osipy/ivim/fitting/estimators.py | 58 ++++++- osipy/ivim/models/binding.py | 38 +++- osipy/pipeline/ivim_pipeline.py | 15 +- tests/integration/test_ivim_pipeline.py | 145 ++++++++++++++++ tests/unit/cli/test_config.py | 198 ++++++++++++++++----- tests/unit/cli/test_wizard.py | 66 ++++++- tests/unit/ivim/test_ivim_config.py | 145 ++++++++++++++++ 11 files changed, 876 insertions(+), 168 deletions(-) create mode 100644 osipy/ivim/config.py create mode 100644 tests/unit/ivim/test_ivim_config.py diff --git a/osipy/cli/config.py b/osipy/cli/config.py index 580b780..fa69c0b 100644 --- a/osipy/cli/config.py +++ b/osipy/cli/config.py @@ -34,6 +34,13 @@ VFAConfig, ) from osipy.dsc.deconvolution.config import DECONVOLVER_CONFIGS, OSVDConfig +from osipy.ivim.config import ( + IVIM_FITTING_CONFIGS, + IVIM_MODEL_CONFIGS, + MODEL_DISCRIMINATOR, + BiexponentialModelConfig, + SegmentedFittingConfig, +) logger = logging.getLogger(__name__) @@ -188,62 +195,6 @@ def validate_bounds( return v -class BayesianIVIMFittingConfig(BaseModel): - """Bayesian IVIM fitting configuration from YAML.""" - - prior_scale: float = Field(default=1.5) - noise_std: float | None = Field(default=None, examples=[0.01]) - compute_uncertainty: bool = Field(default=True) - - -class IVIMFittingConfig(BaseModel): - """IVIM model fitting configuration from YAML.""" - - max_iterations: int = Field(default=500) - tolerance: float = Field(default=1e-6) - bounds: dict[str, list[float]] | None = Field( - default=None, - description="override model defaults (omit to use model defaults)", - json_schema_extra={ - "yaml_example": ( - "S0: [0.0, 1.0e+10] # signal units\n" - "D: [1.0e-4, 5.0e-3] # mm^2/s\n" - "D_star: [2.0e-3, 0.1] # mm^2/s\n" - "f: [0.0, 0.7] # dimensionless" - ) - }, - ) - initial_guess: dict[str, float] | None = Field( - default=None, - description="override data-driven initial estimates", - json_schema_extra={ - "yaml_example": ( - "D: 1.0e-3 # mm^2/s\n" - "D_star: 0.01 # mm^2/s\n" - "f: 0.1 # dimensionless" - ) - }, - ) - bayesian: BayesianIVIMFittingConfig = BayesianIVIMFittingConfig() - - @field_validator("bounds") - @classmethod - def validate_bounds( - cls, v: dict[str, list[float]] | None - ) -> dict[str, list[float]] | None: - """Validate bounds are [lower, upper] pairs.""" - if v is None: - return v - for name, pair in v.items(): - if len(pair) != 2: - msg = f"Bounds for '{name}' must be [lower, upper], got {pair}" - raise ValueError(msg) - if pair[0] > pair[1]: - msg = f"Lower bound > upper bound for '{name}': {pair}" - raise ValueError(msg) - return v - - # --------------------------------------------------------------------------- # DCE modality # --------------------------------------------------------------------------- @@ -438,30 +389,30 @@ def validate_order(cls, v: str) -> str: # --------------------------------------------------------------------------- +# Discriminated unions of IVIM selection-point configs, generated from the +# registries: selecting a method/model pulls in exactly that option's params. +_IVIMFittingConfig = method_union(IVIM_FITTING_CONFIGS) +_IVIMModelConfig = method_union(IVIM_MODEL_CONFIGS, discriminator=MODEL_DISCRIMINATOR) + + class IVIMPipelineYAML(BaseModel): """IVIM pipeline settings from YAML.""" - fitting_method: str = Field( - default="segmented", description="segmented | full | bayesian" + fitting: _IVIMFittingConfig = Field( + default_factory=SegmentedFittingConfig, + description=( + "fitting strategy + parameters " + "(method: segmented | full | bayesian); " + "bayesian adds prior_scale/noise_std, segmented/bayesian add b_threshold" + ), ) - b_threshold: float = Field( - default=200.0, - description="s/mm^2, threshold separating D and D* regimes", + model: _IVIMModelConfig = Field( + default_factory=BiexponentialModelConfig, + description="IVIM signal model (model: biexponential | simplified)", ) normalize_signal: bool = Field( default=True, description="normalize to S(b=0) before fitting" ) - fitting: IVIMFittingConfig = IVIMFittingConfig() - - @field_validator("fitting_method") - @classmethod - def validate_fitting(cls, v: str) -> str: - """Validate IVIM fitting method.""" - valid = ["segmented", "full", "bayesian"] - if v not in valid: - msg = f"Invalid fitting method '{v}'. Valid: {valid}" - raise ValueError(msg) - return v # --------------------------------------------------------------------------- diff --git a/osipy/cli/runner.py b/osipy/cli/runner.py index c1c2b38..8ebcdbb 100644 --- a/osipy/cli/runner.py +++ b/osipy/cli/runner.py @@ -742,32 +742,39 @@ def _run_ivim(config: PipelineConfig, data_path: Path, output_dir: Path) -> None affine = dataset.affine mask = _load_mask(config.data.mask, base_dir) - # Map string to FittingMethod enum - method_map = { - "segmented": FittingMethod.SEGMENTED, - "full": FittingMethod.FULL, - "bayesian": FittingMethod.BAYESIAN, - } - fitting_method = method_map[mc.fitting_method] # type: ignore[attr-defined] + # The fitting strategy is a validated discriminated union carrying its own + # method + per-method knobs; the signal model likewise. Pass them straight + # through to the pipeline config (no per-knob re-mapping). + fitting = mc.fitting # type: ignore[attr-defined] # validated MethodConfig + model_cfg = mc.model # type: ignore[attr-defined] # validated MethodConfig + + fitting_method = FittingMethod(fitting.method) - fitting = mc.fitting # type: ignore[attr-defined] # IVIMFittingConfig bounds = ( {k: tuple(v) for k, v in fitting.bounds.items()} if fitting.bounds else None ) - # Convert Bayesian config if using Bayesian method + # b_threshold lives on the fitting method (segmented/bayesian) for fitting + # and on the simplified model for its perfusion cutoff. Prefer the model's + # threshold when the simplified model is selected (so they stay consistent), + # otherwise use the fitting method's threshold (default 200 for "full"). + b_threshold = getattr(model_cfg, "b_threshold", None) + if b_threshold is None: + b_threshold = getattr(fitting, "b_threshold", 200.0) + + # Bayesian-only knobs surface only on the bayesian config. bayesian_params = None if fitting_method == FittingMethod.BAYESIAN: - bc = fitting.bayesian bayesian_params = { - "prior_scale": bc.prior_scale, - "noise_std": bc.noise_std, - "compute_uncertainty": bc.compute_uncertainty, + "prior_scale": fitting.prior_scale, + "noise_std": fitting.noise_std, + "compute_uncertainty": fitting.compute_uncertainty, } pipeline_cfg = IVIMPipelineConfig( fitting_method=fitting_method, - b_threshold=mc.b_threshold, # type: ignore[attr-defined] + signal_model=model_cfg.model, + b_threshold=b_threshold, normalize_signal=mc.normalize_signal, # type: ignore[attr-defined] bounds=bounds, initial_guess=fitting.initial_guess, diff --git a/osipy/cli/wizard.py b/osipy/cli/wizard.py index 7ca6dc3..36aa1f5 100644 --- a/osipy/cli/wizard.py +++ b/osipy/cli/wizard.py @@ -293,6 +293,11 @@ def _collect_method_config( for fname, finfo in configs[chosen].model_fields.items(): if fname == discriminator: continue + # Skip fields whose default is None or a complex container (e.g. + # bounds / initial_guess dicts): they have no sensible inline prompt, + # so leave them at the config default and let the user edit the YAML. + if finfo.default is None or isinstance(finfo.default, (dict, list)): + continue selection[fname] = _prompt_value( finfo.description or fname, default=finfo.default, @@ -487,26 +492,29 @@ def _collect_quantification_config(configs: dict[str, Any]) -> dict[str, Any]: def _collect_ivim_config() -> dict[str, Any]: """Collect IVIM pipeline settings.""" + from osipy.ivim.config import IVIM_FITTING_CONFIGS, IVIM_MODEL_CONFIGS + print("\n--- IVIM Pipeline Settings ---") cfg: dict[str, Any] = {} - fitting_methods = ["segmented", "full", "bayesian"] - cfg["fitting_method"] = _prompt_choice( - "Fitting method:", fitting_methods, default="segmented" + # Fitting strategy + its params (selecting a method surfaces exactly that + # method's knobs: e.g. bayesian adds prior_scale, full has no b_threshold). + cfg["fitting"] = _collect_method_config( + "Fitting method:", IVIM_FITTING_CONFIGS, default="segmented" ) - cfg["b_threshold"] = _prompt_value( - "B-value threshold (s/mm^2)", default=200.0, expected_type=float + + # Signal model + its params (simplified exposes its b_threshold). + cfg["model"] = _collect_method_config( + "Signal model:", + IVIM_MODEL_CONFIGS, + default="biexponential", + discriminator="model", ) + cfg["normalize_signal"] = _prompt_yes_no( "Normalize signal to S(b=0)?", default=True ) - if cfg["fitting_method"] == "bayesian": - print( - " Note: Bayesian fitting uses default priors and MCMC settings.\n" - " Edit the generated YAML to customize (fitting.bayesian section)." - ) - return cfg diff --git a/osipy/ivim/config.py b/osipy/ivim/config.py new file mode 100644 index 0000000..b8a2491 --- /dev/null +++ b/osipy/ivim/config.py @@ -0,0 +1,219 @@ +"""Registry-driven config models for IVIM selection points. + +Each IVIM selection point — the fitting *strategy* (segmented / full / +Bayesian) and the signal *model* (bi-exponential / simplified) — declares +its tunable knobs as a :class:`~osipy.common.config.MethodConfig`. These +compose into discriminated unions for the CLI config (so selecting a +method/model surfaces exactly that option's parameters and rejects +cross-method keys), and the matching registries build the live component +from a validated config instance. + +Mirrors :mod:`osipy.asl.config` and :mod:`osipy.dce.config`. Adding +``@register_ivim_fitter`` / ``@register_ivim_model`` plus an entry here +automatically surfaces the new option (and its knobs) as a CLI toggle that +both validates input *and* builds the component — an option can never be +"collected but silently ignored". + +Notes +----- +* The **fitting strategy** is discriminated by ``method``. Shared knobs + (``max_iterations``, ``tolerance``, ``bounds``, ``initial_guess``, + ``normalize_signal``) live on every strategy; ``b_threshold`` appears + only on the methods that use it (segmented + Bayesian — the *full* + strategy fits all b-values jointly and sets the threshold to 0 + internally), and the Bayesian prior knobs (``prior_scale``, + ``noise_std``, ``compute_uncertainty``) appear only for Bayesian. +* The **signal model** is discriminated by ``model``. The bi-exponential + model carries no constructor knobs; the simplified model exposes its + ``b_threshold`` (above which the perfusion term is treated as negligible). + +References +---------- +.. [1] OSIPI CAPLEX, https://osipi.github.io/OSIPI_CAPLEX/ +.. [2] Le Bihan D et al. (1988). Radiology 168(2):497-505. +.. [3] Dickie BR et al. MRM 2024;91(5):1761-1773. doi:10.1002/mrm.29840 +""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field, field_validator + +from osipy.common.config import MethodConfig + +# Import the models package so the built-in models register themselves +# (``register_ivim_model("biexponential"/"simplified")``) before we +# snapshot the registry below. +from osipy.ivim import models as _ivim_models # noqa: F401 +from osipy.ivim.models.registry import IVIM_MODEL_REGISTRY + +# --------------------------------------------------------------------------- +# Shared fitting knobs (validators reused across strategy configs) +# --------------------------------------------------------------------------- + + +def _validate_bounds_pairs( + v: dict[str, list[float]] | None, +) -> dict[str, list[float]] | None: + """Validate parameter bounds are ``[lower, upper]`` pairs with lower<=upper.""" + if v is None: + return v + for name, pair in v.items(): + if len(pair) != 2: + msg = f"Bounds for '{name}' must be [lower, upper], got {pair}" + raise ValueError(msg) + if pair[0] > pair[1]: + msg = f"Lower bound > upper bound for '{name}': {pair}" + raise ValueError(msg) + return v + + +class _IVIMFittingBase(MethodConfig): + """Shared knobs for every IVIM fitting strategy. + + The discriminator (``method``) and any method-specific knobs are added + by the concrete subclasses below. + """ + + max_iterations: int = Field(default=500, gt=0) + tolerance: float = Field(default=1e-6, gt=0.0) + bounds: dict[str, list[float]] | None = Field( + default=None, + description="override model defaults (omit to use model defaults)", + json_schema_extra={ + "yaml_example": ( + "S0: [0.0, 1.0e+10] # signal units\n" + "D: [1.0e-4, 5.0e-3] # mm^2/s\n" + "D_star: [2.0e-3, 0.1] # mm^2/s\n" + "f: [0.0, 0.7] # dimensionless" + ) + }, + ) + initial_guess: dict[str, float] | None = Field( + default=None, + description="override data-driven initial estimates", + json_schema_extra={ + "yaml_example": ( + "D: 1.0e-3 # mm^2/s\n" + "D_star: 0.01 # mm^2/s\n" + "f: 0.1 # dimensionless" + ) + }, + ) + + @field_validator("bounds") + @classmethod + def _check_bounds( + cls, v: dict[str, list[float]] | None + ) -> dict[str, list[float]] | None: + return _validate_bounds_pairs(v) + + +# --------------------------------------------------------------------------- +# Fitting strategy selection (@register_ivim_fitter) +# +# Discriminated by ``method``. ``b_threshold`` appears only on the strategies +# that use it; the Bayesian prior knobs appear only for Bayesian. +# --------------------------------------------------------------------------- + + +class SegmentedFittingConfig(_IVIMFittingBase): + """Two-step (segmented) IVIM fitting.""" + + method: Literal["segmented"] = "segmented" + b_threshold: float = Field( + default=200.0, + gt=0.0, + description="s/mm^2, threshold separating D and D* regimes", + ) + + +class FullFittingConfig(_IVIMFittingBase): + """Full bi-exponential IVIM fitting (all b-values jointly). + + The full strategy fits every b-value at once and uses no segmentation + threshold, so ``b_threshold`` is intentionally absent. + """ + + method: Literal["full"] = "full" + + +class BayesianFittingConfig(_IVIMFittingBase): + """Two-stage Bayesian MAP IVIM fitting with empirical priors.""" + + method: Literal["bayesian"] = "bayesian" + b_threshold: float = Field( + default=200.0, + gt=0.0, + description="s/mm^2, threshold separating D and D* regimes", + ) + prior_scale: float = Field( + default=1.5, gt=0.0, description="scale of the empirical priors" + ) + noise_std: float | None = Field( + default=None, + description="measurement noise std (estimated from data when omitted)", + examples=[0.01], + ) + compute_uncertainty: bool = Field( + default=True, description="propagate posterior uncertainty" + ) + + +#: name -> config model (source for the discriminated union) +IVIM_FITTING_CONFIGS: dict[str, type[MethodConfig]] = { + "segmented": SegmentedFittingConfig, + "full": FullFittingConfig, + "bayesian": BayesianFittingConfig, +} + + +# --------------------------------------------------------------------------- +# Signal model selection (@register_ivim_model) +# +# Discriminated by ``model``. The bi-exponential model takes no knobs; the +# simplified model exposes its perfusion-cutoff ``b_threshold``. +# --------------------------------------------------------------------------- + +MODEL_DISCRIMINATOR = "model" + + +class BiexponentialModelConfig(MethodConfig): + """IVIM bi-exponential signal model: S0, D, D*, f.""" + + model: Literal["biexponential"] = "biexponential" + + +class SimplifiedModelConfig(MethodConfig): + """IVIM simplified signal model: S0, D, f (assumes D* >> D).""" + + model: Literal["simplified"] = "simplified" + b_threshold: float = Field( + default=200.0, + gt=0.0, + description="s/mm^2, b-value above which the perfusion term is negligible", + ) + + +#: name -> config model (source for the discriminated union) +IVIM_MODEL_CONFIGS: dict[str, type[MethodConfig]] = { + "biexponential": BiexponentialModelConfig, + "simplified": SimplifiedModelConfig, +} + +#: name -> model class (constructed from config; see ``IVIM_MODEL_REGISTRY``) +IVIM_SIGNAL_MODEL_REGISTRY: dict[str, Any] = dict(IVIM_MODEL_REGISTRY) + + +__all__ = [ + "IVIM_FITTING_CONFIGS", + "IVIM_MODEL_CONFIGS", + "IVIM_SIGNAL_MODEL_REGISTRY", + "MODEL_DISCRIMINATOR", + "BayesianFittingConfig", + "BiexponentialModelConfig", + "FullFittingConfig", + "SegmentedFittingConfig", + "SimplifiedModelConfig", +] diff --git a/osipy/ivim/fitting/estimators.py b/osipy/ivim/fitting/estimators.py index 493fc13..579ed67 100644 --- a/osipy/ivim/fitting/estimators.py +++ b/osipy/ivim/fitting/estimators.py @@ -38,7 +38,6 @@ from osipy.common.fitting.base import BaseFitter from osipy.common.fitting.least_squares import LevenbergMarquardtFitter from osipy.common.parameter_map import ParameterMap -from osipy.ivim.models.biexponential import IVIMBiexponentialModel from osipy.ivim.models.binding import BoundIVIMModel if TYPE_CHECKING: @@ -72,6 +71,14 @@ class IVIMFitParams: Convergence tolerance. bounds : dict | None Custom parameter bounds. + initial_guess : dict | None + Custom initial parameter estimates (e.g. ``{"D": 1e-3, "f": 0.1}``). + Any parameter named here seeds the optimizer in place of the + data-driven estimate; unspecified parameters keep their + data-driven guess. + signal_model : str + Registered IVIM signal model name (``"biexponential"`` or + ``"simplified"``). Selects the forward model used during fitting. bayesian_params : dict | None Bayesian-specific parameters (prior_std, noise_std, compute_uncertainty). Only used when ``method`` is @@ -83,9 +90,37 @@ class IVIMFitParams: max_iterations: int = 500 tolerance: float = 1e-6 bounds: dict[str, tuple[float, float]] | None = None + initial_guess: dict[str, float] | None = None + signal_model: str = "biexponential" bayesian_params: Any = None +def _build_signal_model(params: "IVIMFitParams") -> Any: + """Construct the IVIM signal model selected in *params*. + + Looks the model up in the IVIM model registry by ``params.signal_model`` + (``"biexponential"`` or ``"simplified"``). The simplified model takes a + ``b_threshold`` constructor knob, which we seed from the fit params so its + perfusion-cutoff matches the segmentation threshold. + + Parameters + ---------- + params : IVIMFitParams + Fitting parameters carrying the ``signal_model`` selection. + + Returns + ------- + IVIMModel + Instantiated signal model. + """ + from osipy.ivim.models.registry import get_ivim_model + + name = getattr(params, "signal_model", "biexponential") or "biexponential" + if name == "simplified": + return get_ivim_model(name, b_threshold=params.b_threshold) + return get_ivim_model(name) + + @dataclass class IVIMFitResult: """Result of IVIM fitting. @@ -309,9 +344,15 @@ def _fit_ivim_vectorized( b_values = to_gpu(b_values) mask_3d = to_gpu(mask_3d) - # Create BoundIVIMModel with analytical Jacobian - model = IVIMBiexponentialModel() - bound_model = BoundIVIMModel(model, b_values, b_threshold=params.b_threshold) + # Create BoundIVIMModel with analytical Jacobian, using the selected + # signal model and seeding any user-supplied initial guesses. + model = _build_signal_model(params) + bound_model = BoundIVIMModel( + model, + b_values, + b_threshold=params.b_threshold, + initial_guess=params.initial_guess, + ) # Use shared fitter — returns dict[str, ParameterMap] fitter = fitter or LevenbergMarquardtFitter() @@ -443,8 +484,13 @@ def _ivim_bayesian( b_values = to_gpu(b_values) mask_3d = to_gpu(mask_3d) - model = IVIMBiexponentialModel() - bound_model = BoundIVIMModel(model, b_values, b_threshold=params.b_threshold) + model = _build_signal_model(params) + bound_model = BoundIVIMModel( + model, + b_values, + b_threshold=params.b_threshold, + initial_guess=params.initial_guess, + ) bayesian_cfg = getattr(params, "bayesian_params", None) or {} fitter = TwoStageBayesianIVIMFitter( diff --git a/osipy/ivim/models/binding.py b/osipy/ivim/models/binding.py index 5874374..67b4515 100644 --- a/osipy/ivim/models/binding.py +++ b/osipy/ivim/models/binding.py @@ -38,6 +38,11 @@ class BoundIVIMModel(BaseBoundModel): Parameters to fix at constant values during fitting. b_threshold : float b-value threshold for segmented initial guess estimation. + initial_guess : dict[str, float] | None + Per-parameter overrides for the optimizer's starting values + (e.g. ``{"D": 1e-3, "f": 0.1}``). Any free parameter named here + seeds the fit with the given value in place of the data-driven + estimate; unspecified parameters keep their data-driven guess. """ def __init__( @@ -46,12 +51,14 @@ def __init__( b_values: NDArray[np.floating[Any]], fixed: dict[str, float] | None = None, b_threshold: float = 200.0, + initial_guess: dict[str, float] | None = None, ) -> None: super().__init__(model, fixed) xp = get_array_module(b_values) self._b_values = xp.asarray(b_values, dtype=xp.float64) self._ivim_model: IVIMModel = model self._b_threshold = b_threshold + self._initial_guess = initial_guess or {} def ensure_device(self, xp: Any) -> None: """Transfer b-values array to the target device.""" @@ -196,12 +203,20 @@ def get_initial_guess_batch( full_guess[d_star_idx, :] = d_star_init if not self._fixed: - return full_guess - - # Filter to free params only - free_guess = xp.zeros((self._n_free, n_voxels), dtype=full_guess.dtype) - for free_idx, all_idx in enumerate(self._free_indices): - free_guess[free_idx, :] = full_guess[all_idx, :] + free_guess = full_guess + else: + # Filter to free params only + free_guess = xp.zeros((self._n_free, n_voxels), dtype=full_guess.dtype) + for free_idx, all_idx in enumerate(self._free_indices): + free_guess[free_idx, :] = full_guess[all_idx, :] + + # Apply user-supplied initial-guess overrides (keyed by free param + # name): seed the optimizer with the given constant in place of the + # data-driven estimate. Unspecified parameters are left untouched. + if self._initial_guess: + for free_idx, name in enumerate(self._free_params): + if name in self._initial_guess: + free_guess[free_idx, :] = self._initial_guess[name] return free_guess def compute_jacobian_batch( @@ -264,8 +279,15 @@ def compute_jacobian_batch( # dS/df = S0 * (-exp(-b*D) + exp(-b*D*)) all_cols["f"] = s0[xp.newaxis, :] * (-exp_d + exp_ds) else: - # Simplified model: no D*, dS/df = S0 * -exp(-b*D) - all_cols["f"] = s0[xp.newaxis, :] * (-exp_d) + # Simplified model: no D*. The forward model is + # b > threshold: S = S0 * (1-f) * exp(-b*D) + # b <= threshold: S = S0 * ((1-f) * exp(-b*D) + f) + # so dS/df = S0 * (-exp(-b*D)) for b > threshold + # dS/df = S0 * (-exp(-b*D) + 1) for b <= threshold + threshold = getattr(self._ivim_model, "b_threshold", self._b_threshold) + low_b = (self._b_values <= threshold)[:, xp.newaxis] # (n_b, 1) + df = -exp_d + xp.where(low_b, 1.0, 0.0) + all_cols["f"] = s0[xp.newaxis, :] * df # Select only free parameter columns return xp.stack([all_cols[p] for p in self._free_params]) diff --git a/osipy/pipeline/ivim_pipeline.py b/osipy/pipeline/ivim_pipeline.py index 2fe3308..03a0ee3 100644 --- a/osipy/pipeline/ivim_pipeline.py +++ b/osipy/pipeline/ivim_pipeline.py @@ -45,6 +45,9 @@ class IVIMPipelineConfig: ---------- fitting_method : FittingMethod IVIM fitting method. + signal_model : str + Registered IVIM signal model name (``"biexponential"`` or + ``"simplified"``). Selects the forward model used during fitting. b_threshold : float b-value threshold for segmented fitting (s/mm²). normalize_signal : bool @@ -54,7 +57,8 @@ class IVIMPipelineConfig: bounds : dict[str, tuple[float, float]] | None Custom parameter bounds, e.g. ``{"D": (1e-4, 5e-3)}``. initial_guess : dict[str, float] | None - Custom initial parameter estimates, e.g. ``{"D": 1e-3}``. + Custom initial parameter estimates, e.g. ``{"D": 1e-3}``. Seeds the + optimizer's starting values; threaded through to ``IVIMFitParams``. max_iterations : int Maximum iterations for optimization. tolerance : float @@ -66,6 +70,7 @@ class IVIMPipelineConfig: """ fitting_method: FittingMethod = FittingMethod.SEGMENTED + signal_model: str = "biexponential" b_threshold: float = 200.0 normalize_signal: bool = True output_dir: Path | None = None @@ -168,8 +173,10 @@ def run( fit_params = IVIMFitParams( method=self.config.fitting_method, + signal_model=self.config.signal_model, b_threshold=self.config.b_threshold, bounds=self.config.bounds, + initial_guess=self.config.initial_guess, max_iterations=self.config.max_iterations, tolerance=self.config.tolerance, bayesian_params=self.config.bayesian_params, @@ -180,9 +187,9 @@ def run( b_values=b_values, mask=mask, params=fit_params, - progress_callback=lambda p: progress_callback("IVIM Fitting", p) - if progress_callback - else None, + progress_callback=lambda p: ( + progress_callback("IVIM Fitting", p) if progress_callback else None + ), ) if progress_callback: diff --git a/tests/integration/test_ivim_pipeline.py b/tests/integration/test_ivim_pipeline.py index b61c4c3..4799791 100644 --- a/tests/integration/test_ivim_pipeline.py +++ b/tests/integration/test_ivim_pipeline.py @@ -366,3 +366,148 @@ def test_parameter_map_structure(self, synthetic_ivim_data: dict) -> None: assert hasattr(param_map, "values") assert hasattr(param_map, "affine") assert param_map.affine.shape == (4, 4) + + +class TestIVIMPipelineRegistryConfig: + """Tests for the registry-driven IVIM config wiring (config -> pipeline).""" + + @staticmethod + def _synthetic_biexp(seed: int = 7): + """Generate a small noisy bi-exponential IVIM dataset.""" + rng = np.random.default_rng(seed) + b = np.array( + [0, 10, 20, 30, 50, 80, 100, 150, 200, 400, 600, 800], dtype=float + ) + nx, ny, nz = 4, 4, 2 + s0 = rng.uniform(900, 1100, (nx, ny, nz)) + d = rng.uniform(0.8e-3, 1.5e-3, (nx, ny, nz)) + dstar = rng.uniform(8e-3, 25e-3, (nx, ny, nz)) + f = rng.uniform(0.05, 0.25, (nx, ny, nz)) + sig = s0[..., None] * ( + (1 - f[..., None]) * np.exp(-b * d[..., None]) + + f[..., None] * np.exp(-b * dstar[..., None]) + ) + sig = sig + rng.standard_normal(sig.shape) * 5.0 + return sig, b + + def test_load_config_to_pipeline_round_trip(self, tmp_path) -> None: + """A nested IVIM YAML loads and drives the pipeline end-to-end.""" + from osipy.cli.config import load_config + from osipy.ivim.fitting import FittingMethod + from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig + + sig, b = self._synthetic_biexp() + cfg_path = tmp_path / "ivim.yaml" + cfg_path.write_text( + "modality: ivim\n" + "pipeline:\n" + " fitting:\n" + " method: segmented\n" + " b_threshold: 180.0\n" + " initial_guess:\n" + " D: 1.0e-3\n" + " f: 0.1\n" + " model:\n" + " model: biexponential\n" + " normalize_signal: true\n" + ) + mc = load_config(cfg_path).get_modality_config() + + pipeline_cfg = IVIMPipelineConfig( + fitting_method=FittingMethod(mc.fitting.method), + signal_model=mc.model.model, + b_threshold=mc.fitting.b_threshold, + normalize_signal=mc.normalize_signal, + initial_guess=mc.fitting.initial_guess, + ) + result = IVIMPipeline(pipeline_cfg).run(sig, b) + assert int(result.fit_result.quality_mask.sum()) > 0 + assert result.config.signal_model == "biexponential" + assert result.config.initial_guess == {"D": 1.0e-3, "f": 0.1} + + def test_simplified_model_selectable_and_fits(self) -> None: + """The simplified model is selectable via config and produces fits.""" + from osipy.ivim.fitting import FittingMethod + from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig + + sig, b = self._synthetic_biexp(seed=11) + cfg = IVIMPipelineConfig( + fitting_method=FittingMethod.SEGMENTED, + signal_model="simplified", + ) + result = IVIMPipeline(cfg).run(sig, b) + qmask = result.fit_result.quality_mask + assert int(qmask.sum()) > 0 + d_vals = result.fit_result.d_map.values[qmask] + assert np.all(d_vals > 0) + # simplified model has no D*; the pipeline still produces a D* map + # (zeros) so downstream consumers see a uniform interface. + assert result.fit_result.d_star_map is not None + + def test_all_fitting_methods_run_through_pipeline(self) -> None: + """segmented / full / bayesian all run via the pipeline config.""" + from osipy.ivim.fitting import FittingMethod + from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig + + sig, b = self._synthetic_biexp(seed=3) + for method in ( + FittingMethod.SEGMENTED, + FittingMethod.FULL, + FittingMethod.BAYESIAN, + ): + cfg = IVIMPipelineConfig(fitting_method=method) + result = IVIMPipeline(cfg).run(sig, b) + assert int(result.fit_result.quality_mask.sum()) > 0 + + def test_initial_guess_seeds_the_fit(self) -> None: + """A user initial_guess reaches the optimizer (seeds the starting D/f).""" + from unittest.mock import patch + + import osipy.ivim.models.binding as binding_module + from osipy.ivim.fitting import FittingMethod + from osipy.pipeline.ivim_pipeline import IVIMPipeline, IVIMPipelineConfig + + sig, b = self._synthetic_biexp(seed=5) + guess = {"D": 2.0e-3, "f": 0.3} + cfg = IVIMPipelineConfig( + fitting_method=FittingMethod.SEGMENTED, + initial_guess=guess, + ) + + seen: dict[str, object] = {} + original_init = binding_module.BoundIVIMModel.__init__ + + def spy_init(self, *args, **kwargs): + seen["initial_guess"] = kwargs.get("initial_guess") + return original_init(self, *args, **kwargs) + + with patch.object(binding_module.BoundIVIMModel, "__init__", spy_init): + IVIMPipeline(cfg).run(sig, b) + + # The configured initial guess flows through to BoundIVIMModel. + assert seen.get("initial_guess") == guess + + def test_initial_guess_changes_starting_point(self) -> None: + """get_initial_guess_batch honors the override in place of data-driven seed.""" + from osipy.ivim.models import IVIMBiexponentialModel + from osipy.ivim.models.binding import BoundIVIMModel + + sig, b = self._synthetic_biexp(seed=9) + obs = sig.reshape(-1, sig.shape[-1]).T # (n_b, n_voxels) + + model = IVIMBiexponentialModel() + bm_default = BoundIVIMModel(model, b, b_threshold=200.0) + bm_override = BoundIVIMModel( + model, b, b_threshold=200.0, initial_guess={"D": 2.5e-3, "f": 0.33} + ) + g0 = bm_default.get_initial_guess_batch(obs, np) + g1 = bm_override.get_initial_guess_batch(obs, np) + + free = bm_default.parameters + d_idx = free.index("D") + f_idx = free.index("f") + assert np.allclose(g1[d_idx, :], 2.5e-3) + assert np.allclose(g1[f_idx, :], 0.33) + # Unspecified parameters keep their data-driven guess. + s0_idx = free.index("S0") + assert np.allclose(g0[s0_idx, :], g1[s0_idx, :]) diff --git a/tests/unit/cli/test_config.py b/tests/unit/cli/test_config.py index 05f1742..8d6df76 100644 --- a/tests/unit/cli/test_config.py +++ b/tests/unit/cli/test_config.py @@ -15,12 +15,10 @@ from osipy.cli.config import ( ASLPipelineYAML, BackendConfig, - BayesianIVIMFittingConfig, DataConfig, DCEFittingConfig, DCEPipelineYAML, DSCPipelineYAML, - IVIMFittingConfig, IVIMPipelineYAML, LoggingConfig, OutputConfig, @@ -178,12 +176,49 @@ def test_load_ivim_config(self, tmp_config) -> None: path = tmp_config("""\ modality: ivim pipeline: - fitting_method: segmented - b_threshold: 200.0 + fitting: + method: segmented + b_threshold: 200.0 """) config = load_config(path) assert config.modality == "ivim" + def test_load_ivim_nested_method_configs_round_trip(self, tmp_config) -> None: + """Nested IVIM selection-point configs round-trip through load_config. + + Selecting bayesian surfaces its prior knobs, the simplified model is + selectable with its own b_threshold, and per-method/shared knobs all + round-trip. + """ + path = tmp_config("""\ + modality: ivim + pipeline: + fitting: + method: bayesian + b_threshold: 150.0 + prior_scale: 2.0 + compute_uncertainty: false + max_iterations: 800 + initial_guess: + D: 0.9e-3 + f: 0.12 + model: + model: simplified + b_threshold: 180.0 + normalize_signal: false + """) + config = load_config(path) + mc = config.get_modality_config() + assert mc.fitting.method == "bayesian" + assert mc.fitting.b_threshold == 150.0 + assert mc.fitting.prior_scale == 2.0 + assert mc.fitting.compute_uncertainty is False + assert mc.fitting.max_iterations == 800 + assert mc.fitting.initial_guess == {"D": 0.9e-3, "f": 0.12} + assert mc.model.model == "simplified" + assert mc.model.b_threshold == 180.0 + assert mc.normalize_signal is False + def test_load_nonexistent_file(self) -> None: """Missing config file raises FileNotFoundError.""" with pytest.raises(FileNotFoundError): @@ -469,27 +504,65 @@ def test_single_pld_rejects_multi_pld_keys(self) -> None: class TestIVIMPipelineYAML: - """Tests for IVIM pipeline config validation.""" + """Tests for IVIM pipeline config validation (registry-driven unions).""" def test_defaults(self) -> None: - """Default IVIM config values match expected.""" + """Default IVIM config uses segmented fitting + biexponential model.""" cfg = IVIMPipelineYAML() - assert cfg.fitting_method == "segmented" - assert cfg.b_threshold == 200.0 - assert cfg.normalize_signal is True + assert cfg.fitting.method == "segmented" + assert cfg.fitting.b_threshold == 200.0 assert cfg.fitting.max_iterations == 500 assert cfg.fitting.tolerance == 1e-6 + assert cfg.model.model == "biexponential" + assert cfg.normalize_signal is True def test_invalid_fitting_method(self) -> None: """Invalid fitting method raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid fitting method"): - IVIMPipelineYAML(fitting_method="invalid") + with pytest.raises(ValidationError): + IVIMPipelineYAML(fitting={"method": "invalid"}) def test_valid_fitting_methods(self) -> None: - """All valid fitting methods are accepted.""" - for method in ("segmented", "full", "bayesian"): - cfg = IVIMPipelineYAML(fitting_method=method) - assert cfg.fitting_method == method + """All registered fitting methods are accepted.""" + from osipy.ivim.config import IVIM_FITTING_CONFIGS + + for method in IVIM_FITTING_CONFIGS: + cfg = IVIMPipelineYAML(fitting={"method": method}) + assert cfg.fitting.method == method + + def test_bayesian_surfaces_prior_scale(self) -> None: + """Selecting bayesian surfaces prior_scale; segmented does not.""" + cfg = IVIMPipelineYAML(fitting={"method": "bayesian", "prior_scale": 2.5}) + assert cfg.fitting.method == "bayesian" + assert cfg.fitting.prior_scale == 2.5 + # prior_scale is a bayesian-only knob — segmented must reject it. + with pytest.raises(ValidationError): + IVIMPipelineYAML(fitting={"method": "segmented", "prior_scale": 2.5}) + + def test_full_rejects_b_threshold(self) -> None: + """The full strategy has no b_threshold knob (cross-method key rejected).""" + with pytest.raises(ValidationError): + IVIMPipelineYAML(fitting={"method": "full", "b_threshold": 150.0}) + + def test_fitting_rejects_unknown_keys(self) -> None: + """Unknown keys on the fitting config are rejected (extra=forbid).""" + with pytest.raises(ValidationError): + IVIMPipelineYAML(fitting={"method": "segmented", "nonsense": 1}) + + def test_valid_signal_models(self) -> None: + """Both registered signal models are selectable.""" + from osipy.ivim.config import IVIM_MODEL_CONFIGS + + for name in IVIM_MODEL_CONFIGS: + cfg = IVIMPipelineYAML(model={"model": name}) + assert cfg.model.model == name + + def test_simplified_model_surfaces_b_threshold(self) -> None: + """The simplified model exposes its own b_threshold; biexp rejects it.""" + cfg = IVIMPipelineYAML(model={"model": "simplified", "b_threshold": 175.0}) + assert cfg.model.model == "simplified" + assert cfg.model.b_threshold == 175.0 + with pytest.raises(ValidationError): + IVIMPipelineYAML(model={"model": "biexponential", "b_threshold": 175.0}) # --------------------------------------------------------------------------- @@ -594,20 +667,25 @@ def test_dce_config_from_yaml(self, tmp_config) -> None: class TestIVIMFittingConfig: - """Tests for IVIM fitting configuration.""" + """Tests for the registry-driven IVIM fitting MethodConfig models.""" - def test_defaults(self) -> None: - """Default IVIM fitting config values match expected.""" - cfg = IVIMFittingConfig() + def test_segmented_defaults(self) -> None: + """Default segmented fitting config values match expected.""" + from osipy.ivim.config import SegmentedFittingConfig + + cfg = SegmentedFittingConfig() + assert cfg.method == "segmented" assert cfg.max_iterations == 500 assert cfg.tolerance == 1e-6 + assert cfg.b_threshold == 200.0 assert cfg.bounds is None assert cfg.initial_guess is None - assert isinstance(cfg.bayesian, BayesianIVIMFittingConfig) def test_bounds_override(self) -> None: """IVIM bounds override parses correctly.""" - cfg = IVIMFittingConfig( + from osipy.ivim.config import SegmentedFittingConfig + + cfg = SegmentedFittingConfig( bounds={"D": [1e-4, 3e-3], "D_star": [5e-3, 0.05], "f": [0.0, 0.5]} ) assert cfg.bounds["D"] == [1e-4, 3e-3] @@ -615,78 +693,106 @@ def test_bounds_override(self) -> None: def test_bounds_validation_wrong_length(self) -> None: """Bounds with != 2 elements raises ValidationError.""" + from osipy.ivim.config import SegmentedFittingConfig + with pytest.raises(ValidationError, match="must be"): - IVIMFittingConfig(bounds={"D": [1e-4]}) + SegmentedFittingConfig(bounds={"D": [1e-4]}) + + def test_bounds_validation_lower_gt_upper(self) -> None: + """Lower bound > upper bound raises ValidationError.""" + from osipy.ivim.config import SegmentedFittingConfig + + with pytest.raises(ValidationError, match="Lower bound > upper bound"): + SegmentedFittingConfig(bounds={"D": [3e-3, 1e-4]}) def test_initial_guess_override(self) -> None: """IVIM initial guess override parses correctly.""" - cfg = IVIMFittingConfig(initial_guess={"D": 0.8e-3, "D_star": 0.02, "f": 0.15}) + from osipy.ivim.config import SegmentedFittingConfig + + cfg = SegmentedFittingConfig(initial_guess={"D": 0.8e-3, "f": 0.15}) assert cfg.initial_guess["D"] == 0.8e-3 def test_bayesian_defaults(self) -> None: - """Bayesian sub-config has expected defaults.""" - cfg = BayesianIVIMFittingConfig() + """Bayesian fitting config has expected prior knobs.""" + from osipy.ivim.config import BayesianFittingConfig + + cfg = BayesianFittingConfig() + assert cfg.method == "bayesian" assert cfg.prior_scale == 1.5 assert cfg.noise_std is None assert cfg.compute_uncertainty is True + assert cfg.b_threshold == 200.0 def test_bayesian_custom_priors(self) -> None: """Custom Bayesian parameters parse correctly.""" - cfg = IVIMFittingConfig( - bayesian={"prior_scale": 2.0, "compute_uncertainty": False} - ) - assert cfg.bayesian.prior_scale == 2.0 - assert cfg.bayesian.compute_uncertainty is False - assert cfg.bayesian.noise_std is None # default preserved + from osipy.ivim.config import BayesianFittingConfig + + cfg = BayesianFittingConfig(prior_scale=2.0, compute_uncertainty=False) + assert cfg.prior_scale == 2.0 + assert cfg.compute_uncertainty is False + assert cfg.noise_std is None # default preserved + + def test_full_has_no_b_threshold(self) -> None: + """The full strategy intentionally exposes no b_threshold knob.""" + from osipy.ivim.config import FullFittingConfig + + cfg = FullFittingConfig() + assert cfg.method == "full" + assert "b_threshold" not in cfg.model_fields def test_ivim_yaml_includes_fitting(self) -> None: - """IVIMPipelineYAML includes fitting sub-config with defaults.""" + """IVIMPipelineYAML includes a validated fitting MethodConfig.""" + from osipy.ivim.config import SegmentedFittingConfig + cfg = IVIMPipelineYAML() - assert isinstance(cfg.fitting, IVIMFittingConfig) + assert isinstance(cfg.fitting, SegmentedFittingConfig) assert cfg.fitting.max_iterations == 500 def test_ivim_config_from_yaml(self, tmp_config) -> None: - """Full IVIM config with fitting section loads from YAML.""" + """Full IVIM config with bayesian fitting + simplified model loads.""" path = tmp_config("""\ modality: ivim pipeline: - fitting_method: bayesian - b_threshold: 150.0 fitting: + method: bayesian + b_threshold: 150.0 max_iterations: 1000 tolerance: 1.0e-8 + prior_scale: 2.0 + compute_uncertainty: false bounds: D: [1.0e-4, 3.0e-3] D_star: [5.0e-3, 0.05] f: [0.0, 0.5] initial_guess: D: 0.8e-3 - D_star: 0.02 f: 0.15 - bayesian: - prior_scale: 2.0 - compute_uncertainty: false + model: + model: simplified + b_threshold: 180.0 """) config = load_config(path) mc = config.get_modality_config() - assert mc.fitting_method == "bayesian" + assert mc.fitting.method == "bayesian" assert mc.fitting.max_iterations == 1000 assert mc.fitting.tolerance == 1e-8 assert mc.fitting.bounds["D"] == [1e-4, 3e-3] assert mc.fitting.initial_guess["D"] == 0.8e-3 - assert mc.fitting.bayesian.prior_scale == 2.0 - assert mc.fitting.bayesian.compute_uncertainty is False + assert mc.fitting.prior_scale == 2.0 + assert mc.fitting.compute_uncertainty is False + assert mc.model.model == "simplified" + assert mc.model.b_threshold == 180.0 def test_fitting_section_optional(self, tmp_config) -> None: - """IVIM config without fitting section uses defaults.""" + """IVIM config without explicit fitting section uses defaults.""" path = tmp_config("""\ modality: ivim pipeline: - fitting_method: segmented - b_threshold: 200.0 + normalize_signal: true """) config = load_config(path) mc = config.get_modality_config() + assert mc.fitting.method == "segmented" assert mc.fitting.max_iterations == 500 assert mc.fitting.tolerance == 1e-6 assert mc.fitting.bounds is None diff --git a/tests/unit/cli/test_wizard.py b/tests/unit/cli/test_wizard.py index 3924085..0ccb13f 100644 --- a/tests/unit/cli/test_wizard.py +++ b/tests/unit/cli/test_wizard.py @@ -619,14 +619,63 @@ class TestCollectIVIMConfig: """Tests for _collect_ivim_config().""" def test_defaults(self) -> None: - """Pressing Enter for all prompts returns defaults.""" - inputs = _make_input_fn(["", "", ""]) + """Pressing Enter for all prompts returns defaults (segmented + biexp).""" + inputs = _make_input_fn( + [ + "", # fitting method: segmented + "", # max_iterations + "", # tolerance + "", # b_threshold + "", # signal model: biexponential + "", # normalize: yes + ] + ) with patch("builtins.input", side_effect=inputs): cfg = _collect_ivim_config() - assert cfg["fitting_method"] == "segmented" - assert cfg["b_threshold"] == 200.0 + assert cfg["fitting"]["method"] == "segmented" + assert cfg["fitting"]["b_threshold"] == 200.0 + assert cfg["model"]["model"] == "biexponential" assert cfg["normalize_signal"] is True + def test_bayesian_surfaces_prior_scale(self) -> None: + """Selecting bayesian surfaces its prior_scale knob in the collected dict.""" + # bayesian fields: max_iterations, tolerance, bounds(skip), initial_guess(skip), + # method, b_threshold, prior_scale, noise_std(skip None), compute_uncertainty + inputs = _make_input_fn( + [ + "bayesian", # fitting method + "", # max_iterations + "", # tolerance + "", # b_threshold + "", # prior_scale + "", # compute_uncertainty + "", # signal model: biexponential + "", # normalize + ] + ) + with patch("builtins.input", side_effect=inputs): + cfg = _collect_ivim_config() + assert cfg["fitting"]["method"] == "bayesian" + assert cfg["fitting"]["prior_scale"] == 1.5 + + def test_simplified_model_selectable(self) -> None: + """Selecting the simplified model surfaces its b_threshold knob.""" + inputs = _make_input_fn( + [ + "", # fitting method: segmented + "", # max_iterations + "", # tolerance + "", # b_threshold (fitting) + "simplified", # signal model + "", # model b_threshold + "", # normalize + ] + ) + with patch("builtins.input", side_effect=inputs): + cfg = _collect_ivim_config() + assert cfg["model"]["model"] == "simplified" + assert cfg["model"]["b_threshold"] == 200.0 + # --------------------------------------------------------------------------- # All-modalities round-trip validation @@ -671,8 +720,8 @@ def test_defaults_round_trip(self, modality: str) -> None: "label_control_order": "label_first", }, "ivim": { - "fitting_method": "segmented", - "b_threshold": 200.0, + "fitting": {"method": "segmented", "b_threshold": 200.0}, + "model": {"model": "biexponential"}, "normalize_signal": True, }, } @@ -762,7 +811,10 @@ def test_full_ivim_wizard(self, tmp_path: Path) -> None: "4", # modality: ivim # -- pipeline (first) -- "", # fitting method: segmented - "", # b threshold: 200.0 + "", # max_iterations + "", # tolerance + "", # b_threshold (fitting) + "", # signal model: biexponential "", # normalize: yes # -- data (second) -- "", # format: auto diff --git a/tests/unit/ivim/test_ivim_config.py b/tests/unit/ivim/test_ivim_config.py new file mode 100644 index 0000000..5f3f1c4 --- /dev/null +++ b/tests/unit/ivim/test_ivim_config.py @@ -0,0 +1,145 @@ +"""Unit tests for the registry-driven IVIM config models and wiring. + +Covers the discriminated-union config models (fitting strategy + signal +model), the ``_build_signal_model`` selection helper, and the +``initial_guess`` threading through ``BoundIVIMModel``. +""" + +from __future__ import annotations + +import numpy as np +import pytest +from pydantic import ValidationError + +from osipy.ivim.config import ( + IVIM_FITTING_CONFIGS, + IVIM_MODEL_CONFIGS, + IVIM_SIGNAL_MODEL_REGISTRY, + BayesianFittingConfig, + BiexponentialModelConfig, + FullFittingConfig, + SegmentedFittingConfig, + SimplifiedModelConfig, +) + + +class TestIVIMFittingConfigs: + """Discriminated-union fitting-strategy MethodConfig models.""" + + def test_registry_keys(self) -> None: + """All three strategies are exposed as config models.""" + assert set(IVIM_FITTING_CONFIGS) == {"segmented", "full", "bayesian"} + + def test_shared_knobs_present_on_all(self) -> None: + """Every strategy carries the shared fitting knobs.""" + for cfg_cls in IVIM_FITTING_CONFIGS.values(): + fields = cfg_cls.model_fields + assert "max_iterations" in fields + assert "tolerance" in fields + assert "bounds" in fields + assert "initial_guess" in fields + + def test_b_threshold_only_on_segmented_and_bayesian(self) -> None: + """The full strategy has no segmentation threshold knob.""" + assert "b_threshold" in SegmentedFittingConfig.model_fields + assert "b_threshold" in BayesianFittingConfig.model_fields + assert "b_threshold" not in FullFittingConfig.model_fields + + def test_prior_knobs_only_on_bayesian(self) -> None: + """Bayesian-only knobs do not leak onto the other strategies.""" + assert "prior_scale" in BayesianFittingConfig.model_fields + assert "prior_scale" not in SegmentedFittingConfig.model_fields + assert "prior_scale" not in FullFittingConfig.model_fields + + def test_extra_keys_forbidden(self) -> None: + """MethodConfig rejects unknown keys.""" + with pytest.raises(ValidationError): + SegmentedFittingConfig(nonsense=1) + + def test_bounds_validation(self) -> None: + """Bounds must be [lower, upper] with lower <= upper.""" + with pytest.raises(ValidationError, match="must be"): + SegmentedFittingConfig(bounds={"D": [1e-4]}) + with pytest.raises(ValidationError, match="Lower bound > upper bound"): + SegmentedFittingConfig(bounds={"D": [3e-3, 1e-4]}) + + +class TestIVIMModelConfigs: + """Discriminated-union signal-model MethodConfig models.""" + + def test_registry_keys(self) -> None: + """Both signal models are exposed as config models.""" + assert set(IVIM_MODEL_CONFIGS) == {"biexponential", "simplified"} + + def test_signal_model_registry_constructs(self) -> None: + """The registry maps names to constructible model classes.""" + assert set(IVIM_SIGNAL_MODEL_REGISTRY) == {"biexponential", "simplified"} + biexp = IVIM_SIGNAL_MODEL_REGISTRY["biexponential"]() + assert biexp.parameters == ["S0", "D", "D*", "f"] + + def test_simplified_exposes_b_threshold(self) -> None: + """The simplified model config carries its perfusion-cutoff knob.""" + assert "b_threshold" in SimplifiedModelConfig.model_fields + assert "b_threshold" not in BiexponentialModelConfig.model_fields + + def test_simplified_extra_keys_forbidden(self) -> None: + """Cross-model keys are rejected.""" + with pytest.raises(ValidationError): + BiexponentialModelConfig(b_threshold=180.0) + + +class TestBuildSignalModel: + """The ``_build_signal_model`` selection helper.""" + + def test_default_biexponential(self) -> None: + from osipy.ivim.fitting.estimators import IVIMFitParams, _build_signal_model + + model = _build_signal_model(IVIMFitParams()) + assert model.parameters == ["S0", "D", "D*", "f"] + + def test_simplified_uses_b_threshold(self) -> None: + from osipy.ivim.fitting.estimators import IVIMFitParams, _build_signal_model + + params = IVIMFitParams(signal_model="simplified", b_threshold=175.0) + model = _build_signal_model(params) + assert model.parameters == ["S0", "D", "f"] + assert model.b_threshold == 175.0 + + +class TestInitialGuessThreading: + """``initial_guess`` flows into ``BoundIVIMModel.get_initial_guess_batch``.""" + + def test_override_seeds_named_params(self) -> None: + from osipy.ivim.models import IVIMBiexponentialModel + from osipy.ivim.models.binding import BoundIVIMModel + + b = np.array([0, 10, 50, 100, 200, 400, 800], dtype=float) + obs = np.ones((len(b), 3)) * 0.5 + + model = IVIMBiexponentialModel() + bound = BoundIVIMModel( + model, b, b_threshold=200.0, initial_guess={"D": 2.5e-3, "f": 0.33} + ) + guess = bound.get_initial_guess_batch(obs, np) + free = bound.parameters + assert np.allclose(guess[free.index("D"), :], 2.5e-3) + assert np.allclose(guess[free.index("f"), :], 0.33) + + def test_no_override_preserves_data_driven_guess(self) -> None: + from osipy.ivim.models import IVIMBiexponentialModel + from osipy.ivim.models.binding import BoundIVIMModel + + b = np.array([0, 10, 50, 100, 200, 400, 800], dtype=float) + obs = np.ones((len(b), 3)) * 0.5 + + model = IVIMBiexponentialModel() + g0 = BoundIVIMModel(model, b, b_threshold=200.0).get_initial_guess_batch( + obs, np + ) + g1 = BoundIVIMModel( + model, b, b_threshold=200.0, initial_guess={"D": 2.5e-3} + ).get_initial_guess_batch(obs, np) + free = BoundIVIMModel(model, b, b_threshold=200.0).parameters + # Only D changed; S0 and f keep the data-driven values. + assert np.allclose(g0[free.index("S0"), :], g1[free.index("S0"), :]) + assert np.allclose(g0[free.index("f"), :], g1[free.index("f"), :]) From e898955369dd442324656769b9209e8d30a15151 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 00:35:00 -0400 Subject: [PATCH 06/15] verify script: update example configs to nested registry-driven schema Reflect the new nested YAML (model/t1_mapping_method/concentration/population_aif for DCE; m0/difference/quantification for ASL; fitting/model for IVIM; deconvolution for DSC). All 8 real-data CLI pipelines pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/verify_local_pipelines.sh | 33 ++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/verify_local_pipelines.sh b/scripts/verify_local_pipelines.sh index d39e1d4..d3b811f 100755 --- a/scripts/verify_local_pipelines.sh +++ b/scripts/verify_local_pipelines.sh @@ -137,10 +137,16 @@ run_cli_pipeline() { cat > "$CONFIG_DIR/dce.yaml" <<'YAML' modality: dce pipeline: - model: extended_tofts - t1_mapping_method: vfa + model: + method: extended_tofts + t1_mapping_method: + method: vfa + fit_method: linear + concentration: + method: spgr aif_source: population - population_aif: parker + population_aif: + name: parker acquisition: tr: 5.0 flip_angles: [5, 10, 15, 20, 25, 30] @@ -158,8 +164,11 @@ for region in brain abdomen; do cat > "$CONFIG_DIR/ivim_${region}.yaml" < Date: Mon, 8 Jun 2026 00:51:56 -0400 Subject: [PATCH 07/15] docs: update for registry-driven nested config Convert all YAML pipeline-config examples (tutorials, how-tos, architecture) to the new nested registry-driven schema; document newly CLI-selectable capabilities (DCE nonlinear-VFA + linear-concentration; ASL multi-PLD/ATT mode; IVIM simplified model + per-method bayesian knobs + initial_guess; DSC per-method deconvolution params). Add docs/explanation/configuration.md describing the registry x schema config generation. Fix docs/gen_config_docs.py (it imported removed flat config classes and broke mkdocs build) to render the discriminated unions. mkdocs build is clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/explanation/architecture.md | 6 + docs/explanation/configuration.md | 123 +++++++++++++++++++++ docs/explanation/index.md | 2 + docs/gen_config_docs.py | 158 ++++++++++++++++++++------- docs/how-to/choose-population-aif.md | 11 +- docs/how-to/fit-multiple-models.md | 6 +- docs/how-to/load-perfusion-data.md | 6 +- docs/how-to/run-complete-pipeline.md | 6 +- docs/how-to/run-pipeline-cli.md | 66 +++++++++-- docs/how-to/use-custom-aif.md | 5 +- docs/tutorials/asl-analysis.md | 2 +- docs/tutorials/dce-analysis.md | 2 +- docs/tutorials/dsc-analysis.md | 2 +- docs/tutorials/ivim-analysis.md | 2 +- mkdocs.yml | 1 + 15 files changed, 332 insertions(+), 66 deletions(-) create mode 100644 docs/explanation/configuration.md diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index a2dab8d..3ab033f 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -504,6 +504,12 @@ All extension points use the registry pattern — one file, one decorator. 17+ r All registries use `DataValidationError` for unknown names and `logging.getLogger(__name__)` with warnings for overwrites. +Each selectable component also declares a pydantic `MethodConfig`, so the CLI +config, interactive wizard, and `--dump-defaults` templates are generated +directly from `registry × schema`. See +[Registry-Driven Configuration](configuration.md) for how a single decorator +plus a config model turns a component into a validated, wired CLI toggle. + ### Adding a New Model 1. Create class inheriting from `BasePerfusionModel` diff --git a/docs/explanation/configuration.md b/docs/explanation/configuration.md new file mode 100644 index 0000000..5d0b872 --- /dev/null +++ b/docs/explanation/configuration.md @@ -0,0 +1,123 @@ +# Registry-Driven Configuration + +osipy's YAML/CLI configuration is **generated from the component registries**, +not hand-written. Each pipeline-component selection — the DCE pharmacokinetic +model, the DSC deconvolution method, the ASL M0 calibration, the IVIM fitting +strategy, and so on — is a nested, validated block whose available options and +parameters come directly from what is registered. This page explains why that +design exists and how it works. + +## The Problem It Solves + +Earlier, each pipeline option was a flat string in the config, with the +component's parameters as separate sibling keys disconnected from the +selection. This had two recurring failure modes: + +- **Collected but ignored.** A knob could be parsed into the config object yet + never reach the component that needed it, so changing it silently did nothing. +- **Mismatched parameters.** A threshold meant for one method could be set while + a different method was selected, with no error — the value was simply unused. + +The registry-driven config closes this gap: the same schema that *validates* +your input is also what *constructs* the component, so an option can never be +silently dropped or applied to the wrong method. + +## How It Works + +### Each component declares a `MethodConfig` + +Every selectable component ships a small pydantic model +(`osipy.common.config.MethodConfig`) that carries a discriminator field — a +`Literal` equal to the component's registry name — plus exactly that +component's tunable knobs: + +```python +class OSVDConfig(MethodConfig): + method: Literal["oSVD"] = "oSVD" + oscillation_index: float = Field(0.035, gt=0.0) + default_threshold: float = Field(0.2, gt=0.0, lt=1.0) +``` + +`MethodConfig` sets `extra="forbid"`, so a typo or a knob that belongs to a +different method raises a validation error instead of being quietly ignored. + +### The discriminator selects the parameters you see + +In YAML, you select a component by its discriminator and then only the knobs +for *that* component are valid: + +```yaml +deconvolution: + method: oSVD # oSVD | sSVD | cSVD + oscillation_index: 0.035 + default_threshold: 0.2 +``` + +Switch the method and the surfaced knobs change with it — `sSVD` and `cSVD` +expose a single `threshold`, while `oSVD` exposes `oscillation_index` and +`default_threshold`. The discriminator is `method` for most components, +`mode` for the ASL quantification block (single-PLD vs multi-PLD), `model` +for the IVIM signal model, and `name` for the population AIF. + +### The CLI config is generated from `registry × schema` + +The per-component config models are composed into discriminated unions +(`method_union()`), which form the modality config models used by the CLI. +The `--dump-defaults` templates and the interactive wizard (`--help-me-pls`) +are produced from these same models, so the documented defaults always match +the code. + +### The same schema validates *and* builds the component + +When a config is loaded, the discriminator picks the registry entry and the +remaining fields become that component's constructor arguments +(`construct_from_config()`): + +```python +deconvolver = construct_from_config(DECONVOLVER_REGISTRY, cfg) # cfg.method -> instance +``` + +Because validation and construction share one schema, every accepted knob is +guaranteed to reach the live component. + +## Consequences for Contributors + +Adding a new method is the registry pattern plus one config model: + +1. Register the component, e.g. `@register_deconvolver("mymethod")` (see + [Extension Points](architecture.md#extension-points)). +2. Give it a `MethodConfig` subclass listing its discriminator and knobs, and + add it to the modality's `*_CONFIGS` mapping. + +That's it — the new method automatically appears as a selectable option in the +CLI config, the `--dump-defaults` template, and the interactive wizard, with +its parameters validated and wired through. No hand-editing of the config +schema, runner, or wizard is required. + +## Per-Modality Shape + +The nested blocks per modality are: + +| Modality | Nested component blocks | +|----------|-------------------------| +| DCE | `model.method`, `t1_mapping_method.method` (+ `fit_method`), `concentration.method`, `population_aif.name` | +| DSC | `deconvolution.method` (+ method-specific thresholds) | +| ASL | `m0.method`, `difference.method`, `quantification.mode` (single-PLD or multi-PLD + ATT) | +| IVIM | `fitting.method` (segmented / full / bayesian), `model.model` (biexponential / simplified) | + +Physiological and acquisition parameters that are not method-specific (such as +the ASL labeling timing, the DSC echo time, or IVIM `normalize_signal`) stay +as flat keys in the `pipeline` block. See +[How to Run a Pipeline from YAML](../how-to/run-pipeline-cli.md) for complete, +runnable examples, and generate an authoritative template at any time with: + +```bash +osipy --dump-defaults dce # or dsc, asl, ivim +``` + +## See Also + +- [Architecture Overview](architecture.md) — the registry pattern and the full + extension-point table +- [How to Run a Pipeline from YAML](../how-to/run-pipeline-cli.md) — task-oriented + config recipes diff --git a/docs/explanation/index.md b/docs/explanation/index.md index f51c533..00e4a40 100644 --- a/docs/explanation/index.md +++ b/docs/explanation/index.md @@ -3,6 +3,7 @@ ## Software Architecture - [Architecture Overview](architecture.md) — Module structure, data flow, and registry-driven extensibility. +- [Registry-Driven Configuration](configuration.md) — How the CLI/YAML config is generated from the registries via `MethodConfig` schemas. - [The xp Abstraction Pattern](xp-abstraction.md) — GPU/CPU agnostic code using `xp = get_array_module()`. - [OSIPI Standards](osipi-standards.md) — CAPLEX naming, units, and validation against DROs. @@ -18,6 +19,7 @@ | If you want to understand... | Read | |------------------------------|------| | How the code is organized | [Architecture Overview](architecture.md) | +| Why the YAML config is shaped the way it is | [Registry-Driven Configuration](configuration.md) | | How GPU acceleration works | [xp Abstraction Pattern](xp-abstraction.md) | | What OSIPI standards mean | [OSIPI Standards](osipi-standards.md) | | How DCE models work mathematically | [Pharmacokinetic Models](pharmacokinetic-models.md) | diff --git a/docs/gen_config_docs.py b/docs/gen_config_docs.py index f474749..2f9ca43 100644 --- a/docs/gen_config_docs.py +++ b/docs/gen_config_docs.py @@ -20,13 +20,11 @@ from osipy.cli.config import ( ASLPipelineYAML, BackendConfig, - BayesianIVIMFittingConfig, DataConfig, DCEAcquisitionYAML, DCEFittingConfig, DCEPipelineYAML, DSCPipelineYAML, - IVIMFittingConfig, IVIMPipelineYAML, LoggingConfig, OutputConfig, @@ -37,40 +35,27 @@ # Valid-value mapping: (ClassName, field_name) -> callable returning list # --------------------------------------------------------------------------- +# Only genuinely-flat string fields need an explicit valid-value list. The +# component-selection blocks (model, t1_mapping_method, concentration, +# population_aif, deconvolution, m0, difference, quantification, fitting, +# IVIM model) are discriminated unions whose members are documented as nested +# sub-tables, so their valid values are self-describing via the discriminator. VALID_VALUES: dict[tuple[str, str], Any] = { ("PipelineConfig", "modality"): lambda: ["dce", "dsc", "asl", "ivim"], - ("DCEPipelineYAML", "model"): lambda: _safe_registry("osipy.dce", "list_models"), - ("DCEPipelineYAML", "t1_mapping_method"): lambda: ["vfa", "look_locker"], ("DCEPipelineYAML", "aif_source"): lambda: [ "population", "detect", "manual", ], - ("DCEPipelineYAML", "population_aif"): lambda: _safe_registry( - "osipy.common.aif", "list_aifs" - ), - ("DSCPipelineYAML", "deconvolution_method"): lambda: _safe_registry( - "osipy.dsc", "list_deconvolvers" - ), ("ASLPipelineYAML", "labeling_scheme"): lambda: [ "pasl", "casl", "pcasl", ], - ("ASLPipelineYAML", "m0_method"): lambda: [ - "single", - "voxelwise", - "reference_region", - ], ("ASLPipelineYAML", "label_control_order"): lambda: [ "label_first", "control_first", ], - ("IVIMPipelineYAML", "fitting_method"): lambda: [ - "segmented", - "full", - "bayesian", - ], ("DCEFittingConfig", "fitter"): lambda: _safe_registry( "osipy.common.fitting.registry", "list_fitters" ), @@ -224,6 +209,76 @@ def _render_table( return lines +def _union_members(annotation: Any) -> list[type[BaseModel]]: + """Return the pydantic ``MethodConfig`` members of a (possibly single) union. + + Component-selection fields are typed as a discriminated union of + ``MethodConfig`` subclasses (or a single such class). Returns the member + classes in a stable, name-sorted order. + """ + origin = get_origin(annotation) + if origin is typing.Union or isinstance(annotation, types.UnionType): + members = [a for a in get_args(annotation) if a is not type(None)] + elif isinstance(annotation, type) and issubclass(annotation, BaseModel): + members = [annotation] + else: + return [] + members = [m for m in members if isinstance(m, type) and issubclass(m, BaseModel)] + return sorted(members, key=lambda m: m.__name__) + + +def _render_component_union( + annotation: Any, + *, + field_name: str, + discriminator: str, + heading_level: int, +) -> list[str]: + """Render each member of a component-selection union as a sub-table. + + Each member is a :class:`MethodConfig` whose ``discriminator`` literal + names the selectable option; selecting it surfaces exactly that member's + fields. + """ + lines: list[str] = [] + for member in _union_members(annotation): + # The discriminator literal value identifies the selectable option. + disc_field = member.model_fields.get(discriminator) + choice = "" + if disc_field is not None: + choice_args = get_args(disc_field.annotation) + if choice_args: + choice = str(choice_args[0]) + title = ( + f"`pipeline.{field_name}` with `{discriminator}: {choice}`" + if choice + else f"`pipeline.{field_name}` ({member.__name__})" + ) + lines.extend(_render_table(member, heading=title, heading_level=heading_level)) + return lines + + +def _render_modality_components(parent: type[BaseModel]) -> list[str]: + """Render every component-selection sub-block for a modality pipeline model. + + Each selectable component is a discriminated union; its members are rendered + as nested sub-tables (heading level 4) so the reference mirrors the nested + YAML shape. + """ + lines: list[str] = [] + for field_name, discriminator in _COMPONENT_FIELDS.get(parent, []): + annotation = parent.model_fields[field_name].annotation + lines.extend( + _render_component_union( + annotation, + field_name=field_name, + discriminator=discriminator, + heading_level=4, + ) + ) + return lines + + # --------------------------------------------------------------------------- # Main document assembly # --------------------------------------------------------------------------- @@ -240,6 +295,26 @@ def _render_table( (LoggingConfig, "`logging:` (LoggingConfig)"), ] +# Per-modality component-selection fields that are discriminated unions of +# ``MethodConfig`` members. Each entry maps the parent pipeline model to a list +# of ``(field_name, discriminator)`` pairs; each member is rendered as its own +# sub-table so selecting a method/mode/model surfaces exactly its parameters. +_COMPONENT_FIELDS: dict[type[BaseModel], list[tuple[str, str]]] = { + DCEPipelineYAML: [ + ("model", "method"), + ("t1_mapping_method", "method"), + ("concentration", "method"), + ("population_aif", "name"), + ], + DSCPipelineYAML: [("deconvolution", "method")], + ASLPipelineYAML: [ + ("m0", "method"), + ("difference", "method"), + ("quantification", "mode"), + ], + IVIMPipelineYAML: [("fitting", "method"), ("model", "model")], +} + def generate() -> str: """Build the full reference page as a markdown string.""" @@ -256,15 +331,30 @@ def generate() -> str: (DSCPipelineYAML, "`pipeline:` (DSCPipelineYAML)"), (ASLPipelineYAML, "`pipeline:` (ASLPipelineYAML)"), (IVIMPipelineYAML, "`pipeline:` (IVIMPipelineYAML)"), - (IVIMFittingConfig, "`pipeline.fitting:` (IVIMFittingConfig)"), - ( - BayesianIVIMFittingConfig, - "`pipeline.fitting.bayesian:` (BayesianIVIMFittingConfig)", - ), ] for model_cls, heading in _pre_register: _MODEL_ANCHORS[model_cls.__name__] = _heading_to_anchor(heading) + # Pre-register anchors for every component-union member so that the + # ``A | B | C`` type links rendered in the parent table resolve to the + # sub-tables emitted later. + for parent, fields in _COMPONENT_FIELDS.items(): + for field_name, discriminator in fields: + annotation = parent.model_fields[field_name].annotation + for member in _union_members(annotation): + disc_field = member.model_fields.get(discriminator) + choice = "" + if disc_field is not None: + choice_args = get_args(disc_field.annotation) + if choice_args: + choice = str(choice_args[0]) + title = ( + f"`pipeline.{field_name}` with `{discriminator}: {choice}`" + if choice + else f"`pipeline.{field_name}` ({member.__name__})" + ) + _MODEL_ANCHORS[member.__name__] = _heading_to_anchor(title) + doc: list[str] = [] doc.append("# YAML Configuration Reference") @@ -319,6 +409,7 @@ def generate() -> str: heading_level=4, ) ) + doc.extend(_render_modality_components(DCEPipelineYAML)) # -- DSC -------------------------------------------------------------- doc.append("## DSC Pipeline") @@ -332,6 +423,7 @@ def generate() -> str: heading_level=3, ) ) + doc.extend(_render_modality_components(DSCPipelineYAML)) # -- ASL -------------------------------------------------------------- doc.append("## ASL Pipeline") @@ -345,6 +437,7 @@ def generate() -> str: heading_level=3, ) ) + doc.extend(_render_modality_components(ASLPipelineYAML)) # -- IVIM ------------------------------------------------------------- doc.append("## IVIM Pipeline") @@ -358,20 +451,7 @@ def generate() -> str: heading_level=3, ) ) - doc.extend( - _render_table( - IVIMFittingConfig, - heading="`pipeline.fitting:` (IVIMFittingConfig)", - heading_level=4, - ) - ) - doc.extend( - _render_table( - BayesianIVIMFittingConfig, - heading="`pipeline.fitting.bayesian:` (BayesianIVIMFittingConfig)", - heading_level=5, - ) - ) + doc.extend(_render_modality_components(IVIMPipelineYAML)) return "\n".join(doc) diff --git a/docs/how-to/choose-population-aif.md b/docs/how-to/choose-population-aif.md index 9fd61ee..77a34e2 100644 --- a/docs/how-to/choose-population-aif.md +++ b/docs/how-to/choose-population-aif.md @@ -4,17 +4,20 @@ Select the appropriate population-based arterial input function for DCE-MRI anal ## Via CLI (YAML Config) -Set `population_aif` in your pipeline config: +Set the `population_aif` block in your pipeline config. The AIF is selected by +its `name`: ```yaml modality: dce pipeline: - model: extended_tofts + model: + method: extended_tofts aif_source: population - population_aif: parker # or georgiou, fritz_hansen, weinmann, mcgrath + population_aif: + name: parker # or georgiou, fritz_hansen, weinmann, mcgrath ``` -Available values: `parker`, `georgiou`, `fritz_hansen`, `weinmann`, `mcgrath`. See the characteristics table below to choose. +Available names: `parker`, `georgiou`, `fritz_hansen`, `weinmann`, `mcgrath`. See the characteristics table below to choose. ## Via Python API diff --git a/docs/how-to/fit-multiple-models.md b/docs/how-to/fit-multiple-models.md index 3eaf239..b027974 100644 --- a/docs/how-to/fit-multiple-models.md +++ b/docs/how-to/fit-multiple-models.md @@ -9,13 +9,15 @@ Run separate configs with different models and compare outputs: ```bash # Generate a base config osipy --dump-defaults dce > config_tofts.yaml -# Edit config_tofts.yaml: set model: tofts, then copy and change model name +# Edit config_tofts.yaml: set model.method: tofts, then copy and change the name osipy config_tofts.yaml data.nii.gz -o results/tofts/ osipy config_etofts.yaml data.nii.gz -o results/extended_tofts/ osipy config_patlak.yaml data.nii.gz -o results/patlak/ ``` -The only field that changes between configs is `pipeline.model`. Compare the output parameter maps and R² maps across runs. +The only field that changes between configs is `pipeline.model.method` +(`tofts`, `extended_tofts`, `patlak`, `2cum`, or `2cxm`). Compare the output +parameter maps and R² maps across runs. ## Available Models diff --git a/docs/how-to/load-perfusion-data.md b/docs/how-to/load-perfusion-data.md index 6ec0db1..26e63f3 100644 --- a/docs/how-to/load-perfusion-data.md +++ b/docs/how-to/load-perfusion-data.md @@ -328,9 +328,11 @@ modality: dce data: format: auto pipeline: - model: extended_tofts + model: + method: extended_tofts aif_source: population - population_aif: parker + population_aif: + name: parker acquisition: tr: 5.0 flip_angles: [2, 5, 10, 15] diff --git a/docs/how-to/run-complete-pipeline.md b/docs/how-to/run-complete-pipeline.md index 9efa934..f298d64 100644 --- a/docs/how-to/run-complete-pipeline.md +++ b/docs/how-to/run-complete-pipeline.md @@ -117,11 +117,13 @@ Complete DSC-MRI analysis: ```python import osipy +from osipy.dsc.deconvolution.config import OSVDConfig from osipy.pipeline import DSCPipeline, DSCPipelineConfig -# Create pipeline with config object +# Create pipeline with config object. The deconvolution method is a nested +# config: OSVDConfig (per-voxel adaptive threshold), SSVDConfig, or CSVDConfig. config = DSCPipelineConfig( - deconvolution_method="oSVD", + deconvolution=OSVDConfig(oscillation_index=0.035, default_threshold=0.2), apply_leakage_correction=True, ) diff --git a/docs/how-to/run-pipeline-cli.md b/docs/how-to/run-pipeline-cli.md index e5d8a8a..e9bcdb1 100644 --- a/docs/how-to/run-pipeline-cli.md +++ b/docs/how-to/run-pipeline-cli.md @@ -100,10 +100,16 @@ With T1 mapping from VFA data: ```yaml modality: dce pipeline: - model: extended_tofts - t1_mapping_method: vfa + model: + method: extended_tofts + t1_mapping_method: + method: vfa + fit_method: linear # linear (fast) or nonlinear (LM refinement) + concentration: + method: spgr # spgr or linear aif_source: population - population_aif: parker + population_aif: + name: parker acquisition: tr: 5.0 flip_angles: [2, 5, 10, 15] @@ -115,14 +121,21 @@ output: format: nifti ``` +Each component selection is a nested block discriminated by a key +(`method` for the PK model, T1 method, and concentration model; `name` for +the population AIF). Selecting a method surfaces exactly that method's +knobs — for example, `t1_mapping_method.fit_method` only exists for `vfa`. + Without T1 data (assumed T1): ```yaml modality: dce pipeline: - model: extended_tofts + model: + method: extended_tofts aif_source: population - population_aif: parker + population_aif: + name: parker acquisition: t1_assumed: 1400.0 baseline_frames: 5 @@ -139,16 +152,22 @@ output: modality: dsc pipeline: te: 30.0 - deconvolution_method: oSVD - apply_leakage_correction: true - svd_threshold: 0.2 baseline_frames: 10 + apply_leakage_correction: true + deconvolution: + method: oSVD # oSVD | sSVD | cSVD + oscillation_index: 0.035 + default_threshold: 0.2 data: mask: brain_mask.nii.gz output: format: nifti ``` +`deconvolution` is discriminated by `method`. `oSVD` exposes +`oscillation_index` and `default_threshold`; `sSVD` and `cSVD` instead take a +single `threshold`, e.g. `deconvolution: {method: sSVD, threshold: 0.2}`. + ### ASL ```yaml @@ -159,7 +178,12 @@ pipeline: label_duration: 1800.0 t1_blood: 1650.0 labeling_efficiency: 0.85 - m0_method: single + m0: + method: single # single | voxelwise | reference_region + difference: + method: pairwise # pairwise | surround | mean + quantification: + mode: single_pld # single_pld | multi_pld data: mask: brain_mask.nii.gz m0_data: m0.nii.gz @@ -167,13 +191,26 @@ output: format: nifti ``` +For multi-PLD acquisitions, switch the quantification mode to estimate both +CBF and arterial transit time (ATT) via the Buxton general kinetic model: + +```yaml + quantification: + mode: multi_pld + plds: [500.0, 1000.0, 1500.0, 2000.0, 2500.0] # ms, one volume per PLD + att_model: buxton +``` + ### IVIM ```yaml modality: ivim pipeline: - fitting_method: segmented - b_threshold: 200.0 + fitting: + method: segmented # segmented | full | bayesian + b_threshold: 200.0 # only for segmented / bayesian + model: + model: biexponential # biexponential | simplified normalize_signal: true data: mask: brain_mask.nii.gz @@ -182,6 +219,13 @@ output: format: nifti ``` +`fitting` is discriminated by `method`: `segmented` and `bayesian` expose +`b_threshold`, while `full` fits all b-values jointly (no threshold). +`bayesian` adds `prior_scale`, `noise_std`, and `compute_uncertainty`. +The `model` block selects the signal model — `simplified` exposes its own +`b_threshold` above which the perfusion term is treated as negligible. +`bounds` and `initial_guess` overrides live under `fitting`. + ## See Also - [YAML Configuration Reference](../reference/cli-config.md) — Complete field-by-field reference (auto-generated) diff --git a/docs/how-to/use-custom-aif.md b/docs/how-to/use-custom-aif.md index c397b86..a56c010 100644 --- a/docs/how-to/use-custom-aif.md +++ b/docs/how-to/use-custom-aif.md @@ -9,8 +9,9 @@ Point to your AIF file in the config: ```yaml modality: dce pipeline: - model: extended_tofts - aif_source: file + model: + method: extended_tofts + aif_source: manual data: aif_file: /path/to/aif.npy # NumPy array with shape (n_timepoints,) ``` diff --git a/docs/tutorials/asl-analysis.md b/docs/tutorials/asl-analysis.md index 5592f07..90135df 100644 --- a/docs/tutorials/asl-analysis.md +++ b/docs/tutorials/asl-analysis.md @@ -8,7 +8,7 @@ Quantify Cerebral Blood Flow (CBF) from Arterial Spin Labeling (ASL) MRI data: l - ASL data with known acquisition parameters - M0 calibration scan (recommended) -**Using the CLI?** Generate a config with `osipy --dump-defaults asl > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. +**Using the CLI?** Generate a config with `osipy --dump-defaults asl > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. The M0 calibration, label/control difference, and quantification mode are each selectable config blocks — set `quantification.mode: multi_pld` to estimate both CBF and arterial transit time (ATT) from a multi-PLD acquisition. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. ## Background diff --git a/docs/tutorials/dce-analysis.md b/docs/tutorials/dce-analysis.md index f67a2d3..948da9c 100644 --- a/docs/tutorials/dce-analysis.md +++ b/docs/tutorials/dce-analysis.md @@ -8,7 +8,7 @@ DCE-MRI workflow: data loading, T1 mapping, signal-to-concentration conversion, - DCE-MRI data with known acquisition parameters - VFA data for T1 mapping (or pre-computed T1 map) -**Using the CLI?** Generate a config with `osipy --dump-defaults dce > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. +**Using the CLI?** Generate a config with `osipy --dump-defaults dce > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. The PK model, T1 mapping method, concentration model, and population AIF are each selectable config blocks — for example set `t1_mapping_method.fit_method: nonlinear` for LM-refined VFA T1, or `concentration.method: linear` for the linear signal-to-concentration approximation. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. ## Background diff --git a/docs/tutorials/dsc-analysis.md b/docs/tutorials/dsc-analysis.md index 1a9a1ea..da79dfe 100644 --- a/docs/tutorials/dsc-analysis.md +++ b/docs/tutorials/dsc-analysis.md @@ -8,7 +8,7 @@ Generate CBV, CBF, and MTT maps from Dynamic Susceptibility Contrast (DSC) MRI u - DSC-MRI data with known acquisition parameters - Understanding of basic perfusion MRI concepts -**Using the CLI?** Generate a config with `osipy --dump-defaults dsc > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. +**Using the CLI?** Generate a config with `osipy --dump-defaults dsc > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. The deconvolution method is a selectable config block (`deconvolution.method`: oSVD, sSVD, or cSVD) that surfaces its own parameters — `oSVD` takes `oscillation_index`/`default_threshold`, while `sSVD`/`cSVD` take a single `threshold`. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. ## Background diff --git a/docs/tutorials/ivim-analysis.md b/docs/tutorials/ivim-analysis.md index 884c762..6689bf0 100644 --- a/docs/tutorials/ivim-analysis.md +++ b/docs/tutorials/ivim-analysis.md @@ -8,7 +8,7 @@ Separate diffusion and perfusion components from multi-b-value DWI data using bi - Multi-b-value DWI data (minimum 4 b-values, ideally 8+) - Understanding of basic diffusion MRI concepts -**Using the CLI?** Generate a config with `osipy --dump-defaults ivim > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. +**Using the CLI?** Generate a config with `osipy --dump-defaults ivim > config.yaml`, edit it, then run `osipy config.yaml data.nii.gz`. The fitting strategy (`fitting.method`: segmented, full, or bayesian) and the signal model (`model.model`: biexponential or simplified) are selectable config blocks; the Bayesian strategy exposes `prior_scale`/`noise_std`/`compute_uncertainty`, and `bounds`/`initial_guess` overrides live under `fitting`. See [How to Run Pipeline from YAML](../how-to/run-pipeline-cli.md). The tutorial below covers the Python API for step-by-step control. ## Background diff --git a/mkdocs.yml b/mkdocs.yml index a8187d2..1027220 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -165,6 +165,7 @@ nav: - explanation/index.md - Software Architecture: - Architecture Overview: explanation/architecture.md + - Registry-Driven Configuration: explanation/configuration.md - xp Abstraction Pattern: explanation/xp-abstraction.md - OSIPI Standards: explanation/osipi-standards.md - MRI Physics: From f42901025c8350161e181506a76b11ac6579398e Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 01:20:16 -0400 Subject: [PATCH 08/15] cosmetic: annotate DCE concentration method in --dump-defaults Add a description to SPGRConcentrationConfig.method so the generated YAML template shows 'spgr | linear' for the concentration block. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/dce/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osipy/dce/config.py b/osipy/dce/config.py index c6a1912..6b54da1 100644 --- a/osipy/dce/config.py +++ b/osipy/dce/config.py @@ -126,7 +126,9 @@ class LookLockerConfig(MethodConfig): class SPGRConcentrationConfig(MethodConfig): """Spoiled gradient-echo signal-to-concentration model (OSIPI: P.SC1.001).""" - method: Literal["spgr"] = "spgr" + method: Literal["spgr"] = Field( + "spgr", description="signal-to-concentration model: spgr | linear" + ) class LinearConcentrationConfig(MethodConfig): From 70c9aadf60b213d22b093de6d956b8ac5258a40f Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 01:36:25 -0400 Subject: [PATCH 09/15] Cleanup: remove genuinely-internal dead code (non-features) Remove unused internals that are NOT config toggles / public capabilities: - convolution low-level numerical API (conv/uconv/matrix/deconv + registry); keep only the production-used convolve_aif + expconv - shadowed duplicate DSCAcquisitionParams in dsc/concentration (live one is osipy.common.types.DSCAcquisitionParams) - dead custom exceptions MetadataError / osipy ValidationError (never raised; the cli/config ValidationError is pydantic's) Kept (NOT dead): get_gpu_memory_info (used by BatchProcessor in backend/batch.py), _reset_gpu_cache (test utility), all list_*() registry helpers, legacy SVD classes, register_aif, types.FittingMethod. Gate: 795 passed, 0 failed (core incl. GPU); 8/8 real-data CLI pipelines. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/common/__init__.py | 34 +- osipy/common/convolution/__init__.py | 50 +-- osipy/common/convolution/conv.py | 267 -------------- osipy/common/convolution/deconv.py | 334 ------------------ osipy/common/convolution/expconv.py | 227 ------------ osipy/common/convolution/fft.py | 114 +----- osipy/common/convolution/matrix.py | 289 --------------- osipy/common/convolution/registry.py | 35 -- osipy/common/exceptions.py | 39 -- osipy/common/io/bids.py | 2 - osipy/common/io/metadata/mapper.py | 5 - osipy/dsc/__init__.py | 2 - osipy/dsc/concentration/__init__.py | 2 - osipy/dsc/concentration/signal_to_conc.py | 24 -- osipy/dsc/deconvolution/svd.py | 5 - .../test_convolution_integration.py | 62 +--- tests/integration/test_gpu_equivalence.py | 58 +-- tests/unit/common/backend/test_convolution.py | 102 +----- .../common/backend/test_gpu_equivalence.py | 21 -- .../unit/common/convolution/test_accuracy.py | 271 -------------- tests/unit/common/convolution/test_conv.py | 200 ----------- tests/unit/common/convolution/test_deconv.py | 281 --------------- tests/unit/common/convolution/test_expconv.py | 134 +------ tests/unit/common/convolution/test_matrix.py | 220 ------------ tests/unit/dsc/test_concentration.py | 24 -- 25 files changed, 16 insertions(+), 2786 deletions(-) delete mode 100644 osipy/common/convolution/conv.py delete mode 100644 osipy/common/convolution/deconv.py delete mode 100644 osipy/common/convolution/matrix.py delete mode 100644 osipy/common/convolution/registry.py delete mode 100644 tests/unit/common/convolution/test_accuracy.py delete mode 100644 tests/unit/common/convolution/test_conv.py delete mode 100644 tests/unit/common/convolution/test_deconv.py delete mode 100644 tests/unit/common/convolution/test_matrix.py diff --git a/osipy/common/__init__.py b/osipy/common/__init__.py index 1ead62e..c6dac4d 100644 --- a/osipy/common/__init__.py +++ b/osipy/common/__init__.py @@ -30,31 +30,14 @@ MRM 2024;91(5):1761-1773. doi:10.1002/mrm.29840 """ -from osipy.common.convolution import ( - biexpconv, - conv, - convmat, - deconv, - expconv, - fft_convolve, - invconvmat, - nexpconv, - uconv, -) -from osipy.common.convolution.registry import ( - get_convolution, - list_convolutions, - register_convolution, -) +from osipy.common.convolution import convolve_aif, expconv from osipy.common.dataset import PerfusionDataset from osipy.common.exceptions import ( AIFError, DataValidationError, FittingError, IOError, - MetadataError, OsipyError, - ValidationError, ) from osipy.common.fitting.registry import get_fitter, list_fitters, register_fitter from osipy.common.parameter_map import ParameterMap @@ -84,7 +67,6 @@ "IOError", "IVIMAcquisitionParams", "LabelingType", - "MetadataError", # Enums "Modality", # Exceptions @@ -92,23 +74,11 @@ "ParameterMap", # Core data containers "PerfusionDataset", - "ValidationError", - "biexpconv", # Convolution functions - "conv", - "convmat", - "deconv", + "convolve_aif", "expconv", - "fft_convolve", - "get_convolution", "get_fitter", - "invconvmat", - "list_convolutions", "list_fitters", - "nexpconv", - # Convolution registry - "register_convolution", # Fitter registry "register_fitter", - "uconv", ] diff --git a/osipy/common/convolution/__init__.py b/osipy/common/convolution/__init__.py index 5d71007..3bfcf1f 100644 --- a/osipy/common/convolution/__init__.py +++ b/osipy/common/convolution/__init__.py @@ -1,19 +1,8 @@ -"""Convolution and deconvolution operations for pharmacokinetic modeling. - -This module provides accurate numerical convolution and deconvolution operations -following the dcmri approach (https://github.com/dcmri/dcmri) for improved -numerical accuracy, particularly for non-uniform time grids. +"""Convolution operations for pharmacokinetic modeling. Key functions: - - conv(): Piecewise-linear convolution with analytic integration - - uconv(): Optimized convolution for uniform time grids - expconv(): Recursive exponential convolution (Flouri et al. 2016) - - biexpconv(): Analytical bi-exponential convolution - - nexpconv(): N-exponential gamma-variate convolution - - deconv(): Matrix-based deconvolution with TSVD/Tikhonov regularization - - convmat(): Convolution matrix construction - - invconvmat(): Regularized pseudo-inverse of convolution matrix - - fft_convolve(): FFT-based convolution for large uniform datasets + - convolve_aif(): FFT-based AIF/impulse-response convolution References ---------- @@ -29,36 +18,7 @@ (Apache-2.0); see the project NOTICE file for attribution. """ -from osipy.common.convolution.conv import conv, uconv -from osipy.common.convolution.deconv import ( - deconv, - deconvolve_svd, - deconvolve_svd_batch, -) -from osipy.common.convolution.expconv import biexpconv, expconv, nexpconv -from osipy.common.convolution.fft import convolve_aif, fft_convolve -from osipy.common.convolution.matrix import convmat, invconvmat -from osipy.common.convolution.registry import ( - get_convolution, - list_convolutions, - register_convolution, -) +from osipy.common.convolution.expconv import expconv +from osipy.common.convolution.fft import convolve_aif -__all__ = [ - "biexpconv", - "conv", - "convmat", - "convolve_aif", - "deconv", - "deconvolve_svd", - "deconvolve_svd_batch", - "expconv", - "fft_convolve", - "get_convolution", - "invconvmat", - "list_convolutions", - "nexpconv", - # Registry - "register_convolution", - "uconv", -] +__all__ = ["convolve_aif", "expconv"] diff --git a/osipy/common/convolution/conv.py b/osipy/common/convolution/conv.py deleted file mode 100644 index cb56a5b..0000000 --- a/osipy/common/convolution/conv.py +++ /dev/null @@ -1,267 +0,0 @@ -"""Piecewise-linear convolution with trapezoidal integration. - -This module implements convolution using piecewise-linear interpolation -and trapezoidal integration between time points, supporting non-uniform -time sampling. (Note: this is an approximate trapezoidal scheme; it does -not perform the exact product-of-linears integration used by dcmri.) - -References ----------- - - dcmri (https://github.com/dcmri/dcmri) utils.conv() - - Sourbron & Buckley (2013). Classic models for dynamic - contrast-enhanced MRI. NMR Biomed. 26(8):1004-1027. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from osipy.common.backend import get_array_module -from osipy.common.convolution.registry import register_convolution -from osipy.common.exceptions import DataValidationError - -if TYPE_CHECKING: - import numpy as np - from numpy.typing import NDArray - - -@register_convolution("piecewise_linear") -def conv( - f: NDArray[np.floating], - h: NDArray[np.floating], - t: NDArray[np.floating], - *, - dt: float | None = None, -) -> NDArray[np.floating]: - """Convolve two signals using piecewise-linear integration. - - Computes the convolution integral: - (f * h)(t) = integral_0^t f(u) * h(t - u) du - - using piecewise-linear interpolation and analytical integration - between time points. This method is accurate for non-uniform time - grids and preserves endpoint behavior. - - Parameters - ---------- - f : ndarray - Input signal (e.g., arterial input function). Shape: (n_times,). - h : ndarray - Impulse response function (e.g., residue function). Shape: (n_times,). - t : ndarray - Time points in seconds. Shape: (n_times,). Must be monotonically - increasing and start at 0. - dt : float, optional - If provided, assumes uniform time grid with this step size. - Enables optimized computation path. - - Returns - ------- - ndarray - Convolution result at each time point. Shape: (n_times,). - - Notes - ----- - The convolution is computed using the trapezoidal rule with - piecewise-linear interpolation. For each time point t[i], the - integral is computed as: - - conv[i] = sum_{j=0}^{i-1} integral_{t[j]}^{t[j+1]} f(u) * h(t[i]-u) du - - where the integral within each interval is evaluated analytically - assuming linear interpolation of both f and h. - - For uniform time grids (when dt is provided), a more efficient - algorithm is used. - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import conv - >>> t = np.linspace(0, 10, 101) # 0 to 10 seconds - >>> aif = np.exp(-t / 2) # Exponential decay AIF - >>> irf = np.exp(-t / 5) # Exponential IRF - >>> result = conv(aif, irf, t) - - References - ---------- - .. [1] dcmri library: https://github.com/dcmri/dcmri - """ - xp = get_array_module(f) - - f = xp.asarray(f) - h = xp.asarray(h) - t = xp.asarray(t) - - n = len(t) - if len(f) != n or len(h) != n: - raise DataValidationError("f, h, and t must have the same length") - - if n == 0: - return xp.array([], dtype=f.dtype) - - if n == 1: - return xp.array([0.0], dtype=f.dtype) - - # Check for uniform time grid - if dt is not None: - return uconv(f, h, dt) - - # Check if time grid is approximately uniform - dt_arr = xp.diff(t) - dt_mean = xp.mean(dt_arr) - if xp.allclose(dt_arr, dt_mean, rtol=1e-6): - return uconv(f, h, float(dt_mean)) - - # Non-uniform time grid: use full piecewise-linear integration - return _conv_nonuniform(f, h, t) - - -def _conv_nonuniform( - f: NDArray[np.floating], - h: NDArray[np.floating], - t: NDArray[np.floating], -) -> NDArray[np.floating]: - """Convolution for non-uniform time grids. - - Uses piecewise-linear integration with analytical evaluation - of the integral within each time interval. - """ - xp = get_array_module(f) - n = len(t) - result = xp.zeros(n, dtype=f.dtype) - - # Convolution using trapezoidal integration - # For each output point i, sum contributions from intervals [j, j+1] - for i in range(1, n): - total = 0.0 - for j in range(i): - # Time interval - t0 = t[j] - t1 = t[j + 1] if j + 1 < n else t[j] - dt_j = t1 - t0 - - if dt_j <= 0: - continue - - # Linear interpolation coefficients for f on [t0, t1] - f0 = f[j] - f1 = f[j + 1] if j + 1 < n else f[j] - - # h is evaluated at (t[i] - u) for u in [t0, t1] - # So h argument ranges from (t[i] - t1) to (t[i] - t0) - tau0 = t[i] - t1 # lower argument for h - tau1 = t[i] - t0 # upper argument for h - - # Interpolate h at tau0 and tau1 - h0 = _interp_value(h, t, tau0) - h1 = _interp_value(h, t, tau1) - - # Trapezoidal integration: (f0*h1 + f1*h0) * dt_j / 2 - # This is the standard trapezoidal rule for f(u)*h(t-u) - total += (f0 * h1 + f1 * h0) * dt_j / 2.0 - - result[i] = total - - return result - - -def _interp_value( - y: NDArray[np.floating], - t: NDArray[np.floating], - t_query: float, -) -> float: - """Linear interpolation of y at t_query.""" - xp = get_array_module(y) - - if t_query <= t[0]: - return float(y[0]) - if t_query >= t[-1]: - return float(y[-1]) - - # Find interval containing t_query - idx = int(xp.searchsorted(t, t_query)) - 1 - idx = max(0, min(idx, len(t) - 2)) - - t0, t1 = float(t[idx]), float(t[idx + 1]) - y0, y1 = float(y[idx]), float(y[idx + 1]) - - if t1 == t0: - return y0 - - # Linear interpolation - alpha = (t_query - t0) / (t1 - t0) - return y0 + alpha * (y1 - y0) - - -def uconv( - f: NDArray[np.floating], - h: NDArray[np.floating], - dt: float, -) -> NDArray[np.floating]: - """Convolve two signals on a uniform time grid. - - Optimized convolution for uniform time sampling using the - trapezoidal rule. More efficient than the general non-uniform - algorithm when time points are equally spaced. - - Parameters - ---------- - f : ndarray - Input signal. Shape: (n_times,). - h : ndarray - Impulse response function. Shape: (n_times,). - dt : float - Time step between samples in seconds. - - Returns - ------- - ndarray - Convolution result. Shape: (n_times,). - - Notes - ----- - Uses the discrete convolution formula with trapezoidal weighting: - - conv[i] = dt * sum_{j=0}^{i} w[j] * f[j] * h[i-j] - - where w[j] = 0.5 for j=0 and j=i, and w[j] = 1 otherwise - (trapezoidal weights). - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import uconv - >>> dt = 0.1 # 100 ms time step - >>> n = 100 - >>> f = np.exp(-np.arange(n) * dt / 2) - >>> h = np.exp(-np.arange(n) * dt / 5) - >>> result = uconv(f, h, dt) - """ - xp = get_array_module(f) - - f = xp.asarray(f) - h = xp.asarray(h) - - n = len(f) - if len(h) != n: - raise DataValidationError("f and h must have the same length") - - if n == 0: - return xp.array([], dtype=f.dtype) - - if n == 1: - return xp.array([0.0], dtype=f.dtype) - - # Discrete convolution with trapezoidal rule - result = xp.zeros(n, dtype=f.dtype) - - for i in range(1, n): - # Trapezoidal integration - total = 0.5 * f[0] * h[i] # First point weight = 0.5 - for j in range(1, i): - total += f[j] * h[i - j] - total += 0.5 * f[i] * h[0] # Last point weight = 0.5 - result[i] = total * dt - - return result diff --git a/osipy/common/convolution/deconv.py b/osipy/common/convolution/deconv.py deleted file mode 100644 index 2214e7a..0000000 --- a/osipy/common/convolution/deconv.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Matrix-based deconvolution with regularization. - -This module provides deconvolution operations using convolution matrix -construction and regularized pseudo-inverse. Supports both TSVD -(Truncated SVD) and Tikhonov regularization methods. - -Also includes batch SVD deconvolution functions for efficient -multi-voxel processing. - -References ----------- - - dcmri (https://github.com/dcmri/dcmri) utils.deconv() - - Wu et al. (2003). Tracer arrival timing-insensitive technique. - Magn Reson Med. 50:164-174. - - Hansen PC (1998). Rank-Deficient and Discrete Ill-Posed Problems. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Literal - -from osipy.common.backend import get_array_module -from osipy.common.convolution.matrix import circulant_convmat, convmat, invconvmat -from osipy.common.exceptions import DataValidationError - -if TYPE_CHECKING: - import numpy as np - from numpy.typing import NDArray - - -def deconv( - c: NDArray[np.floating], - aif: NDArray[np.floating], - t: NDArray[np.floating], - *, - method: Literal["tsvd", "tikhonov"] = "tsvd", - tol: float = 0.1, - lambda_reg: float | None = None, - circulant: bool = False, -) -> NDArray[np.floating]: - """Deconvolve a tissue concentration curve by an arterial input function. - - Computes the residue function R(t) such that: - C(t) = AIF(t) * R(t) - - where * denotes convolution. This is the inverse problem of - pharmacokinetic modeling. - - Parameters - ---------- - c : ndarray - Tissue concentration curve. Shape: (n_times,). - aif : ndarray - Arterial input function. Shape: (n_times,). - t : ndarray - Time points in seconds. Shape: (n_times,). Must be monotonically - increasing. - method : {"tsvd", "tikhonov"}, default "tsvd" - Regularization method: - - "tsvd": Truncated SVD. Robust and widely used in DSC-MRI. - - "tikhonov": Tikhonov regularization. Smoother but may over-smooth. - tol : float, default 0.1 - Regularization tolerance. For TSVD, singular values below - tol * max(s) are truncated. For Tikhonov, controls the - regularization strength if lambda_reg is not provided. - lambda_reg : float, optional - Tikhonov regularization parameter. Only used if method="tikhonov". - If not provided, computed as tol * max(singular_values). - circulant : bool, default False - If True, use block-circulant matrix for delay-insensitive - deconvolution (cSVD/oSVD approach). This makes the result - insensitive to timing differences between AIF and tissue curves. - - Returns - ------- - ndarray - Residue function R(t). Shape: (n_times,). - The residue function satisfies R(0) = 1 for a single-compartment - model with no delay. - - Notes - ----- - The deconvolution problem is ill-posed due to noise amplification. - Regularization is essential for stable solutions. - - **TSVD (Truncated SVD)**: - - Sets small singular values to zero - - Simple and robust - - May introduce ringing artifacts (Gibbs phenomenon) - - **Tikhonov regularization**: - - Damps small singular values smoothly - - Produces smoother solutions - - May over-smooth genuine features - - **Circulant matrix (cSVD/oSVD)**: - - Makes deconvolution insensitive to AIF timing - - Standard approach in DSC-MRI for CBF estimation - - Requires uniform time sampling - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import deconv - >>> t = np.linspace(0, 60, 61) - >>> aif = np.exp(-t / 10) * (1 - np.exp(-t / 2)) # Gamma variate AIF - >>> # Create synthetic tissue curve with known residue function - >>> from osipy.common.convolution import conv - >>> true_irf = np.exp(-t / 20) # Exponential residue - >>> c = conv(aif, true_irf, t) - >>> # Deconvolve to recover residue function - >>> recovered_irf = deconv(c, aif, t, method="tsvd", tol=0.1) - - References - ---------- - .. [1] dcmri library: https://github.com/dcmri/dcmri - .. [2] Wu et al. (2003). Magn Reson Med. 50:164-174. - .. [3] Ostergaard et al. (1996). Magn Reson Med. 36:715-725. - """ - xp = get_array_module(c) - - c = xp.asarray(c, dtype=xp.float64) - aif = xp.asarray(aif, dtype=xp.float64) - t = xp.asarray(t, dtype=xp.float64) - - n = len(t) - if len(c) != n or len(aif) != n: - raise DataValidationError("c, aif, and t must have the same length") - - if n == 0: - return xp.array([], dtype=xp.float64) - - if n == 1: - return xp.array([0.0], dtype=xp.float64) - - # Build convolution matrix - A = circulant_convmat(aif, t) if circulant else convmat(aif, t) - - # Compute regularized pseudo-inverse - A_inv = invconvmat(A, method=method, tol=tol, lambda_reg=lambda_reg) - - # Deconvolution: R = A_inv @ C - R = A_inv @ c - - return R - - -def deconv_osvd( - c: NDArray[np.floating], - aif: NDArray[np.floating], - t: NDArray[np.floating], - *, - osc_threshold: float = 0.035, -) -> NDArray[np.floating]: - """Oscillation-index SVD deconvolution. - - Implements the oSVD algorithm which selects the optimal truncation - threshold based on minimizing oscillations in the residue function. - - Parameters - ---------- - c : ndarray - Tissue concentration curve. Shape: (n_times,). - aif : ndarray - Arterial input function. Shape: (n_times,). - t : ndarray - Time points in seconds. Shape: (n_times,). - osc_threshold : float, default 0.035 - Target oscillation index threshold. The algorithm finds - the smallest truncation that keeps oscillations below this. - - Returns - ------- - ndarray - Residue function with minimized oscillations. Shape: (n_times,). - - Notes - ----- - The oscillation index (OI) is defined as: - OI = (1/N) * sum(|R[i] - R[i-1]|) / max(R) - - oSVD iteratively increases the truncation threshold until OI < osc_threshold. - - References - ---------- - .. [1] Wu et al. (2003). Magn Reson Med. 50:164-174. - """ - xp = get_array_module(c) - - # Try different truncation thresholds - tol_values = [0.01, 0.02, 0.05, 0.1, 0.15, 0.2, 0.3] - - best_R = None - best_oi = float("inf") - - for tol_try in tol_values: - R = deconv(c, aif, t, method="tsvd", tol=tol_try, circulant=True) - - # Compute oscillation index - oi = _oscillation_index(R) - - if oi < osc_threshold: - return R - - if oi < best_oi: - best_oi = oi - best_R = R - - # Return best result if threshold not achieved - return best_R if best_R is not None else xp.zeros(len(t), dtype=xp.float64) - - -def _oscillation_index(R: NDArray[np.floating]) -> float: - """Compute oscillation index of a residue function.""" - xp = get_array_module(R) - - if len(R) < 2: - return 0.0 - - R_max = float(xp.max(xp.abs(R))) - if R_max < 1e-10: - return 0.0 - - # Sum of absolute differences normalized by max - diff_sum = float(xp.sum(xp.abs(xp.diff(R)))) - oi = diff_sum / (len(R) * R_max) - - return oi - - -def deconvolve_svd( - ct: NDArray[Any], - aif: NDArray[Any], - dt: float = 1.0, - threshold: float = 0.1, -) -> tuple[NDArray[Any], NDArray[Any]]: - """Deconvolve tissue curve with AIF using SVD. - - Solves the inverse problem to recover the impulse response function - from the measured tissue curve and AIF. - - Parameters - ---------- - ct : NDArray - Tissue concentration curve, shape (n_timepoints,) or (n_timepoints, n_voxels). - aif : NDArray - Arterial input function, shape (n_timepoints,). - dt : float, optional - Time step in seconds. Default is 1.0. - threshold : float, optional - SVD truncation threshold as fraction of maximum singular value. - Default is 0.1 (10%). - - Returns - ------- - irf : NDArray - Recovered impulse response function. - residue : NDArray - Residue function (cumulative integral of IRF). - """ - xp = get_array_module(ct, aif) - - n_time = ct.shape[0] - - conv_matrix = xp.zeros((n_time, n_time), dtype=ct.dtype) - for i in range(n_time): - conv_matrix[i, : i + 1] = aif[i::-1] - conv_matrix *= dt - - u, s, vh = xp.linalg.svd(conv_matrix, full_matrices=False) - - s_max = xp.max(s) - s_thresh = s_max * threshold - s_inv = xp.where(s > s_thresh, 1.0 / s, 0.0) - - conv_inv = vh.T @ xp.diag(s_inv) @ u.T - - irf = conv_inv @ ct - - residue = xp.cumsum(irf, axis=0) * dt - - return irf, residue - - -def deconvolve_svd_batch( - ct: NDArray[Any], - aif: NDArray[Any], - dt: float = 1.0, - threshold: float = 0.1, -) -> tuple[NDArray[Any], NDArray[Any]]: - """Deconvolve multiple tissue curves with a single AIF. - - Optimized batch deconvolution: the convolution matrix SVD is - computed only once and applied to all voxels. - - Parameters - ---------- - ct : NDArray - Tissue concentration curves, shape (n_timepoints, n_voxels). - aif : NDArray - Arterial input function, shape (n_timepoints,). - dt : float, optional - Time step in seconds. Default is 1.0. - threshold : float, optional - SVD truncation threshold. Default is 0.1. - - Returns - ------- - irf : NDArray - Recovered impulse response functions, shape (n_timepoints, n_voxels). - residue : NDArray - Residue functions, shape (n_timepoints, n_voxels). - """ - xp = get_array_module(ct, aif) - - n_time = ct.shape[0] - - conv_matrix = xp.zeros((n_time, n_time), dtype=ct.dtype) - for i in range(n_time): - conv_matrix[i, : i + 1] = aif[i::-1] - conv_matrix *= dt - - u, s, vh = xp.linalg.svd(conv_matrix, full_matrices=False) - - s_max = xp.max(s) - s_thresh = s_max * threshold - s_inv = xp.where(s > s_thresh, 1.0 / s, 0.0) - - conv_inv = vh.T @ xp.diag(s_inv) @ u.T - - irf = conv_inv @ ct - residue = xp.cumsum(irf, axis=0) * dt - - return irf, residue diff --git a/osipy/common/convolution/expconv.py b/osipy/common/convolution/expconv.py index d339a9c..f7f15fb 100644 --- a/osipy/common/convolution/expconv.py +++ b/osipy/common/convolution/expconv.py @@ -24,11 +24,9 @@ from __future__ import annotations -import math from typing import TYPE_CHECKING from osipy.common.backend import get_array_module -from osipy.common.convolution.registry import register_convolution from osipy.common.exceptions import DataValidationError if TYPE_CHECKING: @@ -36,32 +34,6 @@ from numpy.typing import NDArray -def _factorial(n: int) -> float: - """Compute factorial using pure Python. - - GPU/CPU agnostic helper function for small n values. - - Parameters - ---------- - n : int - Non-negative integer. - - Returns - ------- - float - n! as a float. - """ - if n < 0: - raise DataValidationError("factorial not defined for negative values") - if n <= 1: - return 1.0 - result = 1.0 - for i in range(2, n + 1): - result *= i - return result - - -@register_convolution("exponential") def expconv( f: NDArray[np.floating], T: float | NDArray[np.floating], @@ -195,202 +167,3 @@ def expconv( result[i + 1] = E[i] * result[i] + add[i] return result * T_arr[xp.newaxis, :] - - -def biexpconv( - f: NDArray[np.floating], - T1: float, - T2: float, - t: NDArray[np.floating], -) -> NDArray[np.floating]: - """Convolve a signal with a bi-exponential function. - - Computes the convolution of f with the bi-exponential function: - h(t) = (exp(-t/T1) - exp(-t/T2)) / (T1 - T2) - - This is useful for two-compartment exchange models. - - Parameters - ---------- - f : ndarray - Input signal. Shape: (n_times,). - T1 : float - First time constant in seconds. Must be positive. - T2 : float - Second time constant in seconds. Must be positive and different from T1. - t : ndarray - Time points in seconds. Shape: (n_times,). - - Returns - ------- - ndarray - Convolution result. Shape: (n_times,). - - Notes - ----- - The bi-exponential convolution is computed as: - biexpconv(f, T1, T2, t) = (expconv(f, T1, t) - expconv(f, T2, t)) / (T1 - T2) - - For T1 -> T2, uses the limiting form involving the derivative. - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import biexpconv - >>> t = np.linspace(0, 10, 101) - >>> aif = np.exp(-t / 2) - >>> result = biexpconv(aif, 3.0, 5.0, t) - - References - ---------- - .. [1] dcmri library: https://github.com/dcmri/dcmri - """ - xp = get_array_module(f) - - if T1 <= 0 or T2 <= 0: - return xp.zeros(len(t), dtype=xp.asarray(f).dtype) - - # Handle case where T1 == T2 - if abs(T1 - T2) < 1e-10 * max(T1, T2): - # Limiting case: derivative of expconv with respect to T - return _biexpconv_limit(f, T1, t) - - # Standard case - E1 = expconv(f, T1, t) - E2 = expconv(f, T2, t) - - return (E1 - E2) / (T1 - T2) - - -def _biexpconv_limit( - f: NDArray[np.floating], - T: float, - t: NDArray[np.floating], -) -> NDArray[np.floating]: - """Bi-exponential convolution in the limit T1 -> T2. - - This computes the derivative of expconv with respect to T: - d/dT [expconv(f, T, t)] - """ - xp = get_array_module(f) - - f = xp.asarray(f) - t = xp.asarray(t) - - # Use numerical differentiation with small delta - delta = T * 1e-6 - E_plus = expconv(f, T + delta, t) - E_minus = expconv(f, T - delta, t) - - return (E_plus - E_minus) / (2 * delta) - - -def nexpconv( - f: NDArray[np.floating], - T: float, - n_exp: int, - t: NDArray[np.floating], -) -> NDArray[np.floating]: - """Convolve a signal with an n-exponential (gamma variate) function. - - Computes the convolution of f with the n-exponential function: - h(t) = (t/T)^(n-1) * exp(-t/T) / (T * (n-1)!) - - This represents a chain of n identical compartments, each with - time constant T. Also known as the gamma variate function. - - Parameters - ---------- - f : ndarray - Input signal. Shape: (n_times,). - T : float - Time constant of each compartment in seconds. Must be positive. - n_exp : int - Number of exponentials (compartments). Must be >= 1. - t : ndarray - Time points in seconds. Shape: (n_times,). - - Returns - ------- - ndarray - Convolution result. Shape: (n_times,). - - Notes - ----- - For n=1, this is equivalent to expconv. - For n>1, the function is computed recursively: - nexpconv(f, T, n, t) = expconv(nexpconv(f, T, n-1, t), T, t) / T - - For large n, the gamma variate approaches a Gaussian centered at - t = n*T with standard deviation sqrt(n)*T. - - For n > 20, numerical overflow may occur in the factorial term. - In this case, a Gaussian approximation is used. - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import nexpconv - >>> t = np.linspace(0, 20, 201) - >>> aif = np.exp(-t / 2) - >>> result = nexpconv(aif, 2.0, 3, t) # 3-compartment chain - - References - ---------- - .. [1] dcmri library: https://github.com/dcmri/dcmri - """ - xp = get_array_module(f) - - if T <= 0 or n_exp < 1: - return xp.zeros(len(t), dtype=xp.asarray(f).dtype) - - if n_exp == 1: - return expconv(f, T, t) - - # For large n, use Gaussian approximation to avoid overflow - if n_exp > 20: - return _nexpconv_gaussian(f, T, n_exp, t) - - # Recursive computation - result = expconv(f, T, t) - for _ in range(2, n_exp + 1): - result = expconv(result, T, t) / T - - # Normalize by factorial - norm = _factorial(n_exp - 1) - return result * T / norm - - -def _nexpconv_gaussian( - f: NDArray[np.floating], - T: float, - n_exp: int, - t: NDArray[np.floating], -) -> NDArray[np.floating]: - """N-exponential convolution using Gaussian approximation. - - For large n, the gamma variate function approaches a Gaussian. - The gamma variate h(t) = t^(n-1) * exp(-t/T) / (T^n * (n-1)!) - has mean n*T and std sqrt(n)*T. - """ - xp = get_array_module(f) - - from osipy.common.convolution.conv import conv - - f = xp.asarray(f) - t = xp.asarray(t) - - # Gamma variate parameters - mean = n_exp * T - std = math.sqrt(n_exp) * T - - # Create Gaussian impulse response centered at the mean - # The impulse response should be causal (zero for t < 0) - # and peaked at t = mean - h = xp.exp(-((t - mean) ** 2) / (2 * std**2)) - - # Normalize so integral equals 1 (approximately) - dt = t[1] - t[0] if len(t) > 1 else 1.0 - h = h / (xp.sum(h) * dt + 1e-20) - - return conv(f, h, t) diff --git a/osipy/common/convolution/fft.py b/osipy/common/convolution/fft.py index 2d219c4..fb69eb1 100644 --- a/osipy/common/convolution/fft.py +++ b/osipy/common/convolution/fft.py @@ -16,127 +16,15 @@ from __future__ import annotations -import math -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any from osipy.common.backend import get_array_module -from osipy.common.convolution.registry import register_convolution from osipy.common.exceptions import DataValidationError if TYPE_CHECKING: - import numpy as np from numpy.typing import NDArray -@register_convolution("fft") -def fft_convolve( - f: NDArray[np.floating], - h: NDArray[np.floating], - dt: float, - *, - mode: Literal["full", "same", "valid"] = "same", -) -> NDArray[np.floating]: - """Convolve two signals using FFT. - - Efficient convolution for large datasets with uniform time sampling. - Uses FFT for O(n log n) complexity instead of O(n^2) direct convolution. - - Parameters - ---------- - f : ndarray - First input signal. Shape: (n,). - h : ndarray - Second input signal (impulse response). Shape: (m,). - dt : float - Time step between samples in seconds. - mode : {"full", "same", "valid"}, default "same" - Output mode: - - "full": Full convolution result. Shape: (n + m - 1,). - - "same": Output same size as first input. Shape: (n,). - - "valid": Only fully overlapping region. Shape: (max(n, m) - min(n, m) + 1,). - - Returns - ------- - ndarray - Convolution result scaled by dt. - - Notes - ----- - FFT convolution assumes periodic boundary conditions. For pharmacokinetic - modeling, this may introduce artifacts at boundaries. Consider using - piecewise-linear convolution (conv()) for more accurate results when: - - Time sampling is non-uniform - - Boundary behavior is important - - Dealing with small datasets where O(n^2) is acceptable - - This function is GPU-compatible and will use CuPy FFT when the input - arrays are on GPU. - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import fft_convolve - >>> dt = 0.1 # 100 ms time step - >>> n = 1000 - >>> t = np.arange(n) * dt - >>> f = np.exp(-t / 5) # Input signal - >>> h = np.exp(-t / 10) # Impulse response - >>> result = fft_convolve(f, h, dt, mode="same") - - References - ---------- - .. [1] scipy.signal.fftconvolve documentation - """ - xp = get_array_module(f) - - f = xp.asarray(f) - h = xp.asarray(h) - - n = len(f) - m = len(h) - - if n == 0 or m == 0: - return xp.array([], dtype=f.dtype) - - # Compute FFT convolution - # Zero-pad to avoid circular convolution artifacts - fft_size = n + m - 1 - - # Use next power of 2 for efficiency - fft_size = int(2 ** math.ceil(math.log2(fft_size))) - - # Compute FFT — both numpy and cupy provide xp.fft.fft - F = xp.fft.fft(f, n=fft_size) - H = xp.fft.fft(h, n=fft_size) - - # Multiply in frequency domain - Y = F * H - - # Inverse FFT - y = xp.fft.ifft(Y) - - # Take real part (imaginary should be ~0) - y = xp.real(y) - - # Extract appropriate portion based on mode - if mode == "full": - result = y[: n + m - 1] - elif mode == "same": - start = (m - 1) // 2 - result = y[start : start + n] - elif mode == "valid": - start = m - 1 - end = n - result = y[start:end] if end > start else xp.array([], dtype=f.dtype) - else: - raise DataValidationError( - f"Unknown mode: {mode}. Use 'full', 'same', or 'valid'." - ) - - # Scale by dt for physical convolution - return result * dt - - def convolve_aif( aif: NDArray[Any], impulse_response: NDArray[Any], diff --git a/osipy/common/convolution/matrix.py b/osipy/common/convolution/matrix.py deleted file mode 100644 index fa814e4..0000000 --- a/osipy/common/convolution/matrix.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Convolution matrix construction and inversion. - -This module provides functions for building convolution matrices and -computing their regularized pseudo-inverses for deconvolution operations. - -References ----------- - - dcmri (https://github.com/dcmri/dcmri) utils.convmat(), utils.invconvmat() - - Wu et al. (2003). Tracer arrival timing-insensitive technique. - Magn Reson Med. 50:164-174. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Literal - -import numpy as np - -from osipy.common.backend import get_array_module -from osipy.common.exceptions import DataValidationError - -if TYPE_CHECKING: - from numpy.typing import NDArray - - -def convmat( - h: NDArray[np.floating], - t: NDArray[np.floating], - *, - order: Literal[1, 2] = 1, -) -> NDArray[np.floating]: - """Construct a convolution matrix from an impulse response. - - Creates a lower triangular matrix A such that the convolution - f * h can be computed as A @ h when f is known, or the deconvolution - can be computed by solving A @ x = y for x. - - Parameters - ---------- - h : ndarray - Impulse response function (e.g., AIF). Shape: (n_times,). - t : ndarray - Time points in seconds. Shape: (n_times,). Must be monotonically - increasing. - order : {1, 2}, default 1 - Integration order: - - 1: First-order (trapezoidal) integration - - 2: Second-order (Simpson's rule) integration - - Returns - ------- - ndarray - Convolution matrix. Shape: (n_times, n_times). - Lower triangular Toeplitz-like structure. - - Notes - ----- - For uniform time grids, the matrix is Toeplitz (constant diagonals). - For non-uniform grids, the matrix accounts for varying time steps. - - The matrix is constructed such that: - (f * h)[i] = sum_j A[i,j] * f[j] - - Using first-order (trapezoidal) integration: - A[i,j] = dt[j] * (h[i-j] + h[i-j-1]) / 2 for j < i - A[i,i] = 0 - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import convmat - >>> t = np.linspace(0, 10, 11) - >>> aif = np.exp(-t / 3) - >>> A = convmat(aif, t) - >>> # Convolution: result = A @ signal - - References - ---------- - .. [1] dcmri library: https://github.com/dcmri/dcmri - """ - xp = get_array_module(h) - - h = xp.asarray(h) - t = xp.asarray(t) - - n = len(t) - if len(h) != n: - raise DataValidationError("h and t must have the same length") - - if n == 0: - return xp.array([[]], dtype=h.dtype) - - # Compute time steps - dt = xp.diff(t) - dt = xp.concatenate([xp.array([0.0]), dt]) - - # Initialize convolution matrix - A = xp.zeros((n, n), dtype=h.dtype) - - if order == 1: - # First-order (trapezoidal) integration - for i in range(1, n): - for j in range(i): - # Index into h: h[i-j] and h[i-j-1] - idx = i - j - if idx < n and idx >= 1: - # Trapezoidal weight - A[i, j] = dt[j + 1] * (h[idx] + h[idx - 1]) / 2.0 - elif idx == 0: - A[i, j] = dt[j + 1] * h[0] / 2.0 - else: - # Second-order (Simpson's rule) integration - for i in range(1, n): - for j in range(i): - idx = i - j - if idx < n and idx >= 1: - # Simpson's rule weight (approximate for non-uniform grids) - if j == 0 or j == i - 1: - weight = dt[j + 1] / 3.0 - elif j % 2 == 1: - weight = 4.0 * dt[j + 1] / 3.0 - else: - weight = 2.0 * dt[j + 1] / 3.0 - A[i, j] = weight * h[idx] - - return A - - -def invconvmat( - A: NDArray[np.floating], - *, - method: Literal["tsvd", "tikhonov"] = "tsvd", - tol: float = 0.1, - lambda_reg: float | None = None, -) -> NDArray[np.floating]: - """Compute regularized pseudo-inverse of a convolution matrix. - - Uses SVD decomposition with regularization to compute a stable - inverse of the convolution matrix for deconvolution operations. - - Parameters - ---------- - A : ndarray - Convolution matrix. Shape: (n, n) or (m, n). - method : {"tsvd", "tikhonov"}, default "tsvd" - Regularization method: - - "tsvd": Truncated SVD. Singular values below tol * max(s) are - set to zero. - - "tikhonov": Tikhonov regularization. Singular values are damped - as s / (s^2 + lambda^2). - tol : float, default 0.1 - For TSVD: Relative tolerance for singular value truncation. - Singular values s < tol * max(s) are set to zero. - For Tikhonov: Used to compute lambda if not provided. - lambda_reg : float, optional - Tikhonov regularization parameter. If not provided, computed - as lambda = tol * max(singular_values). - - Returns - ------- - ndarray - Regularized pseudo-inverse. Shape: (n, m) where A is (m, n). - - Notes - ----- - TSVD (Truncated Singular Value Decomposition): - A_inv = V @ diag(1/s_truncated) @ U.T - where s_truncated sets small singular values to 0. - - Tikhonov regularization: - A_inv = V @ diag(s / (s^2 + lambda^2)) @ U.T - This damps rather than truncates small singular values. - - The choice of regularization affects noise amplification in - deconvolution. TSVD is simpler but can introduce ringing artifacts. - Tikhonov provides smoother results but may over-smooth. - - Examples - -------- - >>> import numpy as np - >>> from osipy.common.convolution import convmat, invconvmat - >>> t = np.linspace(0, 10, 51) - >>> aif = np.exp(-t / 3) - >>> A = convmat(aif, t) - >>> A_inv = invconvmat(A, method="tsvd", tol=0.1) - >>> # Deconvolution: irf = A_inv @ signal - - References - ---------- - .. [1] dcmri library: https://github.com/dcmri/dcmri - .. [2] Hansen PC (1998). Rank-Deficient and Discrete Ill-Posed Problems. - """ - xp = get_array_module(A) - - A = xp.asarray(A) - - # SVD decomposition - # Handle both numpy and cupy - try: - U, s, Vt = xp.linalg.svd(A, full_matrices=False) - except AttributeError: - # Fallback for older numpy versions - U, s, Vt = np.linalg.svd(A, full_matrices=False) - U = xp.asarray(U) - s = xp.asarray(s) - Vt = xp.asarray(Vt) - - s_max = xp.max(s) if len(s) > 0 else 1.0 - - if method == "tsvd": - # Truncated SVD: set small singular values to 0 - threshold = float(tol * s_max) - s_inv = xp.where(s > threshold, 1.0 / s, 0.0) - - elif method == "tikhonov": - # Tikhonov regularization - if lambda_reg is None: - lambda_reg = float(tol * s_max) - s_inv = s / (s**2 + lambda_reg**2) - - else: - raise DataValidationError( - f"Unknown method: {method}. Use 'tsvd' or 'tikhonov'." - ) - - # Compute pseudo-inverse: V @ diag(s_inv) @ U.T - # Note: Vt is V transposed from SVD - A_inv = (Vt.T * s_inv) @ U.T - - return A_inv - - -def circulant_convmat( - h: NDArray[np.floating], - t: NDArray[np.floating], -) -> NDArray[np.floating]: - """Construct a block-circulant convolution matrix. - - Creates a circulant matrix for delay-insensitive deconvolution - methods (cSVD/oSVD). The circulant structure allows the AIF to - wrap around, making the deconvolution insensitive to tracer - arrival time differences. - - Parameters - ---------- - h : ndarray - Impulse response function (e.g., AIF). Shape: (n_times,). - t : ndarray - Time points in seconds. Shape: (n_times,). - - Returns - ------- - ndarray - Block-circulant convolution matrix. Shape: (n_times, n_times). - - Notes - ----- - The circulant matrix has the form: - C[i,j] = h[(i-j) mod n] - - This is equivalent to circular convolution rather than linear - convolution. The benefit is that the deconvolution becomes - insensitive to shifts between the AIF and tissue curve. - - References - ---------- - .. [1] Wu et al. (2003). Tracer arrival timing-insensitive technique. - Magn Reson Med. 50:164-174. - """ - xp = get_array_module(h) - - h = xp.asarray(h) - t = xp.asarray(t) - - n = len(h) - if n == 0: - return xp.array([[]], dtype=h.dtype) - - # Compute time step (assume uniform for circulant) - dt = (t[-1] - t[0]) / (n - 1) if n > 1 else 1.0 - - # Build circulant matrix - C = xp.zeros((n, n), dtype=h.dtype) - for i in range(n): - for j in range(n): - idx = (i - j) % n - C[i, j] = h[idx] * dt - - return C diff --git a/osipy/common/convolution/registry.py b/osipy/common/convolution/registry.py deleted file mode 100644 index 5e0c757..0000000 --- a/osipy/common/convolution/registry.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Convolution method registry.""" - -import logging -from collections.abc import Callable - -from osipy.common.exceptions import DataValidationError - -logger = logging.getLogger(__name__) - -_CONVOLUTION_REGISTRY: dict[str, Callable] = {} - - -def register_convolution(name: str): - """Register a convolution method.""" - - def decorator(func: Callable) -> Callable: - if name in _CONVOLUTION_REGISTRY: - logger.warning("Overwriting convolution method '%s'", name) - _CONVOLUTION_REGISTRY[name] = func - return func - - return decorator - - -def get_convolution(name: str) -> Callable: - """Get a convolution method by name.""" - if name not in _CONVOLUTION_REGISTRY: - valid = ", ".join(sorted(_CONVOLUTION_REGISTRY.keys())) - raise DataValidationError(f"Unknown convolution method: {name}. Valid: {valid}") - return _CONVOLUTION_REGISTRY[name] - - -def list_convolutions() -> list[str]: - """List registered convolution methods.""" - return sorted(_CONVOLUTION_REGISTRY.keys()) diff --git a/osipy/common/exceptions.py b/osipy/common/exceptions.py index cd110b5..1eacd19 100644 --- a/osipy/common/exceptions.py +++ b/osipy/common/exceptions.py @@ -69,26 +69,6 @@ class FittingError(OsipyError): pass -class MetadataError(OsipyError): - """Raised when required metadata is missing or invalid. - - This exception is raised when: - - Required DICOM tags are missing - - Acquisition parameters cannot be determined - - Metadata values are out of expected ranges - - Required sidecar JSON files are missing - - Examples - -------- - >>> raise MetadataError("Missing required TR value in DICOM header") - Traceback (most recent call last): - ... - osipy.common.exceptions.MetadataError: Missing required TR value in DICOM header - """ - - pass - - class AIFError(OsipyError): """Raised when AIF extraction or validation fails. @@ -127,22 +107,3 @@ class IOError(OsipyError): """ pass - - -class ValidationError(OsipyError): - """Raised when validation against reference data fails. - - This exception is raised when: - - Computed values exceed tolerance thresholds - - Reference data format is invalid - - Comparison dimensions don't match - - Examples - -------- - >>> raise ValidationError("Ktrans values exceed 5% tolerance in 15% of voxels") - Traceback (most recent call last): - ... - osipy.common.exceptions.ValidationError: Ktrans values exceed 5% tolerance - """ - - pass diff --git a/osipy/common/io/bids.py b/osipy/common/io/bids.py index 4233ee3..707109e 100644 --- a/osipy/common/io/bids.py +++ b/osipy/common/io/bids.py @@ -223,8 +223,6 @@ def load_bids( If BIDS directory or required files not found. IOError If BIDS dataset structure is invalid. - MetadataError - If required metadata is missing and interactive=False. Examples -------- diff --git a/osipy/common/io/metadata/mapper.py b/osipy/common/io/metadata/mapper.py index 0dc1259..196e83a 100644 --- a/osipy/common/io/metadata/mapper.py +++ b/osipy/common/io/metadata/mapper.py @@ -97,11 +97,6 @@ def map_to_acquisition_params( ------- AnyAcquisitionParams Modality-specific acquisition parameters. - - Raises - ------ - MetadataError - If required parameters are missing and interactive=False. """ # Merge metadata with priority chain merged = self._merge_metadata( diff --git a/osipy/dsc/__init__.py b/osipy/dsc/__init__.py index 368e672..6cbd4a9 100644 --- a/osipy/dsc/__init__.py +++ b/osipy/dsc/__init__.py @@ -31,7 +31,6 @@ register_arrival_detector, ) from osipy.dsc.concentration import ( - DSCAcquisitionParams, delta_r2_to_concentration, gamma_variate_fit, signal_to_delta_r2, @@ -73,7 +72,6 @@ ) __all__ = [ - "DSCAcquisitionParams", "DSCPerfusionMaps", "DeconvolutionResult", "LeakageCorrectionParams", diff --git a/osipy/dsc/concentration/__init__.py b/osipy/dsc/concentration/__init__.py index a8e3635..9731bcc 100644 --- a/osipy/dsc/concentration/__init__.py +++ b/osipy/dsc/concentration/__init__.py @@ -14,14 +14,12 @@ """ from osipy.dsc.concentration.signal_to_conc import ( - DSCAcquisitionParams, delta_r2_to_concentration, gamma_variate_fit, signal_to_delta_r2, ) __all__ = [ - "DSCAcquisitionParams", "delta_r2_to_concentration", "gamma_variate_fit", "signal_to_delta_r2", diff --git a/osipy/dsc/concentration/signal_to_conc.py b/osipy/dsc/concentration/signal_to_conc.py index 434f32e..cdec909 100644 --- a/osipy/dsc/concentration/signal_to_conc.py +++ b/osipy/dsc/concentration/signal_to_conc.py @@ -23,7 +23,6 @@ 2024;91(5):1761-1773. doi:10.1002/mrm.29840 """ -from dataclasses import dataclass from typing import TYPE_CHECKING, Any from osipy.common.backend.array_module import get_array_module, to_numpy @@ -34,29 +33,6 @@ from numpy.typing import NDArray -@dataclass -class DSCAcquisitionParams: - """Acquisition parameters for DSC-MRI. - - Attributes - ---------- - te : float - Echo time TE (OSIPI: Q.MS1.005) in milliseconds. - tr : float - Repetition time (TR) in milliseconds. - field_strength : float - Main magnetic field strength in Tesla. - r2_star : float - T2* relaxivity of contrast agent (1/s/mM). - Default is for Gd-DTPA at 1.5T. - """ - - te: float = 30.0 # ms - tr: float = 1500.0 # ms - field_strength: float = 1.5 # T - r2_star: float = 32.0 # s⁻¹ mM⁻¹ at 1.5T - - def signal_to_delta_r2( signal: "NDArray[np.floating[Any]]", te: float, diff --git a/osipy/dsc/deconvolution/svd.py b/osipy/dsc/deconvolution/svd.py index c481708..198c9a2 100644 --- a/osipy/dsc/deconvolution/svd.py +++ b/osipy/dsc/deconvolution/svd.py @@ -13,11 +13,6 @@ NO scipy dependency - uses xp.linalg for SVD (numpy.linalg or cupy.linalg). -For general-purpose deconvolution operations, see also: -- `osipy.common.convolution.deconv`: Matrix-based deconvolution with TSVD/Tikhonov -- `osipy.common.convolution.convmat`: Convolution matrix construction -- `osipy.common.convolution.invconvmat`: Regularized matrix inversion - References ---------- .. [1] OSIPI CAPLEX, https://osipi.github.io/OSIPI_CAPLEX/ diff --git a/tests/integration/test_convolution_integration.py b/tests/integration/test_convolution_integration.py index 3929f33..5aa0c09 100644 --- a/tests/integration/test_convolution_integration.py +++ b/tests/integration/test_convolution_integration.py @@ -7,11 +7,7 @@ import numpy as np -from osipy.common.convolution import ( - conv, - deconv, - fft_convolve, -) +from osipy.common.convolution import convolve_aif from osipy.dce.models.extended_tofts import ExtendedToftsModel, ExtendedToftsParams from osipy.dce.models.tofts import ToftsModel, ToftsParams from osipy.dsc.deconvolution import get_deconvolver @@ -91,7 +87,8 @@ def test_svd_deconvolution_produces_valid_cbf(self): residue = true_cbf * np.exp(-t / mtt) # Create tissue concentration: C(t) = CBF * AIF ⊗ R(t) - tissue = conv(aif, residue / true_cbf, t) # Normalized convolution + dt = t[1] - t[0] + tissue = convolve_aif(aif, residue / true_cbf, dt=dt) # Normalized convolution tissue = tissue.reshape(1, 1, 1, n_time) # 4D shape # Run SVD deconvolution via registry @@ -105,56 +102,3 @@ def test_svd_deconvolution_produces_valid_cbf(self): # CBF should be positive assert result.cbf[0, 0, 0] > 0 - - def test_circulant_deconvolution_delay_insensitive(self): - """Test that circulant SVD is insensitive to bolus arrival delay.""" - t = np.linspace(0, 60, 61) - - # Create AIF with and without delay - aif = np.exp(-((t - 10) ** 2) / 10) * (t > 10) - aif_delayed = np.exp(-((t - 15) ** 2) / 10) * (t > 15) - - # Residue function - residue = np.exp(-t / 10) - - # Tissue curves - tissue = conv(aif, residue, t) - tissue_delayed = conv(aif_delayed, residue, t) - - # Deconvolve with circulant method - recovered = deconv(tissue, aif, t, method="tsvd", tol=0.1, circulant=True) - recovered_delayed = deconv( - tissue_delayed, aif_delayed, t, method="tsvd", tol=0.1, circulant=True - ) - - # Both should give similar peak values (CBF proxy) - cbf = np.max(recovered) - cbf_delayed = np.max(recovered_delayed) - - # Within 50% is acceptable for this test - assert abs(cbf - cbf_delayed) / max(cbf, cbf_delayed) < 0.5 - - -class TestConvolutionNumericalAccuracy: - """Test numerical accuracy of convolution operations.""" - - def test_conv_vs_fft_on_uniform_grid(self): - """Compare piecewise-linear and FFT convolution on uniform grid.""" - n = 200 - dt = 0.1 - t = np.arange(n) * dt - - # Test signals - f = np.exp(-t / 2) - h = np.exp(-t / 5) - - # Piecewise-linear - result_pw = conv(f, h, t, dt=dt) - - # FFT - result_fft = fft_convolve(f, h, dt, mode="same") - - # Both should give similar results in the middle - middle = slice(30, 150) - correlation = np.corrcoef(result_pw[middle], result_fft[middle])[0, 1] - assert correlation > 0.7 diff --git a/tests/integration/test_gpu_equivalence.py b/tests/integration/test_gpu_equivalence.py index 7bed7ea..34d1ae7 100644 --- a/tests/integration/test_gpu_equivalence.py +++ b/tests/integration/test_gpu_equivalence.py @@ -194,10 +194,10 @@ def dsc_data(self): residue = np.exp(-t / 5.0) # Create tissue concentration via convolution - t[1] - t[0] - from osipy.common.convolution import conv + dt = t[1] - t[0] + from osipy.common.convolution import convolve_aif - tissue = conv(aif, residue, t) + tissue = convolve_aif(aif, residue, dt=dt) # Create 4D data (small volume) nx, ny, nz = 4, 4, 2 @@ -302,58 +302,6 @@ def test_expconv_gpu_cpu_equivalence(self): err_msg="expconv GPU/CPU results differ", ) - def test_conv_gpu_cpu_equivalence(self): - """Test piecewise linear convolution GPU/CPU equivalence.""" - from osipy.common.convolution import conv - - np.random.seed(42) - n = 100 - t = np.linspace(0, 10, n) - f = np.exp(-t / 2) - h = np.exp(-t / 3) - - # CPU computation - result_cpu = conv(f, h, t) - - # GPU computation - f_gpu = to_gpu(f) - h_gpu = to_gpu(h) - t_gpu = to_gpu(t) - result_gpu = conv(f_gpu, h_gpu, t_gpu) - - result_gpu_np = to_numpy(result_gpu) - - assert_allclose( - result_cpu, result_gpu_np, rtol=1e-6, err_msg="conv GPU/CPU results differ" - ) - - def test_fft_convolve_gpu_cpu_equivalence(self): - """Test FFT convolution GPU/CPU equivalence.""" - from osipy.common.convolution import fft_convolve - - np.random.seed(42) - n = 256 - dt = 0.1 - f = np.random.randn(n) - h = np.exp(-np.arange(n) * dt / 5) - - # CPU computation - result_cpu = fft_convolve(f, h, dt) - - # GPU computation - f_gpu = to_gpu(f) - h_gpu = to_gpu(h) - result_gpu = fft_convolve(f_gpu, h_gpu, dt) - - result_gpu_np = to_numpy(result_gpu) - - assert_allclose( - result_cpu, - result_gpu_np, - rtol=1e-6, - err_msg="FFT convolve GPU/CPU results differ", - ) - class TestFitToftsGPUHandling: """Test DCE model prediction with GPU array handling.""" diff --git a/tests/unit/common/backend/test_convolution.py b/tests/unit/common/backend/test_convolution.py index cc298f1..25cfab6 100644 --- a/tests/unit/common/backend/test_convolution.py +++ b/tests/unit/common/backend/test_convolution.py @@ -12,11 +12,7 @@ "scipy.signal", reason="scipy required for reference test" ) -from osipy.common.convolution import ( - convolve_aif, - deconvolve_svd, - deconvolve_svd_batch, -) +from osipy.common.convolution import convolve_aif from osipy.common.exceptions import DataValidationError @@ -138,86 +134,6 @@ def test_time_mismatch_raises(self) -> None: convolve_aif(aif, irfs) -class TestDeconvolveSvd: - """Tests for deconvolve_svd function.""" - - def test_recovers_impulse(self) -> None: - """Should recover impulse from convolution.""" - n = 50 - aif = np.exp(-0.1 * np.arange(n)) + 0.1 - true_irf = np.zeros(n) - true_irf[0] = 1.0 - dt = 1.0 - - ct = convolve_aif(aif, true_irf, dt=dt) - irf, _ = deconvolve_svd(ct, aif, dt=dt, threshold=0.01) - - # First element should be close to 1, others close to 0 - assert abs(irf[0] - 1.0) < 0.2 - assert np.mean(np.abs(irf[1:])) < 0.2 - - def test_returns_tuple(self) -> None: - """Should return (irf, residue) tuple.""" - n = 30 - ct = np.random.randn(n) - aif = np.exp(-0.1 * np.arange(n)) + 0.1 - - result = deconvolve_svd(ct, aif) - - assert isinstance(result, tuple) - assert len(result) == 2 - irf, residue = result - assert irf.shape == (n,) - assert residue.shape == (n,) - - def test_residue_is_cumulative_irf(self) -> None: - """Residue should be cumulative sum of IRF.""" - n = 30 - ct = np.random.randn(n) - aif = np.exp(-0.1 * np.arange(n)) + 0.1 - dt = 0.5 - - irf, residue = deconvolve_svd(ct, aif, dt=dt) - - expected_residue = np.cumsum(irf) * dt - np.testing.assert_array_almost_equal(residue, expected_residue) - - -class TestDeconvolveSvdBatch: - """Tests for deconvolve_svd_batch function.""" - - def test_batch_shape(self) -> None: - """Should handle batch input correctly.""" - n_time = 30 - n_voxels = 20 - ct = np.random.randn(n_time, n_voxels) - aif = np.exp(-0.1 * np.arange(n_time)) + 0.1 - - irf, residue = deconvolve_svd_batch(ct, aif) - - assert irf.shape == (n_time, n_voxels) - assert residue.shape == (n_time, n_voxels) - - def test_matches_loop_version(self) -> None: - """Batch version should match loop over individual deconvolutions.""" - n_time = 20 - n_voxels = 5 - ct = np.random.randn(n_time, n_voxels) - aif = np.exp(-0.1 * np.arange(n_time)) + 0.1 - dt = 0.5 - threshold = 0.1 - - batch_irf, _batch_residue = deconvolve_svd_batch( - ct, aif, dt=dt, threshold=threshold - ) - - for v in range(n_voxels): - single_irf, _single_residue = deconvolve_svd( - ct[:, v], aif, dt=dt, threshold=threshold - ) - np.testing.assert_array_almost_equal(batch_irf[:, v], single_irf, decimal=5) - - class TestGpuConvolution: """GPU integration tests for convolution functions.""" @@ -235,19 +151,3 @@ def test_gpu_convolve_matches_cpu(self) -> None: np.testing.assert_array_almost_equal( cpu_result, cp.asnumpy(gpu_result), decimal=10 ) - - def test_gpu_deconvolve_matches_cpu(self) -> None: - """GPU deconvolution should match CPU result.""" - cp = pytest.importorskip("cupy") - - n = 50 - ct = np.random.randn(n) - aif = np.exp(-0.1 * np.arange(n)) + 0.1 - - cpu_irf, cpu_residue = deconvolve_svd(ct, aif) - gpu_irf, gpu_residue = deconvolve_svd(cp.asarray(ct), cp.asarray(aif)) - - np.testing.assert_array_almost_equal(cpu_irf, cp.asnumpy(gpu_irf), decimal=8) - np.testing.assert_array_almost_equal( - cpu_residue, cp.asnumpy(gpu_residue), decimal=8 - ) diff --git a/tests/unit/common/backend/test_gpu_equivalence.py b/tests/unit/common/backend/test_gpu_equivalence.py index 527736d..3124d17 100644 --- a/tests/unit/common/backend/test_gpu_equivalence.py +++ b/tests/unit/common/backend/test_gpu_equivalence.py @@ -60,27 +60,6 @@ def test_convolve_aif_batch_equivalence(self) -> None: np.testing.assert_array_almost_equal(cpu_result, gpu_result_np, decimal=4) - def test_deconvolve_svd_equivalence(self) -> None: - """GPU SVD deconvolution should match CPU.""" - cp = pytest.importorskip("cupy") - from osipy.common.convolution import deconvolve_svd - - n = 50 - ct = np.random.randn(n) - aif = np.exp(-0.1 * np.arange(n)) + 0.1 - dt = 1.0 - threshold = 0.1 - - cpu_irf, cpu_residue = deconvolve_svd(ct, aif, dt=dt, threshold=threshold) - gpu_irf, gpu_residue = deconvolve_svd( - cp.asarray(ct), cp.asarray(aif), dt=dt, threshold=threshold - ) - - np.testing.assert_array_almost_equal(cpu_irf, cp.asnumpy(gpu_irf), decimal=4) - np.testing.assert_array_almost_equal( - cpu_residue, cp.asnumpy(gpu_residue), decimal=4 - ) - class TestModelPredictionEquivalence: """Test GPU model predictions match CPU.""" diff --git a/tests/unit/common/convolution/test_accuracy.py b/tests/unit/common/convolution/test_accuracy.py deleted file mode 100644 index e4fcc27..0000000 --- a/tests/unit/common/convolution/test_accuracy.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Tests comparing dcmri-style vs FFT convolution accuracy.""" - -import numpy as np -from numpy.testing import assert_allclose - -from osipy.common.convolution import conv, expconv, fft_convolve - - -class TestConvolutionAccuracy: - """Compare different convolution methods for accuracy.""" - - def test_piecewise_vs_fft_uniform_grid(self): - """Test piecewise-linear vs FFT on uniform grid.""" - n = 200 # Larger dataset for better FFT comparison - dt = 0.1 - t = np.arange(n) * dt - - # Test signals - use decaying signals that are well-suited for convolution - f = np.exp(-t / 2) - h = np.exp(-t / 5) - - # Piecewise-linear convolution - result_pw = conv(f, h, t, dt=dt) - - # FFT convolution - result_fft = fft_convolve(f, h, dt, mode="same") - - # They should be similar but not identical - # Piecewise-linear should be more accurate for pharmacokinetic signals - assert len(result_pw) == len(result_fft) - - # Both should be non-negative for these signals - assert np.min(result_pw) >= -1e-10 - # FFT may have small negative values due to Gibbs phenomenon - - # Check that they have similar shape by comparing middle region - # (avoid boundary effects) - middle = slice(30, 150) - correlation = np.corrcoef(result_pw[middle], result_fft[middle])[0, 1] - # FFT and piecewise-linear use different algorithms so - # correlation > 0.7 indicates they compute similar quantities - assert correlation > 0.7 - - def test_expconv_vs_conv_exponential(self): - """Test that expconv matches conv for exponential impulse response.""" - t = np.linspace(0, 20, 201) - T = 5.0 - - f = np.exp(-t / 2) - h = np.exp(-t / T) - - # expconv (specialized) - result_expconv = expconv(f, T, t) - - # conv with exponential h (general) - result_conv = conv(f, h, t) - - # expconv should be more accurate (uses analytical formula) - # but both should give similar results - mask = t > 1.0 - assert_allclose(result_expconv[mask], result_conv[mask], rtol=0.2) - - def test_fft_boundary_artifacts(self): - """Test that FFT has boundary artifacts while piecewise doesn't.""" - n = 200 # Larger dataset - dt = 0.1 - t = np.arange(n) * dt - - # Signals that don't decay to zero - FFT will have boundary issues - f = np.ones(n) # Constant - h = np.exp(-t / 5) - - # Piecewise-linear - result_pw = conv(f, h, t, dt=dt) - - # FFT - result_fft = fft_convolve(f, h, dt, mode="same") - - # Both methods should produce finite results - assert np.all(np.isfinite(result_pw)) - assert np.all(np.isfinite(result_fft)) - - # The piecewise method should give monotonically increasing result - # for constant input convolved with positive exponential - # Check that the piecewise result increases (expected for this input) - assert result_pw[-1] > result_pw[0] - - # FFT may have issues at boundaries due to circular convolution - # but should still produce reasonable output in the middle - middle = slice(50, 150) - assert np.mean(result_fft[middle]) > 0 # Should be positive - - def test_analytical_comparison_expconv(self): - """Test expconv against analytical solution. - - For f(t) = 1 (constant), h(t) = exp(-t/T): - (f * h)(t) = T * (1 - exp(-t/T)) - """ - t = np.linspace(0, 30, 301) - T = 5.0 - - f = np.ones_like(t) - - # expconv result - result = expconv(f, T, t) - - # Analytical solution - analytical = T * (1 - np.exp(-t / T)) - - # Should match closely - mask = t > 0.5 - assert_allclose(result[mask], analytical[mask], rtol=0.1) - - -class TestNonUniformGridAccuracy: - """Test accuracy on non-uniform time grids.""" - - def test_conv_non_uniform_vs_uniform(self): - """Test conv gives similar results for non-uniform vs uniform grids.""" - # Uniform grid - t_uniform = np.linspace(0, 10, 101) - f_uniform = np.exp(-t_uniform / 2) - h_uniform = np.exp(-t_uniform / 5) - result_uniform = conv(f_uniform, h_uniform, t_uniform) - - # Non-uniform grid (same endpoints) - t_nonuniform = np.sort(np.random.uniform(0, 10, 101)) - t_nonuniform[0] = 0.0 - t_nonuniform[-1] = 10.0 - f_nonuniform = np.exp(-t_nonuniform / 2) - h_nonuniform = np.exp(-t_nonuniform / 5) - result_nonuniform = conv(f_nonuniform, h_nonuniform, t_nonuniform) - - # Interpolate non-uniform result to uniform grid for comparison - result_interp = np.interp(t_uniform, t_nonuniform, result_nonuniform) - - # Should be similar - correlation = np.corrcoef(result_uniform[10:-10], result_interp[10:-10])[0, 1] - assert correlation > 0.8 - - def test_expconv_non_uniform(self): - """Test expconv on non-uniform time grid.""" - # Non-uniform grid with varying density - t = np.concatenate( - [ - np.linspace(0, 2, 21), # Dense near t=0 - np.linspace(2.5, 10, 16), # Sparser later - ] - ) - T = 3.0 - - f = np.exp(-t / 2) - - # Should handle non-uniform grid - result = expconv(f, T, t) - - assert len(result) == len(t) - assert np.all(np.isfinite(result)) - assert result[0] == 0.0 # Starts at zero - - -class TestPharmacokineticScenarios: - """Test accuracy in realistic pharmacokinetic scenarios.""" - - def test_tofts_model_convolution(self): - """Test convolution accuracy for Tofts model tissue response. - - Tofts model: C_t(t) = K_trans * (C_p * exp(-kep*t)) - where * denotes convolution. - """ - t = np.linspace(0, 10, 101) # 10 minutes - - # Parameters - Ktrans = 0.1 # min^-1 - ve = 0.2 # dimensionless - kep = Ktrans / ve - - # Parker AIF approximation - Cp = 5.0 * np.exp(-t / 0.5) + 2.0 * np.exp(-t / 2.0) - - # Expected tissue concentration via expconv - Ct_expected = Ktrans * expconv(Cp, 1 / kep, t) - - # Via general convolution - h = np.exp(-kep * t) - Ct_conv = Ktrans * conv(Cp, h, t) - - # Should match closely - mask = t > 0.5 - assert_allclose(Ct_expected[mask], Ct_conv[mask], rtol=0.2) - - def test_dsc_deconvolution_scenario(self): - """Test convolution for DSC-MRI residue function estimation.""" - t = np.linspace(0, 60, 61) # 60 seconds - - # Gamma variate AIF - alpha, beta = 3.0, 0.5 - aif = (t**alpha) * np.exp(-t / beta) - aif = aif / np.max(aif) # Normalize - - # Exponential residue function - mtt = 5.0 # Mean transit time - residue = np.exp(-t / mtt) - - # Tissue concentration = AIF * residue - Ct = conv(aif, residue, t) - - # Check that convolution is reasonable - assert len(Ct) == len(t) - assert Ct[0] == 0.0 # Starts at zero - assert np.max(Ct) > 0 # Has positive values - - # Peak should be delayed compared to AIF - aif_peak = np.argmax(aif) - ct_peak = np.argmax(Ct) - assert ct_peak > aif_peak # Convolution delays the peak - - -class TestNumericalStability: - """Test numerical stability of convolution operations.""" - - def test_conv_large_values(self): - """Test conv with large input values.""" - t = np.linspace(0, 10, 101) - scale = 1e6 - - f = scale * np.exp(-t / 2) - h = scale * np.exp(-t / 5) - - result = conv(f, h, t) - - # Should handle large values without overflow - assert np.all(np.isfinite(result)) - - def test_conv_small_values(self): - """Test conv with small input values.""" - t = np.linspace(0, 10, 101) - scale = 1e-6 - - f = scale * np.exp(-t / 2) - h = scale * np.exp(-t / 5) - - result = conv(f, h, t) - - # Should handle small values without underflow to exactly zero - assert np.all(np.isfinite(result)) - - def test_expconv_very_small_T(self): - """Test expconv with very small time constant.""" - t = np.linspace(0, 10, 101) - T = 0.001 # Very small - - f = np.ones_like(t) - - result = expconv(f, T, t) - - # Should give approximately zero (delta function behavior) - assert np.all(np.isfinite(result)) - - def test_expconv_very_large_T(self): - """Test expconv with very large time constant.""" - t = np.linspace(0, 10, 101) - T = 10000.0 # Very large - - f = np.ones_like(t) - - result = expconv(f, T, t) - - # Should give approximately T * t (integration behavior) - assert np.all(np.isfinite(result)) - assert result[-1] > result[0] diff --git a/tests/unit/common/convolution/test_conv.py b/tests/unit/common/convolution/test_conv.py deleted file mode 100644 index cba84f6..0000000 --- a/tests/unit/common/convolution/test_conv.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Unit tests for piecewise-linear convolution.""" - -import numpy as np -import pytest -from numpy.testing import assert_allclose - -from osipy.common.convolution import conv, uconv -from osipy.common.exceptions import DataValidationError - - -class TestConv: - """Tests for conv() piecewise-linear convolution.""" - - def test_conv_empty_input(self): - """Test convolution with empty arrays.""" - result = conv(np.array([]), np.array([]), np.array([])) - assert len(result) == 0 - - def test_conv_single_point(self): - """Test convolution with single point returns zero.""" - result = conv(np.array([1.0]), np.array([1.0]), np.array([0.0])) - assert len(result) == 1 - assert result[0] == 0.0 - - def test_conv_uniform_time_grid(self): - """Test convolution on uniform time grid.""" - t = np.linspace(0, 10, 101) - dt = t[1] - t[0] - - # Delta function convolution (should give back h) - f = np.zeros_like(t) - f[0] = 1.0 / dt # Delta function approximation - - h = np.exp(-t / 2) - - result = conv(f, h, t) - - # Result should approximate h (with some delay/smoothing) - # Check that the shape is reasonable - assert len(result) == len(t) - assert result[0] >= 0 # Should be non-negative - - def test_conv_exponential_decay(self): - """Test convolution of two exponentials.""" - t = np.linspace(0, 20, 201) - - # Two exponential functions - T1, T2 = 2.0, 5.0 - f = np.exp(-t / T1) - h = np.exp(-t / T2) - - result = conv(f, h, t) - - # Check basic properties - assert len(result) == len(t) - assert result[0] == 0.0 # Convolution at t=0 is 0 - assert np.all(result >= -1e-10) # Should be non-negative - - # Peak should occur after t=0 - peak_idx = np.argmax(result) - assert peak_idx > 0 - - def test_conv_mismatched_lengths(self): - """Test that mismatched lengths raise error.""" - t = np.linspace(0, 10, 101) - f = np.ones(100) # Wrong length - h = np.ones(101) - - with pytest.raises(DataValidationError, match="same length"): - conv(f, h, t) - - def test_conv_with_explicit_dt(self): - """Test convolution with explicit dt parameter.""" - n = 100 - dt = 0.1 - t = np.arange(n) * dt - - f = np.exp(-t / 2) - h = np.exp(-t / 5) - - # With explicit dt (should use uconv internally) - result = conv(f, h, t, dt=dt) - - assert len(result) == n - assert result[0] == 0.0 - - def test_conv_non_uniform_time_grid(self): - """Test convolution on non-uniform time grid.""" - # Non-uniform spacing - t = np.array([0.0, 0.1, 0.3, 0.6, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0]) - - f = np.exp(-t / 2) - h = np.exp(-t / 3) - - result = conv(f, h, t) - - assert len(result) == len(t) - assert result[0] == 0.0 - - -class TestUconv: - """Tests for uconv() uniform-grid convolution.""" - - def test_uconv_empty_input(self): - """Test uniform convolution with empty arrays.""" - result = uconv(np.array([]), np.array([]), 0.1) - assert len(result) == 0 - - def test_uconv_single_point(self): - """Test uniform convolution with single point.""" - result = uconv(np.array([1.0]), np.array([1.0]), 0.1) - assert len(result) == 1 - assert result[0] == 0.0 - - def test_uconv_basic(self): - """Test basic uniform convolution.""" - n = 100 - dt = 0.1 - - f = np.ones(n) # Constant input - h = np.exp(-np.arange(n) * dt / 2) # Exponential decay - - result = uconv(f, h, dt) - - assert len(result) == n - assert result[0] == 0.0 - # Convolution of constant with exponential should increase then plateau - assert result[-1] > result[1] - - def test_uconv_symmetry(self): - """Test that convolution is approximately commutative.""" - n = 50 - dt = 0.2 - - f = np.exp(-np.arange(n) * dt / 2) - h = np.exp(-np.arange(n) * dt / 5) - - result_fh = uconv(f, h, dt) - result_hf = uconv(h, f, dt) - - # Results should be similar (not exactly equal due to discrete effects) - assert_allclose(result_fh, result_hf, rtol=0.1) - - def test_uconv_mismatched_lengths(self): - """Test that mismatched lengths raise error.""" - f = np.ones(100) - h = np.ones(101) - - with pytest.raises(DataValidationError, match="same length"): - uconv(f, h, 0.1) - - -class TestConvAccuracy: - """Tests for convolution accuracy against analytical solutions.""" - - def test_exponential_convolution_analytical(self): - """Test against analytical solution for exponential convolution. - - For f(t) = exp(-t/T1) and h(t) = exp(-t/T2), the analytical - convolution is: - (f * h)(t) = T1*T2/(T1-T2) * (exp(-t/T1) - exp(-t/T2)) - """ - t = np.linspace(0, 20, 201) - T1, T2 = 2.0, 5.0 - - f = np.exp(-t / T1) - h = np.exp(-t / T2) - - # Numerical convolution - result = conv(f, h, t) - - # Analytical solution (for t > 0) - analytical = T1 * T2 / (T1 - T2) * (np.exp(-t / T1) - np.exp(-t / T2)) - - # Compare (skip t=0 where both are 0) - # Allow reasonable tolerance for numerical integration - mask = t > 1.0 - if np.any(mask): - rel_error = np.abs( - (result[mask] - analytical[mask]) / (analytical[mask] + 1e-10) - ) - assert np.median(rel_error) < 0.3 # Within 30% for most points - - def test_convolution_with_delta(self): - """Test convolution with approximate delta function.""" - t = np.linspace(0, 10, 501) # Fine time grid - dt = t[1] - t[0] - - # Approximate delta function (narrow Gaussian) - sigma = dt * 2 - delta = np.exp(-(t**2) / (2 * sigma**2)) / (sigma * np.sqrt(2 * np.pi)) - - # Impulse response - h = np.exp(-t / 3) - - result = conv(delta, h, t) - - # Result should approximate h (shifted by delta width) - # Check that peak occurs near expected location - assert len(result) == len(t) diff --git a/tests/unit/common/convolution/test_deconv.py b/tests/unit/common/convolution/test_deconv.py deleted file mode 100644 index b296a75..0000000 --- a/tests/unit/common/convolution/test_deconv.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Unit tests for matrix-based deconvolution.""" - -import numpy as np -import pytest -from numpy.testing import assert_allclose - -from osipy.common.convolution import conv, convmat, deconv, invconvmat -from osipy.common.convolution.deconv import deconv_osvd -from osipy.common.exceptions import DataValidationError - - -class TestDeconv: - """Tests for deconv() matrix-based deconvolution.""" - - def test_deconv_empty_input(self): - """Test deconvolution with empty arrays.""" - result = deconv(np.array([]), np.array([]), np.array([])) - assert len(result) == 0 - - def test_deconv_single_point(self): - """Test deconvolution with single point.""" - result = deconv(np.array([1.0]), np.array([1.0]), np.array([0.0])) - assert len(result) == 1 - - def test_deconv_mismatched_lengths(self): - """Test that mismatched lengths raise error.""" - t = np.linspace(0, 10, 101) - c = np.ones(100) - aif = np.ones(101) - - with pytest.raises(DataValidationError, match="same length"): - deconv(c, aif, t) - - def test_deconv_recovers_residue_tsvd(self): - """Test that TSVD deconvolution recovers residue function.""" - t = np.linspace(0, 30, 61) - - # Create known AIF and residue function - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) # Gamma-like AIF - true_irf = np.exp(-t / 10) # Exponential residue - - # Convolve to create tissue curve - c = conv(aif, true_irf, t) - - # Deconvolve - recovered_irf = deconv(c, aif, t, method="tsvd", tol=0.1) - - # Check basic properties - assert len(recovered_irf) == len(t) - - # Recovered IRF should be roughly similar to true IRF - # (exact recovery is not expected due to regularization) - # Check that peak is in reasonable location - peak_idx = np.argmax(recovered_irf) - assert peak_idx < len(t) // 2 # Peak should be early - - def test_deconv_recovers_residue_tikhonov(self): - """Test that Tikhonov deconvolution recovers residue function.""" - t = np.linspace(0, 30, 61) - - # Create known AIF and residue function - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) - true_irf = np.exp(-t / 10) - - # Convolve to create tissue curve - c = conv(aif, true_irf, t) - - # Deconvolve with Tikhonov - recovered_irf = deconv(c, aif, t, method="tikhonov", tol=0.1) - - # Check basic properties - assert len(recovered_irf) == len(t) - assert np.all(np.isfinite(recovered_irf)) - - def test_deconv_circulant(self): - """Test circulant deconvolution (cSVD).""" - t = np.linspace(0, 30, 61) - - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) - true_irf = np.exp(-t / 10) - c = conv(aif, true_irf, t) - - # Circulant deconvolution - recovered_irf = deconv(c, aif, t, method="tsvd", tol=0.1, circulant=True) - - assert len(recovered_irf) == len(t) - assert np.all(np.isfinite(recovered_irf)) - - def test_deconv_invalid_method(self): - """Test that invalid method raises error.""" - t = np.linspace(0, 10, 21) - c = np.ones_like(t) - aif = np.ones_like(t) - - with pytest.raises(DataValidationError, match="Unknown method"): - deconv(c, aif, t, method="invalid") - - -class TestDeconvOSVD: - """Tests for oSVD (oscillation-minimizing) deconvolution.""" - - def test_deconv_osvd_basic(self): - """Test basic oSVD deconvolution.""" - t = np.linspace(0, 30, 61) - - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) - true_irf = np.exp(-t / 10) - c = conv(aif, true_irf, t) - - # oSVD should produce smooth result - recovered_irf = deconv_osvd(c, aif, t) - - assert len(recovered_irf) == len(t) - assert np.all(np.isfinite(recovered_irf)) - - def test_deconv_osvd_reduces_oscillations(self): - """Test that oSVD produces less oscillatory result than standard TSVD.""" - t = np.linspace(0, 30, 61) - - # Add noise to make deconvolution harder - np.random.seed(42) - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) - true_irf = np.exp(-t / 10) - c = conv(aif, true_irf, t) + 0.01 * np.random.randn(len(t)) - - # Standard TSVD with aggressive truncation - irf_tsvd = deconv(c, aif, t, method="tsvd", tol=0.01, circulant=True) - - # oSVD - irf_osvd = deconv_osvd(c, aif, t) - - # Compute oscillation indices - def osc_index(x): - if np.max(np.abs(x)) < 1e-10: - return 0 - return np.sum(np.abs(np.diff(x))) / (len(x) * np.max(np.abs(x))) - - oi_tsvd = osc_index(irf_tsvd) - oi_osvd = osc_index(irf_osvd) - - # oSVD should generally have lower or similar oscillation - # (this test is probabilistic, may occasionally fail) - assert oi_osvd <= oi_tsvd * 1.5 # Allow some tolerance - - -class TestConvmat: - """Tests for convmat() convolution matrix construction.""" - - def test_convmat_empty(self): - """Test convmat with empty arrays.""" - result = convmat(np.array([]), np.array([])) - assert result.shape == (1, 0) or result.size == 0 - - def test_convmat_shape(self): - """Test convmat returns correct shape.""" - n = 10 - t = np.linspace(0, 5, n) - h = np.exp(-t / 2) - - A = convmat(h, t) - - assert A.shape == (n, n) - - def test_convmat_lower_triangular(self): - """Test that convmat is lower triangular (causal).""" - n = 10 - t = np.linspace(0, 5, n) - h = np.exp(-t / 2) - - A = convmat(h, t) - - # Upper triangle should be zero (except maybe diagonal) - for i in range(n): - for j in range(i + 1, n): - assert abs(A[i, j]) < 1e-10 - - def test_convmat_mismatched_lengths(self): - """Test that mismatched lengths raise error.""" - t = np.linspace(0, 5, 10) - h = np.ones(11) - - with pytest.raises(DataValidationError, match="same length"): - convmat(h, t) - - def test_convmat_convolution_equivalence(self): - """Test that A @ f gives approximately the same result as conv(h, f).""" - t = np.linspace(0, 10, 51) - - h = np.exp(-t / 3) # Impulse response - f = np.ones_like(t) # Constant input - - A = convmat(h, t) - result_matrix = A @ f - result_conv = conv(h, f, t) - - # Should be approximately equal (different integration methods) - # Allow larger tolerance due to different algorithms - assert_allclose(result_matrix, result_conv, rtol=0.5, atol=0.5) - - -class TestInvconvmat: - """Tests for invconvmat() regularized pseudo-inverse.""" - - def test_invconvmat_shape(self): - """Test invconvmat returns correct shape.""" - n = 10 - A = np.eye(n) + 0.1 * np.random.randn(n, n) - - A_inv = invconvmat(A, method="tsvd", tol=0.1) - - assert A_inv.shape == (n, n) - - def test_invconvmat_tsvd(self): - """Test TSVD pseudo-inverse.""" - A = np.diag([10, 5, 2, 1, 0.5, 0.1, 0.05, 0.01, 0.001, 0.0001]) - - A_inv = invconvmat(A, method="tsvd", tol=0.1) - - # Check that small singular values were truncated - # Reconstruction should not amplify noise - product = A @ A_inv - # Not identity due to truncation, but should be reasonable - assert np.max(np.abs(product)) < 100 - - def test_invconvmat_tikhonov(self): - """Test Tikhonov pseudo-inverse.""" - n = 10 - A = np.diag([10, 5, 2, 1, 0.5, 0.1, 0.05, 0.01, 0.001, 0.0001]) - - A_inv = invconvmat(A, method="tikhonov", tol=0.1) - - assert A_inv.shape == (n, n) - assert np.all(np.isfinite(A_inv)) - - def test_invconvmat_invalid_method(self): - """Test that invalid method raises error.""" - A = np.eye(5) - - with pytest.raises(DataValidationError, match="Unknown method"): - invconvmat(A, method="invalid") - - -class TestDeconvRoundtrip: - """Tests for convolution-deconvolution round-trip accuracy.""" - - def test_roundtrip_noise_free(self): - """Test that conv then deconv approximately recovers original.""" - t = np.linspace(0, 30, 61) - - # Known functions - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) - true_irf = np.exp(-t / 10) - - # Forward: convolve - c = conv(aif, true_irf, t) - - # Backward: deconvolve - recovered_irf = deconv(c, aif, t, method="tsvd", tol=0.05) - - # Check correlation between true and recovered - correlation = np.corrcoef(true_irf[5:], recovered_irf[5:])[0, 1] - assert correlation > 0.5 # Should be positively correlated - - def test_roundtrip_with_noise(self): - """Test conv-deconv with noisy data.""" - np.random.seed(42) - t = np.linspace(0, 30, 61) - - aif = np.exp(-t / 5) * (1 - np.exp(-t / 1)) - true_irf = np.exp(-t / 10) - - # Forward with noise - c = conv(aif, true_irf, t) - c_noisy = c + 0.02 * np.max(c) * np.random.randn(len(t)) - - # Backward with stronger regularization - recovered_irf = deconv(c_noisy, aif, t, method="tsvd", tol=0.2) - - # Should still recover general shape - assert len(recovered_irf) == len(t) - assert np.all(np.isfinite(recovered_irf)) diff --git a/tests/unit/common/convolution/test_expconv.py b/tests/unit/common/convolution/test_expconv.py index b00c94f..0998405 100644 --- a/tests/unit/common/convolution/test_expconv.py +++ b/tests/unit/common/convolution/test_expconv.py @@ -4,7 +4,7 @@ import pytest from numpy.testing import assert_allclose -from osipy.common.convolution import biexpconv, expconv, nexpconv +from osipy.common.convolution import expconv from osipy.common.exceptions import DataValidationError @@ -100,138 +100,6 @@ def test_expconv_large_time_constant(self): assert result[-1] > result[len(t) // 2] -class TestBiexpconv: - """Tests for biexpconv() bi-exponential convolution.""" - - def test_biexpconv_empty_input(self): - """Test biexpconv with empty arrays.""" - result = biexpconv(np.array([]), 1.0, 2.0, np.array([])) - assert len(result) == 0 - - def test_biexpconv_zero_time_constants(self): - """Test biexpconv with zero time constants.""" - t = np.linspace(0, 10, 101) - f = np.ones_like(t) - - result = biexpconv(f, 0.0, 1.0, t) - assert_allclose(result, np.zeros_like(t)) - - def test_biexpconv_equal_time_constants(self): - """Test biexpconv with equal time constants (limiting case).""" - t = np.linspace(0, 10, 101) - T = 5.0 - - f = np.ones_like(t) - - # Should handle T1 == T2 gracefully - result = biexpconv(f, T, T, t) - - # Should return finite values - assert len(result) == len(t) - assert np.all(np.isfinite(result)) - - def test_biexpconv_basic(self): - """Test basic biexpconv computation.""" - t = np.linspace(0, 20, 201) - T1, T2 = 2.0, 5.0 - - f = np.ones_like(t) - - result = biexpconv(f, T1, T2, t) - - # Check basic properties - assert len(result) == len(t) - assert np.all(np.isfinite(result)) - - def test_biexpconv_symmetry(self): - """Test that biexpconv is symmetric in T1, T2.""" - t = np.linspace(0, 20, 201) - T1, T2 = 2.0, 5.0 - - f = np.exp(-t / 3) - - result_12 = biexpconv(f, T1, T2, t) - result_21 = biexpconv(f, T2, T1, t) - - # biexpconv(f, T1, T2) = (E1 - E2) / (T1 - T2) - # biexpconv(f, T2, T1) = (E2 - E1) / (T2 - T1) = (E1 - E2) / (T1 - T2) - # They should be equal (not negatives) - assert_allclose(result_12, result_21, rtol=1e-10) - - -class TestNexpconv: - """Tests for nexpconv() n-exponential convolution.""" - - def test_nexpconv_empty_input(self): - """Test nexpconv with empty arrays.""" - result = nexpconv(np.array([]), 1.0, 3, np.array([])) - assert len(result) == 0 - - def test_nexpconv_n_equals_1(self): - """Test that n=1 is equivalent to expconv.""" - t = np.linspace(0, 20, 201) - T = 5.0 - - f = np.exp(-t / 2) - - result_nexp = nexpconv(f, T, 1, t) - result_exp = expconv(f, T, t) - - assert_allclose(result_nexp, result_exp, rtol=1e-10) - - def test_nexpconv_invalid_parameters(self): - """Test nexpconv with invalid parameters.""" - t = np.linspace(0, 10, 101) - f = np.ones_like(t) - - # Zero time constant - result = nexpconv(f, 0.0, 3, t) - assert_allclose(result, np.zeros_like(t)) - - # n < 1 - result = nexpconv(f, 1.0, 0, t) - assert_allclose(result, np.zeros_like(t)) - - def test_nexpconv_n_equals_2(self): - """Test nexpconv with n=2 (gamma variate with shape=2).""" - t = np.linspace(0, 30, 301) - T = 5.0 - - f = np.ones_like(t) - - result = nexpconv(f, T, 2, t) - - # Check basic properties - assert len(result) == len(t) - assert result[0] == 0.0 - assert np.all(np.isfinite(result)) - - # n=2 gamma variate should have delayed peak - peak_idx = np.argmax(result) - assert peak_idx > 0 - - def test_nexpconv_large_n(self): - """Test nexpconv with large n (uses Gaussian approximation).""" - t = np.linspace(0, 100, 501) - T = 2.0 - n = 25 # Large n triggers Gaussian approximation - - # Use a decaying input so the convolution result has a clear peak - f = np.exp(-t / 10) - - result = nexpconv(f, T, n, t) - - # Check basic properties - assert len(result) == len(t) - assert np.all(np.isfinite(result)) - - # For decaying input, the result should have a peak - # somewhere after t=0 due to the delayed gamma variate - peak_idx = np.argmax(result) - assert peak_idx > 0 # Peak is not at t=0 - assert result[0] == 0.0 or result[0] < result[peak_idx] # Increases from start - - class TestExpconvFlouri: """Tests verifying Flouri et al. (2016) formula implementation.""" diff --git a/tests/unit/common/convolution/test_matrix.py b/tests/unit/common/convolution/test_matrix.py deleted file mode 100644 index ff3bb70..0000000 --- a/tests/unit/common/convolution/test_matrix.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Unit tests for convolution matrix construction.""" - -import numpy as np -from numpy.testing import assert_allclose - -from osipy.common.convolution.matrix import circulant_convmat, convmat, invconvmat - - -class TestConvmatConstruction: - """Tests for convmat() construction details.""" - - def test_convmat_first_row_zeros(self): - """Test that first row of convmat is all zeros.""" - t = np.linspace(0, 5, 11) - h = np.exp(-t / 2) - - A = convmat(h, t) - - assert_allclose(A[0, :], np.zeros(len(t))) - - def test_convmat_diagonal_small(self): - """Test that diagonal elements are small (integral at single point).""" - t = np.linspace(0, 5, 11) - h = np.exp(-t / 2) - - A = convmat(h, t) - - # Diagonal should be approximately zero (no overlap at same time) - for i in range(len(t)): - assert abs(A[i, i]) < 0.5 - - def test_convmat_uniform_time(self): - """Test convmat for uniform time grid.""" - n = 20 - dt = 0.25 - t = np.arange(n) * dt - h = np.exp(-t / 2) - - A = convmat(h, t) - - # Check structure - assert A.shape == (n, n) - - # Should be approximately Toeplitz-like - # (constant along diagonals, up to boundary effects) - - def test_convmat_non_uniform_time(self): - """Test convmat for non-uniform time grid.""" - t = np.array([0.0, 0.1, 0.3, 0.6, 1.0, 1.5, 2.5, 4.0]) - h = np.exp(-t / 2) - - A = convmat(h, t) - - assert A.shape == (len(t), len(t)) - assert np.all(np.isfinite(A)) - - def test_convmat_order_1_vs_2(self): - """Test first-order vs second-order integration.""" - t = np.linspace(0, 5, 21) - h = np.exp(-t / 2) - - A1 = convmat(h, t, order=1) - A2 = convmat(h, t, order=2) - - # Both should have same shape - assert A1.shape == A2.shape - - # Both should be finite - assert np.all(np.isfinite(A1)) - assert np.all(np.isfinite(A2)) - - -class TestCirculantConvmat: - """Tests for circulant_convmat() construction.""" - - def test_circulant_shape(self): - """Test circulant matrix has correct shape.""" - n = 10 - t = np.linspace(0, 5, n) - h = np.exp(-t / 2) - - C = circulant_convmat(h, t) - - assert C.shape == (n, n) - - def test_circulant_is_circulant(self): - """Test that the matrix is actually circulant.""" - n = 8 - t = np.linspace(0, 4, n) - h = np.exp(-t / 2) - - C = circulant_convmat(h, t) - - # For a circulant matrix, C[i,j] = C[(i+1) mod n, (j+1) mod n] - for i in range(n - 1): - for j in range(n - 1): - i_next = (i + 1) % n - j_next = (j + 1) % n - assert_allclose(C[i, j], C[i_next, j_next], rtol=1e-10) - - def test_circulant_first_column(self): - """Test that first column contains h values.""" - n = 10 - dt = 0.5 - t = np.arange(n) * dt - h = np.exp(-t / 2) - - C = circulant_convmat(h, t) - - # First column should be h scaled by dt - assert_allclose(C[:, 0], h * dt, rtol=1e-10) - - def test_circulant_empty(self): - """Test circulant with empty arrays.""" - C = circulant_convmat(np.array([]), np.array([])) - assert C.size == 0 - - -class TestInvconvmatDetails: - """Tests for invconvmat() implementation details.""" - - def test_invconvmat_identity_approx(self): - """Test that well-conditioned matrix gives identity.""" - n = 10 - A = np.eye(n) + 0.01 * np.random.randn(n, n) - - A_inv = invconvmat(A, method="tsvd", tol=0.001) - - # A @ A_inv should be close to identity - product = A @ A_inv - assert_allclose(product, np.eye(n), atol=0.1) - - def test_invconvmat_singular_value_truncation(self): - """Test TSVD truncates small singular values.""" - n = 5 - # Create matrix with known singular values - U = np.eye(n) - s = np.array([10.0, 1.0, 0.1, 0.01, 0.001]) - Vt = np.eye(n) - A = U @ np.diag(s) @ Vt - - # TSVD with tol=0.1 should truncate s < 1.0 - A_inv = invconvmat(A, method="tsvd", tol=0.1) - - # The inverse should not amplify the smallest singular value - # Check that A_inv has bounded norm - assert np.max(np.abs(A_inv)) < 100 - - def test_invconvmat_tikhonov_damping(self): - """Test Tikhonov damping of small singular values.""" - # Create matrix with known singular values - s = np.array([10.0, 1.0, 0.1, 0.01, 0.001]) - A = np.diag(s) - - # Tikhonov with lambda = 1.0 - A_inv = invconvmat(A, method="tikhonov", lambda_reg=1.0) - - # Expected: s / (s^2 + lambda^2) - expected_diag = s / (s**2 + 1.0) - assert_allclose(np.diag(A_inv), expected_diag, rtol=1e-10) - - def test_invconvmat_lambda_from_tol(self): - """Test automatic lambda computation from tol.""" - s = np.array([10.0, 5.0, 2.0, 1.0, 0.5]) - A = np.diag(s) - - tol = 0.1 - A_inv = invconvmat(A, method="tikhonov", tol=tol) - - # lambda should be approximately tol * max(s) = 1.0 - expected_lambda = tol * 10.0 - expected_diag = s / (s**2 + expected_lambda**2) - assert_allclose(np.diag(A_inv), expected_diag, rtol=1e-10) - - -class TestMatrixSymmetry: - """Tests for matrix operation symmetry and properties.""" - - def test_convmat_scaling_with_dt(self): - """Test that convmat scales appropriately with time step.""" - h = np.exp(-np.linspace(0, 5, 21) / 2) - - # Fine time grid - t1 = np.linspace(0, 5, 21) - A1 = convmat(h, t1) - - # Coarser time grid (same h values) - t2 = np.linspace(0, 5, 11) - h2 = np.exp(-t2 / 2) - A2 = convmat(h2, t2) - - # Both should have similar structure scaled by dt - assert A1.shape[0] > A2.shape[0] - - def test_invconvmat_non_square(self): - """Test invconvmat handles non-square matrices.""" - A = np.random.randn(10, 5) - - A_inv = invconvmat(A, method="tsvd", tol=0.1) - - # Result should be (5, 10) for pseudo-inverse - assert A_inv.shape == (5, 10) - - def test_circulant_eigenvalues(self): - """Test circulant matrix has DFT eigenvalues.""" - n = 8 - t = np.linspace(0, 4, n) - h = np.exp(-t / 2) - - C = circulant_convmat(h, t) - - # Eigenvalues of circulant matrix are DFT of first column - eigenvalues = np.linalg.eigvals(C) - h_scaled = h * (t[-1] - t[0]) / (n - 1) - fft_h = np.fft.fft(h_scaled) - - # Eigenvalues should match DFT (up to ordering) - eigenvalues_sorted = np.sort(np.abs(eigenvalues)) - fft_sorted = np.sort(np.abs(fft_h)) - assert_allclose(eigenvalues_sorted, fft_sorted, rtol=0.1) diff --git a/tests/unit/dsc/test_concentration.py b/tests/unit/dsc/test_concentration.py index d024f91..8f32d8d 100644 --- a/tests/unit/dsc/test_concentration.py +++ b/tests/unit/dsc/test_concentration.py @@ -5,7 +5,6 @@ from osipy.common.exceptions import DataValidationError from osipy.dsc.concentration import ( - DSCAcquisitionParams, delta_r2_to_concentration, signal_to_delta_r2, ) @@ -102,26 +101,3 @@ def test_4d_data(self) -> None: assert concentration.shape == shape assert np.all(concentration >= 0) - - -class TestDSCAcquisitionParams: - """Tests for DSC acquisition parameters.""" - - def test_default_values(self) -> None: - """Test default parameter values.""" - params = DSCAcquisitionParams() - assert params.te == 30.0 - assert params.tr == 1500.0 - assert params.field_strength == 1.5 - assert params.r2_star == 32.0 - - def test_custom_values(self) -> None: - """Test custom parameter values.""" - params = DSCAcquisitionParams( - te=25.0, - tr=1000.0, - field_strength=3.0, - r2_star=50.0, - ) - assert params.te == 25.0 - assert params.field_strength == 3.0 From cd7b72de337b7a444fa282fe9ddd6b0c6505f458 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 01:39:24 -0400 Subject: [PATCH 10/15] docs: drop references to removed convolution internals architecture.md no longer lists fft_convolve / the @register_convolution registry row (removed in the internal cleanup); convolution example shows the two surviving functions (convolve_aif, expconv). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/explanation/architecture.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 3ab033f..63f6858 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -251,17 +251,13 @@ Used by DCE pharmacokinetic models for convolving the AIF with tissue response f !!! example "Convolution functions" ```python - # Piecewise-linear convolution (registered as "piecewise_linear") - def convolve_aif(aif, time, kernel_func): - """Convolve AIF with a model kernel.""" + # Discrete AIF convolution with a tissue residue/response function + def convolve_aif(aif, residue, dt): + """Convolve the AIF with a model kernel.""" - # Recursive exponential convolution (registered as "exponential") + # Recursive exponential convolution (Flouri et al. 2016) def expconv(time_constant, time, input_function): """Fast exponential convolution for compartment models.""" - - # FFT-based convolution (registered as "fft") - def fft_convolve(signal_a, signal_b, dt): - """Frequency-domain convolution.""" ``` ### Modality Modules @@ -270,7 +266,7 @@ All four modalities use the shared `LevenbergMarquardtFitter` via binding adapte #### DCE Module -The most coupled modality — uses shared Fitting (LM optimizer), AIF (population models and detection), and Convolution (piecewise-linear, exponential, FFT) from common. +The most coupled modality — uses shared Fitting (LM optimizer), AIF (population models and detection), and Convolution (`convolve_aif`, `expconv`) from common. !!! example "DCE module key functions and classes" @@ -500,7 +496,6 @@ All extension points use the registry pattern — one file, one decorator. 17+ r | T1 mapping method | `@register_t1_method("name")` | `get_t1_method("name")` | `list_t1_methods()` | | Concentration model | `@register_concentration_model("name")` | `get_concentration_model("name")` | `list_concentration_models()` | | IVIM fitting strategy | `@register_ivim_fitter("name")` | `get_ivim_fitter("name")` | `list_ivim_fitters()` | -| Convolution method | `@register_convolution("name")` | `get_convolution("name")` | `list_convolutions()` | All registries use `DataValidationError` for unknown names and `logging.getLogger(__name__)` with warnings for overwrites. From ad5ddc7bc51843e4398160aa17ad51d039419b26 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 02:09:02 -0400 Subject: [PATCH 11/15] config: auto-derive option lists in --dump-defaults from the registry The CLI template generator now derives the 'A | B | C' choice annotations from the schema instead of hand-written description strings: - discriminated-union discriminator lines (method/model/mode/name) list their union members' literals (i.e. the registry keys) - Literal[...] fields list their allowed values Convert the remaining flat str enum fields (data.format, output.format, logging.level, DCE fitter/aif_source, ASL labeling_scheme/label_control_order) to Literal so they validate + autogenerate too; drop the now-redundant hand-written validators and '| ' option strings from descriptions. New @register'd method => its choice appears in --dump-defaults with no edits. Gate: 795 passed, 0 failed (core incl. GPU); 8/8 real-data CLI; mkdocs clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/cli/config.py | 183 ++++++++++++++++------------------ osipy/dce/config.py | 4 +- tests/unit/cli/test_config.py | 14 ++- 3 files changed, 94 insertions(+), 107 deletions(-) diff --git a/osipy/cli/config.py b/osipy/cli/config.py index fa69c0b..d6b01c6 100644 --- a/osipy/cli/config.py +++ b/osipy/cli/config.py @@ -8,8 +8,9 @@ from __future__ import annotations import logging +import typing from pathlib import Path -from typing import Any +from typing import Any, Literal import yaml from pydantic import BaseModel, Field, field_validator @@ -53,10 +54,7 @@ class DataConfig(BaseModel): """Data loading configuration.""" - format: str = Field( - default="auto", - description="auto | nifti | dicom | bids", - ) + format: Literal["auto", "nifti", "dicom", "bids"] = Field(default="auto") mask: str | None = Field( default=None, description="tissue mask, relative to data_path or absolute", @@ -102,7 +100,7 @@ class DataConfig(BaseModel): class OutputConfig(BaseModel): """Output configuration.""" - format: str = Field(default="nifti", description="nifti") + format: Literal["nifti"] = Field(default="nifti") class BackendConfig(BaseModel): @@ -117,7 +115,7 @@ class BackendConfig(BaseModel): class LoggingConfig(BaseModel): """Logging configuration.""" - level: str = Field(default="INFO", description="DEBUG | INFO | WARNING | ERROR") + level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(default="INFO") # --------------------------------------------------------------------------- @@ -128,7 +126,7 @@ class LoggingConfig(BaseModel): class DCEFittingConfig(BaseModel): """DCE model fitting configuration from YAML.""" - fitter: str = Field(default="lm", description="lm | bayesian") + fitter: Literal["lm", "bayesian"] = Field(default="lm") max_iterations: int = Field(default=100) tolerance: float = Field(default=1e-6) r2_threshold: float = Field( @@ -165,18 +163,6 @@ class DCEFittingConfig(BaseModel): }, ) - @field_validator("fitter") - @classmethod - def validate_fitter(cls, v: str) -> str: - """Validate fitter name against registry.""" - from osipy.common.fitting.registry import FITTER_REGISTRY - - if v not in FITTER_REGISTRY: - valid = sorted(FITTER_REGISTRY.keys()) - msg = f"Invalid fitter '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - @field_validator("bounds") @classmethod def validate_bounds( @@ -244,43 +230,25 @@ class DCEPipelineYAML(BaseModel): model: _DCEModelConfig = Field( default_factory=ExtendedToftsConfig, - description=( - "pharmacokinetic model + parameters " - "(method: tofts | extended_tofts | patlak | 2cxm | 2cum)" - ), + description="pharmacokinetic model + parameters", ) t1_mapping_method: _T1MethodConfig = Field( default_factory=VFAConfig, - description="T1 mapping method + parameters (method: vfa | look_locker)", + description="T1 mapping method + parameters", ) concentration: _ConcentrationConfig = Field( default_factory=SPGRConcentrationConfig, - description="signal-to-concentration model (method: spgr | linear)", - ) - aif_source: str = Field( - default="population", description="population | detect | manual" + description="signal-to-concentration model", ) + aif_source: Literal["population", "detect", "manual"] = Field(default="population") population_aif: _PopulationAIFConfig = Field( default_factory=_default_population_aif, - description=( - "population AIF (name: parker | georgiou | fritz_hansen | " - "weinmann | mcgrath); used when aif_source: population" - ), + description="population AIF model; used when aif_source: population", ) save_intermediate: bool = Field(default=False) acquisition: DCEAcquisitionYAML = DCEAcquisitionYAML() fitting: DCEFittingConfig = DCEFittingConfig() - @field_validator("aif_source") - @classmethod - def validate_aif_source(cls, v: str) -> str: - """Validate AIF source.""" - valid = ["population", "detect", "manual"] - if v not in valid: - msg = f"Invalid AIF source '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - # --------------------------------------------------------------------------- # DSC modality @@ -305,7 +273,7 @@ class DSCPipelineYAML(BaseModel): apply_leakage_correction: bool = Field(default=True) deconvolution: _DeconvolverConfig = Field( default_factory=OSVDConfig, - description="deconvolution method + parameters (method: oSVD | cSVD | sSVD)", + description="deconvolution method + parameters", ) @@ -326,7 +294,7 @@ class DSCPipelineYAML(BaseModel): class ASLPipelineYAML(BaseModel): """ASL pipeline settings from YAML.""" - labeling_scheme: str = Field(default="pcasl", description="pcasl | pasl | casl") + labeling_scheme: Literal["pcasl", "pasl", "casl"] = Field(default="pcasl") pld: float = Field(default=1800.0, description="ms, post-labeling delay") label_duration: float = Field(default=1800.0, description="ms, labeling duration") t1_blood: float = Field( @@ -343,46 +311,20 @@ class ASLPipelineYAML(BaseModel): ) m0: _M0Config = Field( default_factory=SingleM0Config, - description=( - "M0 calibration method + parameters " - "(method: single | voxelwise | reference_region)" - ), + description="M0 calibration method + parameters", ) difference: _DifferenceConfig = Field( default_factory=PairwiseDifferenceConfig, - description="label-control subtraction method (method: pairwise | surround | mean)", + description="label-control subtraction method", ) quantification: _QuantificationConfig = Field( default_factory=SinglePLDConfig, - description=( - "CBF quantification mode + parameters " - "(mode: single_pld | multi_pld); multi_pld adds plds + ATT estimation" - ), + description="CBF quantification mode (multi_pld adds plds + ATT estimation)", ) - label_control_order: str = Field( - default="label_first", description="label_first | control_first" + label_control_order: Literal["label_first", "control_first"] = Field( + default="label_first" ) - @field_validator("labeling_scheme") - @classmethod - def validate_labeling(cls, v: str) -> str: - """Validate ASL labeling scheme.""" - valid = ["pasl", "casl", "pcasl"] - if v not in valid: - msg = f"Invalid labeling scheme '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - - @field_validator("label_control_order") - @classmethod - def validate_order(cls, v: str) -> str: - """Validate label/control ordering.""" - valid = ["label_first", "control_first"] - if v not in valid: - msg = f"Invalid label/control order '{v}'. Valid: {valid}" - raise ValueError(msg) - return v - # --------------------------------------------------------------------------- # IVIM modality @@ -400,15 +342,11 @@ class IVIMPipelineYAML(BaseModel): fitting: _IVIMFittingConfig = Field( default_factory=SegmentedFittingConfig, - description=( - "fitting strategy + parameters " - "(method: segmented | full | bayesian); " - "bayesian adds prior_scale/noise_std, segmented/bayesian add b_threshold" - ), + description="fitting strategy + parameters", ) model: _IVIMModelConfig = Field( default_factory=BiexponentialModelConfig, - description="IVIM signal model (model: biexponential | simplified)", + description="IVIM signal model", ) normalize_signal: bool = Field( default=True, description="normalize to S(b=0) before fitting" @@ -542,13 +480,58 @@ def _yaml_scalar(value: Any) -> str: return str(value) -def _render_model_yaml(model: BaseModel, indent: int = 0) -> list[str]: +def _discriminator_choices(field: Any) -> tuple[str, list[str]] | None: + """For a discriminated-union field, return ``(discriminator, [choices])``. + + Choices are each union member's discriminator-literal value (i.e. the + registry keys), so the inline comment is derived from the registry rather + than hand-maintained. + """ + disc = getattr(field, "discriminator", None) + if not disc: + return None + choices: list[str] = [] + for member in typing.get_args(field.annotation): + member_fields = getattr(member, "model_fields", None) + if member_fields and disc in member_fields: + choices.append(str(member_fields[disc].default)) + if not choices: + return None + return disc, sorted(choices) + + +def _literal_choices(field: Any) -> list[str] | None: + """Return the allowed values of a ``Literal[...]`` field, else ``None``.""" + if typing.get_origin(field.annotation) is Literal: + return [str(a) for a in typing.get_args(field.annotation)] + return None + + +def _comment(choices: list[str] | None, description: str) -> str: + """Compose an inline ``# ...`` comment from derived choices + description.""" + parts: list[str] = [] + if choices: + parts.append(" | ".join(choices)) + if description and description not in parts: + parts.append(description) + return " # " + " — ".join(parts) if parts else "" + + +def _render_model_yaml( + model: BaseModel, + indent: int = 0, + choices_override: dict[str, list[str]] | None = None, +) -> list[str]: """Recursively render a pydantic model instance as commented YAML lines. For each field on ``model``: - * If the value is a nested ``BaseModel``, emit ``name:`` and recurse. - * If the default value is not ``None``, emit ``name: value # description``. + * If the value is a nested ``BaseModel``, emit ``name:`` and recurse. For a + discriminated-union field, the allowed discriminator values are derived + from the union members (the registry keys) and rendered as the inline + comment on the discriminator line of the recursed member. + * ``Literal[...]`` fields render their allowed values as the comment. + * If the default value is not ``None``, emit ``name: value # comment``. * If the default is ``None``, emit a commented-out example line using the field's ``examples=[...]`` metadata, or — for complex dict fields — use the multi-line block in ``json_schema_extra["yaml_example"]``. @@ -559,6 +542,10 @@ def _render_model_yaml(model: BaseModel, indent: int = 0) -> list[str]: Populated pydantic model (use defaults via ``ModelCls()``). indent : int Number of leading spaces (for nesting). + choices_override : dict[str, list[str]] | None + Inline-comment choices to apply to specific fields of *model* — used to + carry a discriminated union's choices down onto the member's + discriminator line. Returns ------- @@ -566,23 +553,31 @@ def _render_model_yaml(model: BaseModel, indent: int = 0) -> list[str]: YAML lines (without trailing newlines). """ pad = " " * indent + overrides = choices_override or {} lines: list[str] = [] for name, field in type(model).model_fields.items(): description = (field.description or "").strip() extra = field.json_schema_extra or {} value = getattr(model, name) - # Nested pydantic model: recurse. + # Nested pydantic model: recurse, carrying any union choices onto the + # member's discriminator line. if isinstance(value, BaseModel): lines.append(f"{pad}{name}:") - lines.extend(_render_model_yaml(value, indent=indent + 2)) + disc = _discriminator_choices(field) + sub_override = {disc[0]: disc[1]} if disc else None + lines.extend( + _render_model_yaml( + value, indent=indent + 2, choices_override=sub_override + ) + ) continue + choices = overrides.get(name) or _literal_choices(field) + # Commented-out optional field with a multi-line example block. if value is None and isinstance(extra, dict) and "yaml_example" in extra: - header = f"{pad}# {name}:" - if description: - header += f" # {description}" + header = f"{pad}# {name}:{_comment(choices, description)}" lines.append(header) for sub in str(extra["yaml_example"]).splitlines(): lines.append(f"{pad}# {sub}" if sub else f"{pad}#") @@ -597,16 +592,12 @@ def _render_model_yaml(model: BaseModel, indent: int = 0) -> list[str]: rendered_example = ( _yaml_scalar(example) if example is not None else "" ) - line = f"{pad}# {name}: {rendered_example}" - if description: - line += f" # {description}" + line = f"{pad}# {name}: {rendered_example}{_comment(choices, description)}" lines.append(line) continue # Normal field with a default value. - line = f"{pad}{name}: {_yaml_scalar(value)}" - if description: - line += f" # {description}" + line = f"{pad}{name}: {_yaml_scalar(value)}{_comment(choices, description)}" lines.append(line) return lines diff --git a/osipy/dce/config.py b/osipy/dce/config.py index 6b54da1..c6a1912 100644 --- a/osipy/dce/config.py +++ b/osipy/dce/config.py @@ -126,9 +126,7 @@ class LookLockerConfig(MethodConfig): class SPGRConcentrationConfig(MethodConfig): """Spoiled gradient-echo signal-to-concentration model (OSIPI: P.SC1.001).""" - method: Literal["spgr"] = Field( - "spgr", description="signal-to-concentration model: spgr | linear" - ) + method: Literal["spgr"] = "spgr" class LinearConcentrationConfig(MethodConfig): diff --git a/tests/unit/cli/test_config.py b/tests/unit/cli/test_config.py index 8d6df76..dbc487c 100644 --- a/tests/unit/cli/test_config.py +++ b/tests/unit/cli/test_config.py @@ -311,7 +311,7 @@ def test_invalid_t1_method(self) -> None: def test_invalid_aif_source(self) -> None: """Invalid AIF source raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid AIF source"): + with pytest.raises(ValidationError): DCEPipelineYAML(aif_source="invalid_source") def test_valid_models(self) -> None: @@ -426,7 +426,7 @@ def test_defaults(self) -> None: def test_invalid_labeling_scheme(self) -> None: """Invalid labeling scheme raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid labeling scheme"): + with pytest.raises(ValidationError): ASLPipelineYAML(labeling_scheme="invalid") def test_invalid_m0_method(self) -> None: @@ -436,7 +436,7 @@ def test_invalid_m0_method(self) -> None: def test_invalid_label_control_order(self) -> None: """Invalid label/control order raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid label/control order"): + with pytest.raises(ValidationError): ASLPipelineYAML(label_control_order="invalid") def test_valid_labeling_schemes(self) -> None: @@ -585,14 +585,12 @@ def test_defaults(self) -> None: def test_invalid_fitter(self) -> None: """Invalid fitter name raises ValidationError.""" - with pytest.raises(ValidationError, match="Invalid fitter"): + with pytest.raises(ValidationError): DCEFittingConfig(fitter="nonexistent_fitter") def test_valid_fitters(self) -> None: - """All registered fitters are accepted.""" - from osipy.common.fitting.registry import list_fitters - - for name in list_fitters(): + """The DCE-supported fitters are accepted.""" + for name in ("lm", "bayesian"): cfg = DCEFittingConfig(fitter=name) assert cfg.fitter == name From dd2cc60238dfe761b6e40e6835bc91252ab99cb4 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 02:15:12 -0400 Subject: [PATCH 12/15] chore: gitignore the local pipeline verification script scripts/verify_local_pipelines.sh hard-codes machine-specific data paths and is a local-only helper; untrack it and add to .gitignore (kept in the working tree). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 5 +- scripts/verify_local_pipelines.sh | 293 ------------------------------ 2 files changed, 4 insertions(+), 294 deletions(-) delete mode 100755 scripts/verify_local_pipelines.sh diff --git a/.gitignore b/.gitignore index e726be0..830a96f 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,7 @@ outputs/ output/ test_runs/ .claude/ -site/ \ No newline at end of file +site/ + +# Local-only pipeline verification helper (machine-specific data paths) +scripts/verify_local_pipelines.sh \ No newline at end of file diff --git a/scripts/verify_local_pipelines.sh b/scripts/verify_local_pipelines.sh deleted file mode 100755 index d3b811f..0000000 --- a/scripts/verify_local_pipelines.sh +++ /dev/null @@ -1,293 +0,0 @@ -#!/usr/bin/env bash -# -# verify_local_pipelines.sh — run the osipy CLI against the local datasets and -# verify every pipeline still produces valid output maps. -# -# This reproduces the 8 passing "localdata" checks used as the dead-code-removal -# regression baseline, but drives them through the *real* `osipy` command-line -# entry point (config YAML + data path) so you can verify them yourself: -# -# 5 end-to-end CLI pipelines 3 DICOM vendor-load checks -# --------------------------- -------------------------- -# 1. DCE (Clinical_P1, DICOM) 6. DICOM load: GE -# 2. IVIM (brain, NIfTI) 7. DICOM load: Siemens -# 3. IVIM (abdomen, NIfTI) 8. DICOM load: Philips -# 4. ASL (ExploreASL, BIDS) -# 5. ASL (OSIPI Dataset1, BIDS) -# -# (DSC has no local dataset, so it is covered only by the unit/integration suite.) -# -# Usage: -# scripts/verify_local_pipelines.sh -# -# Environment overrides: -# OSIPY_DATA Path to the data directory (default: /home/ltorres/projects/osipy/data) -# OUTPUT_DIR Where to write outputs (default: a fresh temp dir) -# PYTHON Python interpreter (default: .venv/bin/python) -# OSIPY osipy CLI executable (default: .venv/bin/osipy) -# -set -u # (intentionally NOT -e: we want to run every pipeline and tally failures) - -# --- locations ------------------------------------------------------------- -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -OSIPY_DATA="${OSIPY_DATA:-/home/ltorres/projects/osipy/data}" -PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}" -OSIPY="${OSIPY:-$REPO_ROOT/.venv/bin/osipy}" -OUTPUT_DIR="${OUTPUT_DIR:-$(mktemp -d -t osipy_verify.XXXXXX)}" -CONFIG_DIR="$OUTPUT_DIR/configs" -mkdir -p "$CONFIG_DIR" - -# Fall back to PATH executables if the venv ones are absent. -[ -x "$PYTHON" ] || PYTHON="python3" -[ -x "$OSIPY" ] || OSIPY="osipy" - -PASS=0 -FAIL=0 -declare -a RESULTS - -echo "==================================================================" -echo " osipy local pipeline verification" -echo " repo: $REPO_ROOT" -echo " data: $OSIPY_DATA" -echo " python: $PYTHON" -echo " osipy: $OSIPY" -echo " output: $OUTPUT_DIR" -echo "==================================================================" - -if [ ! -d "$OSIPY_DATA" ]; then - echo "ERROR: data directory not found: $OSIPY_DATA" >&2 - echo "Set OSIPY_DATA to point at your data checkout." >&2 - exit 2 -fi - -# --- helpers --------------------------------------------------------------- - -# assert_valid_nifti -> 0 if a loadable, non-empty, not-all-NaN NIfTI -assert_valid_nifti() { - "$PYTHON" - "$1" <<'PY' -import sys -import numpy as np -import nibabel as nib -path = sys.argv[1] -img = nib.load(path) -data = np.asarray(img.dataobj) -assert data.size > 0, f"{path}: empty array" -assert np.isfinite(data).any(), f"{path}: entirely NaN/Inf" -PY -} - -# record -record() { - local name="$1" status="$2" detail="$3" - if [ "$status" -eq 0 ]; then - PASS=$((PASS + 1)) - RESULTS+=("PASS $name") - echo " -> PASS: $name" - else - FAIL=$((FAIL + 1)) - RESULTS+=("FAIL $name ($detail)") - echo " -> FAIL: $name ($detail)" - fi -} - -# run_cli_pipeline -run_cli_pipeline() { - local name="$1" config="$2" data_path="$3"; shift 3 - local expected=("$@") - local out="$OUTPUT_DIR/${name}" - rm -rf "$out" - - echo - echo "------------------------------------------------------------------" - echo "[$name] osipy $(basename "$config") $data_path -o $out" - echo "------------------------------------------------------------------" - - if [ ! -e "$data_path" ]; then - record "$name" 1 "data not found: $data_path" - return - fi - - "$OSIPY" "$config" "$data_path" -o "$out" - local rc=$? - if [ $rc -ne 0 ]; then - record "$name" 1 "CLI exited $rc" - return - fi - - local missing="" - for f in "${expected[@]}"; do - if [ ! -f "$out/$f" ]; then - missing="$missing $f" - continue - fi - if [[ "$f" == *.nii.gz ]] && ! assert_valid_nifti "$out/$f"; then - missing="$missing $f(invalid)" - fi - done - - if [ -n "$missing" ]; then - record "$name" 1 "missing/invalid outputs:$missing" - else - echo " outputs: ${expected[*]}" - record "$name" 0 "" - fi -} - -# --- 1. DCE (Clinical_P1 DICOM) ------------------------------------------- -cat > "$CONFIG_DIR/dce.yaml" <<'YAML' -modality: dce -pipeline: - model: - method: extended_tofts - t1_mapping_method: - method: vfa - fit_method: linear - concentration: - method: spgr - aif_source: population - population_aif: - name: parker - acquisition: - tr: 5.0 - flip_angles: [5, 10, 15, 20, 25, 30] - baseline_frames: 5 - relaxivity: 4.5 -data: - format: auto -YAML -run_cli_pipeline "1_dce_clinical_p1" "$CONFIG_DIR/dce.yaml" \ - "$OSIPY_DATA/dce/Clinical_P1/Visit1/09-15-1904-BRAINRESEARCH-89964" \ - osipy_run.json ktrans.nii.gz ve.nii.gz quality_mask.nii.gz - -# --- 2 & 3. IVIM (brain + abdomen NIfTI) ---------------------------------- -for region in brain abdomen; do - cat > "$CONFIG_DIR/ivim_${region}.yaml" < ms) -cat > "$CONFIG_DIR/asl_explore.yaml" <<'YAML' -modality: asl -pipeline: - labeling_scheme: pcasl - pld: 2025.0 - label_duration: 1650.0 - m0: - method: single - difference: - method: pairwise - quantification: - mode: single_pld - label_control_order: label_first -data: - format: bids - subject: Sub1 -YAML -run_cli_pipeline "4_asl_exploreasl" "$CONFIG_DIR/asl_explore.yaml" \ - "$OSIPY_DATA/asl/ExploreASL_TestDataSet/rawdata" \ - osipy_run.json cbf.nii.gz - -# OSIPI Dataset1: PCASL, PLD 2.025 s, LD 1.8 s -cat > "$CONFIG_DIR/asl_osipi1.yaml" <<'YAML' -modality: asl -pipeline: - labeling_scheme: pcasl - pld: 2025.0 - label_duration: 1800.0 - m0: - method: single - difference: - method: pairwise - quantification: - mode: single_pld - label_control_order: label_first -data: - format: bids - subject: "001" -YAML -run_cli_pipeline "5_asl_osipi_dataset1" "$CONFIG_DIR/asl_osipi1.yaml" \ - "$OSIPY_DATA/asl/OSIPI_TESTING/OSIPI_Dataset1/rawdata" \ - osipy_run.json cbf.nii.gz - -# --- 6, 7, 8. DICOM vendor-load checks (GE / Siemens / Philips) ----------- -# These exercise the DICOM loader (discover + load_dicom_series) directly, -# matching tests/unit/common/test_dicom.py::test_load_vendor_dce. -verify_vendor_dicom() { - local vendor="$1" - local vendor_dir="$OSIPY_DATA/test_dicom/${vendor}/dce" - echo - echo "------------------------------------------------------------------" - echo "[${vendor}] DICOM load check: $vendor_dir" - echo "------------------------------------------------------------------" - if [ ! -d "$vendor_dir" ]; then - record "${vendor}_dicom_load" 1 "data not found: $vendor_dir" - return - fi - if OSIPY_VENDOR_DIR="$vendor_dir" "$PYTHON" - <<'PY' -import os -import sys -import numpy as np -from pathlib import Path -from osipy.common.dataset import PerfusionDataset -from osipy.common.io.discovery import discover_dicom, load_dicom_series - -vendor_dir = Path(os.environ["OSIPY_VENDOR_DIR"]) -# leaf = first directory under vendor_dir that contains .dcm files -leaf = None -for p in sorted(vendor_dir.rglob("*.dcm")): - leaf = p.parent - break -if leaf is None: - print(f" no .dcm files under {vendor_dir}", file=sys.stderr) - sys.exit(1) - -series_list = discover_dicom(leaf) -assert series_list, f"no series discovered under {leaf}" -ds = load_dicom_series(series_list[0]) -assert isinstance(ds, PerfusionDataset), type(ds) -data = np.asarray(ds.data) -assert data.size > 0, "empty volume" -assert data.ndim >= 3, f"ndim={data.ndim}" -assert np.isfinite(data).any(), "all NaN/Inf" -assert ds.acquisition_params is not None, "no acquisition params" -print(f" loaded {leaf.name}: shape={data.shape}") -PY - then - record "${vendor}_dicom_load" 0 "" - else - record "${vendor}_dicom_load" 1 "loader raised / empty volume" - fi -} -verify_vendor_dicom ge -verify_vendor_dicom siemens -verify_vendor_dicom philips - -# --- summary --------------------------------------------------------------- -echo -echo "==================================================================" -echo " SUMMARY" -echo "==================================================================" -for r in "${RESULTS[@]}"; do echo " $r"; done -echo "------------------------------------------------------------------" -echo " $PASS passed, $FAIL failed (outputs under $OUTPUT_DIR)" -echo "==================================================================" - -[ "$FAIL" -eq 0 ] From e719ca389ef64b6c145ae521a55d6ff595800e8a Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 02:17:53 -0400 Subject: [PATCH 13/15] docs: trim configuration.md to just how it works Drop the 'problem it solves' section and condense; keep the mechanics (MethodConfig, discriminator, registry x schema generation, validate+build), a brief add-a-method note, and the per-modality table. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/explanation/configuration.md | 110 +++++++++++------------------- 1 file changed, 38 insertions(+), 72 deletions(-) diff --git a/docs/explanation/configuration.md b/docs/explanation/configuration.md index 5d0b872..c51193b 100644 --- a/docs/explanation/configuration.md +++ b/docs/explanation/configuration.md @@ -1,35 +1,18 @@ # Registry-Driven Configuration -osipy's YAML/CLI configuration is **generated from the component registries**, -not hand-written. Each pipeline-component selection — the DCE pharmacokinetic -model, the DSC deconvolution method, the ASL M0 calibration, the IVIM fitting -strategy, and so on — is a nested, validated block whose available options and -parameters come directly from what is registered. This page explains why that -design exists and how it works. +osipy's YAML/CLI configuration is **generated from the component registries**. +Each pipeline-component selection — the DCE pharmacokinetic model, the DSC +deconvolution method, the ASL M0 calibration, the IVIM fitting strategy, and so +on — is a nested, validated block whose options and parameters come directly +from what is registered. -## The Problem It Solves - -Earlier, each pipeline option was a flat string in the config, with the -component's parameters as separate sibling keys disconnected from the -selection. This had two recurring failure modes: - -- **Collected but ignored.** A knob could be parsed into the config object yet - never reach the component that needed it, so changing it silently did nothing. -- **Mismatched parameters.** A threshold meant for one method could be set while - a different method was selected, with no error — the value was simply unused. - -The registry-driven config closes this gap: the same schema that *validates* -your input is also what *constructs* the component, so an option can never be -silently dropped or applied to the wrong method. - -## How It Works +## How it works ### Each component declares a `MethodConfig` Every selectable component ships a small pydantic model -(`osipy.common.config.MethodConfig`) that carries a discriminator field — a -`Literal` equal to the component's registry name — plus exactly that -component's tunable knobs: +(`osipy.common.config.MethodConfig`) with a discriminator field — a `Literal` +equal to the component's registry name — plus that component's tunable knobs: ```python class OSVDConfig(MethodConfig): @@ -38,13 +21,13 @@ class OSVDConfig(MethodConfig): default_threshold: float = Field(0.2, gt=0.0, lt=1.0) ``` -`MethodConfig` sets `extra="forbid"`, so a typo or a knob that belongs to a -different method raises a validation error instead of being quietly ignored. +`MethodConfig` sets `extra="forbid"`, so a typo or a knob belonging to a +different method raises a validation error instead of being silently ignored. ### The discriminator selects the parameters you see -In YAML, you select a component by its discriminator and then only the knobs -for *that* component are valid: +In YAML you pick a component by its discriminator, and only that component's +knobs are valid: ```yaml deconvolution: @@ -53,50 +36,44 @@ deconvolution: default_threshold: 0.2 ``` -Switch the method and the surfaced knobs change with it — `sSVD` and `cSVD` -expose a single `threshold`, while `oSVD` exposes `oscillation_index` and -`default_threshold`. The discriminator is `method` for most components, -`mode` for the ASL quantification block (single-PLD vs multi-PLD), `model` -for the IVIM signal model, and `name` for the population AIF. +Switch the method and the surfaced knobs change with it (`sSVD`/`cSVD` expose a +single `threshold`; `oSVD` exposes `oscillation_index` and `default_threshold`). +The discriminator is `method` for most components, `mode` for the ASL +quantification block, `model` for the IVIM signal model, and `name` for the +population AIF. -### The CLI config is generated from `registry × schema` +### The config is generated from `registry × schema` -The per-component config models are composed into discriminated unions -(`method_union()`), which form the modality config models used by the CLI. -The `--dump-defaults` templates and the interactive wizard (`--help-me-pls`) -are produced from these same models, so the documented defaults always match -the code. +The per-component models are composed into discriminated unions +(`method_union()`) that form the modality config models. The `--dump-defaults` +templates and the `--help-me-pls` wizard are produced from these same models — +including the `# A | B | C` option comments, which are derived from the union +members (the registry keys), not hand-written. ### The same schema validates *and* builds the component -When a config is loaded, the discriminator picks the registry entry and the -remaining fields become that component's constructor arguments -(`construct_from_config()`): +On load, the discriminator picks the registry entry and the remaining fields +become its constructor arguments (`construct_from_config()`): ```python deconvolver = construct_from_config(DECONVOLVER_REGISTRY, cfg) # cfg.method -> instance ``` -Because validation and construction share one schema, every accepted knob is +Validation and construction share one schema, so every accepted knob is guaranteed to reach the live component. -## Consequences for Contributors - -Adding a new method is the registry pattern plus one config model: +### Adding a method 1. Register the component, e.g. `@register_deconvolver("mymethod")` (see [Extension Points](architecture.md#extension-points)). -2. Give it a `MethodConfig` subclass listing its discriminator and knobs, and - add it to the modality's `*_CONFIGS` mapping. +2. Give it a `MethodConfig` subclass and add it to the modality's `*_CONFIGS` + mapping. -That's it — the new method automatically appears as a selectable option in the -CLI config, the `--dump-defaults` template, and the interactive wizard, with -its parameters validated and wired through. No hand-editing of the config -schema, runner, or wizard is required. +It then appears automatically as a selectable option in the config, +`--dump-defaults`, and the wizard — no hand-editing of the schema, runner, or +wizard. -## Per-Modality Shape - -The nested blocks per modality are: +## Per-modality shape | Modality | Nested component blocks | |----------|-------------------------| @@ -105,19 +82,8 @@ The nested blocks per modality are: | ASL | `m0.method`, `difference.method`, `quantification.mode` (single-PLD or multi-PLD + ATT) | | IVIM | `fitting.method` (segmented / full / bayesian), `model.model` (biexponential / simplified) | -Physiological and acquisition parameters that are not method-specific (such as -the ASL labeling timing, the DSC echo time, or IVIM `normalize_signal`) stay -as flat keys in the `pipeline` block. See -[How to Run a Pipeline from YAML](../how-to/run-pipeline-cli.md) for complete, -runnable examples, and generate an authoritative template at any time with: - -```bash -osipy --dump-defaults dce # or dsc, asl, ivim -``` - -## See Also - -- [Architecture Overview](architecture.md) — the registry pattern and the full - extension-point table -- [How to Run a Pipeline from YAML](../how-to/run-pipeline-cli.md) — task-oriented - config recipes +Non-method-specific parameters (ASL labeling timing, DSC echo time, IVIM +`normalize_signal`, …) stay as flat keys in the `pipeline` block. Generate an +authoritative template with `osipy --dump-defaults dce` (or `dsc`/`asl`/`ivim`); +see [How to Run a Pipeline from YAML](../how-to/run-pipeline-cli.md) for runnable +examples. From 904c4e82a13b2f4d1645ef9b5637c6b94033f70c Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 02:21:40 -0400 Subject: [PATCH 14/15] style: apply ruff-format; drop obsolete root asl_config.yaml - ruff-format (pre-commit pinned ruff) reformats a test array that the editor formatter left expanded - remove the stale root asl_config.yaml example (flat pre-rollout schema, no longer valid; was already deleted in the working tree) Co-Authored-By: Claude Opus 4.8 (1M context) --- asl_config.yaml | 25 ------------------------- tests/integration/test_ivim_pipeline.py | 4 +--- 2 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 asl_config.yaml diff --git a/asl_config.yaml b/asl_config.yaml deleted file mode 100644 index b1c2efc..0000000 --- a/asl_config.yaml +++ /dev/null @@ -1,25 +0,0 @@ -modality: asl -pipeline: - labeling_scheme: pcasl # pcasl | pasl | casl - pld: 1800.0 # ms, post-labeling delay - label_duration: 1800.0 # ms, labeling duration - t1_blood: 1650.0 # ms, longitudinal relaxation time of blood - labeling_efficiency: 0.85 # labeling efficiency (0 to 1) - m0_method: single # single | voxelwise | reference_region - t1_tissue: 1330.0 # ms, longitudinal relaxation time of tissue - partition_coefficient: 0.9 # blood-brain partition coefficient (mL/g) - difference_method: pairwise # pairwise | surround | mean - label_control_order: label_first # label_first | control_first -data: - format: auto # auto | nifti | dicom | bids - # m0_data: sub-Sub1_m0scan.nii.gz # M0 calibration image (M0=1.0 if omitted) - # mask: brain_mask.nii.gz # tissue mask, relative to data_path or absolute - # subject: "01" # BIDS subject ID (required when format: bids) - # session: "01" # BIDS session ID -output: - format: nifti # nifti -backend: - force_cpu: false -logging: - level: INFO # DEBUG | INFO | WARNING | ERROR - diff --git a/tests/integration/test_ivim_pipeline.py b/tests/integration/test_ivim_pipeline.py index 4799791..01f7fcc 100644 --- a/tests/integration/test_ivim_pipeline.py +++ b/tests/integration/test_ivim_pipeline.py @@ -375,9 +375,7 @@ class TestIVIMPipelineRegistryConfig: def _synthetic_biexp(seed: int = 7): """Generate a small noisy bi-exponential IVIM dataset.""" rng = np.random.default_rng(seed) - b = np.array( - [0, 10, 20, 30, 50, 80, 100, 150, 200, 400, 600, 800], dtype=float - ) + b = np.array([0, 10, 20, 30, 50, 80, 100, 150, 200, 400, 600, 800], dtype=float) nx, ny, nz = 4, 4, 2 s0 = rng.uniform(900, 1100, (nx, ny, nz)) d = rng.uniform(0.8e-3, 1.5e-3, (nx, ny, nz)) From ecbcea37ceb58c749f228a5bbcfa196088077981 Mon Sep 17 00:00:00 2001 From: Luis Torres Date: Mon, 8 Jun 2026 02:25:43 -0400 Subject: [PATCH 15/15] fix(cli): keep --dump-defaults templates ASCII (Windows round-trip) The option/description comment separator used an em-dash (U+2014); on Windows the dump_defaults -> write_text -> load_config round-trip wrote it as cp1252 0x97 and failed to re-read as UTF-8 (UnicodeDecodeError). Use an ASCII ' - ' separator and assert templates are ASCII in test_dump_defaults_are_valid so it can't regress. Co-Authored-By: Claude Opus 4.8 (1M context) --- osipy/cli/config.py | 4 +++- tests/unit/cli/test_config.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/osipy/cli/config.py b/osipy/cli/config.py index d6b01c6..5d3b817 100644 --- a/osipy/cli/config.py +++ b/osipy/cli/config.py @@ -514,7 +514,9 @@ def _comment(choices: list[str] | None, description: str) -> str: parts.append(" | ".join(choices)) if description and description not in parts: parts.append(description) - return " # " + " — ".join(parts) if parts else "" + # ASCII-only separator: generated templates must round-trip on Windows + # (cp1252) as well as UTF-8. + return " # " + " - ".join(parts) if parts else "" def _render_model_yaml( diff --git a/tests/unit/cli/test_config.py b/tests/unit/cli/test_config.py index dbc487c..e55ab11 100644 --- a/tests/unit/cli/test_config.py +++ b/tests/unit/cli/test_config.py @@ -813,6 +813,9 @@ def test_dump_defaults_are_valid(self, tmp_path: Path) -> None: """Round-trip: dump_defaults output can be loaded and validated.""" for modality in ("dce", "dsc", "asl", "ivim"): template = dump_defaults(modality) + # Templates must be ASCII so they round-trip on Windows (cp1252) + # as well as UTF-8. + template.encode("ascii") config_path = tmp_path / f"{modality}_config.yaml" config_path.write_text(template) config = load_config(config_path)